Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2021-08-17 12:08:42 +00:00
parent 0fd2296553
commit b487021bd3
28 changed files with 346 additions and 426 deletions

View file

@ -1 +1 @@
f6ebdc9e7754cf97776fe388f3b0914ae123f5ad
633a58b478d6fcffc59fe600fe8dcbd66bec42f1

View file

@ -11,7 +11,12 @@ import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
defaultClient: createDefaultClient(
{},
{
assumeImmutableResults: true,
},
),
});
export default (params = {}) => {

View file

@ -0,0 +1,50 @@
import { Mark, markInputRule, mergeAttributes } from '@tiptap/core';
export const inputRegexAddition = /(\{\+(.+?)\+\})$/gm;
export const inputRegexDeletion = /(\{-(.+?)-\})$/gm;
export default Mark.create({
name: 'inlineDiff',
defaultOptions: {
HTMLAttributes: {},
},
addAttributes() {
return {
type: {
default: 'addition',
parseHTML: (element) => {
return {
type: element.classList.contains('deletion') ? 'deletion' : 'addition',
};
},
},
};
},
parseHTML() {
return [
{
tag: 'span.idiff',
},
];
},
renderHTML({ HTMLAttributes: { type, ...HTMLAttributes } }) {
return [
'span',
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
class: `idiff left right ${type}`,
}),
0,
];
},
addInputRules() {
return [
markInputRule(inputRegexAddition, this.type, () => ({ type: 'addition' })),
markInputRule(inputRegexDeletion, this.type, () => ({ type: 'deletion' })),
];
},
});

View file

@ -16,6 +16,7 @@ import Heading from '../extensions/heading';
import History from '../extensions/history';
import HorizontalRule from '../extensions/horizontal_rule';
import Image from '../extensions/image';
import InlineDiff from '../extensions/inline_diff';
import Italic from '../extensions/italic';
import Link from '../extensions/link';
import ListItem from '../extensions/list_item';
@ -74,6 +75,7 @@ export const createContentEditor = ({
History,
HorizontalRule,
Image,
InlineDiff,
Italic,
Link,
ListItem,

View file

@ -13,6 +13,7 @@ import HardBreak from '../extensions/hard_break';
import Heading from '../extensions/heading';
import HorizontalRule from '../extensions/horizontal_rule';
import Image from '../extensions/image';
import InlineDiff from '../extensions/inline_diff';
import Italic from '../extensions/italic';
import Link from '../extensions/link';
import ListItem from '../extensions/list_item';
@ -36,6 +37,15 @@ const defaultSerializerConfig = {
[Italic.name]: { open: '_', close: '_', mixable: true, expelEnclosingWhitespace: true },
[Subscript.name]: { open: '<sub>', close: '</sub>', mixable: true },
[Superscript.name]: { open: '<sup>', close: '</sup>', mixable: true },
[InlineDiff.name]: {
mixable: true,
open(state, mark) {
return mark.attrs.type === 'addition' ? '{+' : '{-';
},
close(state, mark) {
return mark.attrs.type === 'addition' ? '+}' : '-}';
},
},
[Link.name]: {
open() {
return '[';

View file

@ -1,27 +1,19 @@
<script>
import {
GlToken,
GlFilteredSearchToken,
GlFilteredSearchSuggestion,
GlDropdownDivider,
GlLoadingIcon,
} from '@gitlab/ui';
import { debounce } from 'lodash';
import { GlFilteredSearchSuggestion } from '@gitlab/ui';
import createFlash from '~/flash';
import { __ } from '~/locale';
import { DEBOUNCE_DELAY } from '../constants';
import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue';
export default {
components: {
GlToken,
GlFilteredSearchToken,
BaseToken,
GlFilteredSearchSuggestion,
GlDropdownDivider,
GlLoadingIcon,
},
props: {
active: {
type: Boolean,
required: true,
},
config: {
type: Object,
required: true,
@ -34,82 +26,62 @@ export default {
data() {
return {
branches: this.config.initialBranches || [],
defaultBranches: this.config.defaultBranches || [],
loading: true,
loading: false,
};
},
computed: {
currentValue() {
return this.value.data.toLowerCase();
},
activeBranch() {
return this.branches.find((branch) => branch.name.toLowerCase() === this.currentValue);
},
},
watch: {
active: {
immediate: true,
handler(newValue) {
if (!newValue && !this.branches.length) {
this.fetchBranchBySearchTerm(this.value.data);
}
},
defaultBranches() {
return this.config.defaultBranches || [];
},
},
methods: {
fetchBranchBySearchTerm(searchTerm) {
getActiveBranch(branches, data) {
return branches.find((branch) => branch.name.toLowerCase() === data.toLowerCase());
},
fetchBranches(searchTerm) {
this.loading = true;
this.config
.fetchBranches(searchTerm)
.then(({ data }) => {
this.branches = data;
})
.catch(() => createFlash({ message: __('There was a problem fetching branches.') }))
.catch(() => {
createFlash({ message: __('There was a problem fetching branches.') });
})
.finally(() => {
this.loading = false;
});
},
searchBranches: debounce(function debouncedSearch({ data }) {
this.fetchBranchBySearchTerm(data);
}, DEBOUNCE_DELAY),
},
};
</script>
<template>
<gl-filtered-search-token
<base-token
:active="active"
:config="config"
v-bind="{ ...$props, ...$attrs }"
:value="value"
:default-suggestions="defaultBranches"
:suggestions="branches"
:suggestions-loading="loading"
:get-active-token-value="getActiveBranch"
@fetch-suggestions="fetchBranches"
v-on="$listeners"
@input="searchBranches"
>
<template #view-token="{ inputValue }">
<gl-token variant="search-value">{{
activeBranch ? activeBranch.name : inputValue
}}</gl-token>
<template #view="{ viewTokenProps: { inputValue, activeTokenValue } }">
{{ activeTokenValue ? activeTokenValue.name : inputValue }}
</template>
<template #suggestions>
<template #suggestions-list="{ suggestions }">
<gl-filtered-search-suggestion
v-for="branch in defaultBranches"
:key="branch.value"
:value="branch.value"
v-for="branch in suggestions"
:key="branch.id"
:value="branch.name"
>
{{ branch.text }}
<div class="gl-display-flex">
<span class="gl-display-inline-block gl-mr-3 gl-p-3"></span>
{{ branch.name }}
</div>
</gl-filtered-search-suggestion>
<gl-dropdown-divider v-if="defaultBranches.length" />
<gl-loading-icon v-if="loading" size="sm" />
<template v-else>
<gl-filtered-search-suggestion
v-for="branch in branches"
:key="branch.id"
:value="branch.name"
>
<div class="gl-display-flex">
<span class="gl-display-inline-block gl-mr-3 gl-p-3"></span>
<div>{{ branch.name }}</div>
</div>
</gl-filtered-search-suggestion>
</template>
</template>
</gl-filtered-search-token>
</base-token>
</template>

View file

@ -1,26 +1,21 @@
<script>
import {
GlFilteredSearchToken,
GlFilteredSearchSuggestion,
GlDropdownDivider,
GlLoadingIcon,
} from '@gitlab/ui';
import { debounce } from 'lodash';
import { GlFilteredSearchSuggestion } from '@gitlab/ui';
import createFlash from '~/flash';
import { __ } from '~/locale';
import { DEBOUNCE_DELAY, DEFAULT_NONE_ANY } from '../constants';
import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue';
import { DEFAULT_NONE_ANY } from '../constants';
import { stripQuotes } from '../filtered_search_utils';
export default {
components: {
GlFilteredSearchToken,
BaseToken,
GlFilteredSearchSuggestion,
GlDropdownDivider,
GlLoadingIcon,
},
props: {
active: {
type: Boolean,
required: true,
},
config: {
type: Object,
required: true,
@ -33,87 +28,63 @@ export default {
data() {
return {
emojis: this.config.initialEmojis || [],
defaultEmojis: this.config.defaultEmojis || DEFAULT_NONE_ANY,
loading: true,
loading: false,
};
},
computed: {
currentValue() {
return this.value.data.toLowerCase();
},
activeEmoji() {
return this.emojis.find(
(emoji) => emoji.name.toLowerCase() === stripQuotes(this.currentValue),
);
},
},
watch: {
active: {
immediate: true,
handler(newValue) {
if (!newValue && !this.emojis.length) {
this.fetchEmojiBySearchTerm(this.value.data);
}
},
defaultEmojis() {
return this.config.defaultEmojis || DEFAULT_NONE_ANY;
},
},
methods: {
fetchEmojiBySearchTerm(searchTerm) {
getActiveEmoji(emojis, data) {
return emojis.find((emoji) => emoji.name.toLowerCase() === stripQuotes(data).toLowerCase());
},
fetchEmojis(searchTerm) {
this.loading = true;
this.config
.fetchEmojis(searchTerm)
.then((res) => {
this.emojis = Array.isArray(res) ? res : res.data;
.then((response) => {
this.emojis = Array.isArray(response) ? response : response.data;
})
.catch(() => {
createFlash({ message: __('There was a problem fetching emojis.') });
})
.catch(() =>
createFlash({
message: __('There was a problem fetching emojis.'),
}),
)
.finally(() => {
this.loading = false;
});
},
searchEmojis: debounce(function debouncedSearch({ data }) {
this.fetchEmojiBySearchTerm(data);
}, DEBOUNCE_DELAY),
},
};
</script>
<template>
<gl-filtered-search-token
<base-token
:active="active"
:config="config"
v-bind="{ ...$props, ...$attrs }"
:value="value"
:default-suggestions="defaultEmojis"
:suggestions="emojis"
:suggestions-loading="loading"
:get-active-token-value="getActiveEmoji"
@fetch-suggestions="fetchEmojis"
v-on="$listeners"
@input="searchEmojis"
>
<template #view="{ inputValue }">
<gl-emoji v-if="activeEmoji" :data-name="activeEmoji.name" />
<span v-else>{{ inputValue }}</span>
<template #view="{ viewTokenProps: { inputValue, activeTokenValue } }">
<gl-emoji v-if="activeTokenValue" :data-name="activeTokenValue.name" />
<template v-else>{{ inputValue }}</template>
</template>
<template #suggestions>
<template #suggestions-list="{ suggestions }">
<gl-filtered-search-suggestion
v-for="emoji in defaultEmojis"
:key="emoji.value"
:value="emoji.value"
v-for="emoji in suggestions"
:key="emoji.name"
:value="emoji.name"
>
{{ emoji.value }}
<div class="gl-display-flex">
<gl-emoji class="gl-mr-3" :data-name="emoji.name" />
{{ emoji.name }}
</div>
</gl-filtered-search-suggestion>
<gl-dropdown-divider v-if="defaultEmojis.length" />
<gl-loading-icon v-if="loading" size="sm" />
<template v-else>
<gl-filtered-search-suggestion
v-for="emoji in emojis"
:key="emoji.name"
:value="emoji.name"
>
<div class="gl-display-flex">
<gl-emoji :data-name="emoji.name" />
<span class="gl-ml-3">{{ emoji.name }}</span>
</div>
</gl-filtered-search-suggestion>
</template>
</template>
</gl-filtered-search-token>
</base-token>
</template>

View file

@ -1,24 +1,21 @@
<script>
import {
GlDropdownDivider,
GlFilteredSearchSuggestion,
GlFilteredSearchToken,
GlLoadingIcon,
} from '@gitlab/ui';
import { debounce } from 'lodash';
import { GlFilteredSearchSuggestion } from '@gitlab/ui';
import createFlash from '~/flash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { __ } from '~/locale';
import { DEBOUNCE_DELAY, DEFAULT_ITERATIONS } from '../constants';
import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue';
import { DEFAULT_ITERATIONS } from '../constants';
export default {
components: {
GlDropdownDivider,
BaseToken,
GlFilteredSearchSuggestion,
GlFilteredSearchToken,
GlLoadingIcon,
},
props: {
active: {
type: Boolean,
required: true,
},
config: {
type: Object,
required: true,
@ -35,84 +32,58 @@ export default {
};
},
computed: {
currentValue() {
return this.value.data;
},
activeIteration() {
return this.iterations.find(
(iteration) => getIdFromGraphQLId(iteration.id) === Number(this.currentValue),
);
},
defaultIterations() {
return this.config.defaultIterations || DEFAULT_ITERATIONS;
},
},
watch: {
active: {
immediate: true,
handler(newValue) {
if (!newValue && !this.iterations.length) {
this.fetchIterationBySearchTerm(this.currentValue);
}
},
},
},
methods: {
getValue(iteration) {
return String(getIdFromGraphQLId(iteration.id));
getActiveIteration(iterations, data) {
return iterations.find((iteration) => this.getValue(iteration) === data);
},
fetchIterationBySearchTerm(searchTerm) {
const fetchPromise = this.config.fetchPath
? this.config.fetchIterations(this.config.fetchPath, searchTerm)
: this.config.fetchIterations(searchTerm);
fetchIterations(searchTerm) {
this.loading = true;
fetchPromise
this.config
.fetchIterations(searchTerm)
.then((response) => {
this.iterations = Array.isArray(response) ? response : response.data;
})
.catch(() => createFlash({ message: __('There was a problem fetching iterations.') }))
.catch(() => {
createFlash({ message: __('There was a problem fetching iterations.') });
})
.finally(() => {
this.loading = false;
});
},
searchIterations: debounce(function debouncedSearch({ data }) {
this.fetchIterationBySearchTerm(data);
}, DEBOUNCE_DELAY),
getValue(iteration) {
return String(getIdFromGraphQLId(iteration.id));
},
},
};
</script>
<template>
<gl-filtered-search-token
<base-token
:active="active"
:config="config"
v-bind="{ ...$props, ...$attrs }"
:value="value"
:default-suggestions="defaultIterations"
:suggestions="iterations"
:suggestions-loading="loading"
:get-active-token-value="getActiveIteration"
@fetch-suggestions="fetchIterations"
v-on="$listeners"
@input="searchIterations"
>
<template #view="{ inputValue }">
{{ activeIteration ? activeIteration.title : inputValue }}
<template #view="{ viewTokenProps: { inputValue, activeTokenValue } }">
{{ activeTokenValue ? activeTokenValue.title : inputValue }}
</template>
<template #suggestions>
<template #suggestions-list="{ suggestions }">
<gl-filtered-search-suggestion
v-for="iteration in defaultIterations"
:key="iteration.value"
:value="iteration.value"
v-for="iteration in suggestions"
:key="iteration.id"
:value="getValue(iteration)"
>
{{ iteration.text }}
{{ iteration.title }}
</gl-filtered-search-suggestion>
<gl-dropdown-divider v-if="defaultIterations.length" />
<gl-loading-icon v-if="loading" size="sm" />
<template v-else>
<gl-filtered-search-suggestion
v-for="iteration in iterations"
:key="iteration.id"
:value="getValue(iteration)"
>
{{ iteration.title }}
</gl-filtered-search-suggestion>
</template>
</template>
</gl-filtered-search-token>
</base-token>
</template>

View file

@ -1,27 +1,22 @@
<script>
import {
GlFilteredSearchToken,
GlFilteredSearchSuggestion,
GlDropdownDivider,
GlLoadingIcon,
} from '@gitlab/ui';
import { debounce } from 'lodash';
import { GlFilteredSearchSuggestion } from '@gitlab/ui';
import createFlash from '~/flash';
import { __ } from '~/locale';
import { sortMilestonesByDueDate } from '~/milestones/milestone_utils';
import { DEFAULT_MILESTONES, DEBOUNCE_DELAY } from '../constants';
import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue';
import { DEFAULT_MILESTONES } from '../constants';
import { stripQuotes } from '../filtered_search_utils';
export default {
components: {
GlFilteredSearchToken,
BaseToken,
GlFilteredSearchSuggestion,
GlDropdownDivider,
GlLoadingIcon,
},
props: {
active: {
type: Boolean,
required: true,
},
config: {
type: Object,
required: true,
@ -34,36 +29,21 @@ export default {
data() {
return {
milestones: this.config.initialMilestones || [],
defaultMilestones: this.config.defaultMilestones || DEFAULT_MILESTONES,
loading: false,
};
},
computed: {
currentValue() {
return this.value.data.toLowerCase();
},
activeMilestone() {
return this.milestones.find(
(milestone) => milestone.title.toLowerCase() === stripQuotes(this.currentValue),
);
},
},
watch: {
active: {
immediate: true,
handler(newValue) {
if (!newValue && !this.milestones.length) {
this.fetchMilestoneBySearchTerm(this.value.data);
}
},
defaultMilestones() {
return this.config.defaultMilestones || DEFAULT_MILESTONES;
},
},
methods: {
fetchMilestoneBySearchTerm(searchTerm = '') {
if (this.loading) {
return;
}
getActiveMilestone(milestones, data) {
return milestones.find(
(milestone) => milestone.title.toLowerCase() === stripQuotes(data).toLowerCase(),
);
},
fetchMilestones(searchTerm) {
this.loading = true;
this.config
.fetchMilestones(searchTerm)
@ -71,47 +51,40 @@ export default {
const data = Array.isArray(response) ? response : response.data;
this.milestones = data.slice().sort(sortMilestonesByDueDate);
})
.catch(() => createFlash({ message: __('There was a problem fetching milestones.') }))
.catch(() => {
createFlash({ message: __('There was a problem fetching milestones.') });
})
.finally(() => {
this.loading = false;
});
},
searchMilestones: debounce(function debouncedSearch({ data }) {
this.fetchMilestoneBySearchTerm(data);
}, DEBOUNCE_DELAY),
},
};
</script>
<template>
<gl-filtered-search-token
<base-token
:active="active"
:config="config"
v-bind="{ ...$props, ...$attrs }"
:value="value"
:default-suggestions="defaultMilestones"
:suggestions="milestones"
:suggestions-loading="loading"
:get-active-token-value="getActiveMilestone"
@fetch-suggestions="fetchMilestones"
v-on="$listeners"
@input="searchMilestones"
>
<template #view="{ inputValue }">
<span>%{{ activeMilestone ? activeMilestone.title : inputValue }}</span>
<template #view="{ viewTokenProps: { inputValue, activeTokenValue } }">
%{{ activeTokenValue ? activeTokenValue.title : inputValue }}
</template>
<template #suggestions>
<template #suggestions-list="{ suggestions }">
<gl-filtered-search-suggestion
v-for="milestone in defaultMilestones"
:key="milestone.value"
:value="milestone.value"
v-for="milestone in suggestions"
:key="milestone.id"
:value="milestone.title"
>
{{ milestone.text }}
{{ milestone.title }}
</gl-filtered-search-suggestion>
<gl-dropdown-divider v-if="defaultMilestones.length" />
<gl-loading-icon v-if="loading" size="sm" />
<template v-else>
<gl-filtered-search-suggestion
v-for="milestone in milestones"
:key="milestone.id"
:value="milestone.title"
>
<div>{{ milestone.title }}</div>
</gl-filtered-search-suggestion>
</template>
</template>
</gl-filtered-search-token>
</base-token>
</template>

View file

@ -1,16 +1,20 @@
<script>
import { GlDropdownDivider, GlFilteredSearchSuggestion, GlFilteredSearchToken } from '@gitlab/ui';
import { GlFilteredSearchSuggestion } from '@gitlab/ui';
import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue';
import { DEFAULT_NONE_ANY, WEIGHT_TOKEN_SUGGESTIONS_SIZE } from '../constants';
const weights = Array.from(Array(WEIGHT_TOKEN_SUGGESTIONS_SIZE), (_, index) => index.toString());
export default {
components: {
GlDropdownDivider,
BaseToken,
GlFilteredSearchSuggestion,
GlFilteredSearchToken,
},
props: {
active: {
type: Boolean,
required: true,
},
config: {
type: Object,
required: true,
@ -23,12 +27,19 @@ export default {
data() {
return {
weights,
defaultWeights: this.config.defaultWeights || DEFAULT_NONE_ANY,
};
},
computed: {
defaultWeights() {
return this.config.defaultWeights || DEFAULT_NONE_ANY;
},
},
methods: {
updateWeights({ data }) {
const weight = parseInt(data, 10);
getActiveWeight(weightSuggestions, data) {
return weightSuggestions.find((weight) => weight === data);
},
updateWeights(searchTerm) {
const weight = parseInt(searchTerm, 10);
this.weights = Number.isNaN(weight) ? weights : [String(weight)];
},
},
@ -36,24 +47,20 @@ export default {
</script>
<template>
<gl-filtered-search-token
<base-token
:active="active"
:config="config"
v-bind="{ ...$props, ...$attrs }"
:value="value"
:default-suggestions="defaultWeights"
:suggestions="weights"
:get-active-token-value="getActiveWeight"
@fetch-suggestions="updateWeights"
v-on="$listeners"
@input="updateWeights"
>
<template #suggestions>
<gl-filtered-search-suggestion
v-for="weight in defaultWeights"
:key="weight.value"
:value="weight.value"
>
{{ weight.text }}
</gl-filtered-search-suggestion>
<gl-dropdown-divider v-if="defaultWeights.length" />
<gl-filtered-search-suggestion v-for="weight of weights" :key="weight" :value="weight">
<template #suggestions-list="{ suggestions }">
<gl-filtered-search-suggestion v-for="weight of suggestions" :key="weight" :value="weight">
{{ weight }}
</gl-filtered-search-suggestion>
</template>
</gl-filtered-search-token>
</base-token>
</template>

View file

@ -296,7 +296,7 @@ class Group < Namespace
end
def add_users(users, access_level, current_user: nil, expires_at: nil)
Members::Groups::CreatorService.add_users( # rubocop:todo CodeReuse/ServiceClass
Members::Groups::CreatorService.add_users( # rubocop:disable CodeReuse/ServiceClass
self,
users,
access_level,
@ -306,7 +306,7 @@ class Group < Namespace
end
def add_user(user, access_level, current_user: nil, expires_at: nil, ldap: false)
Members::Groups::CreatorService.new(self, # rubocop:todo CodeReuse/ServiceClass
Members::Groups::CreatorService.new(self, # rubocop:disable CodeReuse/ServiceClass
user,
access_level,
current_user: current_user,

View file

@ -44,7 +44,7 @@ class ProjectMember < Member
project_ids.each do |project_id|
project = Project.find(project_id)
Members::Projects::CreatorService.add_users( # rubocop:todo CodeReuse/ServiceClass
Members::Projects::CreatorService.add_users( # rubocop:disable CodeReuse/ServiceClass
project,
users,
access_level,

View file

@ -16,7 +16,7 @@ class NotificationSetting < ApplicationRecord
validates :user_id, uniqueness: { scope: [:source_type, :source_id],
message: "already exists in source",
allow_nil: true }
validate :owns_notification_email, if: :notification_email_changed?
validate :notification_email_verified, if: :notification_email_changed?
scope :for_groups, -> { where(source_type: 'Namespace') }
@ -110,11 +110,11 @@ class NotificationSetting < ApplicationRecord
has_attribute?(event) && !!read_attribute(event)
end
def owns_notification_email
def notification_email_verified
return if user.temp_oauth_email?
return if notification_email.empty?
errors.add(:notification_email, _("is not an email you own")) unless user.verified_emails.include?(notification_email)
errors.add(:notification_email, _("must be an email you have verified")) unless user.verified_emails.include?(notification_email)
end
end

View file

@ -42,7 +42,7 @@ class ProjectTeam
end
def add_users(users, access_level, current_user: nil, expires_at: nil)
Members::Projects::CreatorService.add_users( # rubocop:todo CodeReuse/ServiceClass
Members::Projects::CreatorService.add_users( # rubocop:disable CodeReuse/ServiceClass
project,
users,
access_level,
@ -52,7 +52,7 @@ class ProjectTeam
end
def add_user(user, access_level, current_user: nil, expires_at: nil)
Members::Projects::CreatorService.new(project, # rubocop:todo CodeReuse/ServiceClass
Members::Projects::CreatorService.new(project, # rubocop:disable CodeReuse/ServiceClass
user,
access_level,
current_user: current_user,

View file

@ -224,7 +224,7 @@ class User < ApplicationRecord
validates :email, confirmation: true
validates :notification_email, presence: true
validates :notification_email, devise_email: true, if: ->(user) { user.notification_email != user.email }
validates :public_email, presence: true, uniqueness: true, devise_email: true, allow_blank: true
validates :public_email, uniqueness: true, devise_email: true, allow_blank: true
validates :commit_email, devise_email: true, allow_nil: true, if: ->(user) { user.commit_email != user.email }
validates :projects_limit,
presence: true,
@ -235,9 +235,9 @@ class User < ApplicationRecord
validate :namespace_move_dir_allowed, if: :username_changed?
validate :unique_email, if: :email_changed?
validate :owns_notification_email, if: :notification_email_changed?
validate :owns_public_email, if: :public_email_changed?
validate :owns_commit_email, if: :commit_email_changed?
validate :notification_email_verified, if: :notification_email_changed?
validate :public_email_verified, if: :public_email_changed?
validate :commit_email_verified, if: :commit_email_changed?
validate :signup_email_valid?, on: :create, if: ->(user) { !user.created_by_id }
validate :check_username_format, if: :username_changed?
@ -928,22 +928,22 @@ class User < ApplicationRecord
end
end
def owns_notification_email
def notification_email_verified
return if new_record? || temp_oauth_email?
errors.add(:notification_email, _("is not an email you own")) unless verified_emails.include?(notification_email)
errors.add(:notification_email, _("must be an email you have verified")) unless verified_emails.include?(notification_email)
end
def owns_public_email
def public_email_verified
return if public_email.blank?
errors.add(:public_email, _("is not an email you own")) unless verified_emails.include?(public_email)
errors.add(:public_email, _("must be an email you have verified")) unless verified_emails.include?(public_email)
end
def owns_commit_email
def commit_email_verified
return if read_attribute(:commit_email).blank?
errors.add(:commit_email, _("is not an email you own")) unless verified_emails.include?(commit_email)
errors.add(:commit_email, _("must be an email you have verified")) unless verified_emails.include?(commit_email)
end
# Define commit_email-related attribute methods explicitly instead of relying

View file

@ -70,10 +70,10 @@
= render 'projects/mirrors/disabled_mirror_badge'
- if mirror.last_error.present?
.badge.mirror-error-badge{ data: { toggle: 'tooltip', html: 'true', qa_selector: 'mirror_error_badge' }, title: html_escape(mirror.last_error.try(:strip)) }= _('Error')
%td
%td.gl-display-flex
- if mirror_settings_enabled
.btn-group.mirror-actions-group.float-right{ role: 'group' }
%button.js-delete-mirror.qa-delete-mirror.rspec-delete-mirror.btn.btn-icon.gl-button.btn-danger.gl-mr-3{ type: 'button', data: { mirror_id: mirror.id, toggle: 'tooltip', container: 'body' }, title: _('Remove') }= sprite_icon('remove')
.btn-group.mirror-actions-group{ role: 'group' }
- if mirror.ssh_key_auth?
= clipboard_button(text: mirror.ssh_public_key, class: 'gl-button btn btn-default', title: _('Copy SSH public key'), qa_selector: 'copy_public_key_button')
= render 'shared/remote_mirror_update_button', remote_mirror: mirror
%button.js-delete-mirror.qa-delete-mirror.rspec-delete-mirror.btn.btn-icon.gl-button.btn-danger{ type: 'button', data: { mirror_id: mirror.id, toggle: 'tooltip', container: 'body' }, title: _('Remove') }= sprite_icon('remove')

View file

@ -450,11 +450,11 @@ You can learn more about the different ways Git can undo changes in the
### Merge a branch with default branch
When you are ready to add your changes to
the default branch, you `merge` the two together:
the default branch, you merge the feature branch into it:
```shell
git checkout <feature-branch>
git merge <default-branch>
git checkout <default-branch>
git merge <feature-branch>
```
In GitLab, you typically use a [merge request](../user/project/merge_requests/) to merge your changes, instead of using the command line.

View file

@ -22179,9 +22179,6 @@ msgstr ""
msgid "NetworkPolicies|Save changes"
msgstr ""
msgid "NetworkPolicies|Scan Execution"
msgstr ""
msgid "NetworkPolicies|Something went wrong, failed to update policy"
msgstr ""
@ -29556,6 +29553,9 @@ msgstr ""
msgid "SecurityConfiguration|Vulnerability details and statistics in the merge request"
msgstr ""
msgid "SecurityOrchestration|All policies"
msgstr ""
msgid "SecurityOrchestration|An error occurred assigning your security policy project"
msgstr ""
@ -29568,6 +29568,9 @@ msgstr ""
msgid "SecurityOrchestration|Enforce security for this project. %{linkStart}More information.%{linkEnd}"
msgstr ""
msgid "SecurityOrchestration|Network"
msgstr ""
msgid "SecurityOrchestration|New policy"
msgstr ""
@ -29580,6 +29583,12 @@ msgstr ""
msgid "SecurityOrchestration|Policy editor"
msgstr ""
msgid "SecurityOrchestration|Scan Execution"
msgstr ""
msgid "SecurityOrchestration|Scan execution"
msgstr ""
msgid "SecurityOrchestration|Security policy project was linked successfully"
msgstr ""
@ -29598,9 +29607,6 @@ msgstr ""
msgid "SecurityPolicies|+%{count} more"
msgstr ""
msgid "SecurityPolicies|All policies"
msgstr ""
msgid "SecurityPolicies|Description"
msgstr ""
@ -29613,9 +29619,6 @@ msgstr ""
msgid "SecurityPolicies|Latest scan"
msgstr ""
msgid "SecurityPolicies|Network"
msgstr ""
msgid "SecurityPolicies|Policy type"
msgstr ""
@ -39476,9 +39479,6 @@ msgstr ""
msgid "is not allowed. We do not currently support project-level iterations"
msgstr ""
msgid "is not an email you own"
msgstr ""
msgid "is not from an allowed domain."
msgstr ""
@ -39917,6 +39917,9 @@ msgstr ""
msgid "must be after start"
msgstr ""
msgid "must be an email you have verified"
msgstr ""
msgid "must be greater than start date"
msgstr ""

View file

@ -57,9 +57,9 @@
"@babel/preset-env": "^7.10.1",
"@gitlab/at.js": "1.5.7",
"@gitlab/favicon-overlay": "2.0.0",
"@gitlab/svgs": "1.210.0",
"@gitlab/svgs": "1.211.0",
"@gitlab/tributejs": "1.0.0",
"@gitlab/ui": "32.2.0",
"@gitlab/ui": "32.2.1",
"@gitlab/visual-review-tools": "1.6.1",
"@rails/actioncable": "6.1.3-2",
"@rails/ujs": "6.1.3-2",

View file

@ -0,0 +1,27 @@
import { inputRegexAddition, inputRegexDeletion } from '~/content_editor/extensions/inline_diff';
describe('content_editor/extensions/inline_diff', () => {
describe.each`
inputRegex | description | input | matches
${inputRegexAddition} | ${'inputRegexAddition'} | ${'hello{+world+}'} | ${true}
${inputRegexAddition} | ${'inputRegexAddition'} | ${'hello{+ world +}'} | ${true}
${inputRegexAddition} | ${'inputRegexAddition'} | ${'hello {+ world+}'} | ${true}
${inputRegexAddition} | ${'inputRegexAddition'} | ${'{+hello world +}'} | ${true}
${inputRegexAddition} | ${'inputRegexAddition'} | ${'{+hello with \nnewline+}'} | ${false}
${inputRegexAddition} | ${'inputRegexAddition'} | ${'{+open only'} | ${false}
${inputRegexAddition} | ${'inputRegexAddition'} | ${'close only+}'} | ${false}
${inputRegexDeletion} | ${'inputRegexDeletion'} | ${'hello{-world-}'} | ${true}
${inputRegexDeletion} | ${'inputRegexDeletion'} | ${'hello{- world -}'} | ${true}
${inputRegexDeletion} | ${'inputRegexDeletion'} | ${'hello {- world-}'} | ${true}
${inputRegexDeletion} | ${'inputRegexDeletion'} | ${'{-hello world -}'} | ${true}
${inputRegexDeletion} | ${'inputRegexDeletion'} | ${'{+hello with \nnewline+}'} | ${false}
${inputRegexDeletion} | ${'inputRegexDeletion'} | ${'{-open only'} | ${false}
${inputRegexDeletion} | ${'inputRegexDeletion'} | ${'close only-}'} | ${false}
`('$description', ({ inputRegex, input, matches }) => {
it(`${matches ? 'matches' : 'does not match'}: "${input}"`, () => {
const match = new RegExp(inputRegex).test(input);
expect(match).toBe(matches);
});
});
});

View file

@ -8,6 +8,10 @@
markdown: '_emphasized text_'
- name: inline_code
markdown: '`code`'
- name: inline_diff
markdown: |-
* {-deleted-}
* {+added+}
- name: subscript
markdown: H<sub>2</sub>O
- name: superscript

View file

@ -61,40 +61,16 @@ describe('BranchToken', () => {
wrapper.destroy();
});
describe('computed', () => {
beforeEach(async () => {
wrapper = createComponent({ value: { data: mockBranches[0].name } });
wrapper.setData({
branches: mockBranches,
});
await wrapper.vm.$nextTick();
});
describe('currentValue', () => {
it('returns lowercase string for `value.data`', () => {
expect(wrapper.vm.currentValue).toBe('main');
});
});
describe('activeBranch', () => {
it('returns object for currently present `value.data`', () => {
expect(wrapper.vm.activeBranch).toEqual(mockBranches[0]);
});
});
});
describe('methods', () => {
beforeEach(() => {
wrapper = createComponent();
});
describe('fetchBranchBySearchTerm', () => {
describe('fetchBranches', () => {
it('calls `config.fetchBranches` with provided searchTerm param', () => {
jest.spyOn(wrapper.vm.config, 'fetchBranches');
wrapper.vm.fetchBranchBySearchTerm('foo');
wrapper.vm.fetchBranches('foo');
expect(wrapper.vm.config.fetchBranches).toHaveBeenCalledWith('foo');
});
@ -102,7 +78,7 @@ describe('BranchToken', () => {
it('sets response to `branches` when request is succesful', () => {
jest.spyOn(wrapper.vm.config, 'fetchBranches').mockResolvedValue({ data: mockBranches });
wrapper.vm.fetchBranchBySearchTerm('foo');
wrapper.vm.fetchBranches('foo');
return waitForPromises().then(() => {
expect(wrapper.vm.branches).toEqual(mockBranches);
@ -112,7 +88,7 @@ describe('BranchToken', () => {
it('calls `createFlash` with flash error message when request fails', () => {
jest.spyOn(wrapper.vm.config, 'fetchBranches').mockRejectedValue({});
wrapper.vm.fetchBranchBySearchTerm('foo');
wrapper.vm.fetchBranches('foo');
return waitForPromises().then(() => {
expect(createFlash).toHaveBeenCalledWith({
@ -124,7 +100,7 @@ describe('BranchToken', () => {
it('sets `loading` to false when request completes', () => {
jest.spyOn(wrapper.vm.config, 'fetchBranches').mockRejectedValue({});
wrapper.vm.fetchBranchBySearchTerm('foo');
wrapper.vm.fetchBranches('foo');
return waitForPromises().then(() => {
expect(wrapper.vm.loading).toBe(false);

View file

@ -67,40 +67,16 @@ describe('EmojiToken', () => {
wrapper.destroy();
});
describe('computed', () => {
beforeEach(async () => {
wrapper = createComponent({ value: { data: mockEmojis[0].name } });
wrapper.setData({
emojis: mockEmojis,
});
await wrapper.vm.$nextTick();
});
describe('currentValue', () => {
it('returns lowercase string for `value.data`', () => {
expect(wrapper.vm.currentValue).toBe(mockEmojis[0].name);
});
});
describe('activeEmoji', () => {
it('returns object for currently present `value.data`', () => {
expect(wrapper.vm.activeEmoji).toEqual(mockEmojis[0]);
});
});
});
describe('methods', () => {
beforeEach(() => {
wrapper = createComponent();
});
describe('fetchEmojiBySearchTerm', () => {
describe('fetchEmojis', () => {
it('calls `config.fetchEmojis` with provided searchTerm param', () => {
jest.spyOn(wrapper.vm.config, 'fetchEmojis');
wrapper.vm.fetchEmojiBySearchTerm('foo');
wrapper.vm.fetchEmojis('foo');
expect(wrapper.vm.config.fetchEmojis).toHaveBeenCalledWith('foo');
});
@ -108,7 +84,7 @@ describe('EmojiToken', () => {
it('sets response to `emojis` when request is successful', () => {
jest.spyOn(wrapper.vm.config, 'fetchEmojis').mockResolvedValue(mockEmojis);
wrapper.vm.fetchEmojiBySearchTerm('foo');
wrapper.vm.fetchEmojis('foo');
return waitForPromises().then(() => {
expect(wrapper.vm.emojis).toEqual(mockEmojis);
@ -118,7 +94,7 @@ describe('EmojiToken', () => {
it('calls `createFlash` with flash error message when request fails', () => {
jest.spyOn(wrapper.vm.config, 'fetchEmojis').mockRejectedValue({});
wrapper.vm.fetchEmojiBySearchTerm('foo');
wrapper.vm.fetchEmojis('foo');
return waitForPromises().then(() => {
expect(createFlash).toHaveBeenCalledWith({
@ -130,7 +106,7 @@ describe('EmojiToken', () => {
it('sets `loading` to false when request completes', () => {
jest.spyOn(wrapper.vm.config, 'fetchEmojis').mockRejectedValue({});
wrapper.vm.fetchEmojiBySearchTerm('foo');
wrapper.vm.fetchEmojis('foo');
return waitForPromises().then(() => {
expect(wrapper.vm.loading).toBe(false);

View file

@ -1,5 +1,6 @@
import { GlFilteredSearchToken, GlFilteredSearchTokenSegment } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
import IterationToken from '~/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue';
import { mockIterationToken } from '../mock_data';
@ -13,6 +14,7 @@ describe('IterationToken', () => {
const createComponent = ({ config = mockIterationToken, value = { data: '' } } = {}) =>
mount(IterationToken, {
propsData: {
active: false,
config,
value,
},
@ -69,7 +71,7 @@ describe('IterationToken', () => {
config: { ...mockIterationToken, fetchIterations: fetchIterationsSpy },
});
await wrapper.vm.$nextTick();
await waitForPromises();
expect(createFlash).toHaveBeenCalledWith({
message: 'There was a problem fetching iterations.',

View file

@ -14,12 +14,7 @@ import { sortMilestonesByDueDate } from '~/milestones/milestone_utils';
import { DEFAULT_MILESTONES } from '~/vue_shared/components/filtered_search_bar/constants';
import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue';
import {
mockMilestoneToken,
mockMilestones,
mockRegularMilestone,
mockEscapedMilestone,
} from '../mock_data';
import { mockMilestoneToken, mockMilestones, mockRegularMilestone } from '../mock_data';
jest.mock('~/flash');
jest.mock('~/milestones/milestone_utils');
@ -70,37 +65,12 @@ describe('MilestoneToken', () => {
wrapper.destroy();
});
describe('computed', () => {
beforeEach(async () => {
// Milestone title with spaces is always enclosed in quotations by component.
wrapper = createComponent({ value: { data: `"${mockEscapedMilestone.title}"` } });
wrapper.setData({
milestones: mockMilestones,
});
await wrapper.vm.$nextTick();
});
describe('currentValue', () => {
it('returns lowercase string for `value.data`', () => {
expect(wrapper.vm.currentValue).toBe('"5.0 rc1"');
});
});
describe('activeMilestone', () => {
it('returns object for currently present `value.data`', () => {
expect(wrapper.vm.activeMilestone).toEqual(mockEscapedMilestone);
});
});
});
describe('methods', () => {
describe('fetchMilestoneBySearchTerm', () => {
describe('fetchMilestones', () => {
it('calls `config.fetchMilestones` with provided searchTerm param', () => {
jest.spyOn(wrapper.vm.config, 'fetchMilestones');
wrapper.vm.fetchMilestoneBySearchTerm('foo');
wrapper.vm.fetchMilestones('foo');
expect(wrapper.vm.config.fetchMilestones).toHaveBeenCalledWith('foo');
});
@ -110,7 +80,7 @@ describe('MilestoneToken', () => {
data: mockMilestones,
});
wrapper.vm.fetchMilestoneBySearchTerm();
wrapper.vm.fetchMilestones();
return waitForPromises().then(() => {
expect(wrapper.vm.milestones).toEqual(mockMilestones);
@ -121,7 +91,7 @@ describe('MilestoneToken', () => {
it('calls `createFlash` with flash error message when request fails', () => {
jest.spyOn(wrapper.vm.config, 'fetchMilestones').mockRejectedValue({});
wrapper.vm.fetchMilestoneBySearchTerm('foo');
wrapper.vm.fetchMilestones('foo');
return waitForPromises().then(() => {
expect(createFlash).toHaveBeenCalledWith({
@ -133,7 +103,7 @@ describe('MilestoneToken', () => {
it('sets `loading` to false when request completes', () => {
jest.spyOn(wrapper.vm.config, 'fetchMilestones').mockRejectedValue({});
wrapper.vm.fetchMilestoneBySearchTerm('foo');
wrapper.vm.fetchMilestones('foo');
return waitForPromises().then(() => {
expect(wrapper.vm.loading).toBe(false);

View file

@ -12,6 +12,7 @@ describe('WeightToken', () => {
const createComponent = ({ config = mockWeightToken, value = { data: '' } } = {}) =>
mount(WeightToken, {
propsData: {
active: false,
config,
value,
},

View file

@ -704,7 +704,7 @@ RSpec.describe User do
user.notification_email = email.email
expect(user).to be_invalid
expect(user.errors[:notification_email]).to include('is not an email you own')
expect(user.errors[:notification_email]).to include(_('must be an email you have verified'))
end
end
@ -723,7 +723,7 @@ RSpec.describe User do
user.public_email = email.email
expect(user).to be_invalid
expect(user.errors[:public_email]).to include('is not an email you own')
expect(user.errors[:public_email]).to include(_('must be an email you have verified'))
end
end

View file

@ -898,25 +898,25 @@
stylelint-declaration-strict-value "1.7.7"
stylelint-scss "3.18.0"
"@gitlab/svgs@1.210.0":
version "1.210.0"
resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.210.0.tgz#f2d36a2073eb5059fe48a08130145d740c220efa"
integrity sha512-IVALHZKM4QB0djEWJbwgWlpYFD4UF9sqml2SLS5vS/p/FVDeMe7fz7hYOH42xZaZn2iWE6XE9DOVyd9IDNWzPg==
"@gitlab/svgs@1.211.0":
version "1.211.0"
resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.211.0.tgz#0351fa4cc008c4830f366aede535df0a8e63dda6"
integrity sha512-fkHJfmKiy7lDwLFQ6z64sbGL+/hDDLzcMTj8O+VBC1xnlBVAIxe2eIs2DZLJcJwgLWncf4Uovp8+CeEfCY12sw==
"@gitlab/tributejs@1.0.0":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@gitlab/tributejs/-/tributejs-1.0.0.tgz#672befa222aeffc83e7d799b0500a7a4418e59b8"
integrity sha512-nmKw1+hB6MHvlmPz63yPwVs1qQkycHwsKgxpEbzmky16Y6mL4EJMk3w1b8QlOAF/AIAzjCERPhe/R4MJiohbZw==
"@gitlab/ui@32.2.0":
version "32.2.0"
resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-32.2.0.tgz#cfff74221c62292bfe329274381352f2335ca493"
integrity sha512-ASezPNr97rGmIsSbUWkmY9kDBtat/FSnErQYRx/xGYOf9KkCHzVvYV6s8536abAux7LyIIMv5iwtg2U39wEv9A==
"@gitlab/ui@32.2.1":
version "32.2.1"
resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-32.2.1.tgz#e019124af981e8ceffd39f30cf08d315c53d4ac8"
integrity sha512-19qe30gHtBG7g7wJy36bvS+ji1puJwVzAwqJOFXAh3axSa0Getyjyl9t5gmfwmZ2HehFTWLlThXppclnJVCEGA==
dependencies:
"@babel/standalone" "^7.0.0"
bootstrap-vue "2.18.1"
copy-to-clipboard "^3.0.8"
dompurify "^2.3.0"
dompurify "^2.3.1"
echarts "^4.9.0"
highlight.js "^10.6.0"
js-beautify "^1.8.8"
@ -4568,7 +4568,7 @@ domhandler@^4.0.0, domhandler@^4.2.0:
dependencies:
domelementtype "^2.2.0"
dompurify@^2.3.0, dompurify@^2.3.1:
dompurify@^2.3.1:
version "2.3.1"
resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-2.3.1.tgz#a47059ca21fd1212d3c8f71fdea6943b8bfbdf6a"
integrity sha512-xGWt+NHAQS+4tpgbOAI08yxW0Pr256Gu/FNE2frZVTbgrBUn8M7tz7/ktS/LZ2MHeGqz6topj0/xY+y8R5FBFw==