Commit c3dfb2ec authored by GitLab Bot's avatar GitLab Bot

Automatic merge of gitlab-org/gitlab master

parents 715349d6 619f2044
...@@ -81,7 +81,7 @@ MarkdownPreview.prototype.fetchMarkdownPreview = function (text, url, success) { ...@@ -81,7 +81,7 @@ MarkdownPreview.prototype.fetchMarkdownPreview = function (text, url, success) {
}) })
.catch(() => .catch(() =>
createFlash({ createFlash({
message: __('An error occurred while fetching markdown preview'), message: __('An error occurred while fetching Markdown preview'),
}), }),
); );
}; };
......
...@@ -25,7 +25,7 @@ export default { ...@@ -25,7 +25,7 @@ export default {
lazy: true, lazy: true,
}, },
translations: { translations: {
cronPlaceholder: __('* * * * *'), cronPlaceholder: '* * * * *',
cronSyntaxInstructions: __( cronSyntaxInstructions: __(
'Define a custom deploy freeze pattern with %{cronSyntaxStart}cron syntax%{cronSyntaxEnd}', 'Define a custom deploy freeze pattern with %{cronSyntaxStart}cron syntax%{cronSyntaxEnd}',
), ),
......
...@@ -31,7 +31,7 @@ export const i18n = { ...@@ -31,7 +31,7 @@ export const i18n = {
title: __('Custom notification events'), title: __('Custom notification events'),
bodyTitle: __('Notification events'), bodyTitle: __('Notification events'),
bodyMessage: __( bodyMessage: __(
'Custom notification levels are the same as participating levels. With custom notification levels you will also receive notifications for select events. To find out more, check out %{notificationLinkStart} notification emails%{notificationLinkEnd}.', 'Custom notification levels are the same as participating levels. With custom notification levels you will also receive notifications for select events. To find out more, check out %{notificationLinkStart}notification emails%{notificationLinkEnd}.',
), ),
}, },
eventNames: { eventNames: {
......
...@@ -2,51 +2,51 @@ import { s__ } from '~/locale'; ...@@ -2,51 +2,51 @@ import { s__ } from '~/locale';
export const PIPELINE_SOURCES = [ export const PIPELINE_SOURCES = [
{ {
text: s__('Pipeline|Source|Push'), text: s__('PipelineSource|Push'),
value: 'push', value: 'push',
}, },
{ {
text: s__('Pipeline|Source|Web'), text: s__('PipelineSource|Web'),
value: 'web', value: 'web',
}, },
{ {
text: s__('Pipeline|Source|Trigger'), text: s__('PipelineSource|Trigger'),
value: 'trigger', value: 'trigger',
}, },
{ {
text: s__('Pipeline|Source|Schedule'), text: s__('PipelineSource|Schedule'),
value: 'schedule', value: 'schedule',
}, },
{ {
text: s__('Pipeline|Source|API'), text: s__('PipelineSource|API'),
value: 'api', value: 'api',
}, },
{ {
text: s__('Pipeline|Source|External'), text: s__('PipelineSource|External'),
value: 'external', value: 'external',
}, },
{ {
text: s__('Pipeline|Source|Pipeline'), text: s__('PipelineSource|Pipeline'),
value: 'pipeline', value: 'pipeline',
}, },
{ {
text: s__('Pipeline|Source|Chat'), text: s__('PipelineSource|Chat'),
value: 'chat', value: 'chat',
}, },
{ {
text: s__('Pipeline|Source|Web IDE'), text: s__('PipelineSource|Web IDE'),
value: 'webide', value: 'webide',
}, },
{ {
text: s__('Pipeline|Source|Merge Request'), text: s__('PipelineSource|Merge Request'),
value: 'merge_request_event', value: 'merge_request_event',
}, },
{ {
text: s__('Pipeline|Source|External Pull Request'), text: s__('PipelineSource|External Pull Request'),
value: 'external_pull_request_event', value: 'external_pull_request_event',
}, },
{ {
text: s__('Pipeline|Source|Parent Pipeline'), text: s__('PipelineSource|Parent Pipeline'),
value: 'parent_pipeline', value: 'parent_pipeline',
}, },
]; ];
<script> <script>
import { GlButton, GlLoadingIcon, GlIcon, GlLink, GlBadge, GlSafeHtmlDirective } from '@gitlab/ui'; import { GlButton, GlLoadingIcon, GlIcon, GlLink, GlBadge, GlSafeHtmlDirective } from '@gitlab/ui';
import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue'; import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue';
import StatusIcon from '../mr_widget_status_icon.vue'; import { EXTENSION_ICON_CLASS } from '../../constants';
import StatusIcon from './status_icon.vue';
export const LOADING_STATES = { export const LOADING_STATES = {
collapsedLoading: 'collapsedLoading', collapsedLoading: 'collapsedLoading',
...@@ -45,14 +46,6 @@ export default { ...@@ -45,14 +46,6 @@ export default {
return true; return true;
}, },
statusIconName() { statusIconName() {
if (this.isLoadingSummary) {
return 'loading';
}
if (this.loadingState === LOADING_STATES.collapsedError) {
return 'warning';
}
return this.statusIcon(this.collapsedData); return this.statusIcon(this.collapsedData);
}, },
}, },
...@@ -96,13 +89,18 @@ export default { ...@@ -96,13 +89,18 @@ export default {
}); });
}, },
}, },
EXTENSION_ICON_CLASS,
}; };
</script> </script>
<template> <template>
<section class="media-section mr-widget-border-top"> <section class="media-section mr-widget-border-top" data-testid="widget-extension">
<div class="media gl-p-5"> <div class="media gl-p-5">
<status-icon :status="statusIconName" class="align-self-center" /> <status-icon
:name="$options.name"
:is-loading="isLoadingSummary"
:icon-name="statusIconName"
/>
<div class="media-body d-flex flex-align-self-center align-items-center"> <div class="media-body d-flex flex-align-self-center align-items-center">
<div class="code-text"> <div class="code-text">
<template v-if="isLoadingSummary"> <template v-if="isLoadingSummary">
...@@ -114,13 +112,18 @@ export default { ...@@ -114,13 +112,18 @@ export default {
v-if="isCollapsible" v-if="isCollapsible"
size="small" size="small"
class="float-right align-self-center" class="float-right align-self-center"
data-testid="toggle-button"
@click="toggleCollapsed" @click="toggleCollapsed"
> >
{{ isCollapsed ? __('Expand') : __('Collapse') }} {{ isCollapsed ? __('Expand') : __('Collapse') }}
</gl-button> </gl-button>
</div> </div>
</div> </div>
<div v-if="!isCollapsed" class="mr-widget-grouped-section"> <div
v-if="!isCollapsed"
class="mr-widget-grouped-section"
data-testid="widget-extension-collapsed-section"
>
<div v-if="isLoadingExpanded" class="report-block-container"> <div v-if="isLoadingExpanded" class="report-block-container">
<gl-loading-icon size="sm" inline /> {{ __('Loading...') }} <gl-loading-icon size="sm" inline /> {{ __('Loading...') }}
</div> </div>
......
import { extensions } from './index'; import { registeredExtensions } from './index';
export default { export default {
props: { props: {
...@@ -8,6 +8,8 @@ export default { ...@@ -8,6 +8,8 @@ export default {
}, },
}, },
render(h) { render(h) {
const { extensions } = registeredExtensions;
if (extensions.length === 0) return null; if (extensions.length === 0) return null;
return h('div', {}, [ return h('div', {}, [
......
import Vue from 'vue';
import ExtensionBase from './base.vue'; import ExtensionBase from './base.vue';
// Holds all the currently registered extensions // Holds all the currently registered extensions
export const extensions = []; export const registeredExtensions = Vue.observable({ extensions: [] });
export const registerExtension = (extension) => { export const registerExtension = (extension) => {
// Pushes into the extenions array a dynamically created Vue component // Pushes into the extenions array a dynamically created Vue component
// that gets exteneded from `base.vue` // that gets exteneded from `base.vue`
extensions.push({ registeredExtensions.extensions.push({
extends: ExtensionBase, extends: ExtensionBase,
name: extension.name, name: extension.name,
props: extension.props, props: extension.props,
......
<script>
import { GlLoadingIcon, GlIcon } from '@gitlab/ui';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
import { EXTENSION_ICON_CLASS, EXTENSION_ICONS } from '../../constants';
export default {
components: {
GlLoadingIcon,
GlIcon,
},
props: {
name: {
type: String,
required: true,
},
isLoading: {
type: Boolean,
required: true,
},
iconName: {
type: String,
required: true,
},
},
computed: {
iconAriaLabel() {
const statusLabel = Object.keys(EXTENSION_ICONS).find(
(k) => EXTENSION_ICONS[k] === this.iconName,
);
return `${capitalizeFirstCharacter(statusLabel)} ${this.name}`;
},
},
EXTENSION_ICON_CLASS,
};
</script>
<template>
<div
:class="[$options.EXTENSION_ICON_CLASS[iconName], { 'mr-widget-extension-icon': !isLoading }]"
class="align-self-center gl-rounded-full gl-mr-3 gl-relative gl-p-2"
>
<gl-loading-icon v-if="isLoading" size="md" inline class="gl-display-block" />
<gl-icon
v-else
:name="iconName"
:size="16"
:aria-label="iconAriaLabel"
class="gl-display-block"
/>
</div>
</template>
...@@ -91,4 +91,19 @@ export const stateToTransitionMap = { ...@@ -91,4 +91,19 @@ export const stateToTransitionMap = {
export const stateToComponentMap = { export const stateToComponentMap = {
[states.MERGING]: classStateMap[stateKey.merging], [states.MERGING]: classStateMap[stateKey.merging],
}; };
export const EXTENSION_ICONS = {
failed: 'status-failed',
warning: 'status-alert',
success: 'status-success',
neutral: 'status-neutral',
};
export const EXTENSION_ICON_CLASS = {
[EXTENSION_ICONS.failed]: 'gl-text-red-500',
[EXTENSION_ICONS.warning]: 'gl-text-orange-500',
[EXTENSION_ICONS.success]: 'gl-text-green-500',
[EXTENSION_ICONS.neutral]: 'gl-text-gray-400',
};
export { STATE_MACHINE }; export { STATE_MACHINE };
/* eslint-disable */ /* eslint-disable */
import { EXTENSION_ICONS } from '../constants';
import issuesCollapsedQuery from './issues_collapsed.query.graphql'; import issuesCollapsedQuery from './issues_collapsed.query.graphql';
import issuesQuery from './issues.query.graphql'; import issuesQuery from './issues.query.graphql';
export default { export default {
// Give the extension a name // Give the extension a name
// Make it easier to track in Vue dev tools // Make it easier to track in Vue dev tools
name: 'WidgetIssues', name: 'Issues',
// Add an array of props // Add an array of props
// These then get mapped to values stored in the MR Widget store // These then get mapped to values stored in the MR Widget store
props: ['targetProjectFullPath'], props: ['targetProjectFullPath'],
...@@ -14,12 +15,12 @@ export default { ...@@ -14,12 +15,12 @@ export default {
// Small summary text to be displayed in the collapsed state // Small summary text to be displayed in the collapsed state
// Receives the collapsed data as an argument // Receives the collapsed data as an argument
summary(count) { summary(count) {
return `<strong>${count}</strong> open issue`; return 'Summary text';
}, },
// Status icon to be used next to the summary text // Status icon to be used next to the summary text
// Receives the collapsed data as an argument // Receives the collapsed data as an argument
statusIcon(count) { statusIcon(count) {
return count > 0 ? 'warning' : 'success'; return EXTENSION_ICONS.warning;
}, },
}, },
methods: { methods: {
......
...@@ -97,7 +97,7 @@ export default { ...@@ -97,7 +97,7 @@ export default {
}); });
}) })
.catch(() => { .catch(() => {
this.previewContent = __('An error occurred while fetching markdown preview'); this.previewContent = __('An error occurred while fetching Markdown preview');
this.isLoading = false; this.isLoading = false;
}); });
} }
......
...@@ -254,7 +254,7 @@ export default { ...@@ -254,7 +254,7 @@ export default {
.then(() => $(this.$refs['markdown-preview']).renderGFM()) .then(() => $(this.$refs['markdown-preview']).renderGFM())
.catch(() => .catch(() =>
createFlash({ createFlash({
message: __('Error rendering markdown preview'), message: __('Error rendering Markdown preview'),
}), }),
); );
}, },
......
...@@ -1040,3 +1040,17 @@ $tabs-holder-z-index: 250; ...@@ -1040,3 +1040,17 @@ $tabs-holder-z-index: 250;
margin-bottom: 1px; margin-bottom: 1px;
} }
} }
.mr-widget-extension-icon::before {
@include gl-content-empty;
@include gl-absolute;
@include gl-left-0;
@include gl-top-0;
@include gl-opacity-3;
@include gl-border-solid;
@include gl-border-4;
@include gl-rounded-full;
width: 24px;
height: 24px;
}
...@@ -31,6 +31,10 @@ module Types ...@@ -31,6 +31,10 @@ module Types
mount_mutation Mutations::Boards::Lists::Update mount_mutation Mutations::Boards::Lists::Update
mount_mutation Mutations::Boards::Lists::Destroy mount_mutation Mutations::Boards::Lists::Destroy
mount_mutation Mutations::Branches::Create, calls_gitaly: true mount_mutation Mutations::Branches::Create, calls_gitaly: true
mount_mutation Mutations::Clusters::Agents::Create
mount_mutation Mutations::Clusters::Agents::Delete
mount_mutation Mutations::Clusters::AgentTokens::Create
mount_mutation Mutations::Clusters::AgentTokens::Delete
mount_mutation Mutations::Commits::Create, calls_gitaly: true mount_mutation Mutations::Commits::Create, calls_gitaly: true
mount_mutation Mutations::CustomEmoji::Create, feature_flag: :custom_emoji mount_mutation Mutations::CustomEmoji::Create, feature_flag: :custom_emoji
mount_mutation Mutations::CustomEmoji::Destroy, feature_flag: :custom_emoji mount_mutation Mutations::CustomEmoji::Destroy, feature_flag: :custom_emoji
......
...@@ -87,9 +87,9 @@ module SearchHelper ...@@ -87,9 +87,9 @@ module SearchHelper
def search_entries_info_template(collection) def search_entries_info_template(collection)
if collection.total_pages > 1 if collection.total_pages > 1
s_("SearchResults|Showing %{from} - %{to} of %{count} %{scope} for%{term_element}").html_safe s_("SearchResults|Showing %{from} - %{to} of %{count} %{scope} for %{term_element}").html_safe
else else
s_("SearchResults|Showing %{count} %{scope} for%{term_element}").html_safe s_("SearchResults|Showing %{count} %{scope} for %{term_element}").html_safe
end end
end end
......
...@@ -71,7 +71,7 @@ class AuditEvent < ApplicationRecord ...@@ -71,7 +71,7 @@ class AuditEvent < ApplicationRecord
end end
def lazy_author def lazy_author
BatchLoader.for(author_id).batch(replace_methods: false) do |author_ids, loader| BatchLoader.for(author_id).batch do |author_ids, loader|
User.select(:id, :name, :username).where(id: author_ids).find_each do |user| User.select(:id, :name, :username).where(id: author_ids).find_each do |user|
loader.call(user.id, user) loader.call(user.id, user)
end end
......
...@@ -133,7 +133,7 @@ class Commit ...@@ -133,7 +133,7 @@ class Commit
end end
def lazy(container, oid) def lazy(container, oid)
BatchLoader.for({ container: container, oid: oid }).batch(replace_methods: false) do |items, loader| BatchLoader.for({ container: container, oid: oid }).batch do |items, loader|
items_by_container = items.group_by { |i| i[:container] } items_by_container = items.group_by { |i| i[:container] }
items_by_container.each do |container, commit_ids| items_by_container.each do |container, commit_ids|
......
...@@ -110,7 +110,7 @@ module Avatarable ...@@ -110,7 +110,7 @@ module Avatarable
def retrieve_upload_from_batch(identifier) def retrieve_upload_from_batch(identifier)
BatchLoader.for(identifier: identifier, model: self) BatchLoader.for(identifier: identifier, model: self)
.batch(key: self.class, cache: true, replace_methods: false) do |upload_params, loader, args| .batch(key: self.class) do |upload_params, loader, args|
model_class = args[:key] model_class = args[:key]
paths = upload_params.flat_map do |params| paths = upload_params.flat_map do |params|
params[:model].upload_paths(params[:identifier]) params[:model].upload_paths(params[:identifier])
......
...@@ -1791,7 +1791,7 @@ class Project < ApplicationRecord ...@@ -1791,7 +1791,7 @@ class Project < ApplicationRecord
def open_issues_count(current_user = nil) def open_issues_count(current_user = nil)
return Projects::OpenIssuesCountService.new(self, current_user).count unless current_user.nil? return Projects::OpenIssuesCountService.new(self, current_user).count unless current_user.nil?
BatchLoader.for(self).batch(replace_methods: false) do |projects, loader| BatchLoader.for(self).batch do |projects, loader|
issues_count_per_project = ::Projects::BatchOpenIssuesCountService.new(projects).refresh_cache_and_retrieve_data issues_count_per_project = ::Projects::BatchOpenIssuesCountService.new(projects).refresh_cache_and_retrieve_data
issues_count_per_project.each do |project, count| issues_count_per_project.each do |project, count|
...@@ -2256,7 +2256,7 @@ class Project < ApplicationRecord ...@@ -2256,7 +2256,7 @@ class Project < ApplicationRecord
# rubocop: disable CodeReuse/ServiceClass # rubocop: disable CodeReuse/ServiceClass
def forks_count def forks_count
BatchLoader.for(self).batch(replace_methods: false) do |projects, loader| BatchLoader.for(self).batch do |projects, loader|
fork_count_per_project = ::Projects::BatchForksCountService.new(projects).refresh_cache_and_retrieve_data fork_count_per_project = ::Projects::BatchForksCountService.new(projects).refresh_cache_and_retrieve_data
fork_count_per_project.each do |project, count| fork_count_per_project.each do |project, count|
......
...@@ -6,7 +6,6 @@ module Clusters ...@@ -6,7 +6,6 @@ module Clusters
ALLOWED_PARAMS = %i[agent_id description name].freeze ALLOWED_PARAMS = %i[agent_id description name].freeze
def execute def execute
return error_feature_not_available unless container.feature_available?(:cluster_agents)
return error_no_permissions unless current_user.can?(:create_cluster, container) return error_no_permissions unless current_user.can?(:create_cluster, container)
token = ::Clusters::AgentToken.new(filtered_params.merge(created_by_user: current_user)) token = ::Clusters::AgentToken.new(filtered_params.merge(created_by_user: current_user))
...@@ -20,10 +19,6 @@ module Clusters ...@@ -20,10 +19,6 @@ module Clusters
private private
def error_feature_not_available
ServiceResponse.error(message: s_('ClusterAgent|This feature is only available for premium plans'))
end
def error_no_permissions def error_no_permissions
ServiceResponse.error(message: s_('ClusterAgent|User has insufficient permissions to create a token for this project')) ServiceResponse.error(message: s_('ClusterAgent|User has insufficient permissions to create a token for this project'))
end end
......
...@@ -4,7 +4,6 @@ module Clusters ...@@ -4,7 +4,6 @@ module Clusters
module Agents module Agents
class CreateService < BaseService class CreateService < BaseService
def execute(name:) def execute(name:)
return error_not_premium_plan unless project.feature_available?(:cluster_agents)
return error_no_permissions unless cluster_agent_permissions? return error_no_permissions unless cluster_agent_permissions?
agent = ::Clusters::Agent.new(name: name, project: project, created_by_user: current_user) agent = ::Clusters::Agent.new(name: name, project: project, created_by_user: current_user)
...@@ -25,10 +24,6 @@ module Clusters ...@@ -25,10 +24,6 @@ module Clusters
def error_no_permissions def error_no_permissions
error(s_('ClusterAgent|You have insufficient permissions to create a cluster agent for this project')) error(s_('ClusterAgent|You have insufficient permissions to create a cluster agent for this project'))
end end
def error_not_premium_plan
error(s_('ClusterAgent|This feature is only available for premium plans'))
end
end end
end end
end end
...@@ -83,7 +83,7 @@ module Users ...@@ -83,7 +83,7 @@ module Users
end end
def lazy_user_availability(user) def lazy_user_availability(user)
BatchLoader.for(user.id).batch(replace_methods: false) do |user_ids, loader| BatchLoader.for(user.id).batch do |user_ids, loader|
user_ids.each_slice(1_000) do |sliced_user_ids| user_ids.each_slice(1_000) do |sliced_user_ids|
UserStatus UserStatus
.select(:user_id, :availability) .select(:user_id, :availability)
......
<%= _(" %{name}, confirm your email address now! ") % { name: @resource.user.name } %> <%= _("%{name}, confirm your email address now!") % { name: @resource.user.name } %>
<%= _("Use the link below to confirm your email address (%{email})") % { email: @resource.email } %> <%= _("Use the link below to confirm your email address (%{email})") % { email: @resource.email } %>
......
# frozen_string_literal: true # frozen_string_literal: true
Rails.application.config.middleware.use(BatchLoader::Middleware) Rails.application.config.middleware.use(BatchLoader::Middleware)
# Disables replace_methods by default.
# See https://github.com/exAspArk/batch-loader#replacing-methods for more information.
module BatchLoaderWithoutMethodReplacementByDefault
def batch(replace_methods: false, **kw_args, &batch_block)
super
end
end
BatchLoader.prepend(BatchLoaderWithoutMethodReplacementByDefault)
...@@ -298,6 +298,8 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do ...@@ -298,6 +298,8 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
end end
end end
resources :cluster_agents, only: [:show], param: :name
concerns :clusterable concerns :clusterable
namespace :serverless do namespace :serverless do
......
...@@ -106,6 +106,7 @@ with [domain expertise](#domain-experts). ...@@ -106,6 +106,7 @@ with [domain expertise](#domain-experts).
1. If your merge request includes user-facing changes (*3*), it must be 1. If your merge request includes user-facing changes (*3*), it must be
**approved by a [Product Designer](https://about.gitlab.com/handbook/engineering/projects/#gitlab_reviewers_UX)**, **approved by a [Product Designer](https://about.gitlab.com/handbook/engineering/projects/#gitlab_reviewers_UX)**,
based on assignments in the appropriate [DevOps stage group](https://about.gitlab.com/handbook/product/categories/#devops-stages). based on assignments in the appropriate [DevOps stage group](https://about.gitlab.com/handbook/product/categories/#devops-stages).
See the [design and user interface guidelines](contributing/design.md) for details.
1. If your merge request includes adding a new JavaScript library (*1*)... 1. If your merge request includes adding a new JavaScript library (*1*)...
- If the library significantly increases the - If the library significantly increases the
[bundle size](https://gitlab.com/gitlab-org/frontend/playground/webpack-memory-metrics/-/blob/master/doc/report.md), it must [bundle size](https://gitlab.com/gitlab-org/frontend/playground/webpack-memory-metrics/-/blob/master/doc/report.md), it must
......
...@@ -5,34 +5,102 @@ group: Development ...@@ -5,34 +5,102 @@ group: Development
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments
--- ---
# Implement design & UI elements # Design and user interface changes
For guidance on UX implementation at GitLab, please refer to our [Design System](https://design.gitlab.com/). Follow these guidelines when contributing or reviewing design and user interface
(UI) changes. Refer to our [code review guide](../code_review.md) for broader
advice and best practices for code review in general.
The UX team uses labels to manage their workflow. The basis for most of these guidelines is [Pajamas](https://design.gitlab.com/),
GitLab design system. We encourage you to [contribute to Pajamas](https://design.gitlab.com/get-started/contribute)
with additions and improvements.
The `~UX` label on an issue is a signal to the UX team that it will need UX attention. ## Merge request reviews
To better understand the priority by which UX tackles issues, see the [UX section](https://about.gitlab.com/handbook/engineering/ux/) of the handbook.
Once an issue has been worked on and is ready for development, a UXer removes the `~UX` label and applies the `~"UX ready"` label to that issue. As a merge request (MR) author, you must include _Before_ and _After_
screenshots (or videos) of your changes in the description, as explained in our
[MR workflow](merge_request_workflow.md). These screenshots/videos are very helpful
for all reviewers and can speed up the review process, especially if the changes
are small.
There is a special type label called `~"product discovery"` intended for UX (user experience), ## Checklist
PM (product manager), FE (frontend), and BE (backend). It represents a discovery issue to discuss the problem and
potential solutions. The final output for this issue could be a doc of
requirements, a design artifact, or even a prototype. The solution will be
developed in a subsequent milestone.
`~"product discovery"` issues are like any other issue and should contain a milestone label, `~Deliverable` or `~Stretch`, when scheduled in the current milestone. Check these aspects both when _designing_ and _reviewing_ UI changes.
The initial issue should be about the problem we are solving. If a separate [product discovery issue](https://about.gitlab.com/handbook/engineering/ux/ux-department-workflow/#how-we-use-labels) ### Writing
is needed for additional research and design work, it will be created by a PM or UX person.
Assign the `~UX`, `~"product discovery"` and `~Deliverable` labels, add a milestone and
use a title that makes it clear that the scheduled issue is product discovery
(for example, `Product discovery for XYZ`).
In order to complete a product discovery issue in a release, you must complete the following: - Follow [Pajamas](https://design.gitlab.com/content/punctuation/) as the primary
guidelines for UI text and [documentation style guide](../documentation/styleguide/index.md)
as the secondary.
- Use clear and consistent [terminology](https://design.gitlab.com/content/terminology).
- Check grammar and spelling.
- Consider help content and follow its [guidelines](https://design.gitlab.com/usability/helping-users).
- Request review from the [appropriate Technical Writer](https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers),
indicating any specific files or lines they should review, and how to preview
or understand the location/context of the text from the user's perspective.
1. UXer removes the `~UX` label, adds the `~"UX ready"` label. ### Patterns
1. Modify the issue description in the product discovery issue to contain the final design. If it makes sense, the original information indicating the need for the design can be moved to a lower "Original Information" section.
1. Copy the design to the description of the delivery issue for which the product discovery issue was created. Do not simply refer to the product discovery issue as a separate source of truth. - Consider similar patterns used in the product and justify in the issue when diverging
1. In some cases, a product discovery issue also identifies future enhancements that will not go into the issue that originated the product discovery issue. For these items, create new issues containing the designs to ensure they are not lost. Put the issues in the backlog if they are agreed upon as good ideas. Otherwise leave them for triage. from them.
- Use appropriate [components](https://design.gitlab.com/components/overview)
and [data visualizations](https://design.gitlab.com/data-visualization/overview).
### States
- Account for all applicable states ([error](https://design.gitlab.com/content/error-messages),
rest, loading, focus, hover, selected, disabled).
- Account for states dependent on data size ([empty](https://design.gitlab.com/regions/empty-states),
some data, and lots of data).
- Account for states dependent on user role, user preferences, and subscription.
- Consider animations and transitions, and follow their [guidelines](https://design.gitlab.com/product-foundations/motion).
### Visual design
- Use recommended [colors](https://design.gitlab.com/product-foundations/colors)
and [typography](https://design.gitlab.com/product-foundations/type-fundamentals).
- Follow [layout guidelines](https://design.gitlab.com/layout/grid).
- Use existing [icons](http://gitlab-org.gitlab.io/gitlab-svgs/) and [illustrations](http://gitlab-org.gitlab.io/gitlab-svgs/illustrations)
or propose new ones according to [iconography](https://design.gitlab.com/product-foundations/iconography)
and [illustration](https://design.gitlab.com/product-foundations/illustration)
guidelines.
- _Optionally_ consider [dark mode](../../user/profile/preferences.md#dark-mode). [^1]
[^1]: You're not required to design for [dark mode](../../user/profile/preferences.md#dark-mode) while the feature is in [alpha](https://about.gitlab.com/handbook/product/gitlab-the-product/#alpha). The [UX Foundations team](https://about.gitlab.com/direction/ecosystem/foundations/) plans to improve the dark mode in the future. Until we integrate [Pajamas](https://design.gitlab.com/) components into the product and the underlying design strategy is in place to support dark mode, we cannot guarantee that we won't introduce bugs and debt to this mode. At your discretion, evaluate the need to create dark mode patches.
### Responsive
- Account for resizing, collapsing, moving, or wrapping of elements across
all breakpoints (even if larger viewports are prioritized).
- Provide the same information and actions in all breakpoints.
### Accessibility
- Conform to level AA of the World Wide Web Consortium (W3C) [Web Content Accessibility Guidelines 2.1](https://www.w3.org/TR/WCAG21/),
according to our [statement of compliance](https://design.gitlab.com/accessibility/a11y).
- Follow accessibility [best practices](https://design.gitlab.com/accessibility/best-practices)
and [checklist](../fe_guide/accessibility.md#quick-checklist).
### Handoff
- Share design specifications in the related issue, preferably through a [Figma link](https://help.figma.com/hc/en-us/articles/360040531773-Share-Files-with-anyone-using-Link-Sharing#Copy_links)
link or [GitLab Designs feature](../../user/project/issues/design_management.md#the-design-management-section).
See [when you should use each tool](https://about.gitlab.com/handbook/engineering/ux/product-designer/#deliver).
- Document user flow and states (for example, using [Mermaid flowcharts in Markdown](../../user/markdown.md#mermaid)).
- Document animations and transitions.
- Document responsive behaviors.
- Document non-evident behaviors (for example, field is auto-focused).
- Document accessibility behaviors (for example, using [accessibility annotations in Figma](https://www.figma.com/file/g7QtDbfxF3pCdWiyskIr0X/Accessibility-bluelines)).
- Contribute new icons or illustrations to the [GitLab SVGs](https://gitlab.com/gitlab-org/gitlab-svgs)
project.
### Follow-ups
- Contribute [issues to Pajamas](https://design.gitlab.com/get-started/contribute#contribute-an-issue)
for additions or enhancements to the design system.
- Create issues with the [`~UX debt`](issue_workflow.md#technical-and-ux-debt)
label for intentional deviations from the agreed-upon UX requirements due to
time or feasibility challenges, linking back to the corresponding issue(s) or
MR(s).
- Create issues for [feature additions or enhancements](issue_workflow.md#feature-proposals)
outside the agreed-upon UX requirements to avoid scope creep.
...@@ -342,19 +342,22 @@ To create a feature proposal, open an issue on the ...@@ -342,19 +342,22 @@ To create a feature proposal, open an issue on the
[issue tracker](https://gitlab.com/gitlab-org/gitlab/-/issues). [issue tracker](https://gitlab.com/gitlab-org/gitlab/-/issues).
In order to help track the feature proposals, we have created a In order to help track the feature proposals, we have created a
[`feature`](https://gitlab.com/gitlab-org/gitlab/-/issues?label_name=feature) label. For the time being, users that are not members [`feature`](https://gitlab.com/gitlab-org/gitlab/-/issues?label_name=feature) label.
of the project cannot add labels. You can instead ask one of the [core team](https://about.gitlab.com/community/core-team/) For the time being, users that are not members of the project cannot add labels.
members to add the label ~feature to the issue or add the following You can instead ask one of the [core team](https://about.gitlab.com/community/core-team/)
members to add the label `~feature` to the issue or add the following
code snippet right after your description in a new line: `~feature`. code snippet right after your description in a new line: `~feature`.
Please keep feature proposals as small and simple as possible, complex ones Please keep feature proposals as small and simple as possible, complex ones
might be edited to make them small and simple. might be edited to make them small and simple.
Please submit Feature Proposals using the ['Feature Proposal' issue template](https://gitlab.com/gitlab-org/gitlab/-/blob/master/.gitlab/issue_templates/Feature%20proposal%20-%20detailed.md) provided on the issue tracker. Please submit feature proposals using the ['Feature Proposal' issue template](https://gitlab.com/gitlab-org/gitlab/-/blob/master/.gitlab/issue_templates/Feature%20proposal%20-%20detailed.md) provided on the issue tracker.
For changes in the interface, it is helpful to include a mockup. Issues that add to, or change, the interface should For changes to the user interface (UI), follow our [design and UI guidelines](design.md),
be given the ~"UX" label. This will allow the UX team to provide input and guidance. You may and include a visual example (screenshot, wireframe, or mockup). Such issues should
need to ask one of the [core team](https://about.gitlab.com/community/core-team/) members to add the label, if you do not have permissions to do it by yourself. be given the `~UX"` label for the Product Design team to provide input and guidance.
You may need to ask one of the [core team](https://about.gitlab.com/community/core-team/)
members to add the label, if you do not have permissions to do it by yourself.
If you want to create something yourself, consider opening an issue first to If you want to create something yourself, consider opening an issue first to
discuss whether it is interesting to include this in GitLab. discuss whether it is interesting to include this in GitLab.
......
...@@ -18,8 +18,8 @@ in order to ensure the work is finished before the release date. ...@@ -18,8 +18,8 @@ in order to ensure the work is finished before the release date.
If you want to add a new feature that is not labeled, it is best to first create If you want to add a new feature that is not labeled, it is best to first create
an issue (if there isn't one already) and leave a comment asking for it an issue (if there isn't one already) and leave a comment asking for it
to be marked as `Accepting Merge Requests`. Please include screenshots or to be marked as `Accepting merge requests`. See the [feature proposals](issue_workflow.md#feature-proposals)
wireframes of the proposed feature if it will also change the UI. section.
Merge requests should be submitted to the appropriate project at GitLab.com, for example Merge requests should be submitted to the appropriate project at GitLab.com, for example
[GitLab](https://gitlab.com/gitlab-org/gitlab/-/merge_requests), [GitLab](https://gitlab.com/gitlab-org/gitlab/-/merge_requests),
......
...@@ -3,11 +3,11 @@ import { PIPELINE_SOURCES as CE_PIPELINE_SOURCES } from '~/pipelines/components/ ...@@ -3,11 +3,11 @@ import { PIPELINE_SOURCES as CE_PIPELINE_SOURCES } from '~/pipelines/components/
const EE_PIPELINE_SOURCES = [ const EE_PIPELINE_SOURCES = [
{ {
text: s__('Pipeline|Source|On-Demand DAST Scan'), text: s__('PipelineSource|On-Demand DAST Scan'),
value: 'ondemand_dast_scan', value: 'ondemand_dast_scan',
}, },
{ {
text: s__('Pipeline|Source|On-Demand DAST Validation'), text: s__('PipelineSource|On-Demand DAST Validation'),
value: 'ondemand_dast_validation', value: 'ondemand_dast_validation',
}, },
{ {
......
...@@ -6,10 +6,6 @@ module EE ...@@ -6,10 +6,6 @@ module EE
extend ActiveSupport::Concern extend ActiveSupport::Concern
prepended do prepended do
mount_mutation ::Mutations::Clusters::Agents::Create
mount_mutation ::Mutations::Clusters::Agents::Delete
mount_mutation ::Mutations::Clusters::AgentTokens::Create
mount_mutation ::Mutations::Clusters::AgentTokens::Delete
mount_mutation ::Mutations::ComplianceManagement::Frameworks::Destroy mount_mutation ::Mutations::ComplianceManagement::Frameworks::Destroy
mount_mutation ::Mutations::ComplianceManagement::Frameworks::Update mount_mutation ::Mutations::ComplianceManagement::Frameworks::Update
mount_mutation ::Mutations::ComplianceManagement::Frameworks::Create mount_mutation ::Mutations::ComplianceManagement::Frameworks::Create
......
...@@ -63,9 +63,9 @@ module EE ...@@ -63,9 +63,9 @@ module EE
return super unless gitlab_com_snippet_db_search? return super unless gitlab_com_snippet_db_search?
if collection.total_pages > 1 if collection.total_pages > 1
s_("SearchResults|Showing %{from} - %{to} of %{count} %{scope} for%{term_element} in your personal and project snippets").html_safe s_("SearchResults|Showing %{from} - %{to} of %{count} %{scope} for %{term_element} in your personal and project snippets").html_safe
else else
s_("SearchResults|Showing %{count} %{scope} for%{term_element} in your personal and project snippets").html_safe s_("SearchResults|Showing %{count} %{scope} for %{term_element} in your personal and project snippets").html_safe
end end
end end
......
...@@ -47,7 +47,7 @@ module EE ...@@ -47,7 +47,7 @@ module EE
def lazy_entity def lazy_entity
BatchLoader.for(entity_id) BatchLoader.for(entity_id)
.batch( .batch(
key: entity_type, default_value: ::Gitlab::Audit::NullEntity.new, replace_methods: false key: entity_type, default_value: ::Gitlab::Audit::NullEntity.new
) do |ids, loader, args| ) do |ids, loader, args|
model = Object.const_get(args[:key], false) model = Object.const_get(args[:key], false)
model.where(id: ids).find_each { |record| loader.call(record.id, record) } model.where(id: ids).find_each { |record| loader.call(record.id, record) }
......
...@@ -153,7 +153,7 @@ module Vulnerabilities ...@@ -153,7 +153,7 @@ module Vulnerabilities
end end
def load_feedback def load_feedback
BatchLoader.for(finding_key).batch(replace_methods: false) do |finding_keys, loader| BatchLoader.for(finding_key).batch do |finding_keys, loader|
project_ids = finding_keys.map { |key| key[:project_id] } project_ids = finding_keys.map { |key| key[:project_id] }
categories = finding_keys.map { |key| key[:category] } categories = finding_keys.map { |key| key[:category] }
fingerprints = finding_keys.map { |key| key[:project_fingerprint] } fingerprints = finding_keys.map { |key| key[:project_fingerprint] }
......
...@@ -136,8 +136,6 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do ...@@ -136,8 +136,6 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
resources :oncall_schedules, only: [:index], path: 'oncall_schedules' resources :oncall_schedules, only: [:index], path: 'oncall_schedules'
resources :escalation_policies, only: [:index], path: 'escalation_policies' resources :escalation_policies, only: [:index], path: 'escalation_policies'
end end
resources :cluster_agents, only: [:show], param: :name
end end
# End of the /-/ scope. # End of the /-/ scope.
......
...@@ -190,7 +190,7 @@ module EE ...@@ -190,7 +190,7 @@ module EE
scope :without_uuid, -> { where(uuid: nil) } scope :without_uuid, -> { where(uuid: nil) }
def feedback def feedback
BatchLoader.for(finding_key).batch(replace_methods: false) do |finding_keys, loader| BatchLoader.for(finding_key).batch do |finding_keys, loader|
project_ids = finding_keys.map { |key| key[:project_id] } project_ids = finding_keys.map { |key| key[:project_id] }
categories = finding_keys.map { |key| key[:category] } categories = finding_keys.map { |key| key[:category] }
fingerprints = finding_keys.map { |key| key[:project_fingerprint] } fingerprints = finding_keys.map { |key| key[:project_fingerprint] }
......
...@@ -149,7 +149,7 @@ RSpec.describe SearchHelper do ...@@ -149,7 +149,7 @@ RSpec.describe SearchHelper do
let(:show_snippets) { true } let(:show_snippets) { true }
let(:collection) { Kaminari.paginate_array([:foo]).page(1).per(10) } let(:collection) { Kaminari.paginate_array([:foo]).page(1).per(10) }
let(:user) { create(:user) } let(:user) { create(:user) }
let(:message) { "Showing %{count} %{scope} for%{term_element}" } let(:message) { "Showing %{count} %{scope} for %{term_element}" }
let(:new_message) { message + " in your personal and project snippets" } let(:new_message) { message + " in your personal and project snippets" }
subject { search_entries_info_template(collection) } subject { search_entries_info_template(collection) }
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Clusters::AgentTokens::CreateService do
subject(:service) { described_class.new(container: project, current_user: user, params: params) }
let_it_be(:user) { create(:user) }
let(:cluster_agent) { create(:cluster_agent) }
let(:project) { cluster_agent.project }
let(:params) { { agent_id: cluster_agent.id, description: 'token description', name: 'token name' } }
before do
stub_licensed_features(cluster_agents: false)
end
describe '#execute' do
subject { service.execute }
context 'without premium plan' do
it 'does not create a new token' do
expect { subject }.not_to change(Clusters::AgentToken, :count)
end
it 'returns missing license error' do
expect(subject.status).to eq(:error)
expect(subject.message).to eq('This feature is only available for premium plans')
end
context 'with premium plan' do
before do
stub_licensed_features(cluster_agents: true)
end
it 'does not create a new token due to user permissions' do
expect { subject }.not_to change(::Clusters::AgentToken, :count)
end
it 'returns permission errors', :aggregate_failures do
expect(subject.status).to eq(:error)
expect(subject.message).to eq('User has insufficient permissions to create a token for this project')
end
context 'with user permissions' do
before do
project.add_maintainer(user)
end
it 'creates a new token' do
expect { subject }.to change { ::Clusters::AgentToken.count }.by(1)
end
it 'returns success status', :aggregate_failures do
expect(subject.status).to eq(:success)
expect(subject.message).to be_nil
end
it 'returns token information', :aggregate_failures do
token = subject.payload[:token]
expect(subject.payload[:secret]).not_to be_nil
expect(token.created_by_user).to eq(user)
expect(token.description).to eq(params[:description])
expect(token.name).to eq(params[:name])
end
context 'when params are invalid' do
let(:params) { { agent_id: 'bad_id' } }
it 'does not create a new token' do
expect { subject }.not_to change(::Clusters::AgentToken, :count)
end
it 'returns validation errors', :aggregate_failures do
expect(subject.status).to eq(:error)
expect(subject.message).to eq(["Agent must exist", "Name can't be blank"])
end
end
end
end
end
end
end
...@@ -24,7 +24,7 @@ module API ...@@ -24,7 +24,7 @@ module API
# entity according to the current top-level entity options, such # entity according to the current top-level entity options, such
# as the current_user. # as the current_user.
def lazy_issuable_metadata def lazy_issuable_metadata
BatchLoader.for(object).batch(key: [current_user, :issuable_metadata], replace_methods: false) do |models, loader, args| BatchLoader.for(object).batch(key: [current_user, :issuable_metadata]) do |models, loader, args|
current_user = args[:key].first current_user = args[:key].first
issuable_metadata = Gitlab::IssuableMetadata.new(current_user, models) issuable_metadata = Gitlab::IssuableMetadata.new(current_user, models)
......
...@@ -38,7 +38,7 @@ module Gitlab ...@@ -38,7 +38,7 @@ module Gitlab
end end
def vulnerability_finding def vulnerability_finding
BatchLoader.for(finding_key).batch(replace_methods: false) do |finding_keys, loader| BatchLoader.for(finding_key).batch do |finding_keys, loader|
project_ids = finding_keys.map { |key| key[:project_id] } project_ids = finding_keys.map { |key| key[:project_id] }
categories = finding_keys.map { |key| key[:category] } categories = finding_keys.map { |key| key[:category] }
fingerprints = finding_keys.map { |key| key[:project_fingerprint] } fingerprints = finding_keys.map { |key| key[:project_fingerprint] }
......
...@@ -15,7 +15,7 @@ module Gitlab ...@@ -15,7 +15,7 @@ module Gitlab
def tagline def tagline
[ [
s_('InProductMarketing|Start a free trial of GitLab Ultimate – no CC required'), s_('InProductMarketing|Start a free trial of GitLab Ultimate – no credit card required'),
s_('InProductMarketing|Improve app security with a 30-day trial'), s_('InProductMarketing|Improve app security with a 30-day trial'),
s_('InProductMarketing|Start with a GitLab Ultimate free trial') s_('InProductMarketing|Start with a GitLab Ultimate free trial')
][series] ][series]
......
...@@ -6,10 +6,11 @@ module Gitlab ...@@ -6,10 +6,11 @@ module Gitlab
class Iterator class Iterator
UnsupportedScopeOrder = Class.new(StandardError) UnsupportedScopeOrder = Class.new(StandardError)
def initialize(scope:, use_union_optimization: true, in_operator_optimization_options: nil) def initialize(scope:, cursor: {}, use_union_optimization: true, in_operator_optimization_options: nil)
@scope, success = Gitlab::Pagination::Keyset::SimpleOrderBuilder.build(scope) @scope, success = Gitlab::Pagination::Keyset::SimpleOrderBuilder.build(scope)
raise(UnsupportedScopeOrder, 'The order on the scope does not support keyset pagination') unless success raise(UnsupportedScopeOrder, 'The order on the scope does not support keyset pagination') unless success
@cursor = cursor
@order = Gitlab::Pagination::Keyset::Order.extract_keyset_order_object(scope) @order = Gitlab::Pagination::Keyset::Order.extract_keyset_order_object(scope)
@use_union_optimization = in_operator_optimization_options ? false : use_union_optimization @use_union_optimization = in_operator_optimization_options ? false : use_union_optimization
@in_operator_optimization_options = in_operator_optimization_options @in_operator_optimization_options = in_operator_optimization_options
...@@ -17,11 +18,9 @@ module Gitlab ...@@ -17,11 +18,9 @@ module Gitlab
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
def each_batch(of: 1000) def each_batch(of: 1000)
cursor_attributes = {}
loop do loop do
current_scope = scope.dup current_scope = scope.dup
relation = order.apply_cursor_conditions(current_scope, cursor_attributes, keyset_options) relation = order.apply_cursor_conditions(current_scope, cursor, keyset_options)
relation = relation.reorder(order) unless @in_operator_optimization_options relation = relation.reorder(order) unless @in_operator_optimization_options
relation = relation.limit(of) relation = relation.limit(of)
...@@ -30,14 +29,14 @@ module Gitlab ...@@ -30,14 +29,14 @@ module Gitlab
last_record = relation.last last_record = relation.last
break unless last_record break unless last_record
cursor_attributes = order.cursor_attributes_for_node(last_record) @cursor = order.cursor_attributes_for_node(last_record)
end end
end end
# rubocop: enable CodeReuse/ActiveRecord # rubocop: enable CodeReuse/ActiveRecord
private private
attr_reader :scope, :order attr_reader :scope, :cursor, :order
def keyset_options def keyset_options
{ {
......
...@@ -16,9 +16,6 @@ msgstr "" ...@@ -16,9 +16,6 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\n" "Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\n"
msgid " %{name}, confirm your email address now! "
msgstr ""
msgid " %{start} to %{end}" msgid " %{start} to %{end}"
msgstr "" msgstr ""
...@@ -1135,9 +1132,6 @@ msgstr "" ...@@ -1135,9 +1132,6 @@ msgstr ""
msgid "(we need your current password to confirm your changes)" msgid "(we need your current password to confirm your changes)"
msgstr "" msgstr ""
msgid "* * * * *"
msgstr ""
msgid "+ %{amount} more" msgid "+ %{amount} more"
msgstr "" msgstr ""
...@@ -3587,6 +3581,9 @@ msgstr "" ...@@ -3587,6 +3581,9 @@ msgstr ""
msgid "An error occurred while enabling Service Desk." msgid "An error occurred while enabling Service Desk."
msgstr "" msgstr ""
msgid "An error occurred while fetching Markdown preview"
msgstr ""
msgid "An error occurred while fetching ancestors" msgid "An error occurred while fetching ancestors"
msgstr "" msgstr ""
...@@ -3617,9 +3614,6 @@ msgstr "" ...@@ -3617,9 +3614,6 @@ msgstr ""
msgid "An error occurred while fetching label colors." msgid "An error occurred while fetching label colors."
msgstr "" msgstr ""
msgid "An error occurred while fetching markdown preview"
msgstr ""
msgid "An error occurred while fetching participants" msgid "An error occurred while fetching participants"
msgstr "" msgstr ""
...@@ -7345,9 +7339,6 @@ msgstr "" ...@@ -7345,9 +7339,6 @@ msgstr ""
msgid "ClusterAgents|You will need to create a token to connect to your agent" msgid "ClusterAgents|You will need to create a token to connect to your agent"
msgstr "" msgstr ""
msgid "ClusterAgent|This feature is only available for premium plans"
msgstr ""
msgid "ClusterAgent|User has insufficient permissions to create a token for this project" msgid "ClusterAgent|User has insufficient permissions to create a token for this project"
msgstr "" msgstr ""
...@@ -9999,7 +9990,7 @@ msgstr "" ...@@ -9999,7 +9990,7 @@ msgstr ""
msgid "Custom notification events" msgid "Custom notification events"
msgstr "" msgstr ""
msgid "Custom notification levels are the same as participating levels. With custom notification levels you will also receive notifications for select events. To find out more, check out %{notificationLinkStart} notification emails%{notificationLinkEnd}." msgid "Custom notification levels are the same as participating levels. With custom notification levels you will also receive notifications for select events. To find out more, check out %{notificationLinkStart}notification emails%{notificationLinkEnd}."
msgstr "" msgstr ""
msgid "Custom project templates" msgid "Custom project templates"
...@@ -13366,7 +13357,7 @@ msgstr "" ...@@ -13366,7 +13357,7 @@ msgstr ""
msgid "Error parsing CSV file. Please make sure it has" msgid "Error parsing CSV file. Please make sure it has"
msgstr "" msgstr ""
msgid "Error rendering markdown preview" msgid "Error rendering Markdown preview"
msgstr "" msgstr ""
msgid "Error saving label update." msgid "Error saving label update."
...@@ -17606,7 +17597,7 @@ msgstr "" ...@@ -17606,7 +17597,7 @@ msgstr ""
msgid "InProductMarketing|Start a GitLab Ultimate trial today in less than one minute, no credit card required." msgid "InProductMarketing|Start a GitLab Ultimate trial today in less than one minute, no credit card required."
msgstr "" msgstr ""
msgid "InProductMarketing|Start a free trial of GitLab Ultimate – no CC required" msgid "InProductMarketing|Start a free trial of GitLab Ultimate – no credit card required"
msgstr "" msgstr ""
msgid "InProductMarketing|Start a trial" msgid "InProductMarketing|Start a trial"
...@@ -24874,6 +24865,48 @@ msgstr "" ...@@ -24874,6 +24865,48 @@ msgstr ""
msgid "PipelineSchedules|Variables" msgid "PipelineSchedules|Variables"
msgstr "" msgstr ""
msgid "PipelineSource|API"
msgstr ""
msgid "PipelineSource|Chat"
msgstr ""
msgid "PipelineSource|External"
msgstr ""
msgid "PipelineSource|External Pull Request"
msgstr ""
msgid "PipelineSource|Merge Request"
msgstr ""
msgid "PipelineSource|On-Demand DAST Scan"
msgstr ""
msgid "PipelineSource|On-Demand DAST Validation"
msgstr ""
msgid "PipelineSource|Parent Pipeline"
msgstr ""
msgid "PipelineSource|Pipeline"
msgstr ""
msgid "PipelineSource|Push"
msgstr ""
msgid "PipelineSource|Schedule"
msgstr ""
msgid "PipelineSource|Trigger"
msgstr ""
msgid "PipelineSource|Web"
msgstr ""
msgid "PipelineSource|Web IDE"
msgstr ""
msgid "PipelineStatusTooltip|Pipeline: %{ciStatus}" msgid "PipelineStatusTooltip|Pipeline: %{ciStatus}"
msgstr "" msgstr ""
...@@ -25174,51 +25207,9 @@ msgstr "" ...@@ -25174,51 +25207,9 @@ msgstr ""
msgid "Pipeline|Source" msgid "Pipeline|Source"
msgstr "" msgstr ""
msgid "Pipeline|Source|API"
msgstr ""
msgid "Pipeline|Source|Chat"
msgstr ""
msgid "Pipeline|Source|External"
msgstr ""
msgid "Pipeline|Source|External Pull Request"
msgstr ""
msgid "Pipeline|Source|Merge Request"
msgstr ""
msgid "Pipeline|Source|On-Demand DAST Scan"
msgstr ""
msgid "Pipeline|Source|On-Demand DAST Validation"
msgstr ""
msgid "Pipeline|Source|Parent Pipeline"
msgstr ""
msgid "Pipeline|Source|Pipeline"
msgstr ""
msgid "Pipeline|Source|Push"
msgstr ""
msgid "Pipeline|Source|Schedule"
msgstr ""
msgid "Pipeline|Source|Security Policy" msgid "Pipeline|Source|Security Policy"
msgstr "" msgstr ""
msgid "Pipeline|Source|Trigger"
msgstr ""
msgid "Pipeline|Source|Web"
msgstr ""
msgid "Pipeline|Source|Web IDE"
msgstr ""
msgid "Pipeline|Specify variable values to be used in this run. The values specified in %{linkStart}CI/CD settings%{linkEnd} will be used by default." msgid "Pipeline|Specify variable values to be used in this run. The values specified in %{linkStart}CI/CD settings%{linkEnd} will be used by default."
msgstr "" msgstr ""
...@@ -29763,16 +29754,16 @@ msgstr "" ...@@ -29763,16 +29754,16 @@ msgstr ""
msgid "SearchCodeResults|of %{link_to_project}" msgid "SearchCodeResults|of %{link_to_project}"
msgstr "" msgstr ""
msgid "SearchResults|Showing %{count} %{scope} for%{term_element}" msgid "SearchResults|Showing %{count} %{scope} for %{term_element}"
msgstr "" msgstr ""
msgid "SearchResults|Showing %{count} %{scope} for%{term_element} in your personal and project snippets" msgid "SearchResults|Showing %{count} %{scope} for %{term_element} in your personal and project snippets"
msgstr "" msgstr ""
msgid "SearchResults|Showing %{from} - %{to} of %{count} %{scope} for%{term_element}" msgid "SearchResults|Showing %{from} - %{to} of %{count} %{scope} for %{term_element}"
msgstr "" msgstr ""
msgid "SearchResults|Showing %{from} - %{to} of %{count} %{scope} for%{term_element} in your personal and project snippets" msgid "SearchResults|Showing %{from} - %{to} of %{count} %{scope} for %{term_element} in your personal and project snippets"
msgstr "" msgstr ""
msgid "SearchResults|code result" msgid "SearchResults|code result"
......
import { registerExtension, extensions } from '~/vue_merge_request_widget/components/extensions'; import {
registerExtension,
registeredExtensions,
} from '~/vue_merge_request_widget/components/extensions';
import ExtensionBase from '~/vue_merge_request_widget/components/extensions/base.vue'; import ExtensionBase from '~/vue_merge_request_widget/components/extensions/base.vue';
describe('MR widget extension registering', () => { describe('MR widget extension registering', () => {
...@@ -14,7 +17,7 @@ describe('MR widget extension registering', () => { ...@@ -14,7 +17,7 @@ describe('MR widget extension registering', () => {
}, },
}); });
expect(extensions[0]).toEqual( expect(registeredExtensions.extensions[0]).toEqual(
expect.objectContaining({ expect.objectContaining({
extends: ExtensionBase, extends: ExtensionBase,
name: 'Test', name: 'Test',
......
import { GlIcon, GlLoadingIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import StatusIcon from '~/vue_merge_request_widget/components/extensions/status_icon.vue';
let wrapper;
function factory(propsData = {}) {
wrapper = shallowMount(StatusIcon, {
propsData,
});
}
describe('MR widget extensions status icon', () => {
afterEach(() => {
wrapper.destroy();
});
it('renders loading icon', () => {
factory({ name: 'test', isLoading: true, iconName: 'status-failed' });
expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true);
});
it('renders status icon', () => {
factory({ name: 'test', isLoading: false, iconName: 'status-failed' });
expect(wrapper.findComponent(GlIcon).exists()).toBe(true);
expect(wrapper.findComponent(GlIcon).props('name')).toBe('status-failed');
});
it('sets aria-label for status icon', () => {
factory({ name: 'test', isLoading: false, iconName: 'status-failed' });
expect(wrapper.findComponent(GlIcon).props('ariaLabel')).toBe('Failed test');
});
});
import { GlBadge, GlLink, GlIcon } from '@gitlab/ui';
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import Vue, { nextTick } from 'vue'; import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper'; import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { securityReportMergeRequestDownloadPathsQueryResponse } from 'jest/vue_shared/security_reports/mock_data'; import { securityReportMergeRequestDownloadPathsQueryResponse } from 'jest/vue_shared/security_reports/mock_data';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import { setFaviconOverlay } from '~/lib/utils/favicon'; import { setFaviconOverlay } from '~/lib/utils/favicon';
import notify from '~/lib/utils/notify'; import notify from '~/lib/utils/notify';
import SmartInterval from '~/smart_interval'; import SmartInterval from '~/smart_interval';
import { registerExtension } from '~/vue_merge_request_widget/components/extensions';
import { SUCCESS } from '~/vue_merge_request_widget/components/deployment/constants'; import { SUCCESS } from '~/vue_merge_request_widget/components/deployment/constants';
import eventHub from '~/vue_merge_request_widget/event_hub'; import eventHub from '~/vue_merge_request_widget/event_hub';
import MrWidgetOptions from '~/vue_merge_request_widget/mr_widget_options.vue'; import MrWidgetOptions from '~/vue_merge_request_widget/mr_widget_options.vue';
...@@ -15,6 +18,7 @@ import { stateKey } from '~/vue_merge_request_widget/stores/state_maps'; ...@@ -15,6 +18,7 @@ import { stateKey } from '~/vue_merge_request_widget/stores/state_maps';
import securityReportMergeRequestDownloadPathsQuery from '~/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql'; import securityReportMergeRequestDownloadPathsQuery from '~/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql';
import { faviconDataUrl, overlayDataUrl } from '../lib/utils/mock_data'; import { faviconDataUrl, overlayDataUrl } from '../lib/utils/mock_data';
import mockData from './mock_data'; import mockData from './mock_data';
import testExtension from './test_extension';
jest.mock('~/smart_interval'); jest.mock('~/smart_interval');
...@@ -879,4 +883,46 @@ describe('MrWidgetOptions', () => { ...@@ -879,4 +883,46 @@ describe('MrWidgetOptions', () => {
}); });
}); });
}); });
describe('mock extension', () => {
beforeEach(() => {
createComponent();
});
it('renders collapsed data', async () => {
registerExtension(testExtension);
await waitForPromises();
expect(wrapper.text()).toContain('Test extension summary count: 1');
});
it('renders full data', async () => {
registerExtension(testExtension);
await waitForPromises();
wrapper
.find('[data-testid="widget-extension"] [data-testid="toggle-button"]')
.trigger('click');
await Vue.nextTick();
const collapsedSection = wrapper.find('[data-testid="widget-extension-collapsed-section"]');
expect(collapsedSection.exists()).toBe(true);
expect(collapsedSection.text()).toContain('Hello world');
// Renders icon in the row
expect(collapsedSection.find(GlIcon).exists()).toBe(true);
expect(collapsedSection.find(GlIcon).props('name')).toBe('status_failed_borderless');
// Renders badge in the row
expect(collapsedSection.find(GlBadge).exists()).toBe(true);
expect(collapsedSection.find(GlBadge).text()).toBe('Closed');
// Renders a link in the row
expect(collapsedSection.find(GlLink).exists()).toBe(true);
expect(collapsedSection.find(GlLink).text()).toBe('GitLab.com');
});
});
}); });
export default {
name: 'WidgetTestExtension',
props: ['targetProjectFullPath'],
computed: {
summary({ count, targetProjectFullPath }) {
return `Test extension summary count: ${count} & ${targetProjectFullPath}`;
},
statusIcon({ count }) {
return count > 0 ? 'warning' : 'success';
},
},
methods: {
fetchCollapsedData({ targetProjectFullPath }) {
return Promise.resolve({ targetProjectFullPath, count: 1 });
},
fetchFullData() {
return Promise.resolve([
{
id: 1,
text: 'Hello world',
icon: {
name: 'status_failed_borderless',
class: 'text-danger',
},
badge: {
text: 'Closed',
},
link: {
href: 'https://gitlab.com',
text: 'GitLab.com',
},
},
]);
},
},
};
...@@ -30,19 +30,8 @@ RSpec.describe Mutations::Clusters::AgentTokens::Create do ...@@ -30,19 +30,8 @@ RSpec.describe Mutations::Clusters::AgentTokens::Create do
end end
end end
context 'without premium plan' do context 'with user permissions' do
before do before do
stub_licensed_features(cluster_agents: false)
cluster_agent.project.add_maintainer(user)
end
it { expect(subject[:secret]).to be_nil }
it { expect(subject[:errors]).to eq(['This feature is only available for premium plans']) }
end
context 'with premium plan and user permissions' do
before do
stub_licensed_features(cluster_agents: true)
cluster_agent.project.add_maintainer(user) cluster_agent.project.add_maintainer(user)
end end
......
...@@ -26,19 +26,8 @@ RSpec.describe Mutations::Clusters::Agents::Create do ...@@ -26,19 +26,8 @@ RSpec.describe Mutations::Clusters::Agents::Create do
end end
end end
context 'without premium plan' do context 'with user permissions' do
before do before do
allow(License).to receive(:current).and_return(create(:license, plan: ::License::STARTER_PLAN))
project.add_maintainer(user)
end
it { expect(subject[:clusters_agent]).to be_nil }
it { expect(subject[:errors]).to eq(['This feature is only available for premium plans']) }
end
context 'with premium plan and user permissions' do
before do
allow(License).to receive(:current).and_return(create(:license, plan: ::License::PREMIUM_PLAN))
project.add_maintainer(user) project.add_maintainer(user)
end end
......
...@@ -248,13 +248,13 @@ RSpec.describe SearchHelper do ...@@ -248,13 +248,13 @@ RSpec.describe SearchHelper do
it 'uses the correct singular label' do it 'uses the correct singular label' do
collection = Kaminari.paginate_array([:foo]).page(1).per(10) collection = Kaminari.paginate_array([:foo]).page(1).per(10)
expect(search_entries_info(collection, scope, 'foo')).to eq("Showing 1 #{label} for<span>&nbsp;<code>foo</code>&nbsp;</span>") expect(search_entries_info(collection, scope, 'foo')).to eq("Showing 1 #{label} for <span>&nbsp;<code>foo</code>&nbsp;</span>")
end end
it 'uses the correct plural label' do it 'uses the correct plural label' do
collection = Kaminari.paginate_array([:foo] * 23).page(1).per(10) collection = Kaminari.paginate_array([:foo] * 23).page(1).per(10)
expect(search_entries_info(collection, scope, 'foo')).to eq("Showing 1 - 10 of 23 #{label.pluralize} for<span>&nbsp;<code>foo</code>&nbsp;</span>") expect(search_entries_info(collection, scope, 'foo')).to eq("Showing 1 - 10 of 23 #{label.pluralize} for <span>&nbsp;<code>foo</code>&nbsp;</span>")
end end
end end
......
...@@ -32,8 +32,11 @@ RSpec.describe Gitlab::Pagination::Keyset::Iterator do ...@@ -32,8 +32,11 @@ RSpec.describe Gitlab::Pagination::Keyset::Iterator do
]) ])
end end
let(:iterator_params) { nil }
let(:scope) { project.issues.reorder(custom_reorder) } let(:scope) { project.issues.reorder(custom_reorder) }
subject(:iterator) { described_class.new(**iterator_params) }
shared_examples 'iterator examples' do shared_examples 'iterator examples' do
describe '.each_batch' do describe '.each_batch' do
it 'yields an ActiveRecord::Relation when a block is given' do it 'yields an ActiveRecord::Relation when a block is given' do
...@@ -56,6 +59,30 @@ RSpec.describe Gitlab::Pagination::Keyset::Iterator do ...@@ -56,6 +59,30 @@ RSpec.describe Gitlab::Pagination::Keyset::Iterator do
expect(count).to eq(9) expect(count).to eq(9)
end end
it 'continues after the cursor' do
loaded_records = []
cursor = nil
# stopping the iterator after the first batch and storing the cursor
iterator.each_batch(of: 2) do |relation| # rubocop: disable Lint/UnreachableLoop
loaded_records.concat(relation.to_a)
record = loaded_records.last
cursor = custom_reorder.cursor_attributes_for_node(record)
break
end
expect(loaded_records).to eq(project.issues.order(custom_reorder).take(2))
# continuing the iteration
new_iterator = described_class.new(**iterator_params.merge(cursor: cursor))
new_iterator.each_batch(of: 2) do |relation|
loaded_records.concat(relation.to_a)
end
expect(loaded_records).to eq(project.issues.order(custom_reorder))
end
it 'allows updating of the yielded relations' do it 'allows updating of the yielded relations' do
time = Time.current time = Time.current
...@@ -131,13 +158,13 @@ RSpec.describe Gitlab::Pagination::Keyset::Iterator do ...@@ -131,13 +158,13 @@ RSpec.describe Gitlab::Pagination::Keyset::Iterator do
end end
context 'when use_union_optimization is used' do context 'when use_union_optimization is used' do
subject(:iterator) { described_class.new(scope: scope, use_union_optimization: true) } let(:iterator_params) { { scope: scope, use_union_optimization: true } }
include_examples 'iterator examples' include_examples 'iterator examples'
end end
context 'when use_union_optimization is not used' do context 'when use_union_optimization is not used' do
subject(:iterator) { described_class.new(scope: scope, use_union_optimization: false) } let(:iterator_params) { { scope: scope, use_union_optimization: false } }
include_examples 'iterator examples' include_examples 'iterator examples'
end end
......
...@@ -432,9 +432,9 @@ RSpec.describe Namespace do ...@@ -432,9 +432,9 @@ RSpec.describe Namespace do
end end
describe '.search' do describe '.search' do
let_it_be(:first_group) { build(:group, name: 'my first namespace', path: 'old-path').tap(&:save!) } let_it_be(:first_group) { create(:group, name: 'my first namespace', path: 'old-path') }
let_it_be(:parent_group) { build(:group, name: 'my parent namespace', path: 'parent-path').tap(&:save!) } let_it_be(:parent_group) { create(:group, name: 'my parent namespace', path: 'parent-path') }
let_it_be(:second_group) { build(:group, name: 'my second namespace', path: 'new-path', parent: parent_group).tap(&:save!) } let_it_be(:second_group) { create(:group, name: 'my second namespace', path: 'new-path', parent: parent_group) }
let_it_be(:project_with_same_path) { create(:project, id: second_group.id, path: first_group.path) } let_it_be(:project_with_same_path) { create(:project, id: second_group.id, path: first_group.path) }
it 'returns namespaces with a matching name' do it 'returns namespaces with a matching name' do
......
...@@ -31,21 +31,8 @@ RSpec.describe 'Create a new cluster agent token' do ...@@ -31,21 +31,8 @@ RSpec.describe 'Create a new cluster agent token' do
end end
end end
context 'without premium plan' do
before do
stub_licensed_features(cluster_agents: false)
cluster_agent.project.add_maintainer(current_user)
end
it 'does not create a token and returns error message', :aggregate_failures do
expect { post_graphql_mutation(mutation, current_user: current_user) }.not_to change(Clusters::AgentToken, :count)
expect(mutation_response['errors']).to eq(['This feature is only available for premium plans'])
end
end
context 'with project permissions' do context 'with project permissions' do
before do before do
stub_licensed_features(cluster_agents: true)
cluster_agent.project.add_maintainer(current_user) cluster_agent.project.add_maintainer(current_user)
end end
......
...@@ -28,21 +28,8 @@ RSpec.describe 'Create a new cluster agent' do ...@@ -28,21 +28,8 @@ RSpec.describe 'Create a new cluster agent' do
end end
end end
context 'without premium plan' do context 'with user permissions' do
before do before do
allow(License).to receive(:current).and_return(create(:license, plan: ::License::STARTER_PLAN))
project.add_maintainer(current_user)
end
it 'does not create cluster agent and returns error message', :aggregate_failures do
expect { post_graphql_mutation(mutation, current_user: current_user) }.not_to change(Clusters::Agent, :count)
expect(mutation_response['errors']).to eq(['This feature is only available for premium plans'])
end
end
context 'with premium plan and user permissions' do
before do
allow(License).to receive(:current).and_return(create(:license, plan: ::License::PREMIUM_PLAN))
project.add_maintainer(current_user) project.add_maintainer(current_user)
end end
......
...@@ -30,9 +30,8 @@ RSpec.describe 'Delete a cluster agent' do ...@@ -30,9 +30,8 @@ RSpec.describe 'Delete a cluster agent' do
end end
end end
context 'with premium plan and project permissions' do context 'with project permissions' do
before do before do
allow(License).to receive(:current).and_return(create(:license, plan: ::License::PREMIUM_PLAN))
project.add_maintainer(current_user) project.add_maintainer(current_user)
end end
......
...@@ -23,7 +23,6 @@ RSpec.describe 'Project.cluster_agents' do ...@@ -23,7 +23,6 @@ RSpec.describe 'Project.cluster_agents' do
before do before do
allow(Gitlab::Kas::Client).to receive(:new).and_return(double(get_connected_agents: [])) allow(Gitlab::Kas::Client).to receive(:new).and_return(double(get_connected_agents: []))
stub_licensed_features(cluster_agents: true)
end end
it 'can retrieve cluster agents' do it 'can retrieve cluster agents' do
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Clusters::AgentTokens::CreateService do
subject(:service) { described_class.new(container: project, current_user: user, params: params) }
let_it_be(:user) { create(:user) }
let(:cluster_agent) { create(:cluster_agent) }
let(:project) { cluster_agent.project }
let(:params) { { agent_id: cluster_agent.id, description: 'token description', name: 'token name' } }
describe '#execute' do
subject { service.execute }
it 'does not create a new token due to user permissions' do
expect { subject }.not_to change(::Clusters::AgentToken, :count)
end
it 'returns permission errors', :aggregate_failures do
expect(subject.status).to eq(:error)
expect(subject.message).to eq('User has insufficient permissions to create a token for this project')
end
context 'with user permissions' do
before do
project.add_maintainer(user)
end
it 'creates a new token' do
expect { subject }.to change { ::Clusters::AgentToken.count }.by(1)
end
it 'returns success status', :aggregate_failures do
expect(subject.status).to eq(:success)
expect(subject.message).to be_nil
end
it 'returns token information', :aggregate_failures do
token = subject.payload[:token]
expect(subject.payload[:secret]).not_to be_nil
expect(token.created_by_user).to eq(user)
expect(token.description).to eq(params[:description])
expect(token.name).to eq(params[:name])
end
context 'when params are invalid' do
let(:params) { { agent_id: 'bad_id' } }
it 'does not create a new token' do
expect { subject }.not_to change(::Clusters::AgentToken, :count)
end
it 'returns validation errors', :aggregate_failures do
expect(subject.status).to eq(:error)
expect(subject.message).to eq(["Agent must exist", "Name can't be blank"])
end
end
end
end
end
...@@ -7,27 +7,9 @@ RSpec.describe Clusters::Agents::CreateService do ...@@ -7,27 +7,9 @@ RSpec.describe Clusters::Agents::CreateService do
let(:project) { create(:project, :public, :repository) } let(:project) { create(:project, :public, :repository) }
let(:user) { create(:user) } let(:user) { create(:user) }
let(:license) { create(:license, plan: ::License::PREMIUM_PLAN) }
describe '#execute' do describe '#execute' do
context 'without premium plan' do
before do
allow(License).to receive(:current).and_return(create(:license, plan: ::License::STARTER_PLAN))
end
it 'returns missing plan error' do
expect(service.execute(name: 'without-license')).to eq({
status: :error,
message: 'This feature is only available for premium plans'
})
end
end
context 'without user permissions' do context 'without user permissions' do
before do
allow(License).to receive(:current).and_return(license)
end
it 'returns errors when user does not have permissions' do it 'returns errors when user does not have permissions' do
expect(service.execute(name: 'missing-permissions')).to eq({ expect(service.execute(name: 'missing-permissions')).to eq({
status: :error, status: :error,
...@@ -36,14 +18,13 @@ RSpec.describe Clusters::Agents::CreateService do ...@@ -36,14 +18,13 @@ RSpec.describe Clusters::Agents::CreateService do
end end
end end
context 'with premium plan and user permissions' do context 'with user permissions' do
before do before do
allow(License).to receive(:current).and_return(license)
project.add_maintainer(user) project.add_maintainer(user)
end end
it 'creates a new clusters_agent' do it 'creates a new clusters_agent' do
expect { service.execute(name: 'with-license-and-user') }.to change { ::Clusters::Agent.count }.by(1) expect { service.execute(name: 'with-user') }.to change { ::Clusters::Agent.count }.by(1)
end end
it 'returns success status', :aggregate_failures do it 'returns success status', :aggregate_failures do
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment