Commit 53785258 authored by GitLab Bot's avatar GitLab Bot

Automatic merge of gitlab-org/gitlab master

parents 8c72d757 829aa50d
......@@ -10,6 +10,11 @@ export default {
TiptapEditorContent,
TopToolbar,
},
provide() {
return {
tiptapEditor: this.contentEditor.tiptapEditor,
};
},
props: {
contentEditor: {
type: ContentEditor,
......@@ -38,7 +43,7 @@ export default {
class="md-area"
:class="{ 'is-focused': contentEditor.tiptapEditor.isFocused }"
>
<top-toolbar ref="toolbar" class="gl-mb-4" :content-editor="contentEditor" />
<top-toolbar ref="toolbar" class="gl-mb-4" />
<tiptap-editor-content class="md" :editor="contentEditor.tiptapEditor" />
</div>
</div>
......
<script>
import { debounce } from 'lodash';
export const tiptapToComponentMap = {
update: 'docUpdate',
selectionUpdate: 'selectionUpdate',
transaction: 'transaction',
};
const getComponentEventName = (tiptapEventName) => tiptapToComponentMap[tiptapEventName];
export default {
inject: ['tiptapEditor'],
created() {
this.disposables = [];
Object.keys(tiptapToComponentMap).forEach((tiptapEvent) => {
const eventHandler = debounce((params) => this.handleTipTapEvent(tiptapEvent, params), 100);
this.tiptapEditor?.on(tiptapEvent, eventHandler);
this.disposables.push(() => this.tiptapEditor?.off(tiptapEvent, eventHandler));
});
},
beforeDestroy() {
this.disposables.forEach((dispose) => dispose());
},
methods: {
handleTipTapEvent(tiptapEvent, params) {
this.$emit(getComponentEventName(tiptapEvent), params);
},
},
render() {
return this.$slots.default;
},
};
</script>
<script>
import { GlButton, GlTooltipDirective as GlTooltip } from '@gitlab/ui';
import { Editor as TiptapEditor } from '@tiptap/vue-2';
import EditorStateObserver from './editor_state_observer.vue';
export default {
components: {
GlButton,
EditorStateObserver,
},
directives: {
GlTooltip,
},
inject: ['tiptapEditor'],
props: {
iconName: {
type: String,
required: true,
},
tiptapEditor: {
type: TiptapEditor,
required: true,
},
contentType: {
type: String,
required: true,
......@@ -32,12 +30,15 @@ export default {
default: '',
},
},
computed: {
isActive() {
return this.tiptapEditor.isActive(this.contentType) && this.tiptapEditor.isFocused;
},
data() {
return {
isActive: null,
};
},
methods: {
updateActive({ editor }) {
this.isActive = editor.isActive(this.contentType) && editor.isFocused;
},
execute() {
const { contentType } = this;
......@@ -51,15 +52,17 @@ export default {
};
</script>
<template>
<gl-button
v-gl-tooltip
category="tertiary"
size="small"
class="gl-mx-2"
:class="{ active: isActive }"
:aria-label="label"
:title="label"
:icon="iconName"
@click="execute"
/>
<editor-state-observer @transaction="updateActive">
<gl-button
v-gl-tooltip
category="tertiary"
size="small"
class="gl-mx-2"
:class="{ active: isActive }"
:aria-label="label"
:title="label"
:icon="iconName"
@click="execute"
/>
</editor-state-observer>
</template>
......@@ -8,7 +8,6 @@ import {
GlDropdownItem,
GlTooltipDirective as GlTooltip,
} from '@gitlab/ui';
import { Editor as TiptapEditor } from '@tiptap/vue-2';
import { acceptedMimes } from '../extensions/image';
import { getImageAlt } from '../services/utils';
......@@ -24,12 +23,7 @@ export default {
directives: {
GlTooltip,
},
props: {
tiptapEditor: {
type: TiptapEditor,
required: true,
},
},
inject: ['tiptapEditor'],
data() {
return {
imgSrc: '',
......
......@@ -8,9 +8,9 @@ import {
GlDropdownItem,
GlTooltipDirective as GlTooltip,
} from '@gitlab/ui';
import { Editor as TiptapEditor } from '@tiptap/vue-2';
import Link from '../extensions/link';
import { hasSelection } from '../services/utils';
import EditorStateObserver from './editor_state_observer.vue';
export default {
components: {
......@@ -20,34 +20,25 @@ export default {
GlDropdownDivider,
GlDropdownItem,
GlButton,
EditorStateObserver,
},
directives: {
GlTooltip,
},
props: {
tiptapEditor: {
type: TiptapEditor,
required: true,
},
},
inject: ['tiptapEditor'],
data() {
return {
linkHref: '',
isActive: false,
};
},
computed: {
isActive() {
return this.tiptapEditor.isActive(Link.name);
},
},
mounted() {
this.tiptapEditor.on('selectionUpdate', ({ editor }) => {
methods: {
updateLinkState({ editor }) {
const { canonicalSrc, href } = editor.getAttributes(Link.name);
this.isActive = editor.isActive(Link.name);
this.linkHref = canonicalSrc || href;
});
},
methods: {
},
updateLink() {
this.tiptapEditor
.chain()
......@@ -78,26 +69,28 @@ export default {
};
</script>
<template>
<gl-dropdown
v-gl-tooltip
:aria-label="__('Insert link')"
:title="__('Insert link')"
:toggle-class="{ active: isActive }"
size="small"
category="tertiary"
icon="link"
@show="selectLink()"
>
<gl-dropdown-form class="gl-px-3!">
<gl-form-input-group v-model="linkHref" :placeholder="__('Link URL')">
<template #append>
<gl-button variant="confirm" @click="updateLink()">{{ __('Apply') }}</gl-button>
</template>
</gl-form-input-group>
</gl-dropdown-form>
<gl-dropdown-divider v-if="isActive" />
<gl-dropdown-item v-if="isActive" @click="removeLink()">
{{ __('Remove link') }}
</gl-dropdown-item>
</gl-dropdown>
<editor-state-observer @transaction="updateLinkState">
<gl-dropdown
v-gl-tooltip
:aria-label="__('Insert link')"
:title="__('Insert link')"
:toggle-class="{ active: isActive }"
size="small"
category="tertiary"
icon="link"
@show="selectLink()"
>
<gl-dropdown-form class="gl-px-3!">
<gl-form-input-group v-model="linkHref" :placeholder="__('Link URL')">
<template #append>
<gl-button variant="confirm" @click="updateLink()">{{ __('Apply') }}</gl-button>
</template>
</gl-form-input-group>
</gl-dropdown-form>
<gl-dropdown-divider v-if="isActive" />
<gl-dropdown-item v-if="isActive" @click="removeLink()">
{{ __('Remove link') }}
</gl-dropdown-item>
</gl-dropdown>
</editor-state-observer>
</template>
<script>
import { GlDropdown, GlDropdownDivider, GlDropdownForm, GlButton } from '@gitlab/ui';
import { Editor as TiptapEditor } from '@tiptap/vue-2';
import { __, sprintf } from '~/locale';
import { clamp } from '../services/utils';
......@@ -18,12 +17,7 @@ export default {
GlDropdownForm,
GlButton,
},
props: {
tiptapEditor: {
type: TiptapEditor,
required: true,
},
},
inject: ['tiptapEditor'],
data() {
return {
maxRows: MIN_ROWS,
......
<script>
import { GlDropdown, GlDropdownItem, GlTooltipDirective as GlTooltip } from '@gitlab/ui';
import { Editor as TiptapEditor } from '@tiptap/vue-2';
import { __ } from '~/locale';
import { TEXT_STYLE_DROPDOWN_ITEMS } from '../constants';
import EditorStateObserver from './editor_state_observer.vue';
export default {
components: {
GlDropdown,
GlDropdownItem,
EditorStateObserver,
},
directives: {
GlTooltip,
},
props: {
tiptapEditor: {
type: TiptapEditor,
required: true,
},
inject: ['tiptapEditor'],
data() {
return {
activeItem: null,
};
},
computed: {
activeItem() {
return TEXT_STYLE_DROPDOWN_ITEMS.find((item) =>
this.tiptapEditor.isActive(item.contentType, item.commandParams),
);
},
activeItemLabel() {
const { activeItem } = this;
......@@ -31,6 +27,11 @@ export default {
},
},
methods: {
updateActiveItem({ editor }) {
this.activeItem = TEXT_STYLE_DROPDOWN_ITEMS.find((item) =>
editor.isActive(item.contentType, item.commandParams),
);
},
execute(item) {
const { editorCommand, contentType, commandParams } = item;
const value = commandParams?.level;
......@@ -38,8 +39,8 @@ export default {
if (editorCommand) {
this.tiptapEditor
.chain()
.focus()
[editorCommand](commandParams || {})
.focus()
.run();
}
......@@ -56,20 +57,22 @@ export default {
};
</script>
<template>
<gl-dropdown
v-gl-tooltip="$options.i18n.placeholder"
size="small"
:disabled="!activeItem"
:text="activeItemLabel"
>
<gl-dropdown-item
v-for="(item, index) in $options.items"
:key="index"
is-check-item
:is-checked="isActive(item)"
@click="execute(item)"
<editor-state-observer @transaction="updateActiveItem">
<gl-dropdown
v-gl-tooltip="$options.i18n.placeholder"
size="small"
:disabled="!activeItem"
:text="activeItemLabel"
>
{{ item.label }}
</gl-dropdown-item>
</gl-dropdown>
<gl-dropdown-item
v-for="(item, index) in $options.items"
:key="index"
is-check-item
:is-checked="isActive(item)"
@click="execute(item)"
>
{{ item.label }}
</gl-dropdown-item>
</gl-dropdown>
</editor-state-observer>
</template>
<script>
import Tracking from '~/tracking';
import { CONTENT_EDITOR_TRACKING_LABEL, TOOLBAR_CONTROL_TRACKING_ACTION } from '../constants';
import { ContentEditor } from '../services/content_editor';
import Divider from './divider.vue';
import ToolbarButton from './toolbar_button.vue';
import ToolbarImageButton from './toolbar_image_button.vue';
......@@ -23,12 +22,6 @@ export default {
Divider,
},
mixins: [trackingMixin],
props: {
contentEditor: {
type: ContentEditor,
required: true,
},
},
methods: {
trackToolbarControlExecution({ contentType: property, value }) {
this.track(TOOLBAR_CONTROL_TRACKING_ACTION, {
......@@ -45,7 +38,6 @@ export default {
>
<toolbar-text-style-dropdown
data-testid="text-styles"
:tiptap-editor="contentEditor.tiptapEditor"
@execute="trackToolbarControlExecution"
/>
<divider />
......@@ -55,7 +47,6 @@ export default {
icon-name="bold"
editor-command="toggleBold"
:label="__('Bold text')"
:tiptap-editor="contentEditor.tiptapEditor"
@execute="trackToolbarControlExecution"
/>
<toolbar-button
......@@ -64,7 +55,6 @@ export default {
icon-name="italic"
editor-command="toggleItalic"
:label="__('Italic text')"
:tiptap-editor="contentEditor.tiptapEditor"
@execute="trackToolbarControlExecution"
/>
<toolbar-button
......@@ -73,7 +63,6 @@ export default {
icon-name="strikethrough"
editor-command="toggleStrike"
:label="__('Strikethrough')"
:tiptap-editor="contentEditor.tiptapEditor"
@execute="trackToolbarControlExecution"
/>
<toolbar-button
......@@ -82,19 +71,13 @@ export default {
icon-name="code"
editor-command="toggleCode"
:label="__('Code')"
:tiptap-editor="contentEditor.tiptapEditor"
@execute="trackToolbarControlExecution"
/>
<toolbar-link-button
data-testid="link"
:tiptap-editor="contentEditor.tiptapEditor"
@execute="trackToolbarControlExecution"
/>
<toolbar-link-button data-testid="link" @execute="trackToolbarControlExecution" />
<divider />
<toolbar-image-button
ref="imageButton"
data-testid="image"
:tiptap-editor="contentEditor.tiptapEditor"
@execute="trackToolbarControlExecution"
/>
<toolbar-button
......@@ -103,7 +86,6 @@ export default {
icon-name="quote"
editor-command="toggleBlockquote"
:label="__('Insert a quote')"
:tiptap-editor="contentEditor.tiptapEditor"
@execute="trackToolbarControlExecution"
/>
<toolbar-button
......@@ -112,7 +94,6 @@ export default {
icon-name="doc-code"
editor-command="toggleCodeBlock"
:label="__('Insert a code block')"
:tiptap-editor="contentEditor.tiptapEditor"
@execute="trackToolbarControlExecution"
/>
<toolbar-button
......@@ -121,7 +102,6 @@ export default {
icon-name="list-bulleted"
editor-command="toggleBulletList"
:label="__('Add a bullet list')"
:tiptap-editor="contentEditor.tiptapEditor"
@execute="trackToolbarControlExecution"
/>
<toolbar-button
......@@ -130,7 +110,6 @@ export default {
icon-name="list-numbered"
editor-command="toggleOrderedList"
:label="__('Add a numbered list')"
:tiptap-editor="contentEditor.tiptapEditor"
@execute="trackToolbarControlExecution"
/>
<toolbar-button
......@@ -139,13 +118,9 @@ export default {
icon-name="dash"
editor-command="setHorizontalRule"
:label="__('Add a horizontal rule')"
:tiptap-editor="contentEditor.tiptapEditor"
@execute="trackToolbarControlExecution"
/>
<toolbar-table-button
:tiptap-editor="contentEditor.tiptapEditor"
@execute="trackToolbarControlExecution"
/>
<toolbar-table-button @execute="trackToolbarControlExecution" />
</div>
</template>
<style>
......
......@@ -134,7 +134,6 @@ export default {
isContentEditorLoading: true,
useContentEditor: false,
commitMessage: '',
contentEditor: null,
isDirty: false,
contentEditorRenderFailed: false,
};
......
......@@ -111,6 +111,7 @@ export default {
:img-src="participant.avatar_url || participant.avatarUrl"
:size="24"
:tooltip-text="participant.name"
:img-alt="participant.name"
css-classes="avatar-inline"
tooltip-placement="bottom"
/>
......
......@@ -75,7 +75,7 @@ class InvitesController < ApplicationController
end
def track_invite_join_click
experiment('members/invite_email', actor: member).track(:join_clicked) if member && Members::InviteEmailExperiment.initial_invite_email?(params[:invite_type])
Gitlab::Tracking.event(self.class.name, 'join_clicked', label: 'invite_email', property: member.id.to_s) if member && initial_invite_email?
end
def authenticate_user!
......@@ -95,7 +95,11 @@ class InvitesController < ApplicationController
def set_session_invite_params
session[:invite_email] = member.invite_email
session[:originating_member_id] = member.id if Members::InviteEmailExperiment.initial_invite_email?(params[:invite_type])
session[:originating_member_id] = member.id if initial_invite_email?
end
def initial_invite_email?
params[:invite_type] == Emails::Members::INITIAL_INVITE
end
def sign_in_redirect_params
......
......@@ -199,7 +199,7 @@ class RegistrationsController < Devise::RegistrationsController
return unless member
experiment('members/invite_email', actor: member).track(:accepted)
Gitlab::Tracking.event(self.class.name, 'accepted', label: 'invite_email', property: member.id.to_s)
end
def context_user
......
# frozen_string_literal: true
module Members
class InviteEmailExperiment < ApplicationExperiment
exclude { context.actor.created_by.blank? }
exclude { context.actor.created_by.avatar_url.nil? }
INVITE_TYPE = 'initial_email'
def self.initial_invite_email?(invite_type)
invite_type == INVITE_TYPE
end
def resolve_variant_name
RoundRobin.new(feature_flag_name, %i[activity control]).execute
end
end
class RoundRobin
CacheError = Class.new(StandardError)
COUNTER_EXPIRE_TIME = 86400 # one day
def initialize(key, variants)
@key = key
@variants = variants
end
def execute
increment_counter
resolve_variant_name
end
# When the counter would expire
#
# @api private Used internally by SRE and debugging purpose
# @return [Integer] Number in seconds until expiration or false if never
def counter_expires_in
Gitlab::Redis::SharedState.with do |redis|
redis.ttl(key)
end
end
# Return the actual counter value
#
# @return [Integer] value
def counter_value
Gitlab::Redis::SharedState.with do |redis|
(redis.get(key) || 0).to_i
end
end
# Reset the counter
#
# @private Used internally by SRE and debugging purpose
# @return [Boolean] whether reset was a success
def reset!
redis_cmd do |redis|
redis.del(key)
end
end
private
attr_reader :key, :variants
# Increase the counter
#
# @return [Boolean] whether operation was a success
def increment_counter
redis_cmd do |redis|
redis.incr(key)
redis.expire(key, COUNTER_EXPIRE_TIME)
end
end
def resolve_variant_name
remainder = counter_value % variants.size
variants[remainder]
end
def redis_cmd
Gitlab::Redis::SharedState.with { |redis| yield(redis) }
true
rescue CacheError => e
Gitlab::AppLogger.warn("GitLab: An unexpected error occurred in writing to Redis: #{e}")
false
end
end
end
......@@ -15,7 +15,8 @@
# state: 'opened' or 'closed' or 'locked' or 'all'
# group_id: integer
# project_id: integer
# milestone_title: string
# milestone_title: string (cannot be simultaneously used with milestone_wildcard_id)
# milestone_wildcard_id: 'none', 'any', 'upcoming', 'started' (cannot be simultaneously used with milestone_title)
# release_tag: string
# author_id: integer
# author_username: string
......
......@@ -4,9 +4,11 @@ class IssuableFinder
class Params < SimpleDelegator
include Gitlab::Utils::StrongMemoize
# This is used as a common filter for None / Any
# This is used as a common filter for None / Any / Upcoming / Started
FILTER_NONE = 'none'
FILTER_ANY = 'any'
FILTER_STARTED = 'started'
FILTER_UPCOMING = 'upcoming'
# This is used in unassigning users
NONE = '0'
......@@ -42,25 +44,35 @@ class IssuableFinder
end
def milestones?
params[:milestone_title].present?
params[:milestone_title].present? || params[:milestone_wildcard_id].present?
end
def filter_by_no_milestone?
# Accepts `No Milestone` for compatibility
params[:milestone_title].to_s.downcase == FILTER_NONE || params[:milestone_title] == Milestone::None.title
# Usage of `No Milestone` and `none`/`None` in milestone_title to be deprecated
# https://gitlab.com/gitlab-org/gitlab/-/issues/336044
params[:milestone_title].to_s.downcase == FILTER_NONE ||
params[:milestone_title] == Milestone::None.title ||
params[:milestone_wildcard_id].to_s.downcase == FILTER_NONE
end
def filter_by_any_milestone?
# Accepts `Any Milestone` for compatibility
params[:milestone_title].to_s.downcase == FILTER_ANY || params[:milestone_title] == Milestone::Any.title
# Usage of `Any Milestone` and `any`/`Any` in milestone_title to be deprecated
# https://gitlab.com/gitlab-org/gitlab/-/issues/336044
params[:milestone_title].to_s.downcase == FILTER_ANY ||
params[:milestone_title] == Milestone::Any.title ||
params[:milestone_wildcard_id].to_s.downcase == FILTER_ANY
end
def filter_by_upcoming_milestone?
params[:milestone_title] == Milestone::Upcoming.name
# Usage of `#upcoming` in milestone_title to be deprecated
# https://gitlab.com/gitlab-org/gitlab/-/issues/336044
params[:milestone_title] == Milestone::Upcoming.name || params[:milestone_wildcard_id].to_s.downcase == FILTER_UPCOMING
end
def filter_by_started_milestone?
params[:milestone_title] == Milestone::Started.name
# Usage of `#started` in milestone_title to be deprecated
# https://gitlab.com/gitlab-org/gitlab/-/issues/336044
params[:milestone_title] == Milestone::Started.name || params[:milestone_wildcard_id].to_s.downcase == FILTER_STARTED
end
def filter_by_no_release?
......
......@@ -11,7 +11,8 @@
# state: 'opened' or 'closed' or 'all'
# group_id: integer
# project_id: integer
# milestone_title: string
# milestone_title: string (cannot be simultaneously used with milestone_wildcard_id)
# milestone_wildcard_id: 'none', 'any', 'upcoming', 'started' (cannot be simultaneously used with milestone_title)
# assignee_id: integer
# search: string
# in: 'title', 'description', or a string joining them with comma
......
......@@ -56,6 +56,9 @@ module IssueResolverArguments
as: :issue_types,
description: 'Filter issues by the given issue types.',
required: false
argument :milestone_wildcard_id, ::Types::MilestoneWildcardIdEnum,
required: false,
description: 'Filter issues by milestone ID wildcard.'
argument :not, Types::Issues::NegatedIssueFilterInputType,
description: 'Negated arguments.',
prepare: ->(negated_args, ctx) { negated_args.to_h },
......@@ -82,10 +85,9 @@ module IssueResolverArguments
end
def ready?(**args)
if args.slice(*mutually_exclusive_assignee_username_args).compact.size > 1
arg_str = mutually_exclusive_assignee_username_args.map { |x| x.to_s.camelize(:lower) }.join(', ')
raise Gitlab::Graphql::Errors::ArgumentError, "only one of [#{arg_str}] arguments is allowed at the same time."
end
params_not_mutually_exclusive(args, mutually_exclusive_assignee_username_args)
params_not_mutually_exclusive(args, mutually_exclusive_milestone_args)
params_not_mutually_exclusive(args.fetch(:not, {}), mutually_exclusive_milestone_args)
super
end
......@@ -106,6 +108,17 @@ module IssueResolverArguments
args[:not][:assignee_username] = args[:not].delete(:assignee_usernames) if args.dig(:not, :assignee_usernames).present?
end
def params_not_mutually_exclusive(args, mutually_exclusive_args)
if args.slice(*mutually_exclusive_args).compact.size > 1
arg_str = mutually_exclusive_args.map { |x| x.to_s.camelize(:lower) }.join(', ')
raise ::Gitlab::Graphql::Errors::ArgumentError, "only one of [#{arg_str}] arguments is allowed at the same time."
end
end
def mutually_exclusive_milestone_args
[:milestone_title, :milestone_wildcard_id]
end
def mutually_exclusive_assignee_username_args
[:assignee_usernames, :assignee_username]
end
......
......@@ -20,6 +20,9 @@ module Types
argument :assignee_id, GraphQL::Types::String,
required: false,
description: 'ID of a user not assigned to the issues.'
argument :milestone_wildcard_id, ::Types::NegatedMilestoneWildcardIdEnum,
required: false,
description: 'Filter by negated milestone wildcard values.'
end
end
end
......
# frozen_string_literal: true
module Types
class MilestoneWildcardIdEnum < BaseEnum
graphql_name 'MilestoneWildcardId'
description 'Milestone ID wildcard values'
value 'NONE', 'No milestone is assigned.'
value 'ANY', 'A milestone is assigned.'
value 'STARTED', 'An open, started milestone (start date <= today).'
value 'UPCOMING', 'An open milestone due in the future (due date >= today).'
end
end
# frozen_string_literal: true
module Types
class NegatedMilestoneWildcardIdEnum < BaseEnum
graphql_name 'NegatedMilestoneWildcardId'
description 'Negated Milestone ID wildcard values'
value 'STARTED', 'An open, started milestone (start date <= today).'
value 'UPCOMING', 'An open milestone due in the future (due date >= today).'
end
end
......@@ -6,6 +6,8 @@ module Emails
include MembersHelper
include Gitlab::Experiment::Dsl
INITIAL_INVITE = 'initial_email'
included do
helper_method :member_source, :member
helper_method :experiment
......@@ -53,6 +55,8 @@ module Emails
return unless member_exists?
Gitlab::Tracking.event(self.class.name, 'invite_email_sent', label: 'invite_email', property: member_id.to_s)
mail(to: member.invite_email, subject: invite_email_subject, **invite_email_headers) do |format|
format.html { render layout: 'unknown_user_mailer' }
format.text { render layout: 'unknown_user_mailer' }
......
- placeholders = { strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe, project_or_group_name: member_source.human_name, project_or_group: member_source.model_name.singular, br_tag: '<br/>'.html_safe, role: member.human_access.downcase }
- placeholders = { strong_start: '<strong>'.html_safe,
strong_end: '</strong>'.html_safe,
project_or_group_name: member_source.human_name,
project_or_group: member_source.model_name.singular,
br_tag: '<br/>'.html_safe,
role: member.human_access.downcase }
- experiment('members/invite_email', actor: member) do |experiment_instance|
- experiment_instance.use do
%tr
%td.text-content
%h2.invite-header
= s_('InviteEmail|You are invited!')
%p
- if member.created_by
= html_escape(s_("InviteEmail|%{inviter} invited you to join the %{strong_start}%{project_or_group_name}%{strong_end}%{br_tag}%{project_or_group} as a %{role}")) % placeholders.merge({ inviter: (link_to member.created_by.name, user_url(member.created_by)).html_safe })
- else
= html_escape(s_("InviteEmail|You are invited to join the %{strong_start}%{project_or_group_name}%{strong_end}%{br_tag}%{project_or_group} as a %{role}")) % placeholders
%p.invite-actions
= link_to s_('InviteEmail|Join now'), invite_url(@token, invite_type: Members::InviteEmailExperiment::INVITE_TYPE), class: 'invite-btn-join'
- experiment_instance.try(:activity) do
%tr
%td.text-content{ colspan: 2 }
%img.mail-avatar{ height: "60", src: avatar_icon_for_user(member.created_by, 60, only_path: false), width: "60", alt: "" }
%p
= html_escape(s_("InviteEmail|%{inviter} invited you to join the %{strong_start}%{project_or_group_name}%{strong_end}%{br_tag}%{project_or_group} as a %{role}")) % placeholders.merge({ inviter: (link_to member.created_by.name, user_url(member.created_by)).html_safe })
%p.invite-actions
= link_to s_('InviteEmail|Join now'), invite_url(@token, invite_type: Members::InviteEmailExperiment::INVITE_TYPE), class: 'invite-btn-join'
%tr.border-top
%td.text-content.mailer-align-left.half-width
%h4
= s_('InviteEmail|%{project_or_group} details') % { project_or_group: member_source.model_name.singular.capitalize }
%ul
%li
%div
%img.mailer-icon{ alt: '', src: image_url("mailers/members/users.png") }
%span
- member_count = member_source.members.size
= n_('%{bold_start}%{count}%{bold_end} member', '%{bold_start}%{count}%{bold_end} members',
member_count).html_safe % { count: number_with_delimiter(member_count),
bold_start: '<b>'.html_safe,
bold_end: '</b>'.html_safe }
%li
%div
%img.mailer-icon{ alt: '', src: image_url("mailers/members/issues.png") }
%span
- issue_count = member_source.open_issues_count(member.created_by)
= n_('%{bold_start}%{count}%{bold_end} issue', '%{bold_start}%{count}%{bold_end} issues',
issue_count).html_safe % { count: number_with_delimiter(issue_count),
bold_start: '<b>'.html_safe,
bold_end: '</b>'.html_safe }
%li
%div
%img.mailer-icon{ alt: '', src: image_url("mailers/members/merge-request-open.png") }
%span
- mr_count = member_source.open_merge_requests_count(member.created_by)
= n_('%{bold_start}%{count}%{bold_end} opened merge request', '%{bold_start}%{count}%{bold_end} opened merge requests',
mr_count).html_safe % { count: number_with_delimiter(mr_count),
bold_start: '<b>'.html_safe,
bold_end: '</b>'.html_safe }
%td.text-content.mailer-align-left.half-width
%h4
= s_("InviteEmail|What's it about?")
%p
= invited_to_description(member_source)
%tr
%td.text-content{ colspan: 2 }
%img.mail-avatar{ height: "60", src: avatar_icon_for_user(member.created_by, 60, only_path: false), width: "60", alt: "" }
%p
- if member.created_by
= html_escape(s_("InviteEmail|%{inviter} invited you to join the %{strong_start}%{project_or_group_name}%{strong_end}%{br_tag}%{project_or_group} as a %{role}")) % placeholders.merge({ inviter: (link_to member.created_by.name, user_url(member.created_by)).html_safe })
- else
= html_escape(s_("InviteEmail|You are invited to join the %{strong_start}%{project_or_group_name}%{strong_end}%{br_tag}%{project_or_group} as a %{role}")) % placeholders
%p.invite-actions
= link_to s_('InviteEmail|Join now'), invite_url(@token, invite_type: Emails::Members::INITIAL_INVITE), class: 'invite-btn-join'
%tr.border-top
%td.text-content.mailer-align-left.half-width
%h4
= s_('InviteEmail|%{project_or_group} details') % { project_or_group: member_source.model_name.singular.capitalize }
%ul
%li
%div
%img.mailer-icon{ alt: '', src: image_url("mailers/members/users.png") }
%span
- member_count = member_source.members.size
= n_('%{bold_start}%{count}%{bold_end} member', '%{bold_start}%{count}%{bold_end} members',
member_count).html_safe % { count: number_with_delimiter(member_count),
bold_start: '<b>'.html_safe,
bold_end: '</b>'.html_safe }
%li
%div
%img.mailer-icon{ alt: '', src: image_url("mailers/members/issues.png") }
%span
- issue_count = member_source.open_issues_count(member.created_by)
= n_('%{bold_start}%{count}%{bold_end} issue', '%{bold_start}%{count}%{bold_end} issues',
issue_count).html_safe % { count: number_with_delimiter(issue_count),
bold_start: '<b>'.html_safe,
bold_end: '</b>'.html_safe }
%li
%div
%img.mailer-icon{ alt: '', src: image_url("mailers/members/merge-request-open.png") }
%span
- mr_count = member_source.open_merge_requests_count(member.created_by)
= n_('%{bold_start}%{count}%{bold_end} opened merge request', '%{bold_start}%{count}%{bold_end} opened merge requests',
mr_count).html_safe % { count: number_with_delimiter(mr_count),
bold_start: '<b>'.html_safe,
bold_end: '</b>'.html_safe }
%td.text-content.mailer-align-left.half-width
%h4
= s_("InviteEmail|What's it about?")
%p
= invited_to_description(member_source)
---
name: members_invite_email
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/51223
rollout_issue_url: https://gitlab.com/gitlab-org/growth/team-tasks/-/issues/325
milestone: '13.9'
type: experiment
group: group::expansion
default_enabled: false
......@@ -9662,6 +9662,7 @@ four standard [pagination arguments](#connection-pagination-arguments):
| <a id="groupissuesiterationwildcardid"></a>`iterationWildcardId` | [`IterationWildcardId`](#iterationwildcardid) | Filter by iteration ID wildcard. |
| <a id="groupissueslabelname"></a>`labelName` | [`[String]`](#string) | Labels applied to this issue. |
| <a id="groupissuesmilestonetitle"></a>`milestoneTitle` | [`[String]`](#string) | Milestone applied to this issue. |
| <a id="groupissuesmilestonewildcardid"></a>`milestoneWildcardId` | [`MilestoneWildcardId`](#milestonewildcardid) | Filter issues by milestone ID wildcard. |
| <a id="groupissuesnot"></a>`not` | [`NegatedIssueFilterInput`](#negatedissuefilterinput) | Negated arguments. |
| <a id="groupissuessearch"></a>`search` | [`String`](#string) | Search query for issue title or description. |
| <a id="groupissuessort"></a>`sort` | [`IssueSort`](#issuesort) | Sort issues by this criteria. |
......@@ -11902,6 +11903,7 @@ Returns [`Issue`](#issue).
| <a id="projectissueiterationwildcardid"></a>`iterationWildcardId` | [`IterationWildcardId`](#iterationwildcardid) | Filter by iteration ID wildcard. |
| <a id="projectissuelabelname"></a>`labelName` | [`[String]`](#string) | Labels applied to this issue. |
| <a id="projectissuemilestonetitle"></a>`milestoneTitle` | [`[String]`](#string) | Milestone applied to this issue. |
| <a id="projectissuemilestonewildcardid"></a>`milestoneWildcardId` | [`MilestoneWildcardId`](#milestonewildcardid) | Filter issues by milestone ID wildcard. |
| <a id="projectissuenot"></a>`not` | [`NegatedIssueFilterInput`](#negatedissuefilterinput) | Negated arguments. |
| <a id="projectissuesearch"></a>`search` | [`String`](#string) | Search query for issue title or description. |
| <a id="projectissuesort"></a>`sort` | [`IssueSort`](#issuesort) | Sort issues by this criteria. |
......@@ -11933,6 +11935,7 @@ Returns [`IssueStatusCountsType`](#issuestatuscountstype).
| <a id="projectissuestatuscountsiids"></a>`iids` | [`[String!]`](#string) | List of IIDs of issues. For example, `["1", "2"]`. |
| <a id="projectissuestatuscountslabelname"></a>`labelName` | [`[String]`](#string) | Labels applied to this issue. |
| <a id="projectissuestatuscountsmilestonetitle"></a>`milestoneTitle` | [`[String]`](#string) | Milestone applied to this issue. |
| <a id="projectissuestatuscountsmilestonewildcardid"></a>`milestoneWildcardId` | [`MilestoneWildcardId`](#milestonewildcardid) | Filter issues by milestone ID wildcard. |
| <a id="projectissuestatuscountsnot"></a>`not` | [`NegatedIssueFilterInput`](#negatedissuefilterinput) | Negated arguments. |
| <a id="projectissuestatuscountssearch"></a>`search` | [`String`](#string) | Search query for issue title or description. |
| <a id="projectissuestatuscountstypes"></a>`types` | [`[IssueType!]`](#issuetype) | Filter issues by the given issue types. |
......@@ -11968,6 +11971,7 @@ four standard [pagination arguments](#connection-pagination-arguments):
| <a id="projectissuesiterationwildcardid"></a>`iterationWildcardId` | [`IterationWildcardId`](#iterationwildcardid) | Filter by iteration ID wildcard. |
| <a id="projectissueslabelname"></a>`labelName` | [`[String]`](#string) | Labels applied to this issue. |
| <a id="projectissuesmilestonetitle"></a>`milestoneTitle` | [`[String]`](#string) | Milestone applied to this issue. |
| <a id="projectissuesmilestonewildcardid"></a>`milestoneWildcardId` | [`MilestoneWildcardId`](#milestonewildcardid) | Filter issues by milestone ID wildcard. |
| <a id="projectissuesnot"></a>`not` | [`NegatedIssueFilterInput`](#negatedissuefilterinput) | Negated arguments. |
| <a id="projectissuessearch"></a>`search` | [`String`](#string) | Search query for issue title or description. |
| <a id="projectissuessort"></a>`sort` | [`IssueSort`](#issuesort) | Sort issues by this criteria. |
......@@ -15044,6 +15048,17 @@ Current state of milestone.
| <a id="milestonestateenumactive"></a>`active` | Milestone is currently active. |
| <a id="milestonestateenumclosed"></a>`closed` | Milestone is closed. |
### `MilestoneWildcardId`
Milestone ID wildcard values.
| Value | Description |
| ----- | ----------- |
| <a id="milestonewildcardidany"></a>`ANY` | A milestone is assigned. |
| <a id="milestonewildcardidnone"></a>`NONE` | No milestone is assigned. |
| <a id="milestonewildcardidstarted"></a>`STARTED` | An open, started milestone (start date <= today). |
| <a id="milestonewildcardidupcoming"></a>`UPCOMING` | An open milestone due in the future (due date >= today). |
### `MoveType`
The position to which the adjacent object should be moved.
......@@ -15080,6 +15095,15 @@ Negated Iteration ID wildcard values.
| ----- | ----------- |
| <a id="negatediterationwildcardidcurrent"></a>`CURRENT` | Current iteration. |
### `NegatedMilestoneWildcardId`
Negated Milestone ID wildcard values.
| Value | Description |
| ----- | ----------- |
| <a id="negatedmilestonewildcardidstarted"></a>`STARTED` | An open, started milestone (start date <= today). |
| <a id="negatedmilestonewildcardidupcoming"></a>`UPCOMING` | An open milestone due in the future (due date >= today). |
### `NetworkPolicyKind`
Kind of the network policy.
......@@ -16774,6 +16798,7 @@ Represents an escalation rule.
| <a id="negatedissuefilterinputiterationwildcardid"></a>`iterationWildcardId` | [`IterationWildcardId`](#iterationwildcardid) | Filter by negated iteration ID wildcard. |
| <a id="negatedissuefilterinputlabelname"></a>`labelName` | [`[String!]`](#string) | Labels not applied to this issue. |
| <a id="negatedissuefilterinputmilestonetitle"></a>`milestoneTitle` | [`[String!]`](#string) | Milestone not applied to this issue. |
| <a id="negatedissuefilterinputmilestonewildcardid"></a>`milestoneWildcardId` | [`NegatedMilestoneWildcardId`](#negatedmilestonewildcardid) | Filter by negated milestone wildcard values. |
| <a id="negatedissuefilterinputweight"></a>`weight` | [`String`](#string) | Weight not applied to the issue. |
### `OncallRotationActivePeriodInputType`
......
......@@ -5,7 +5,10 @@ class DastSite < ApplicationRecord
belongs_to :dast_site_validation
has_many :dast_site_profiles
validates :url, length: { maximum: 255 }, uniqueness: { scope: :project_id }, public_url: true
validates :url, length: { maximum: 255 }, uniqueness: { scope: :project_id }
validates :url, addressable_url: true, if: :runner_validation_enabled?
validates :url, public_url: true, unless: :runner_validation_enabled?
validates :project_id, presence: true
validate :dast_site_validation_project_id_fk
......@@ -18,4 +21,8 @@ class DastSite < ApplicationRecord
errors.add(:project_id, 'does not match dast_site_validation.project')
end
end
def runner_validation_enabled?
::Feature.enabled?(:dast_runner_site_validation, project, default_enabled: :yaml)
end
end
......@@ -3,7 +3,9 @@
require 'spec_helper'
RSpec.describe DastSite, type: :model do
subject { create(:dast_site) }
let_it_be(:project) { create(:project) }
subject { create(:dast_site, project: project) }
describe 'associations' do
it { is_expected.to belong_to(:project) }
......@@ -32,11 +34,26 @@ RSpec.describe DastSite, type: :model do
end
context 'when the url is not public' do
subject { build(:dast_site, url: 'http://127.0.0.1') }
let_it_be(:message) { 'Url is blocked: Requests to localhost are not allowed' }
it 'is not valid' do
expect(subject.valid?).to be_falsey
expect(subject.errors.full_messages).to include('Url is blocked: Requests to localhost are not allowed')
subject { build(:dast_site, project: project, url: 'http://127.0.0.1') }
context 'worker validation' do
before do
stub_feature_flags(dast_runner_site_validation: false)
end
it 'is not valid', :aggregate_failures do
expect(subject).not_to be_valid
expect(subject.errors.full_messages).to include(message)
end
end
context 'runner validation' do
it 'is is valid', :aggregate_failures do
expect(subject).to be_valid
expect(subject.errors.full_messages).not_to include(message)
end
end
end
end
......
......@@ -194,12 +194,10 @@ RSpec.describe 'Epics through GroupQuery' do
epics_with_parent = create_list(:epic, 3, group: group) do |epic|
epic.update!(parent: create(:epic, group: group))
end
group.reload
# Threshold of 3 due to an existing N+1 with licenses
expect do
post_graphql(query({ iids: epics_with_parent.pluck(:iid) }), current_user: user)
end.not_to exceed_query_limit(control_count).with_threshold(3)
end.not_to exceed_query_limit(control_count)
end
end
......
......@@ -37,6 +37,7 @@ RSpec.describe 'Creating a DAST Site Profile' do
end
it_behaves_like 'an on-demand scan mutation when user cannot run an on-demand scan'
it_behaves_like 'an on-demand scan mutation when user can run an on-demand scan' do
it 'updates the dast_site_profile' do
subject
......@@ -49,10 +50,17 @@ RSpec.describe 'Creating a DAST Site Profile' do
end
end
context 'when there is an issue updating the dast_site_profile' do
let(:new_target_url) { 'http://localhost:3000' }
context 'when there is a validation error' do
before do
allow(dast_site_profile).to receive(:valid?).and_return(false)
allow(dast_site_profile).to receive_message_chain(:errors, :full_messages).and_return(['There was a validation error'])
allow_next_instance_of(DastSiteProfilesFinder) do |instance|
allow(instance).to receive_message_chain(:execute, :first!).and_return(dast_site_profile)
end
end
it_behaves_like 'a mutation that returns errors in the response', errors: ['Url is blocked: Requests to localhost are not allowed']
it_behaves_like 'a mutation that returns errors in the response', errors: ['There was a validation error']
end
context 'when the dast_site_profile does not exist' do
......
......@@ -36,7 +36,6 @@ RSpec.describe AppSec::Dast::SiteProfiles::CreateService do
let(:status) { subject.status }
let(:message) { subject.message }
let(:errors) { subject.errors }
let(:payload) { subject.payload }
context 'when a user does not have access to the project' do
......@@ -111,18 +110,6 @@ RSpec.describe AppSec::Dast::SiteProfiles::CreateService do
end
end
context 'when the target url is localhost' do
let(:target_url) { 'http://localhost:3000/hello-world' }
it 'returns an error status' do
expect(status).to eq(:error)
end
it 'populates errors' do
expect(errors).to include('Url is blocked: Requests to localhost are not allowed')
end
end
context 'when excluded_urls is nil' do
let(:excluded_urls) { nil }
......
......@@ -113,18 +113,6 @@ RSpec.describe AppSec::Dast::SiteProfiles::UpdateService do
end
end
context 'when the target url is localhost' do
let(:new_target_url) { 'http://localhost:3000/hello-world' }
it 'returns an error status' do
expect(status).to eq(:error)
end
it 'populates errors' do
expect(errors).to include('Url is blocked: Requests to localhost are not allowed')
end
end
context 'when the target url is nil' do
let(:params) { default_params.merge(target_url: nil) }
......
......@@ -49,12 +49,12 @@ RSpec.describe DastSites::FindOrCreateService do
end
end
context 'when the target url is localhost' do
let(:url) { 'http://localhost:3000/hello-world' }
context 'when the record is invalid' do
let(:url) { 'i-am-not-a-url' }
it 'raises an exception' do
expect { subject }.to raise_error(ActiveRecord::RecordInvalid) do |err|
expect(err.record.errors.full_messages).to include('Url is blocked: Requests to localhost are not allowed')
expect(err.record.errors.full_messages).to include('Url is blocked: Only allowed schemes are http, https')
end
end
end
......
......@@ -17975,9 +17975,6 @@ msgstr ""
msgid "InviteEmail|You are invited to join the %{strong_start}%{project_or_group_name}%{strong_end}%{br_tag}%{project_or_group} as a %{role}"
msgstr ""
msgid "InviteEmail|You are invited!"
msgstr ""
msgid "InviteEmail|You have been invited to join the %{project_or_group_name} %{project_or_group} as a %{role}"
msgstr ""
......
......@@ -25,37 +25,47 @@ RSpec.describe InvitesController do
end
end
describe 'GET #show' do
describe 'GET #show', :snowplow do
subject(:request) { get :show, params: params }
context 'when it is part of our invite email experiment' do
context 'when it is an initial invite email' do
let(:extra_params) { { invite_type: 'initial_email' } }
it 'tracks the experiment' do
experiment = double(track: true)
allow(controller).to receive(:experiment).with('members/invite_email', actor: member).and_return(experiment)
it 'tracks the initial join click from email' do
request
expect(experiment).to have_received(:track).with(:join_clicked)
expect_snowplow_event(
category: described_class.name,
action: 'join_clicked',
label: 'invite_email',
property: member.id.to_s
)
end
context 'when member does not exist' do
let(:raw_invite_token) { '_bogus_token_' }
it 'does not track the experiment' do
expect(controller).not_to receive(:experiment).with('members/invite_email', actor: member)
it 'does not track join click' do
request
expect_no_snowplow_event(
category: described_class.name,
action: 'join_clicked',
label: 'invite_email'
)
end
end
end
context 'when it is not part of our invite email experiment' do
it 'does not track via experiment' do
expect(controller).not_to receive(:experiment).with('members/invite_email', actor: member)
context 'when it is not an initial email' do
it 'does not track the join click' do
request
expect_no_snowplow_event(
category: described_class.name,
action: 'join_clicked',
label: 'invite_email'
)
end
end
......
......@@ -155,7 +155,7 @@ RSpec.describe RegistrationsController do
end
context 'when registration is triggered from an accepted invite' do
context 'when it is part of our invite email experiment', :experiment do
context 'when it is part from the initial invite email', :snowplow do
let_it_be(:member) { create(:project_member, :invited, invite_email: user_params.dig(:user, :email)) }
let(:originating_member_id) { member.id }
......@@ -167,22 +167,29 @@ RSpec.describe RegistrationsController do
end
context 'when member exists from the session key value' do
it 'tracks the experiment' do
expect(experiment('members/invite_email')).to track(:accepted)
.with_context(actor: member)
.on_next_instance
it 'tracks the invite acceptance' do
subject
expect_snowplow_event(
category: 'RegistrationsController',
action: 'accepted',
label: 'invite_email',
property: member.id.to_s
)
end
end
context 'when member does not exist from the session key value' do
let(:originating_member_id) { -1 }
it 'tracks the experiment' do
expect(experiment('members/invite_email')).not_to track(:accepted)
it 'does not track invite acceptance' do
subject
expect_no_snowplow_event(
category: 'RegistrationsController',
action: 'accepted',
label: 'invite_email'
)
end
end
end
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Members::InviteEmailExperiment, :clean_gitlab_redis_shared_state do
subject(:invite_email) { experiment('members/invite_email', **context) }
let(:context) { { actor: double('Member', created_by: double('User', avatar_url: '_avatar_url_')) } }
before do
allow(invite_email).to receive(:enabled?).and_return(true)
end
describe ".initial_invite_email?" do
it "is an initial invite email" do
expect(described_class.initial_invite_email?('initial_email')).to be(true)
end
it "is not an initial invite email" do
expect(described_class.initial_invite_email?('_bogus_')).to be(false)
end
end
describe "exclusions", :experiment do
it "excludes when created by is nil" do
expect(experiment('members/invite_email')).to exclude(actor: double(created_by: nil))
end
it "excludes when avatar_url is nil" do
member_without_avatar_url = double('Member', created_by: double('User', avatar_url: nil))
expect(experiment('members/invite_email')).to exclude(actor: member_without_avatar_url)
end
end
describe "variant resolution" do
it "proves out round robin in variant selection", :aggregate_failures do
instance_1 = described_class.new('members/invite_email', **context)
allow(instance_1).to receive(:enabled?).and_return(true)
instance_2 = described_class.new('members/invite_email', **context)
allow(instance_2).to receive(:enabled?).and_return(true)
instance_1.try { }
expect(instance_1.variant.name).to eq('control')
instance_2.try { }
expect(instance_2.variant.name).to eq('activity')
end
end
describe Members::RoundRobin do
subject(:round_robin) { Members::RoundRobin.new('_key_', %i[variant1 variant2]) }
describe "execute" do
context "when there are 2 variants" do
it "proves out round robin in selection", :aggregate_failures do
expect(round_robin.execute).to eq :variant2
expect(round_robin.execute).to eq :variant1
expect(round_robin.execute).to eq :variant2
end
end
context "when there are more than 2 variants" do
subject(:round_robin) { Members::RoundRobin.new('_key_', %i[variant1 variant2 variant3]) }
it "proves out round robin in selection", :aggregate_failures do
expect(round_robin.execute).to eq :variant2
expect(round_robin.execute).to eq :variant3
expect(round_robin.execute).to eq :variant1
expect(round_robin.execute).to eq :variant2
expect(round_robin.execute).to eq :variant3
expect(round_robin.execute).to eq :variant1
end
end
context "when writing to cache fails" do
subject(:round_robin) { Members::RoundRobin.new('_key_', []) }
it "raises an error and logs" do
allow(Gitlab::Redis::SharedState).to receive(:with).and_raise(Members::RoundRobin::CacheError)
expect(Gitlab::AppLogger).to receive(:warn)
expect { round_robin.execute }.to raise_error(Members::RoundRobin::CacheError)
end
end
end
describe "#counter_expires_in" do
it 'displays the expiration time in seconds' do
round_robin.execute
expect(round_robin.counter_expires_in).to be_between(0, described_class::COUNTER_EXPIRE_TIME)
end
end
describe '#value' do
it 'get the count' do
expect(round_robin.counter_value).to eq(0)
round_robin.execute
expect(round_robin.counter_value).to eq(1)
end
end
describe '#reset!' do
it 'resets the count down to zero' do
3.times { round_robin.execute }
expect { round_robin.reset! }.to change { round_robin.counter_value }.from(3).to(0)
end
end
end
end
......@@ -179,7 +179,7 @@ RSpec.describe 'Group or Project invitations', :aggregate_failures do
context 'when registering using invitation email' do
before do
visit invite_path(group_invite.raw_invite_token, invite_type: Members::InviteEmailExperiment::INVITE_TYPE)
visit invite_path(group_invite.raw_invite_token, invite_type: Emails::Members::INITIAL_INVITE)
end
context 'with admin approval required enabled' do
......@@ -219,13 +219,16 @@ RSpec.describe 'Group or Project invitations', :aggregate_failures do
end
context 'email confirmation enabled' do
context 'with members/invite_email experiment', :experiment do
context 'with invite email acceptance', :snowplow do
it 'tracks the accepted invite' do
expect(experiment('members/invite_email')).to track(:accepted)
.with_context(actor: group_invite)
.on_next_instance
fill_in_sign_up_form(new_user)
expect_snowplow_event(
category: 'RegistrationsController',
action: 'accepted',
label: 'invite_email',
property: group_invite.id.to_s
)
end
end
......
......@@ -38,10 +38,10 @@ describe('ContentEditor', () => {
expect(editorContent.classes()).toContain('md');
});
it('renders top toolbar component and attaches editor instance', () => {
it('renders top toolbar component', () => {
createWrapper(editor);
expect(wrapper.findComponent(TopToolbar).props().contentEditor).toBe(editor);
expect(wrapper.findComponent(TopToolbar).exists()).toBe(true);
});
it.each`
......
import { shallowMount } from '@vue/test-utils';
import { each } from 'lodash';
import EditorStateObserver, {
tiptapToComponentMap,
} from '~/content_editor/components/editor_state_observer.vue';
import { createTestEditor } from '../test_utils';
describe('content_editor/components/editor_state_observer', () => {
let tiptapEditor;
let wrapper;
let onDocUpdateListener;
let onSelectionUpdateListener;
let onTransactionListener;
const buildEditor = () => {
tiptapEditor = createTestEditor();
jest.spyOn(tiptapEditor, 'on');
};
const buildWrapper = () => {
wrapper = shallowMount(EditorStateObserver, {
provide: { tiptapEditor },
listeners: {
docUpdate: onDocUpdateListener,
selectionUpdate: onSelectionUpdateListener,
transaction: onTransactionListener,
},
});
};
beforeEach(() => {
onDocUpdateListener = jest.fn();
onSelectionUpdateListener = jest.fn();
onTransactionListener = jest.fn();
buildEditor();
buildWrapper();
});
afterEach(() => {
wrapper.destroy();
});
describe('when editor content changes', () => {
it('emits update, selectionUpdate, and transaction events', () => {
const content = '<p>My paragraph</p>';
tiptapEditor.commands.insertContent(content);
expect(onDocUpdateListener).toHaveBeenCalledWith(
expect.objectContaining({ editor: tiptapEditor }),
);
expect(onSelectionUpdateListener).toHaveBeenCalledWith(
expect.objectContaining({ editor: tiptapEditor }),
);
expect(onSelectionUpdateListener).toHaveBeenCalledWith(
expect.objectContaining({ editor: tiptapEditor }),
);
});
});
describe('when component is destroyed', () => {
it('removes onTiptapDocUpdate and onTiptapSelectionUpdate hooks', () => {
jest.spyOn(tiptapEditor, 'off');
wrapper.destroy();
each(tiptapToComponentMap, (_, tiptapEvent) => {
expect(tiptapEditor.off).toHaveBeenCalledWith(
tiptapEvent,
tiptapEditor.on.mock.calls.find(([eventName]) => eventName === tiptapEvent)[1],
);
});
});
});
});
import { GlButton } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import EditorStateObserver from '~/content_editor/components/editor_state_observer.vue';
import ToolbarButton from '~/content_editor/components/toolbar_button.vue';
import { createTestEditor, mockChainedCommands } from '../test_utils';
import { createTestEditor, mockChainedCommands, emitEditorEvent } from '../test_utils';
describe('content_editor/components/toolbar_button', () => {
let wrapper;
......@@ -20,9 +21,12 @@ describe('content_editor/components/toolbar_button', () => {
wrapper = shallowMount(ToolbarButton, {
stubs: {
GlButton,
EditorStateObserver,
},
propsData: {
provide: {
tiptapEditor,
},
propsData: {
contentType: CONTENT_TYPE,
iconName: ICON_NAME,
label: LABEL,
......@@ -51,14 +55,20 @@ describe('content_editor/components/toolbar_button', () => {
${{ isActive: true, isFocused: true }} | ${'button is active'} | ${true}
${{ isActive: false, isFocused: true }} | ${'button is not active'} | ${false}
${{ isActive: true, isFocused: false }} | ${'button is not active '} | ${false}
`('$outcomeDescription when when editor state is $editorState', ({ editorState, outcome }) => {
tiptapEditor.isActive.mockReturnValueOnce(editorState.isActive);
tiptapEditor.isFocused = editorState.isFocused;
buildWrapper();
`(
'$outcomeDescription when when editor state is $editorState',
async ({ editorState, outcome }) => {
tiptapEditor.isActive.mockReturnValueOnce(editorState.isActive);
tiptapEditor.isFocused = editorState.isFocused;
expect(findButton().classes().includes('active')).toBe(outcome);
expect(tiptapEditor.isActive).toHaveBeenCalledWith(CONTENT_TYPE);
});
buildWrapper();
await emitEditorEvent({ event: 'transaction', tiptapEditor });
expect(findButton().classes().includes('active')).toBe(outcome);
expect(tiptapEditor.isActive).toHaveBeenCalledWith(CONTENT_TYPE);
},
);
describe('when button is clicked', () => {
it('executes the content type command when executeCommand = true', async () => {
......
......@@ -10,7 +10,7 @@ describe('content_editor/components/toolbar_image_button', () => {
const buildWrapper = () => {
wrapper = mountExtended(ToolbarImageButton, {
propsData: {
provide: {
tiptapEditor: editor,
},
});
......
......@@ -3,7 +3,7 @@ import { mountExtended } from 'helpers/vue_test_utils_helper';
import ToolbarLinkButton from '~/content_editor/components/toolbar_link_button.vue';
import Link from '~/content_editor/extensions/link';
import { hasSelection } from '~/content_editor/services/utils';
import { createTestEditor, mockChainedCommands } from '../test_utils';
import { createTestEditor, mockChainedCommands, emitEditorEvent } from '../test_utils';
jest.mock('~/content_editor/services/utils');
......@@ -13,7 +13,7 @@ describe('content_editor/components/toolbar_link_button', () => {
const buildWrapper = () => {
wrapper = mountExtended(ToolbarLinkButton, {
propsData: {
provide: {
tiptapEditor: editor,
},
});
......@@ -43,6 +43,8 @@ describe('content_editor/components/toolbar_link_button', () => {
beforeEach(async () => {
jest.spyOn(editor, 'isActive').mockReturnValueOnce(true);
buildWrapper();
await emitEditorEvent({ event: 'transaction', tiptapEditor: editor });
});
it('sets dropdown as active when link extension is active', () => {
......@@ -88,7 +90,7 @@ describe('content_editor/components/toolbar_link_button', () => {
href: '/username/my-project/uploads/abcdefgh133535/my-file.zip',
});
await editor.emit('selectionUpdate', { editor });
await emitEditorEvent({ event: 'transaction', tiptapEditor: editor });
expect(findLinkURLInput().element.value).toEqual('uploads/my-file.zip');
});
......@@ -98,7 +100,7 @@ describe('content_editor/components/toolbar_link_button', () => {
href: 'https://gitlab.com',
});
await editor.emit('selectionUpdate', { editor });
await emitEditorEvent({ event: 'transaction', tiptapEditor: editor });
expect(findLinkURLInput().element.value).toEqual('https://gitlab.com');
});
......
......@@ -9,7 +9,7 @@ describe('content_editor/components/toolbar_table_button', () => {
const buildWrapper = () => {
wrapper = mountExtended(ToolbarTableButton, {
propsData: {
provide: {
tiptapEditor: editor,
},
});
......
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import EditorStateObserver from '~/content_editor/components/editor_state_observer.vue';
import ToolbarTextStyleDropdown from '~/content_editor/components/toolbar_text_style_dropdown.vue';
import { TEXT_STYLE_DROPDOWN_ITEMS } from '~/content_editor/constants';
import Heading from '~/content_editor/extensions/heading';
import { createTestEditor, mockChainedCommands } from '../test_utils';
import { createTestEditor, mockChainedCommands, emitEditorEvent } from '../test_utils';
describe('content_editor/components/toolbar_headings_dropdown', () => {
describe('content_editor/components/toolbar_text_style_dropdown', () => {
let wrapper;
let tiptapEditor;
......@@ -22,9 +23,12 @@ describe('content_editor/components/toolbar_headings_dropdown', () => {
stubs: {
GlDropdown,
GlDropdownItem,
EditorStateObserver,
},
propsData: {
provide: {
tiptapEditor,
},
propsData: {
...propsData,
},
});
......@@ -50,7 +54,7 @@ describe('content_editor/components/toolbar_headings_dropdown', () => {
describe('when there is an active item ', () => {
let activeTextStyle;
beforeEach(() => {
beforeEach(async () => {
[, activeTextStyle] = TEXT_STYLE_DROPDOWN_ITEMS;
tiptapEditor.isActive.mockImplementation(
......@@ -59,6 +63,7 @@ describe('content_editor/components/toolbar_headings_dropdown', () => {
);
buildWrapper();
await emitEditorEvent({ event: 'transaction', tiptapEditor });
});
it('displays the active text style label as the dropdown toggle text ', () => {
......@@ -79,9 +84,10 @@ describe('content_editor/components/toolbar_headings_dropdown', () => {
});
describe('when there isn’t an active item', () => {
beforeEach(() => {
beforeEach(async () => {
tiptapEditor.isActive.mockReturnValue(false);
buildWrapper();
await emitEditorEvent({ event: 'transaction', tiptapEditor });
});
it('sets dropdown as disabled', () => {
......
......@@ -6,34 +6,19 @@ import {
TOOLBAR_CONTROL_TRACKING_ACTION,
CONTENT_EDITOR_TRACKING_LABEL,
} from '~/content_editor/constants';
import { createContentEditor } from '~/content_editor/services/create_content_editor';
describe('content_editor/components/top_toolbar', () => {
let wrapper;
let contentEditor;
let trackingSpy;
const buildEditor = () => {
contentEditor = createContentEditor({ renderMarkdown: () => true });
};
const buildWrapper = () => {
wrapper = extendedWrapper(
shallowMount(TopToolbar, {
propsData: {
contentEditor,
},
}),
);
wrapper = extendedWrapper(shallowMount(TopToolbar));
};
beforeEach(() => {
trackingSpy = mockTracking(undefined, null, jest.spyOn);
});
beforeEach(() => {
buildEditor();
});
afterEach(() => {
wrapper.destroy();
});
......@@ -60,7 +45,6 @@ describe('content_editor/components/top_toolbar', () => {
it('renders the toolbar control with the provided properties', () => {
expect(wrapper.findByTestId(testId).props()).toEqual({
...controlProps,
tiptapEditor: contentEditor.tiptapEditor,
});
});
......
......@@ -4,6 +4,7 @@ import { Paragraph } from '@tiptap/extension-paragraph';
import { Text } from '@tiptap/extension-text';
import { Editor } from '@tiptap/vue-2';
import { builders, eq } from 'prosemirror-test-builder';
import { nextTick } from 'vue';
export const createDocBuilder = ({ tiptapEditor, names = {} }) => {
const docBuilders = builders(tiptapEditor.schema, {
......@@ -14,6 +15,12 @@ export const createDocBuilder = ({ tiptapEditor, names = {} }) => {
return { eq, builders: docBuilders };
};
export const emitEditorEvent = ({ tiptapEditor, event, params = {} }) => {
tiptapEditor.emit(event, { editor: tiptapEditor, ...params });
return nextTick();
};
/**
* Creates an instance of the Tiptap Editor class
* with a minimal configuration for testing purposes.
......
......@@ -11,9 +11,9 @@ RSpec.describe Resolvers::IssuesResolver do
let_it_be(:project) { create(:project, group: group) }
let_it_be(:other_project) { create(:project, group: group) }
let_it_be(:milestone) { create(:milestone, project: project) }
let_it_be(:started_milestone) { create(:milestone, project: project, title: "started milestone", start_date: 1.day.ago) }
let_it_be(:assignee) { create(:user) }
let_it_be(:issue1) { create(:incident, project: project, state: :opened, created_at: 3.hours.ago, updated_at: 3.hours.ago, milestone: milestone) }
let_it_be(:issue1) { create(:incident, project: project, state: :opened, created_at: 3.hours.ago, updated_at: 3.hours.ago, milestone: started_milestone) }
let_it_be(:issue2) { create(:issue, project: project, state: :closed, title: 'foo', created_at: 1.hour.ago, updated_at: 1.hour.ago, closed_at: 1.hour.ago, assignees: [assignee]) }
let_it_be(:issue3) { create(:issue, project: other_project, state: :closed, title: 'foo', created_at: 1.hour.ago, updated_at: 1.hour.ago, closed_at: 1.hour.ago, assignees: [assignee]) }
let_it_be(:issue4) { create(:issue) }
......@@ -43,7 +43,63 @@ RSpec.describe Resolvers::IssuesResolver do
end
it 'filters by milestone' do
expect(resolve_issues(milestone_title: [milestone.title])).to contain_exactly(issue1)
expect(resolve_issues(milestone_title: [started_milestone.title])).to contain_exactly(issue1)
end
describe 'filtering by milestone wildcard id' do
let_it_be(:upcoming_milestone) { create(:milestone, project: project, title: "upcoming milestone", start_date: 1.day.ago, due_date: 1.day.from_now) }
let_it_be(:past_milestone) { create(:milestone, project: project, title: "past milestone", due_date: 1.day.ago) }
let_it_be(:future_milestone) { create(:milestone, project: project, title: "future milestone", start_date: 1.day.from_now) }
let_it_be(:issue5) { create(:issue, project: project, state: :opened, milestone: upcoming_milestone) }
let_it_be(:issue6) { create(:issue, project: project, state: :opened, milestone: past_milestone) }
let_it_be(:issue7) { create(:issue, project: project, state: :opened, milestone: future_milestone) }
let(:wildcard_started) { 'STARTED' }
let(:wildcard_upcoming) { 'UPCOMING' }
let(:wildcard_any) { 'ANY' }
let(:wildcard_none) { 'NONE' }
it 'returns issues with started milestone' do
expect(resolve_issues(milestone_wildcard_id: wildcard_started)).to contain_exactly(issue1, issue5)
end
it 'returns issues with upcoming milestone' do
expect(resolve_issues(milestone_wildcard_id: wildcard_upcoming)).to contain_exactly(issue5)
end
it 'returns issues with any milestone' do
expect(resolve_issues(milestone_wildcard_id: wildcard_any)).to contain_exactly(issue1, issue5, issue6, issue7)
end
it 'returns issues with no milestone' do
expect(resolve_issues(milestone_wildcard_id: wildcard_none)).to contain_exactly(issue2)
end
it 'raises a mutually exclusive filter error when wildcard and title are provided' do
expect do
resolve_issues(milestone_title: ["started milestone"], milestone_wildcard_id: wildcard_started)
end.to raise_error(Gitlab::Graphql::Errors::ArgumentError, 'only one of [milestoneTitle, milestoneWildcardId] arguments is allowed at the same time.')
end
context 'negated filtering' do
it 'returns issues matching the searched title after applying a negated filter' do
expect(resolve_issues(milestone_title: ['past milestone'], not: { milestone_wildcard_id: wildcard_upcoming })).to contain_exactly(issue6)
end
it 'returns issues excluding the ones with started milestone' do
expect(resolve_issues(not: { milestone_wildcard_id: wildcard_started })).to contain_exactly(issue7)
end
it 'returns issues excluding the ones with upcoming milestone' do
expect(resolve_issues(not: { milestone_wildcard_id: wildcard_upcoming })).to contain_exactly(issue6)
end
it 'raises a mutually exclusive filter error when wildcard and title are provided as negated filters' do
expect do
resolve_issues(not: { milestone_title: ["started milestone"], milestone_wildcard_id: wildcard_started })
end.to raise_error(Gitlab::Graphql::Errors::ArgumentError, 'only one of [milestoneTitle, milestoneWildcardId] arguments is allowed at the same time.')
end
end
end
it 'filters by two assignees' do
......@@ -169,7 +225,7 @@ RSpec.describe Resolvers::IssuesResolver do
end
it 'returns issues without the specified milestone' do
expect(resolve_issues(not: { milestone_title: [milestone.title] })).to contain_exactly(issue2)
expect(resolve_issues(not: { milestone_title: [started_milestone.title] })).to contain_exactly(issue2)
end
it 'returns issues without the specified assignee_usernames' do
......
......@@ -781,7 +781,9 @@ RSpec.describe Notify do
let(:project_member) { invite_to_project(project, inviter: inviter) }
let(:inviter) { maintainer }
subject { described_class.member_invited_email('project', project_member.id, project_member.invite_token) }
subject(:invite_email) do
described_class.member_invited_email('project', project_member.id, project_member.invite_token)
end
it_behaves_like 'an email sent from GitLab'
it_behaves_like 'it should not have Gmail Actions links'
......@@ -796,23 +798,10 @@ RSpec.describe Notify do
is_expected.to have_body_text project.full_name
is_expected.to have_body_text project_member.human_access.downcase
is_expected.to have_body_text project_member.invite_token
is_expected.to have_link('Join now', href: invite_url(project_member.invite_token, invite_type: Members::InviteEmailExperiment::INVITE_TYPE))
end
it 'contains invite link for the group activity' do
stub_experiments('members/invite_email': :activity)
is_expected.to have_link('Join now', href: invite_url(project_member.invite_token, invite_type: Emails::Members::INITIAL_INVITE))
is_expected.to have_content("#{inviter.name} invited you to join the")
is_expected.to have_content('Project details')
is_expected.to have_content("What's it about?")
is_expected.not_to have_content('You are invited!')
is_expected.not_to have_body_text 'What is a GitLab'
end
it 'has invite link for the control group' do
stub_experiments('members/invite_email': :control)
is_expected.to have_content('You are invited!')
end
end
......@@ -824,6 +813,22 @@ RSpec.describe Notify do
is_expected.to have_body_text project.full_name
is_expected.to have_body_text project_member.human_access.downcase
is_expected.to have_body_text project_member.invite_token
is_expected.to have_link('Join now', href: invite_url(project_member.invite_token, invite_type: Emails::Members::INITIAL_INVITE))
is_expected.to have_content('Project details')
is_expected.to have_content("What's it about?")
end
end
context 'when invite email sent is tracked', :snowplow do
it 'tracks the sent invite' do
invite_email.deliver_now
expect_snowplow_event(
category: 'Notify',
action: 'invite_email_sent',
label: 'invite_email',
property: project_member.id.to_s
)
end
end
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment