Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2021-09-29 12:11:22 +00:00
parent c724e639a9
commit 5f8d4d631d
113 changed files with 2070 additions and 324 deletions

View file

@ -653,7 +653,6 @@
.rails:rules:decomposed-databases:
rules:
- <<: *if-merge-request-run-decomposed
allow_failure: true
.rails:rules:ee-and-foss-migration:
rules:

View file

@ -81,7 +81,7 @@ MarkdownPreview.prototype.fetchMarkdownPreview = function (text, url, success) {
})
.catch(() =>
createFlash({
message: __('An error occurred while fetching markdown preview'),
message: __('An error occurred while fetching Markdown preview'),
}),
);
};

View file

@ -45,6 +45,7 @@ export default () => {
new Vue({
el,
name: 'CycleAnalytics',
apolloProvider: {},
store,
render: (createElement) =>
createElement(CycleAnalytics, {

View file

@ -25,7 +25,7 @@ export default {
lazy: true,
},
translations: {
cronPlaceholder: __('* * * * *'),
cronPlaceholder: '* * * * *',
cronSyntaxInstructions: __(
'Define a custom deploy freeze pattern with %{cronSyntaxStart}cron syntax%{cronSyntaxEnd}',
),

View file

@ -31,7 +31,7 @@ export const i18n = {
title: __('Custom notification events'),
bodyTitle: __('Notification events'),
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: {

View file

@ -73,6 +73,7 @@ export const OLDER_THAN_OPTIONS = [
{ key: 'SEVEN_DAYS', variable: 7, default: false },
{ key: 'FOURTEEN_DAYS', variable: 14, default: false },
{ key: 'THIRTY_DAYS', variable: 30, default: false },
{ key: 'SIXTY_DAYS', variable: 60, default: false },
{ key: 'NINETY_DAYS', variable: 90, default: true },
];

View file

@ -2,6 +2,7 @@ import $ from 'jquery';
import '~/profile/gl_crop';
import Profile from '~/profile/profile';
import initSearchSettings from '~/search_settings';
import initPasswordPrompt from './password_prompt';
// eslint-disable-next-line func-names
$(document).on('input.ssh_key', '#key_key', function () {
@ -19,3 +20,4 @@ $(document).on('input.ssh_key', '#key_key', function () {
new Profile(); // eslint-disable-line no-new
initSearchSettings();
initPasswordPrompt();

View file

@ -0,0 +1,9 @@
import { __, s__ } from '~/locale';
export const I18N_PASSWORD_PROMPT_TITLE = s__('PasswordPrompt|Confirm password to continue');
export const I18N_PASSWORD_PROMPT_FORM_LABEL = s__(
'PasswordPrompt|Please enter your password to confirm',
);
export const I18N_PASSWORD_PROMPT_ERROR_MESSAGE = s__('PasswordPrompt|Password is required');
export const I18N_PASSWORD_PROMPT_CONFIRM_BUTTON = s__('PasswordPrompt|Confirm password');
export const I18N_PASSWORD_PROMPT_CANCEL_BUTTON = __('Cancel');

View file

@ -0,0 +1,58 @@
import Vue from 'vue';
import Translate from '~/vue_shared/translate';
import PasswordPromptModal from './password_prompt_modal.vue';
Vue.use(Translate);
const emailFieldSelector = '#user_email';
const editFormSelector = '.js-password-prompt-form';
const passwordPromptFieldSelector = '.js-password-prompt-field';
const passwordPromptBtnSelector = '.js-password-prompt-btn';
const passwordPromptModalId = 'password-prompt-modal';
const getEmailValue = () => document.querySelector(emailFieldSelector).value.trim();
const passwordPromptButton = document.querySelector(passwordPromptBtnSelector);
const field = document.querySelector(passwordPromptFieldSelector);
const form = document.querySelector(editFormSelector);
const handleConfirmPassword = (pw) => {
// update the validation_password field
field.value = pw;
// submit the form
form.submit();
};
export default () => {
const passwordPromptModalEl = document.getElementById(passwordPromptModalId);
if (passwordPromptModalEl && field) {
return new Vue({
el: passwordPromptModalEl,
data() {
return {
initialEmail: '',
};
},
mounted() {
this.initialEmail = getEmailValue();
passwordPromptButton.addEventListener('click', this.handleSettingsUpdate);
},
methods: {
handleSettingsUpdate(ev) {
const email = getEmailValue();
if (email !== this.initialEmail) {
ev.preventDefault();
this.$root.$emit('bv::show::modal', passwordPromptModalId, passwordPromptBtnSelector);
}
},
},
render(createElement) {
return createElement(PasswordPromptModal, {
props: { handleConfirmPassword },
});
},
});
}
return null;
};

View file

@ -0,0 +1,82 @@
<script>
import { GlModal, GlForm, GlFormGroup, GlFormInput } from '@gitlab/ui';
import {
I18N_PASSWORD_PROMPT_TITLE,
I18N_PASSWORD_PROMPT_FORM_LABEL,
I18N_PASSWORD_PROMPT_ERROR_MESSAGE,
I18N_PASSWORD_PROMPT_CANCEL_BUTTON,
I18N_PASSWORD_PROMPT_CONFIRM_BUTTON,
} from './constants';
export default {
components: {
GlModal,
GlForm,
GlFormGroup,
GlFormInput,
},
props: {
handleConfirmPassword: {
type: Function,
required: true,
},
},
data() {
return {
passwordCheck: '',
};
},
computed: {
isValid() {
return Boolean(this.passwordCheck.length);
},
primaryProps() {
return {
text: I18N_PASSWORD_PROMPT_CONFIRM_BUTTON,
attributes: [{ variant: 'danger' }, { category: 'primary' }, { disabled: !this.isValid }],
};
},
},
methods: {
onConfirmPassword() {
this.handleConfirmPassword(this.passwordCheck);
},
},
cancelProps: {
text: I18N_PASSWORD_PROMPT_CANCEL_BUTTON,
},
i18n: {
title: I18N_PASSWORD_PROMPT_TITLE,
formLabel: I18N_PASSWORD_PROMPT_FORM_LABEL,
errorMessage: I18N_PASSWORD_PROMPT_ERROR_MESSAGE,
},
};
</script>
<template>
<gl-modal
data-testid="password-prompt-modal"
modal-id="password-prompt-modal"
:title="$options.i18n.title"
:action-primary="primaryProps"
:action-cancel="$options.cancelProps"
@primary="onConfirmPassword"
>
<gl-form @submit.prevent="onConfirmPassword">
<gl-form-group
:label="$options.i18n.formLabel"
label-for="password-prompt-confirmation"
:invalid-feedback="$options.i18n.errorMessage"
:state="isValid"
>
<gl-form-input
id="password-prompt-confirmation"
v-model="passwordCheck"
name="password-confirmation"
type="password"
data-testid="password-prompt-field"
/>
</gl-form-group>
</gl-form>
</gl-modal>
</template>

View file

@ -2,51 +2,51 @@ import { s__ } from '~/locale';
export const PIPELINE_SOURCES = [
{
text: s__('Pipeline|Source|Push'),
text: s__('PipelineSource|Push'),
value: 'push',
},
{
text: s__('Pipeline|Source|Web'),
text: s__('PipelineSource|Web'),
value: 'web',
},
{
text: s__('Pipeline|Source|Trigger'),
text: s__('PipelineSource|Trigger'),
value: 'trigger',
},
{
text: s__('Pipeline|Source|Schedule'),
text: s__('PipelineSource|Schedule'),
value: 'schedule',
},
{
text: s__('Pipeline|Source|API'),
text: s__('PipelineSource|API'),
value: 'api',
},
{
text: s__('Pipeline|Source|External'),
text: s__('PipelineSource|External'),
value: 'external',
},
{
text: s__('Pipeline|Source|Pipeline'),
text: s__('PipelineSource|Pipeline'),
value: 'pipeline',
},
{
text: s__('Pipeline|Source|Chat'),
text: s__('PipelineSource|Chat'),
value: 'chat',
},
{
text: s__('Pipeline|Source|Web IDE'),
text: s__('PipelineSource|Web IDE'),
value: 'webide',
},
{
text: s__('Pipeline|Source|Merge Request'),
text: s__('PipelineSource|Merge Request'),
value: 'merge_request_event',
},
{
text: s__('Pipeline|Source|External Pull Request'),
text: s__('PipelineSource|External Pull Request'),
value: 'external_pull_request_event',
},
{
text: s__('Pipeline|Source|Parent Pipeline'),
text: s__('PipelineSource|Parent Pipeline'),
value: 'parent_pipeline',
},
];

View file

@ -123,7 +123,7 @@ export default {
</script>
<template>
<div id="related-issues" class="related-issues-block">
<div id="related-issues" class="related-issues-block gl-mt-5">
<div class="card card-slim gl-overflow-hidden">
<div
:class="{ 'panel-empty-heading border-bottom-0': !hasBody }"

View file

@ -97,11 +97,7 @@ export default {
class="related-issues-token-body bordered-box bg-white"
:class="{ 'sortable-container': canReorder }"
>
<div
v-if="isFetching"
class="related-issues-loading-icon"
data-qa-selector="related_issues_loading_placeholder"
>
<div v-if="isFetching" class="gl-mb-2" data-qa-selector="related_issues_loading_placeholder">
<gl-loading-icon
ref="loadingIcon"
size="sm"

View file

@ -1,30 +1,31 @@
<script>
import { sprintf, s__ } from '~/locale';
import { GlSprintf } from '@gitlab/ui';
import { s__ } from '~/locale';
const timeSpent = s__('TimeTracking|%{spentStart}Spent: %{spentEnd}');
export default {
name: 'TimeTrackingSpentOnlyPane',
timeSpent,
components: {
GlSprintf,
},
props: {
timeSpentHumanReadable: {
type: String,
required: true,
},
},
computed: {
timeSpent() {
return sprintf(
s__('TimeTracking|%{startTag}Spent: %{endTag}%{timeSpentHumanReadable}'),
{
startTag: '<span class="gl-font-weight-bold">',
endTag: '</span>',
timeSpentHumanReadable: this.timeSpentHumanReadable,
},
false,
);
},
},
};
</script>
<template>
<div data-testid="spentOnlyPane" v-html="timeSpent /* eslint-disable-line vue/no-v-html */"></div>
<div data-testid="spentOnlyPane">
<gl-sprintf :message="$options.timeSpent">
<template #spent="{ content }">
<span class="gl-font-weight-bold">{{ content }}</span
>{{ timeSpentHumanReadable }}
</template>
</gl-sprintf>
</div>
</template>

View file

@ -1,7 +1,8 @@
<script>
import { GlButton, GlLoadingIcon, GlIcon, GlLink, GlBadge, GlSafeHtmlDirective } from '@gitlab/ui';
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 = {
collapsedLoading: 'collapsedLoading',
@ -45,14 +46,6 @@ export default {
return true;
},
statusIconName() {
if (this.isLoadingSummary) {
return 'loading';
}
if (this.loadingState === LOADING_STATES.collapsedError) {
return 'warning';
}
return this.statusIcon(this.collapsedData);
},
},
@ -96,13 +89,18 @@ export default {
});
},
},
EXTENSION_ICON_CLASS,
};
</script>
<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">
<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="code-text">
<template v-if="isLoadingSummary">
@ -114,13 +112,18 @@ export default {
v-if="isCollapsible"
size="small"
class="float-right align-self-center"
data-testid="toggle-button"
@click="toggleCollapsed"
>
{{ isCollapsed ? __('Expand') : __('Collapse') }}
</gl-button>
</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">
<gl-loading-icon size="sm" inline /> {{ __('Loading...') }}
</div>

View file

@ -1,4 +1,4 @@
import { extensions } from './index';
import { registeredExtensions } from './index';
export default {
props: {
@ -8,6 +8,8 @@ export default {
},
},
render(h) {
const { extensions } = registeredExtensions;
if (extensions.length === 0) return null;
return h('div', {}, [

View file

@ -1,12 +1,13 @@
import Vue from 'vue';
import ExtensionBase from './base.vue';
// Holds all the currently registered extensions
export const extensions = [];
export const registeredExtensions = Vue.observable({ extensions: [] });
export const registerExtension = (extension) => {
// Pushes into the extenions array a dynamically created Vue component
// that gets exteneded from `base.vue`
extensions.push({
registeredExtensions.extensions.push({
extends: ExtensionBase,
name: extension.name,
props: extension.props,

View file

@ -0,0 +1,52 @@
<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>

View file

@ -91,4 +91,19 @@ export const stateToTransitionMap = {
export const stateToComponentMap = {
[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 };

View file

@ -1,11 +1,12 @@
/* eslint-disable */
import { EXTENSION_ICONS } from '../constants';
import issuesCollapsedQuery from './issues_collapsed.query.graphql';
import issuesQuery from './issues.query.graphql';
export default {
// Give the extension a name
// Make it easier to track in Vue dev tools
name: 'WidgetIssues',
name: 'Issues',
// Add an array of props
// These then get mapped to values stored in the MR Widget store
props: ['targetProjectFullPath'],
@ -14,12 +15,12 @@ export default {
// Small summary text to be displayed in the collapsed state
// Receives the collapsed data as an argument
summary(count) {
return `<strong>${count}</strong> open issue`;
return 'Summary text';
},
// Status icon to be used next to the summary text
// Receives the collapsed data as an argument
statusIcon(count) {
return count > 0 ? 'warning' : 'success';
return EXTENSION_ICONS.warning;
},
},
methods: {

View file

@ -97,7 +97,7 @@ export default {
});
})
.catch(() => {
this.previewContent = __('An error occurred while fetching markdown preview');
this.previewContent = __('An error occurred while fetching Markdown preview');
this.isLoading = false;
});
}

View file

@ -254,7 +254,7 @@ export default {
.then(() => $(this.$refs['markdown-preview']).renderGFM())
.catch(() =>
createFlash({
message: __('Error rendering markdown preview'),
message: __('Error rendering Markdown preview'),
}),
);
},

View file

@ -1040,3 +1040,17 @@ $tabs-holder-z-index: 250;
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;
}

View file

@ -18,7 +18,7 @@ class ProfilesController < Profiles::ApplicationController
def update
respond_to do |format|
result = Users::UpdateService.new(current_user, user_params.merge(user: @user)).execute
result = Users::UpdateService.new(current_user, user_params.merge(user: @user)).execute(check_password: true)
if result[:status] == :success
message = s_("Profiles|Profile was successfully updated")
@ -129,6 +129,7 @@ class ProfilesController < Profiles::ApplicationController
:job_title,
:pronouns,
:pronunciation,
:validation_password,
status: [:emoji, :message, :availability]
]
end

View file

@ -70,11 +70,16 @@ class MembersFinder
end
def project_invited_groups
invited_groups_ids_including_ancestors = Gitlab::ObjectHierarchy
.new(project.invited_groups)
.base_and_ancestors
.public_or_visible_to_user(current_user)
.select(:id)
invited_groups_and_ancestors = if ::Feature.enabled?(:linear_members_finder_ancestor_scopes, current_user, default_enabled: :yaml)
project.invited_groups
.self_and_ancestors
else
Gitlab::ObjectHierarchy
.new(project.invited_groups)
.base_and_ancestors
end
invited_groups_ids_including_ancestors = invited_groups_and_ancestors.public_or_visible_to_user(current_user).select(:id)
GroupMember.with_source_id(invited_groups_ids_including_ancestors).non_minimal_access
end

View file

@ -0,0 +1,69 @@
# frozen_string_literal: true
module Mutations
module Clusters
module AgentTokens
class Create < BaseMutation
graphql_name 'ClusterAgentTokenCreate'
authorize :create_cluster
ClusterAgentID = ::Types::GlobalIDType[::Clusters::Agent]
argument :cluster_agent_id,
ClusterAgentID,
required: true,
description: 'Global ID of the cluster agent that will be associated with the new token.'
argument :description,
GraphQL::Types::String,
required: false,
description: 'Description of the token.'
argument :name,
GraphQL::Types::String,
required: true,
description: 'Name of the token.'
field :secret,
GraphQL::Types::String,
null: true,
description: "Token secret value. Make sure you save it - you won't be able to access it again."
field :token,
Types::Clusters::AgentTokenType,
null: true,
description: 'Token created after mutation.'
def resolve(args)
cluster_agent = authorized_find!(id: args[:cluster_agent_id])
result = ::Clusters::AgentTokens::CreateService
.new(
container: cluster_agent.project,
current_user: current_user,
params: args.merge(agent_id: cluster_agent.id)
)
.execute
payload = result.payload
{
secret: payload[:secret],
token: payload[:token],
errors: Array.wrap(result.message)
}
end
private
def find_object(id:)
# TODO: remove this line when the compatibility layer is removed
# See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
id = ClusterAgentID.coerce_isolated_input(id)
GitlabSchema.find_by_gid(id)
end
end
end
end
end

View file

@ -0,0 +1,35 @@
# frozen_string_literal: true
module Mutations
module Clusters
module AgentTokens
class Delete < BaseMutation
graphql_name 'ClusterAgentTokenDelete'
authorize :admin_cluster
TokenID = ::Types::GlobalIDType[::Clusters::AgentToken]
argument :id, TokenID,
required: true,
description: 'Global ID of the cluster agent token that will be deleted.'
def resolve(id:)
token = authorized_find!(id: id)
token.destroy
{ errors: errors_on_object(token) }
end
private
def find_object(id:)
# TODO: remove this line when the compatibility layer is removed
# See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
id = TokenID.coerce_isolated_input(id)
GitlabSchema.find_by_gid(id)
end
end
end
end
end

View file

@ -0,0 +1,38 @@
# frozen_string_literal: true
module Mutations
module Clusters
module Agents
class Create < BaseMutation
include FindsProject
authorize :create_cluster
graphql_name 'CreateClusterAgent'
argument :project_path, GraphQL::Types::ID,
required: true,
description: 'Full path of the associated project for this cluster agent.'
argument :name, GraphQL::Types::String,
required: true,
description: 'Name of the cluster agent.'
field :cluster_agent,
Types::Clusters::AgentType,
null: true,
description: 'Cluster agent created after mutation.'
def resolve(project_path:, name:)
project = authorized_find!(project_path)
result = ::Clusters::Agents::CreateService.new(project, current_user).execute(name: name)
{
cluster_agent: result[:cluster_agent],
errors: Array.wrap(result[:message])
}
end
end
end
end
end

View file

@ -0,0 +1,39 @@
# frozen_string_literal: true
module Mutations
module Clusters
module Agents
class Delete < BaseMutation
graphql_name 'ClusterAgentDelete'
authorize :admin_cluster
AgentID = ::Types::GlobalIDType[::Clusters::Agent]
argument :id, AgentID,
required: true,
description: 'Global ID of the cluster agent that will be deleted.'
def resolve(id:)
cluster_agent = authorized_find!(id: id)
result = ::Clusters::Agents::DeleteService
.new(container: cluster_agent.project, current_user: current_user)
.execute(cluster_agent)
{
errors: Array.wrap(result.message)
}
end
private
def find_object(id:)
# TODO: remove this line when the compatibility layer is removed
# See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
id = AgentID.coerce_isolated_input(id)
GitlabSchema.find_by_gid(id)
end
end
end
end
end

View file

@ -6,6 +6,7 @@ module Types
'7d': 'SEVEN_DAYS',
'14d': 'FOURTEEN_DAYS',
'30d': 'THIRTY_DAYS',
'60d': 'SIXTY_DAYS',
'90d': 'NINETY_DAYS'
}.freeze

View file

@ -31,6 +31,10 @@ module Types
mount_mutation Mutations::Boards::Lists::Update
mount_mutation Mutations::Boards::Lists::Destroy
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::CustomEmoji::Create, feature_flag: :custom_emoji
mount_mutation Mutations::CustomEmoji::Destroy, feature_flag: :custom_emoji

View file

@ -87,9 +87,9 @@ module SearchHelper
def search_entries_info_template(collection)
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
s_("SearchResults|Showing %{count} %{scope} for%{term_element}").html_safe
s_("SearchResults|Showing %{count} %{scope} for %{term_element}").html_safe
end
end

View file

@ -71,7 +71,7 @@ class AuditEvent < ApplicationRecord
end
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|
loader.call(user.id, user)
end

View file

@ -133,7 +133,7 @@ class Commit
end
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.each do |container, commit_ids|

View file

@ -110,7 +110,7 @@ module Avatarable
def retrieve_upload_from_batch(identifier)
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]
paths = upload_params.flat_map do |params|
params[:model].upload_paths(params[:identifier])

View file

@ -74,6 +74,7 @@ class ContainerExpirationPolicy < ApplicationRecord
'7d': _('%{days} days until tags are automatically removed') % { days: 7 },
'14d': _('%{days} days until tags are automatically removed') % { days: 14 },
'30d': _('%{days} days until tags are automatically removed') % { days: 30 },
'60d': _('%{days} days until tags are automatically removed') % { days: 60 },
'90d': _('%{days} days until tags are automatically removed') % { days: 90 }
}
end

View file

@ -1791,7 +1791,7 @@ class Project < ApplicationRecord
def open_issues_count(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.each do |project, count|
@ -2256,7 +2256,7 @@ class Project < ApplicationRecord
# rubocop: disable CodeReuse/ServiceClass
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.each do |project, count|

View file

@ -0,0 +1,31 @@
# frozen_string_literal: true
module Clusters
module AgentTokens
class CreateService < ::BaseContainerService
ALLOWED_PARAMS = %i[agent_id description name].freeze
def execute
return error_no_permissions unless current_user.can?(:create_cluster, container)
token = ::Clusters::AgentToken.new(filtered_params.merge(created_by_user: current_user))
if token.save
ServiceResponse.success(payload: { secret: token.token, token: token })
else
ServiceResponse.error(message: token.errors.full_messages)
end
end
private
def error_no_permissions
ServiceResponse.error(message: s_('ClusterAgent|User has insufficient permissions to create a token for this project'))
end
def filtered_params
params.slice(*ALLOWED_PARAMS)
end
end
end
end

View file

@ -0,0 +1,29 @@
# frozen_string_literal: true
module Clusters
module Agents
class CreateService < BaseService
def execute(name:)
return error_no_permissions unless cluster_agent_permissions?
agent = ::Clusters::Agent.new(name: name, project: project, created_by_user: current_user)
if agent.save
success.merge(cluster_agent: agent)
else
error(agent.errors.full_messages)
end
end
private
def cluster_agent_permissions?
current_user.can?(:admin_pipeline, project) && current_user.can?(:create_cluster, project)
end
def error_no_permissions
error(s_('ClusterAgent|You have insufficient permissions to create a cluster agent for this project'))
end
end
end
end

View file

@ -0,0 +1,23 @@
# frozen_string_literal: true
module Clusters
module Agents
class DeleteService < ::BaseContainerService
def execute(cluster_agent)
return error_no_permissions unless current_user.can?(:admin_cluster, cluster_agent)
if cluster_agent.destroy
ServiceResponse.success
else
ServiceResponse.error(message: cluster_agent.errors.full_messages)
end
end
private
def error_no_permissions
ServiceResponse.error(message: s_('ClusterAgent|You have insufficient permissions to delete this cluster agent'))
end
end
end
end

View file

@ -5,15 +5,18 @@ module Users
include NewUserNotifier
attr_reader :user, :identity_params
ATTRS_REQUIRING_PASSWORD_CHECK = %w[email].freeze
def initialize(current_user, params = {})
@current_user = current_user
@validation_password = params.delete(:validation_password)
@user = params.delete(:user)
@status_params = params.delete(:status)
@identity_params = params.slice(*identity_attributes)
@params = params.dup
end
def execute(validate: true, &block)
def execute(validate: true, check_password: false, &block)
yield(@user) if block_given?
user_exists = @user.persisted?
@ -21,6 +24,11 @@ module Users
discard_read_only_attributes
assign_attributes
if check_password && require_password_check? && !@user.valid_password?(@validation_password)
return error(s_("Profiles|Invalid password"))
end
assign_identity
build_canonical_email
@ -32,8 +40,8 @@ module Users
end
end
def execute!(*args, &block)
result = execute(*args, &block)
def execute!(*args, **kargs, &block)
result = execute(*args, **kargs, &block)
raise ActiveRecord::RecordInvalid, @user unless result[:status] == :success
@ -42,6 +50,14 @@ module Users
private
def require_password_check?
return false unless @user.persisted?
return false if @user.password_automatically_set?
changes = @user.changed
ATTRS_REQUIRING_PASSWORD_CHECK.any? { |param| changes.include?(param) }
end
def build_canonical_email
return unless @user.email_changed?

View file

@ -1,4 +1,4 @@
<%= _(" %{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 } %>

View file

@ -3,8 +3,11 @@
- email_change_disabled = local_assigns.fetch(:email_change_disabled, nil)
- read_only_help_text = readonly ? s_("Profiles|Your email address was automatically set based on your %{provider_label} account") % { provider_label: attribute_provider_label(:email) } : user_email_help_text(@user)
- help_text = email_change_disabled ? s_("Your account uses dedicated credentials for the \"%{group_name}\" group and can only be updated through SSO.") % { group_name: @user.managing_group.name } : read_only_help_text
- password_automatically_set = @user.password_automatically_set?
= form.text_field :email, required: true, class: 'input-lg gl-form-input', value: (@user.email unless @user.temp_oauth_email?), help: help_text.html_safe, readonly: readonly || email_change_disabled
- unless password_automatically_set
= hidden_field_tag 'user[validation_password]', :validation_password, class: 'js-password-prompt-field', help: s_("Profiles|Enter your password to confirm the email change")
= form.select :public_email, options_for_select(@user.public_verified_emails, selected: @user.public_email),
{ help: s_("Profiles|This email will be displayed on your public profile"), include_blank: s_("Profiles|Do not show on profile") },
control_class: 'select2 input-lg', disabled: email_change_disabled

View file

@ -5,7 +5,7 @@
- availability = availability_values
- custom_emoji = show_status_emoji?(@user.status)
= bootstrap_form_for @user, url: profile_path, method: :put, html: { multipart: true, class: 'edit-user gl-mt-3 js-quick-submit gl-show-field-errors' }, authenticity_token: true do |f|
= bootstrap_form_for @user, url: profile_path, method: :put, html: { multipart: true, class: 'edit-user gl-mt-3 js-quick-submit gl-show-field-errors js-password-prompt-form', remote: true }, authenticity_token: true do |f|
= form_errors(@user)
.row.js-search-settings-section
@ -124,9 +124,11 @@
.help-block
= s_("Profiles|Choose to show contributions of private projects on your public profile without any project, repository or organization information")
%hr
= f.submit s_("Profiles|Update profile settings"), class: 'gl-button btn btn-confirm gl-mr-3'
= f.submit s_("Profiles|Update profile settings"), class: 'gl-button btn btn-confirm gl-mr-3 js-password-prompt-btn'
= link_to _("Cancel"), user_path(current_user), class: 'gl-button btn btn-default btn-cancel'
#password-prompt-modal
.modal.modal-profile-crop{ data: { cropper_css_path: ActionController::Base.helpers.stylesheet_path('lazy_bundles/cropper.css') } }
.modal-dialog
.modal-content

View file

@ -3,4 +3,3 @@
can_add_related_issues: "#{can?(current_user, :admin_issue_link, @issue)}",
help_path: help_page_path('user/project/issues/related_issues'),
show_categorized_issues: "false" } }
- render('projects/issues/related_issues_block')

View file

@ -1,5 +0,0 @@
.related-issues-block
.card.card-slim
.card-header.panel-empty-heading.border-bottom-0
%h3.card-title.mt-0.mb-0.h5
= _('Linked issues')

View file

@ -13,7 +13,8 @@
= highlight_and_truncate_issuable(issuable, @search_term, @search_highlight)
.col-sm-3.gl-mt-3.gl-sm-mt-0.gl-text-right
- if issuable.respond_to?(:upvotes_count) && issuable.upvotes_count > 0
%li.issuable-upvotes.gl-list-style-none.has-tooltip{ title: _('Upvotes') }
= sprite_icon('thumb-up', css_class: "gl-vertical-align-middle")
= issuable.upvotes_count
%li.issuable-upvotes.gl-list-style-none
%span.has-tooltip{ title: _('Upvotes') }
= sprite_icon('thumb-up', css_class: "gl-vertical-align-middle")
= issuable.upvotes_count
%span.gl-text-gray-500= sprintf(s_('updated %{time_ago}'), { time_ago: time_ago_with_tooltip(issuable.updated_at, placement: 'bottom') }).html_safe

View file

@ -26,8 +26,7 @@ module Gitlab
object = representation_class.from_json_hash(hash)
# To better express in the logs what object is being imported.
self.github_id = object.attributes.fetch(:github_id)
self.github_identifiers = object.github_identifiers
info(project.id, message: 'starting importer')
importer_class.new(object, project, client).execute
@ -35,10 +34,10 @@ module Gitlab
Gitlab::GithubImport::ObjectCounter.increment(project, object_type, :imported)
info(project.id, message: 'importer finished')
rescue KeyError => e
rescue NoMethodError => e
# This exception will be more useful in development when a new
# Representation is created but the developer forgot to add a
# `:github_id` field.
# `:github_identifiers` field.
Gitlab::Import::ImportFailureService.track(
project_id: project.id,
error_source: importer_class.name,
@ -72,7 +71,7 @@ module Gitlab
private
attr_accessor :github_id
attr_accessor :github_identifiers
def info(project_id, extra = {})
Logger.info(log_attributes(project_id, extra))
@ -82,7 +81,7 @@ module Gitlab
extra.merge(
project_id: project_id,
importer: importer_class.name,
github_id: github_id
github_identifiers: github_identifiers
)
end
end

View file

@ -0,0 +1,8 @@
---
name: linear_members_finder_ancestor_scopes
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/70583
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/341347
milestone: '14.4'
type: development
group: group::access
default_enabled: false

View file

@ -1,3 +1,13 @@
# frozen_string_literal: true
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)

View file

@ -1,30 +1,8 @@
# frozen_string_literal: true
require 'gettext_i18n_rails/haml_parser'
require 'gettext_i18n_rails_js/parser/javascript'
require 'json'
VUE_TRANSLATE_REGEX = /((%[\w.-]+)(?:\s))?{{ (N|n|s)?__\((.*)\) }}/.freeze
module GettextI18nRails
class HamlParser
singleton_class.send(:alias_method, :old_convert_to_code, :convert_to_code)
# We need to convert text in Mustache format
# to a format that can be parsed by Gettext scripts.
# If we found a content like "{{ __('Stage') }}"
# in a HAML file we convert it to "= _('Stage')", that way
# it can be processed by the "rake gettext:find" script.
#
# Overwrites: https://github.com/grosser/gettext_i18n_rails/blob/8396387a431e0f8ead72fc1cd425cad2fa4992f2/lib/gettext_i18n_rails/haml_parser.rb#L9
def self.convert_to_code(text)
text.gsub!(VUE_TRANSLATE_REGEX, "\\2= \\3_(\\4)")
old_convert_to_code(text)
end
end
end
module GettextI18nRailsJs
module Parser
module Javascript

View file

@ -0,0 +1,22 @@
---
data_category: optional
key_path: counts.projects_with_expiration_policy_enabled_with_older_than_set_to_60d
description: A count of projects with the cleanup policy set delete tags older than
60 days
product_section: ops
product_stage: package
product_group: group::package
product_category: container registry
value_type: number
status: active
time_frame: all
data_source: database
distribution:
- ee
- ce
tier:
- free
- premium
- ultimate
performance_indicator_type: []
milestone: "14.4"

View file

@ -298,6 +298,8 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
end
end
resources :cluster_agents, only: [:show], param: :name
concerns :clusterable
namespace :serverless do

View file

@ -21,6 +21,7 @@ You can use the following environment variables to override certain values:
|--------------------------------------------|---------|---------------------------------------------------------------------------------------------------------|
| `DATABASE_URL` | string | The database URL; is of the form: `postgresql://localhost/blog_development`. |
| `ENABLE_BOOTSNAP` | string | Enables Bootsnap for speeding up initial Rails boot (`1` to enable). |
| `EXTERNAL_URL` | string | Specify the external URL at the [time of installation](https://docs.gitlab.com/omnibus/settings/configuration.html#specifying-the-external-url-at-the-time-of-installation). |
| `EXTERNAL_VALIDATION_SERVICE_TIMEOUT` | integer | Timeout, in seconds, for an [external CI/CD pipeline validation service](external_pipeline_validation.md). Default is `5`. |
| `EXTERNAL_VALIDATION_SERVICE_URL` | string | URL to an [external CI/CD pipeline validation service](external_pipeline_validation.md). |
| `EXTERNAL_VALIDATION_SERVICE_TOKEN` | string | The `X-Gitlab-Token` for authentication with an [external CI/CD pipeline validation service](external_pipeline_validation.md). |

View file

@ -15460,6 +15460,7 @@ Conan file types.
| <a id="containerexpirationpolicyolderthanenumfourteen_days"></a>`FOURTEEN_DAYS` | 14 days until tags are automatically removed. |
| <a id="containerexpirationpolicyolderthanenumninety_days"></a>`NINETY_DAYS` | 90 days until tags are automatically removed. |
| <a id="containerexpirationpolicyolderthanenumseven_days"></a>`SEVEN_DAYS` | 7 days until tags are automatically removed. |
| <a id="containerexpirationpolicyolderthanenumsixty_days"></a>`SIXTY_DAYS` | 60 days until tags are automatically removed. |
| <a id="containerexpirationpolicyolderthanenumthirty_days"></a>`THIRTY_DAYS` | 30 days until tags are automatically removed. |
### `ContainerRepositoryCleanupStatus`

View file

@ -64,7 +64,7 @@ build:
entrypoint: [""]
script:
- mkdir -p /kaniko/.docker
- echo "{\"auths\":{\"$CI_REGISTRY\":{\"auth\":\"$(echo -n ${CI_REGISTRY_USER}:${CI_REGISTRY_PASSWORD} | base64)\"}}}" > /kaniko/.docker/config.json
- echo "{\"auths\":{\"$CI_REGISTRY\":{\"auth\":\"$(echo -n ${CI_REGISTRY_USER}:${CI_REGISTRY_PASSWORD} | base64 | tr -d '\n')\"}}}" > /kaniko/.docker/config.json
- /kaniko/executor --context $CI_PROJECT_DIR --dockerfile $CI_PROJECT_DIR/Dockerfile --destination $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG
rules:
- if: $CI_COMMIT_TAG
@ -91,7 +91,7 @@ build:
- mkdir -p /kaniko/.docker
- |-
KANIKOPROXYBUILDARGS=""
KANIKOCFG="{\"auths\":{\"$CI_REGISTRY\":{\"auth\":\"$(echo -n ${CI_REGISTRY_USER}:${CI_REGISTRY_PASSWORD} | base64)\"}}}"
KANIKOCFG="{\"auths\":{\"$CI_REGISTRY\":{\"auth\":\"$(echo -n ${CI_REGISTRY_USER}:${CI_REGISTRY_PASSWORD} | base64 | tr -d '\n')\"}}}"
if [ "x${http_proxy}" != "x" -o "x${https_proxy}" != "x" ]; then
KANIKOCFG="${KANIKOCFG}, \"proxies\": { \"default\": { \"httpProxy\": \"${http_proxy}\", \"httpsProxy\": \"${https_proxy}\", \"noProxy\": \"${no_proxy}\"}}"
KANIKOPROXYBUILDARGS="--build-arg http_proxy=${http_proxy} --build-arg https_proxy=${https_proxy} --build-arg no_proxy=${no_proxy}"
@ -120,7 +120,7 @@ store:
```yaml
before_script:
- mkdir -p /kaniko/.docker
- echo "{\"auths\":{\"$CI_REGISTRY\":{\"auth\":\"$(echo -n ${CI_REGISTRY_USER}:${CI_REGISTRY_PASSWORD} | base64)\"}}}" > /kaniko/.docker/config.json
- echo "{\"auths\":{\"$CI_REGISTRY\":{\"auth\":\"$(echo -n ${CI_REGISTRY_USER}:${CI_REGISTRY_PASSWORD} | base64 | tr -d '\n')\"}}}" > /kaniko/.docker/config.json
- |
echo "-----BEGIN CERTIFICATE-----
...

View file

@ -106,6 +106,7 @@ with [domain expertise](#domain-experts).
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)**,
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*)...
- 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

View file

@ -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
---
# 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.
To better understand the priority by which UX tackles issues, see the [UX section](https://about.gitlab.com/handbook/engineering/ux/) of the handbook.
## Merge request reviews
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),
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.
## Checklist
`~"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)
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`).
### Writing
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.
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.
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.
### Patterns
- Consider similar patterns used in the product and justify in the issue when diverging
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.

View file

@ -342,19 +342,22 @@ To create a feature proposal, open an issue on the
[issue tracker](https://gitlab.com/gitlab-org/gitlab/-/issues).
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
of the project cannot add labels. 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
[`feature`](https://gitlab.com/gitlab-org/gitlab/-/issues?label_name=feature) label.
For the time being, users that are not members of the project cannot add labels.
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`.
Please keep feature proposals as small and simple as possible, complex ones
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
be given the ~"UX" label. This will allow the UX 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.
For changes to the user interface (UI), follow our [design and UI guidelines](design.md),
and include a visual example (screenshot, wireframe, or mockup). Such issues should
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
discuss whether it is interesting to include this in GitLab.

View file

@ -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
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
wireframes of the proposed feature if it will also change the UI.
to be marked as `Accepting merge requests`. See the [feature proposals](issue_workflow.md#feature-proposals)
section.
Merge requests should be submitted to the appropriate project at GitLab.com, for example
[GitLab](https://gitlab.com/gitlab-org/gitlab/-/merge_requests),

View file

@ -71,6 +71,7 @@ topics and use cases. The most frequently required during database reviewing are
- [Migrations style guide](../migration_style_guide.md) for creating safe SQL migrations.
- [Avoiding downtime in migrations](../avoiding_downtime_in_migrations.md).
- [SQL guidelines](../sql.md) for working with SQL queries.
- [Guidelines for JiHu contributions with database migrations](https://about.gitlab.com/handbook/ceo/chief-of-staff-team/jihu-support/jihu-database-change-process.html)
## How to apply to become a database maintainer

View file

@ -24,7 +24,7 @@ module API
# entity according to the current top-level entity options, such
# as the current_user.
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
issuable_metadata = Gitlab::IssuableMetadata.new(current_user, models)

View file

@ -1085,7 +1085,6 @@ module API
attrs = declared_params(include_missing: false)
service = ::UserPreferences::UpdateService.new(current_user, attrs).execute
if service.success?
present preferences, with: Entities::UserPreferences
else

View file

@ -38,7 +38,7 @@ module Gitlab
end
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] }
categories = finding_keys.map { |key| key[:category] }
fingerprints = finding_keys.map { |key| key[:project_fingerprint] }

View file

@ -15,7 +15,7 @@ module Gitlab
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|Start with a GitLab Ultimate free trial')
][series]

View file

@ -11,7 +11,7 @@ module Gitlab
expose_attribute :noteable_type, :noteable_id, :commit_id, :file_path,
:diff_hunk, :author, :note, :created_at, :updated_at,
:github_id, :original_commit_id
:original_commit_id, :note_id
NOTEABLE_ID_REGEX = %r{/pull/(?<iid>\d+)}i.freeze
@ -40,7 +40,7 @@ module Gitlab
note: note.body,
created_at: note.created_at,
updated_at: note.updated_at,
github_id: note.id
note_id: note.id
}
new(hash)
@ -82,6 +82,14 @@ module Gitlab
new_file: false
}
end
def github_identifiers
{
note_id: note_id,
noteable_id: noteable_id,
noteable_type: noteable_type
}
end
end
end
end

View file

@ -25,7 +25,6 @@ module Gitlab
hash = {
iid: issue.number,
github_id: issue.number,
title: issue.title,
description: issue.body,
milestone_number: issue.milestone&.number,
@ -75,6 +74,13 @@ module Gitlab
def issuable_type
pull_request? ? 'MergeRequest' : 'Issue'
end
def github_identifiers
{
iid: iid,
issuable_type: issuable_type
}
end
end
end
end

View file

@ -16,8 +16,7 @@ module Gitlab
new(
oid: lfs_object.oid,
link: lfs_object.link,
size: lfs_object.size,
github_id: lfs_object.oid
size: lfs_object.size
)
end
@ -31,6 +30,12 @@ module Gitlab
def initialize(attributes)
@attributes = attributes
end
def github_identifiers
{
oid: oid
}
end
end
end
end

View file

@ -10,7 +10,7 @@ module Gitlab
attr_reader :attributes
expose_attribute :noteable_id, :noteable_type, :author, :note,
:created_at, :updated_at, :github_id
:created_at, :updated_at, :note_id
NOTEABLE_TYPE_REGEX = %r{/(?<type>(pull|issues))/(?<iid>\d+)}i.freeze
@ -42,7 +42,7 @@ module Gitlab
note: note.body,
created_at: note.created_at,
updated_at: note.updated_at,
github_id: note.id
note_id: note.id
}
new(hash)
@ -64,6 +64,14 @@ module Gitlab
end
alias_method :issuable_type, :noteable_type
def github_identifiers
{
note_id: note_id,
noteable_id: noteable_id,
noteable_type: noteable_type
}
end
end
end
end

View file

@ -25,7 +25,6 @@ module Gitlab
hash = {
iid: pr.number,
github_id: pr.number,
title: pr.title,
description: pr.body,
source_branch: pr.head.ref,
@ -108,6 +107,13 @@ module Gitlab
def issuable_type
'MergeRequest'
end
def github_identifiers
{
iid: iid,
issuable_type: issuable_type
}
end
end
end
end

View file

@ -9,7 +9,7 @@ module Gitlab
attr_reader :attributes
expose_attribute :author, :note, :review_type, :submitted_at, :github_id, :merge_request_id
expose_attribute :author, :note, :review_type, :submitted_at, :merge_request_id, :review_id
def self.from_api_response(review)
user = Representation::User.from_api_response(review.user) if review.user
@ -20,7 +20,7 @@ module Gitlab
note: review.body,
review_type: review.state,
submitted_at: review.submitted_at,
github_id: review.id
review_id: review.id
)
end
@ -43,6 +43,13 @@ module Gitlab
def approval?
review_type == 'APPROVED'
end
def github_identifiers
{
review_id: review_id,
merge_request_id: merge_request_id
}
end
end
end
end

View file

@ -17,7 +17,6 @@ module Gitlab
def self.from_api_response(user)
new(
id: user.id,
github_id: user.id,
login: user.login
)
end

View file

@ -6,10 +6,11 @@ module Gitlab
class Iterator
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)
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)
@use_union_optimization = in_operator_optimization_options ? false : use_union_optimization
@in_operator_optimization_options = in_operator_optimization_options
@ -17,11 +18,9 @@ module Gitlab
# rubocop: disable CodeReuse/ActiveRecord
def each_batch(of: 1000)
cursor_attributes = {}
loop do
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.limit(of)
@ -30,14 +29,14 @@ module Gitlab
last_record = relation.last
break unless last_record
cursor_attributes = order.cursor_attributes_for_node(last_record)
@cursor = order.cursor_attributes_for_node(last_record)
end
end
# rubocop: enable CodeReuse/ActiveRecord
private
attr_reader :scope, :order
attr_reader :scope, :cursor, :order
def keyset_options
{

View file

@ -16,9 +16,6 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\n"
msgid " %{name}, confirm your email address now! "
msgstr ""
msgid " %{start} to %{end}"
msgstr ""
@ -1135,9 +1132,6 @@ msgstr ""
msgid "(we need your current password to confirm your changes)"
msgstr ""
msgid "* * * * *"
msgstr ""
msgid "+ %{amount} more"
msgstr ""
@ -3587,6 +3581,9 @@ msgstr ""
msgid "An error occurred while enabling Service Desk."
msgstr ""
msgid "An error occurred while fetching Markdown preview"
msgstr ""
msgid "An error occurred while fetching ancestors"
msgstr ""
@ -3617,9 +3614,6 @@ msgstr ""
msgid "An error occurred while fetching label colors."
msgstr ""
msgid "An error occurred while fetching markdown preview"
msgstr ""
msgid "An error occurred while fetching participants"
msgstr ""
@ -7345,9 +7339,6 @@ msgstr ""
msgid "ClusterAgents|You will need to create a token to connect to your agent"
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"
msgstr ""
@ -9999,7 +9990,7 @@ msgstr ""
msgid "Custom notification events"
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 ""
msgid "Custom project templates"
@ -10198,10 +10189,10 @@ msgid_plural "CycleAnalytics|Showing %{subjectFilterText} and %{selectedLabelsCo
msgstr[0] ""
msgstr[1] ""
msgid "CycleAnalytics|Showing data for group '%{groupName}' and %{selectedProjectCount} projects from %{startDate} to %{endDate}"
msgid "CycleAnalytics|Showing data for group '%{groupName}' and %{selectedProjectCount} projects from %{createdAfter} to %{createdBefore}"
msgstr ""
msgid "CycleAnalytics|Showing data for group '%{groupName}' from %{startDate} to %{endDate}"
msgid "CycleAnalytics|Showing data for group '%{groupName}' from %{createdAfter} to %{createdBefore}"
msgstr ""
msgid "CycleAnalytics|Stages"
@ -13366,7 +13357,7 @@ msgstr ""
msgid "Error parsing CSV file. Please make sure it has"
msgstr ""
msgid "Error rendering markdown preview"
msgid "Error rendering Markdown preview"
msgstr ""
msgid "Error saving label update."
@ -17606,7 +17597,7 @@ msgstr ""
msgid "InProductMarketing|Start a GitLab Ultimate trial today in less than one minute, no credit card required."
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 ""
msgid "InProductMarketing|Start a trial"
@ -24487,6 +24478,18 @@ msgstr ""
msgid "Password was successfully updated. Please sign in again."
msgstr ""
msgid "PasswordPrompt|Confirm password"
msgstr ""
msgid "PasswordPrompt|Confirm password to continue"
msgstr ""
msgid "PasswordPrompt|Password is required"
msgstr ""
msgid "PasswordPrompt|Please enter your password to confirm"
msgstr ""
msgid "Passwords should be unique and not used for any other sites or services."
msgstr ""
@ -24862,6 +24865,48 @@ msgstr ""
msgid "PipelineSchedules|Variables"
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}"
msgstr ""
@ -25162,51 +25207,9 @@ msgstr ""
msgid "Pipeline|Source"
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"
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."
msgstr ""
@ -25933,6 +25936,9 @@ msgstr ""
msgid "Profiles|Enter your name, so people you know can recognize you"
msgstr ""
msgid "Profiles|Enter your password to confirm the email change"
msgstr ""
msgid "Profiles|Enter your pronouns to let people know how to refer to you"
msgstr ""
@ -29748,16 +29754,16 @@ msgstr ""
msgid "SearchCodeResults|of %{link_to_project}"
msgstr ""
msgid "SearchResults|Showing %{count} %{scope} for%{term_element}"
msgid "SearchResults|Showing %{count} %{scope} for %{term_element}"
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 ""
msgid "SearchResults|Showing %{from} - %{to} of %{count} %{scope} for%{term_element}"
msgid "SearchResults|Showing %{from} - %{to} of %{count} %{scope} for %{term_element}"
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 ""
msgid "SearchResults|code result"
@ -31274,6 +31280,9 @@ msgstr ""
msgid "Showing all issues"
msgstr ""
msgid "Showing data for group '%{group_name}' from Nov 1, 2019 to Dec 31, 2019"
msgstr ""
msgid "Showing data for workflow items created in this date range. Date range cannot exceed %{maxDateRange} days."
msgstr ""
@ -35150,7 +35159,7 @@ msgstr ""
msgid "TimeTrackingEstimated|Est"
msgstr ""
msgid "TimeTracking|%{startTag}Spent: %{endTag}%{timeSpentHumanReadable}"
msgid "TimeTracking|%{spentStart}Spent: %{spentEnd}"
msgstr ""
msgid "TimeTracking|Estimated:"
@ -35388,6 +35397,9 @@ msgstr ""
msgid "To connect an SVN repository, check out %{svn_link}."
msgstr ""
msgid "To continue using GitLab Enterprise Edition, upload the %{codeOpen}.gitlab-license%{codeClose} file or enter the license key you have received from GitLab Inc."
msgstr ""
msgid "To continue, you need to select the link in the confirmation email we sent to verify your email address. If you didn't get our email, select %{strongStart}Resend confirmation email.%{strongEnd}"
msgstr ""
@ -35493,6 +35505,9 @@ msgstr ""
msgid "To specify the notification level per project of a group you belong to, you need to visit project page and change notification level there."
msgstr ""
msgid "To start using GitLab Enterprise Edition, upload the %{codeOpen}.gitlab-license%{codeClose} file or enter the license key you have received from GitLab Inc."
msgstr ""
msgid "To unsubscribe from this issue, please paste the following link into your browser:"
msgstr ""

View file

@ -3,7 +3,8 @@
require('spec_helper')
RSpec.describe ProfilesController, :request_store do
let(:user) { create(:user) }
let(:password) { 'longsecret987!' }
let(:user) { create(:user, password: password) }
describe 'POST update' do
it 'does not update password' do
@ -23,7 +24,7 @@ RSpec.describe ProfilesController, :request_store do
sign_in(user)
put :update,
params: { user: { email: "john@gmail.com", name: "John" } }
params: { user: { email: "john@gmail.com", name: "John", validation_password: password } }
user.reload

View file

@ -139,6 +139,8 @@ FactoryBot.define do
end
factory :omniauth_user do
password_automatically_set { true }
transient do
extern_uid { '123456' }
provider { 'ldapmain' }

View file

@ -121,7 +121,7 @@ RSpec.describe 'Admin Mode Login' do
end
context 'when logging in via omniauth' do
let(:user) { create(:omniauth_user, :admin, :two_factor, extern_uid: 'my-uid', provider: 'saml')}
let(:user) { create(:omniauth_user, :admin, :two_factor, extern_uid: 'my-uid', provider: 'saml', password_automatically_set: false)}
let(:mock_saml_response) do
File.read('spec/fixtures/authentication/saml_response.xml')
end

View file

@ -19,6 +19,17 @@ RSpec.describe 'User edit profile' do
wait_for_requests if respond_to?(:wait_for_requests)
end
def update_user_email
fill_in 'user_email', with: 'new-email@example.com'
click_button 'Update profile settings'
end
def confirm_password(password)
fill_in 'password-confirmation', with: password
click_button 'Confirm password'
wait_for_requests if respond_to?(:wait_for_requests)
end
def visit_user
visit user_path(user)
wait_for_requests
@ -88,16 +99,42 @@ RSpec.describe 'User edit profile' do
expect(page).to have_content('Website url is not a valid URL')
end
describe 'when I change my email' do
describe 'when I change my email', :js do
before do
user.send_reset_password_instructions
end
it 'will prompt to confirm my password' do
expect(user.reset_password_token?).to be true
update_user_email
expect(page).to have_selector('[data-testid="password-prompt-modal"]')
end
context 'when prompted to confirm password' do
before do
update_user_email
end
it 'with the correct password successfully updates' do
confirm_password(user.password)
expect(page).to have_text("Profile was successfully updated")
end
it 'with the incorrect password fails to update' do
confirm_password("Fake password")
expect(page).to have_text("Invalid password")
end
end
it 'clears the reset password token' do
expect(user.reset_password_token?).to be true
fill_in 'user_email', with: 'new-email@example.com'
submit_settings
update_user_email
confirm_password(user.password)
user.reload
expect(user.confirmation_token).not_to be_nil

View file

@ -874,7 +874,7 @@ RSpec.describe 'Login', :clean_gitlab_redis_shared_state do
end
end
context 'when the user does not have an email configured' do
context 'when the user does not have an email configured', :js do
let(:user) { create(:omniauth_user, extern_uid: 'my-uid', provider: 'saml', email: 'temp-email-for-oauth-user@gitlab.localhost') }
before do

View file

@ -161,42 +161,54 @@ RSpec.describe MembersFinder, '#execute' do
end
context 'when :invited_groups is passed' do
subject { described_class.new(project, user2).execute(include_relations: [:inherited, :direct, :invited_groups]) }
shared_examples 'with invited_groups param' do
subject { described_class.new(project, user2).execute(include_relations: [:inherited, :direct, :invited_groups]) }
let_it_be(:linked_group) { create(:group, :public) }
let_it_be(:nested_linked_group) { create(:group, parent: linked_group) }
let_it_be(:linked_group_member) { linked_group.add_guest(user1) }
let_it_be(:nested_linked_group_member) { nested_linked_group.add_guest(user2) }
let_it_be(:linked_group) { create(:group, :public) }
let_it_be(:nested_linked_group) { create(:group, parent: linked_group) }
let_it_be(:linked_group_member) { linked_group.add_guest(user1) }
let_it_be(:nested_linked_group_member) { nested_linked_group.add_guest(user2) }
it 'includes all the invited_groups members including members inherited from ancestor groups' do
create(:project_group_link, project: project, group: nested_linked_group)
it 'includes all the invited_groups members including members inherited from ancestor groups' do
create(:project_group_link, project: project, group: nested_linked_group)
expect(subject).to contain_exactly(linked_group_member, nested_linked_group_member)
end
it 'includes all the invited_groups members' do
create(:project_group_link, project: project, group: linked_group)
expect(subject).to contain_exactly(linked_group_member)
end
it 'excludes group_members not visible to the user' do
create(:project_group_link, project: project, group: linked_group)
private_linked_group = create(:group, :private)
private_linked_group.add_developer(user3)
create(:project_group_link, project: project, group: private_linked_group)
expect(subject).to contain_exactly(linked_group_member)
end
context 'when the user is a member of invited group and ancestor groups' do
it 'returns the highest access_level for the user limited by project_group_link.group_access', :nested_groups do
create(:project_group_link, project: project, group: nested_linked_group, group_access: Gitlab::Access::REPORTER)
nested_linked_group.add_developer(user1)
expect(subject.map(&:user)).to contain_exactly(user1, user2)
expect(subject.max_by(&:access_level).access_level).to eq(Gitlab::Access::REPORTER)
expect(subject).to contain_exactly(linked_group_member, nested_linked_group_member)
end
it 'includes all the invited_groups members' do
create(:project_group_link, project: project, group: linked_group)
expect(subject).to contain_exactly(linked_group_member)
end
it 'excludes group_members not visible to the user' do
create(:project_group_link, project: project, group: linked_group)
private_linked_group = create(:group, :private)
private_linked_group.add_developer(user3)
create(:project_group_link, project: project, group: private_linked_group)
expect(subject).to contain_exactly(linked_group_member)
end
context 'when the user is a member of invited group and ancestor groups' do
it 'returns the highest access_level for the user limited by project_group_link.group_access', :nested_groups do
create(:project_group_link, project: project, group: nested_linked_group, group_access: Gitlab::Access::REPORTER)
nested_linked_group.add_developer(user1)
expect(subject.map(&:user)).to contain_exactly(user1, user2)
expect(subject.max_by(&:access_level).access_level).to eq(Gitlab::Access::REPORTER)
end
end
end
it_behaves_like 'with invited_groups param'
context 'when feature flag :linear_members_finder_ancestor_scopes is disabled' do
before do
stub_feature_flags(linear_members_finder_ancestor_scopes: false)
end
it_behaves_like 'with invited_groups param'
end
end
end

View file

@ -99,6 +99,12 @@ Array [
"label": "30 days",
"variable": 30,
},
Object {
"default": false,
"key": "SIXTY_DAYS",
"label": "60 days",
"variable": 60,
},
Object {
"default": true,
"key": "NINETY_DAYS",

View file

@ -44,7 +44,7 @@ exports[`Settings Form Keep Regex matches snapshot 1`] = `
exports[`Settings Form OlderThan matches snapshot 1`] = `
<expiration-dropdown-stub
data-testid="older-than-dropdown"
formoptions="[object Object],[object Object],[object Object],[object Object],[object Object]"
formoptions="[object Object],[object Object],[object Object],[object Object],[object Object],[object Object]"
label="Remove tags older than:"
name="older-than"
value="FOURTEEN_DAYS"

View file

@ -0,0 +1,92 @@
import { GlModal } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import {
I18N_PASSWORD_PROMPT_CANCEL_BUTTON,
I18N_PASSWORD_PROMPT_CONFIRM_BUTTON,
} from '~/pages/profiles/password_prompt/constants';
import PasswordPromptModal from '~/pages/profiles/password_prompt/password_prompt_modal.vue';
const createComponent = ({ props }) => {
return shallowMountExtended(PasswordPromptModal, {
propsData: {
...props,
},
});
};
describe('Password prompt modal', () => {
let wrapper;
const mockPassword = 'not+fake+shady+password';
const mockEvent = { preventDefault: jest.fn() };
const handleConfirmPasswordSpy = jest.fn();
const findField = () => wrapper.findByTestId('password-prompt-field');
const findModal = () => wrapper.findComponent(GlModal);
const findConfirmBtn = () => findModal().props('actionPrimary');
const findConfirmBtnDisabledState = () =>
findModal().props('actionPrimary').attributes[2].disabled;
const findCancelBtn = () => findModal().props('actionCancel');
const submitModal = () => findModal().vm.$emit('primary', mockEvent);
const setPassword = (newPw) => findField().vm.$emit('input', newPw);
beforeEach(() => {
wrapper = createComponent({
props: {
handleConfirmPassword: handleConfirmPasswordSpy,
},
});
});
afterEach(() => {
wrapper.destroy();
});
it('renders the password field', () => {
expect(findField().exists()).toBe(true);
});
it('renders the confirm button', () => {
expect(findConfirmBtn().text).toEqual(I18N_PASSWORD_PROMPT_CONFIRM_BUTTON);
});
it('renders the cancel button', () => {
expect(findCancelBtn().text).toEqual(I18N_PASSWORD_PROMPT_CANCEL_BUTTON);
});
describe('confirm button', () => {
describe('with a valid password', () => {
it('calls the `handleConfirmPassword` method when clicked', async () => {
setPassword(mockPassword);
submitModal();
await wrapper.vm.$nextTick();
expect(handleConfirmPasswordSpy).toHaveBeenCalledTimes(1);
expect(handleConfirmPasswordSpy).toHaveBeenCalledWith(mockPassword);
});
it('enables the confirm button', async () => {
setPassword(mockPassword);
expect(findConfirmBtnDisabledState()).toBe(true);
await wrapper.vm.$nextTick();
expect(findConfirmBtnDisabledState()).toBe(false);
});
});
it('without a valid password is disabled', async () => {
setPassword('');
expect(findConfirmBtnDisabledState()).toBe(true);
await wrapper.vm.$nextTick();
expect(findConfirmBtnDisabledState()).toBe(true);
});
});
});

View file

@ -1,4 +1,7 @@
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';
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({
extends: ExtensionBase,
name: 'Test',

View file

@ -0,0 +1,36 @@
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');
});
});

View file

@ -1,13 +1,16 @@
import { GlBadge, GlLink, GlIcon } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { securityReportMergeRequestDownloadPathsQueryResponse } from 'jest/vue_shared/security_reports/mock_data';
import axios from '~/lib/utils/axios_utils';
import { setFaviconOverlay } from '~/lib/utils/favicon';
import notify from '~/lib/utils/notify';
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 eventHub from '~/vue_merge_request_widget/event_hub';
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';
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 mockData from './mock_data';
import testExtension from './test_extension';
jest.mock('~/smart_interval');
@ -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');
});
});
});

View file

@ -0,0 +1,36 @@
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',
},
},
]);
},
},
};

View file

@ -0,0 +1,61 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Mutations::Clusters::AgentTokens::Create do
subject(:mutation) { described_class.new(object: nil, context: context, field: nil) }
let_it_be(:cluster_agent) { create(:cluster_agent) }
let_it_be(:user) { create(:user) }
let(:context) do
GraphQL::Query::Context.new(
query: OpenStruct.new(schema: nil),
values: { current_user: user },
object: nil
)
end
specify { expect(described_class).to require_graphql_authorizations(:create_cluster) }
describe '#resolve' do
let(:description) { 'new token!' }
let(:name) { 'new name' }
subject { mutation.resolve(cluster_agent_id: cluster_agent.to_global_id, description: description, name: name) }
context 'without token permissions' do
it 'raises an error if the resource is not accessible to the user' do
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
context 'with user permissions' do
before do
cluster_agent.project.add_maintainer(user)
end
it 'creates a new token', :aggregate_failures do
expect { subject }.to change { ::Clusters::AgentToken.count }.by(1)
expect(subject[:errors]).to eq([])
end
it 'returns token information', :aggregate_failures do
token = subject[:token]
expect(subject[:secret]).not_to be_nil
expect(token.created_by_user).to eq(user)
expect(token.description).to eq(description)
expect(token.name).to eq(name)
end
context 'invalid params' do
subject { mutation.resolve(cluster_agent_id: cluster_agent.id) }
it 'generates an error message when id invalid', :aggregate_failures do
expect { subject }.to raise_error(::GraphQL::CoercionError)
end
end
end
end
end

View file

@ -0,0 +1,52 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Mutations::Clusters::AgentTokens::Delete do
let(:token) { create(:cluster_agent_token) }
let(:user) { create(:user) }
let(:mutation) do
described_class.new(
object: double,
context: { current_user: user },
field: double
)
end
it { expect(described_class.graphql_name).to eq('ClusterAgentTokenDelete') }
it { expect(described_class).to require_graphql_authorizations(:admin_cluster) }
describe '#resolve' do
let(:global_id) { token.to_global_id }
subject { mutation.resolve(id: global_id) }
context 'without user permissions' do
it 'fails to delete the cluster agent', :aggregate_failures do
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
expect { token.reload }.not_to raise_error
end
end
context 'with user permissions' do
before do
token.agent.project.add_maintainer(user)
end
it 'deletes a cluster agent', :aggregate_failures do
expect { subject }.to change { ::Clusters::AgentToken.count }.by(-1)
expect { token.reload }.to raise_error(ActiveRecord::RecordNotFound)
end
end
context 'with invalid params' do
let(:global_id) { token.id }
it 'raises an error if the cluster agent id is invalid', :aggregate_failures do
expect { subject }.to raise_error(::GraphQL::CoercionError)
expect { token.reload }.not_to raise_error
end
end
end
end

View file

@ -0,0 +1,50 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Mutations::Clusters::Agents::Create do
subject(:mutation) { described_class.new(object: nil, context: context, field: nil) }
let(:project) { create(:project, :public, :repository) }
let(:user) { create(:user) }
let(:context) do
GraphQL::Query::Context.new(
query: OpenStruct.new(schema: nil),
values: { current_user: user },
object: nil
)
end
specify { expect(described_class).to require_graphql_authorizations(:create_cluster) }
describe '#resolve' do
subject { mutation.resolve(project_path: project.full_path, name: 'test-agent') }
context 'without project permissions' do
it 'raises an error if the resource is not accessible to the user' do
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
context 'with user permissions' do
before do
project.add_maintainer(user)
end
it 'creates a new clusters_agent', :aggregate_failures do
expect { subject }.to change { ::Clusters::Agent.count }.by(1)
expect(subject[:cluster_agent].name).to eq('test-agent')
expect(subject[:errors]).to eq([])
end
context 'invalid params' do
subject { mutation.resolve(project_path: project.full_path, name: '@bad_name!') }
it 'generates an error message when name is invalid', :aggregate_failures do
expect(subject[:clusters_agent]).to be_nil
expect(subject[:errors]).to eq(["Name can contain only lowercase letters, digits, and '-', but cannot start or end with '-'"])
end
end
end
end
end

View file

@ -0,0 +1,51 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Mutations::Clusters::Agents::Delete do
subject(:mutation) { described_class.new(object: nil, context: context, field: nil) }
let(:cluster_agent) { create(:cluster_agent) }
let(:project) { cluster_agent.project }
let(:user) { create(:user) }
let(:context) do
GraphQL::Query::Context.new(
query: OpenStruct.new(schema: nil),
values: { current_user: user },
object: nil
)
end
specify { expect(described_class).to require_graphql_authorizations(:admin_cluster) }
describe '#resolve' do
subject { mutation.resolve(id: cluster_agent.to_global_id) }
context 'without user permissions' do
it 'fails to delete the cluster agent', :aggregate_failures do
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
expect { cluster_agent.reload }.not_to raise_error
end
end
context 'with user permissions' do
before do
project.add_maintainer(user)
end
it 'deletes a cluster agent', :aggregate_failures do
expect { subject }.to change { ::Clusters::Agent.count }.by(-1)
expect { cluster_agent.reload }.to raise_error(ActiveRecord::RecordNotFound)
end
end
context 'with invalid params' do
subject { mutation.resolve(id: cluster_agent.id) }
it 'raises an error if the cluster agent id is invalid', :aggregate_failures do
expect { subject }.to raise_error(::GraphQL::CoercionError)
expect { cluster_agent.reload }.not_to raise_error
end
end
end
end

View file

@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe GitlabSchema.types['ContainerExpirationPolicyOlderThanEnum'] do
let_it_be(:expected_values) { %w[SEVEN_DAYS FOURTEEN_DAYS THIRTY_DAYS NINETY_DAYS] }
let_it_be(:expected_values) { %w[SEVEN_DAYS FOURTEEN_DAYS THIRTY_DAYS SIXTY_DAYS NINETY_DAYS] }
it_behaves_like 'exposing container expiration policy option', :older_than
end

View file

@ -40,6 +40,7 @@ RSpec.describe ContainerExpirationPoliciesHelper do
{ key: '7d', label: '7 days until tags are automatically removed' },
{ key: '14d', label: '14 days until tags are automatically removed' },
{ key: '30d', label: '30 days until tags are automatically removed' },
{ key: '60d', label: '60 days until tags are automatically removed' },
{ key: '90d', label: '90 days until tags are automatically removed', default: true }
]

View file

@ -248,13 +248,13 @@ RSpec.describe SearchHelper do
it 'uses the correct singular label' do
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
it 'uses the correct plural label' do
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

View file

@ -51,7 +51,7 @@ RSpec.describe Gitlab::GithubImport::Representation::DiffNote do
end
it 'includes the GitHub ID' do
expect(note.github_id).to eq(1)
expect(note.note_id).to eq(1)
end
it 'returns the noteable type' do
@ -106,7 +106,7 @@ RSpec.describe Gitlab::GithubImport::Representation::DiffNote do
'note' => 'Hello world',
'created_at' => created_at.to_s,
'updated_at' => updated_at.to_s,
'github_id' => 1
'note_id' => 1
}
end
@ -124,7 +124,7 @@ RSpec.describe Gitlab::GithubImport::Representation::DiffNote do
'note' => 'Hello world',
'created_at' => created_at.to_s,
'updated_at' => updated_at.to_s,
'github_id' => 1
'note_id' => 1
}
note = described_class.from_json_hash(hash)
@ -154,7 +154,7 @@ RSpec.describe Gitlab::GithubImport::Representation::DiffNote do
'note' => 'Hello world',
'created_at' => created_at.to_s,
'updated_at' => updated_at.to_s,
'github_id' => 1
'note_id' => 1
)
expect(note.diff_hash).to eq(
@ -167,4 +167,18 @@ RSpec.describe Gitlab::GithubImport::Representation::DiffNote do
)
end
end
describe '#github_identifiers' do
it 'returns a hash with needed identifiers' do
github_identifiers = {
noteable_id: 42,
noteable_type: 'MergeRequest',
note_id: 1
}
other_attributes = { something_else: '_something_else_' }
note = described_class.new(github_identifiers.merge(other_attributes))
expect(note.github_identifiers).to eq(github_identifiers)
end
end
end

View file

@ -181,4 +181,17 @@ RSpec.describe Gitlab::GithubImport::Representation::Issue do
expect(object.truncated_title).to eq('foo')
end
end
describe '#github_identifiers' do
it 'returns a hash with needed identifiers' do
github_identifiers = {
iid: 42,
issuable_type: 'MergeRequest'
}
other_attributes = { pull_request: true, something_else: '_something_else_' }
issue = described_class.new(github_identifiers.merge(other_attributes))
expect(issue.github_identifiers).to eq(github_identifiers)
end
end
end

View file

@ -0,0 +1,17 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::GithubImport::Representation::LfsObject do
describe '#github_identifiers' do
it 'returns a hash with needed identifiers' do
github_identifiers = {
oid: 42
}
other_attributes = { something_else: '_something_else_' }
lfs_object = described_class.new(github_identifiers.merge(other_attributes))
expect(lfs_object.github_identifiers).to eq(github_identifiers)
end
end
end

View file

@ -40,8 +40,8 @@ RSpec.describe Gitlab::GithubImport::Representation::Note do
expect(note.updated_at).to eq(updated_at)
end
it 'includes the GitHub ID' do
expect(note.github_id).to eq(1)
it 'includes the note ID' do
expect(note.note_id).to eq(1)
end
end
end
@ -84,7 +84,7 @@ RSpec.describe Gitlab::GithubImport::Representation::Note do
'note' => 'Hello world',
'created_at' => created_at.to_s,
'updated_at' => updated_at.to_s,
'github_id' => 1
'note_id' => 1
}
end
@ -98,7 +98,7 @@ RSpec.describe Gitlab::GithubImport::Representation::Note do
'note' => 'Hello world',
'created_at' => created_at.to_s,
'updated_at' => updated_at.to_s,
'github_id' => 1
'note_id' => 1
}
note = described_class.from_json_hash(hash)
@ -106,4 +106,18 @@ RSpec.describe Gitlab::GithubImport::Representation::Note do
expect(note.author).to be_nil
end
end
describe '#github_identifiers' do
it 'returns a hash with needed identifiers' do
github_identifiers = {
noteable_id: 42,
noteable_type: 'Issue',
note_id: 1
}
other_attributes = { something_else: '_something_else_' }
note = described_class.new(github_identifiers.merge(other_attributes))
expect(note.github_identifiers).to eq(github_identifiers)
end
end
end

View file

@ -14,7 +14,7 @@ RSpec.describe Gitlab::GithubImport::Representation::PullRequestReview do
expect(review.note).to eq('note')
expect(review.review_type).to eq('APPROVED')
expect(review.submitted_at).to eq(submitted_at)
expect(review.github_id).to eq(999)
expect(review.review_id).to eq(999)
expect(review.merge_request_id).to eq(42)
end
end
@ -50,7 +50,7 @@ RSpec.describe Gitlab::GithubImport::Representation::PullRequestReview do
describe '.from_json_hash' do
let(:hash) do
{
'github_id' => 999,
'review_id' => 999,
'merge_request_id' => 42,
'note' => 'note',
'review_type' => 'APPROVED',
@ -75,4 +75,17 @@ RSpec.describe Gitlab::GithubImport::Representation::PullRequestReview do
expect(review.submitted_at).to be_nil
end
end
describe '#github_identifiers' do
it 'returns a hash with needed identifiers' do
github_identifiers = {
review_id: 999,
merge_request_id: 42
}
other_attributes = { something_else: '_something_else_' }
review = described_class.new(github_identifiers.merge(other_attributes))
expect(review.github_identifiers).to eq(github_identifiers)
end
end
end

View file

@ -288,4 +288,16 @@ RSpec.describe Gitlab::GithubImport::Representation::PullRequest do
expect(object.truncated_title).to eq('foo')
end
end
describe '#github_identifiers' do
it 'returns a hash with needed identifiers' do
github_identifiers = {
iid: 1
}
other_attributes = { something_else: '_something_else_' }
pr = described_class.new(github_identifiers.merge(other_attributes))
expect(pr.github_identifiers).to eq(github_identifiers.merge(issuable_type: 'MergeRequest'))
end
end
end

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