diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js
index 186a0ab01e7..64812e52849 100644
--- a/app/assets/javascripts/api.js
+++ b/app/assets/javascripts/api.js
@@ -156,13 +156,7 @@ const Api = {
});
},
- addGroupMembersByUserId(id, data) {
- const url = Api.buildUrl(this.groupMembersPath).replace(':id', encodeURIComponent(id));
-
- return axios.post(url, data);
- },
-
- inviteGroupMembersByEmail(id, data) {
+ inviteGroupMembers(id, data) {
const url = Api.buildUrl(this.groupInvitationsPath).replace(':id', encodeURIComponent(id));
return axios.post(url, data);
@@ -258,13 +252,7 @@ const Api = {
.then(({ data }) => data);
},
- addProjectMembersByUserId(id, data) {
- const url = Api.buildUrl(this.projectMembersPath).replace(':id', encodeURIComponent(id));
-
- return axios.post(url, data);
- },
-
- inviteProjectMembersByEmail(id, data) {
+ inviteProjectMembers(id, data) {
const url = Api.buildUrl(this.projectInvitationsPath).replace(':id', encodeURIComponent(id));
return axios.post(url, data);
diff --git a/app/assets/javascripts/content_editor/components/code_block_bubble_menu.vue b/app/assets/javascripts/content_editor/components/code_block_bubble_menu.vue
new file mode 100644
index 00000000000..87f22a27856
--- /dev/null
+++ b/app/assets/javascripts/content_editor/components/code_block_bubble_menu.vue
@@ -0,0 +1,146 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ selectedLanguage.label }}
+
+
+
+
+ {{ language.label }}
+
+
+
+
+
+
+
diff --git a/app/assets/javascripts/content_editor/components/content_editor.vue b/app/assets/javascripts/content_editor/components/content_editor.vue
index a942c9f1149..5b3f4f4ddf2 100644
--- a/app/assets/javascripts/content_editor/components/content_editor.vue
+++ b/app/assets/javascripts/content_editor/components/content_editor.vue
@@ -5,6 +5,7 @@ import ContentEditorAlert from './content_editor_alert.vue';
import ContentEditorProvider from './content_editor_provider.vue';
import EditorStateObserver from './editor_state_observer.vue';
import FormattingBubbleMenu from './formatting_bubble_menu.vue';
+import CodeBlockBubbleMenu from './code_block_bubble_menu.vue';
import TopToolbar from './top_toolbar.vue';
import LoadingIndicator from './loading_indicator.vue';
@@ -16,6 +17,7 @@ export default {
TiptapEditorContent,
TopToolbar,
FormattingBubbleMenu,
+ CodeBlockBubbleMenu,
EditorStateObserver,
},
props: {
@@ -89,6 +91,7 @@ export default {
+
diff --git a/app/assets/javascripts/content_editor/components/formatting_bubble_menu.vue b/app/assets/javascripts/content_editor/components/formatting_bubble_menu.vue
index 14a553ff30b..103079534bc 100644
--- a/app/assets/javascripts/content_editor/components/formatting_bubble_menu.vue
+++ b/app/assets/javascripts/content_editor/components/formatting_bubble_menu.vue
@@ -3,6 +3,10 @@ import { GlButtonGroup } from '@gitlab/ui';
import { BubbleMenu } from '@tiptap/vue-2';
import { BUBBLE_MENU_TRACKING_ACTION } from '../constants';
import trackUIControl from '../services/track_ui_control';
+import Code from '../extensions/code';
+import CodeBlockHighlight from '../extensions/code_block_highlight';
+import Diagram from '../extensions/diagram';
+import Frontmatter from '../extensions/frontmatter';
import ToolbarButton from './toolbar_button.vue';
export default {
@@ -16,6 +20,14 @@ export default {
trackToolbarControlExecution({ contentType, value }) {
trackUIControl({ action: BUBBLE_MENU_TRACKING_ACTION, property: contentType, value });
},
+
+ shouldShow: ({ editor, from, to }) => {
+ if (from === to) return false;
+
+ const exclude = [Code.name, CodeBlockHighlight.name, Diagram.name, Frontmatter.name];
+
+ return !exclude.some((type) => editor.isActive(type));
+ },
},
};
@@ -24,6 +36,7 @@ export default {
data-testid="formatting-bubble-menu"
class="gl-shadow gl-rounded-base"
:editor="tiptapEditor"
+ :should-show="shouldShow"
>
element.getAttribute('lang');
-const backtickInputRegex = /^```([a-z]+)?[\s\n]$/;
-const tildeInputRegex = /^~~~([a-z]+)?[\s\n]$/;
-
-const loadLanguageFromInputRule = (languageLoader) => (match) => {
- const language = match[1];
-
- if (isFunction(languageLoader?.loadLanguages)) {
- languageLoader.loadLanguages([language]);
- }
-
- return {
- language,
- };
-};
+export const backtickInputRegex = /^```([a-z]+)?[\s\n]$/;
+export const tildeInputRegex = /^~~~([a-z]+)?[\s\n]$/;
export default CodeBlockLowlight.extend({
isolating: true,
+ exitOnArrowDown: false,
addOptions() {
return {
...this.parent?.(),
- languageLoader: {},
+ languageLoader: codeBlockLanguageLoader,
};
},
@@ -42,26 +31,36 @@ export default CodeBlockLowlight.extend({
},
addInputRules() {
const { languageLoader } = this.options;
+ const getAttributes = (match) => languageLoader?.loadLanguageFromInputRule(match) || {};
return [
textblockTypeInputRule({
find: backtickInputRegex,
type: this.type,
- getAttributes: loadLanguageFromInputRule(languageLoader),
+ getAttributes,
}),
textblockTypeInputRule({
find: tildeInputRegex,
type: this.type,
- getAttributes: loadLanguageFromInputRule(languageLoader),
+ getAttributes,
}),
];
},
+ parseHTML() {
+ return [
+ ...(this.parent?.() || []),
+ {
+ tag: 'div.markdown-code-block',
+ skip: true,
+ },
+ ];
+ },
renderHTML({ HTMLAttributes }) {
return [
'pre',
{
...HTMLAttributes,
- class: `content-editor-code-block ${HTMLAttributes.class}`,
+ class: `content-editor-code-block ${gon.user_color_scheme} ${HTMLAttributes.class}`,
},
['code', {}, 0],
];
diff --git a/app/assets/javascripts/content_editor/services/code_block_language_loader.js b/app/assets/javascripts/content_editor/services/code_block_language_loader.js
index 3c12cf614a5..081400cfd9a 100644
--- a/app/assets/javascripts/content_editor/services/code_block_language_loader.js
+++ b/app/assets/javascripts/content_editor/services/code_block_language_loader.js
@@ -1,11 +1,249 @@
-export default class CodeBlockLanguageLoader {
- constructor(lowlight) {
- this.lowlight = lowlight;
- }
+import { lowlight } from 'lowlight/lib/core';
+import { __, sprintf } from '~/locale';
+
+/* eslint-disable @gitlab/require-i18n-strings */
+// List of languages referenced from https://github.com/wooorm/lowlight#data
+const CODE_BLOCK_LANGUAGES = [
+ { syntax: '1c', label: '1C:Enterprise' },
+ { syntax: 'abnf', label: 'Augmented Backus-Naur Form' },
+ { syntax: 'accesslog', label: 'Apache Access Log' },
+ { syntax: 'actionscript', variants: 'as', label: 'ActionScript' },
+ { syntax: 'ada', label: 'Ada' },
+ { syntax: 'angelscript', variants: 'asc', label: 'AngelScript' },
+ { syntax: 'apache', variants: 'apacheconf', label: 'Apache config' },
+ { syntax: 'applescript', variants: 'osascript', label: 'AppleScript' },
+ { syntax: 'arcade', label: 'ArcGIS Arcade' },
+ { syntax: 'arduino', variants: 'ino', label: 'Arduino' },
+ { syntax: 'armasm', variants: 'arm', label: 'ARM Assembly' },
+ { syntax: 'asciidoc', variants: 'adoc', label: 'AsciiDoc' },
+ { syntax: 'aspectj', label: 'AspectJ' },
+ { syntax: 'autohotkey', variants: 'ahk', label: 'AutoHotkey' },
+ { syntax: 'autoit', label: 'AutoIt' },
+ { syntax: 'avrasm', label: 'AVR Assembly' },
+ { syntax: 'awk', label: 'Awk' },
+ { syntax: 'axapta', variants: 'x++', label: 'X++' },
+ { syntax: 'bash', variants: 'sh', label: 'Bash' },
+ { syntax: 'basic', label: 'BASIC' },
+ { syntax: 'bnf', label: 'Backus-Naur Form' },
+ { syntax: 'brainfuck', variants: 'bf', label: 'Brainfuck' },
+ { syntax: 'c', variants: 'h', label: 'C' },
+ { syntax: 'cal', label: 'C/AL' },
+ { syntax: 'capnproto', variants: 'capnp', label: "Cap'n Proto" },
+ { syntax: 'ceylon', label: 'Ceylon' },
+ { syntax: 'clean', variants: 'icl, dcl', label: 'Clean' },
+ { syntax: 'clojure', variants: 'clj, edn', label: 'Clojure' },
+ { syntax: 'clojure-repl', label: 'Clojure REPL' },
+ { syntax: 'cmake', variants: 'cmake.in', label: 'CMake' },
+ { syntax: 'coffeescript', variants: 'coffee, cson, iced', label: 'CoffeeScript' },
+ { syntax: 'coq', label: 'Coq' },
+ { syntax: 'cos', variants: 'cls', label: 'Caché Object Script' },
+ { syntax: 'cpp', variants: 'cc, c++, h++, hpp, hh, hxx, cxx', label: 'C++' },
+ { syntax: 'crmsh', variants: 'crm, pcmk', label: 'crmsh' },
+ { syntax: 'crystal', variants: 'cr', label: 'Crystal' },
+ { syntax: 'csharp', variants: 'cs, c#', label: 'C#' },
+ { syntax: 'csp', label: 'CSP' },
+ { syntax: 'css', label: 'CSS' },
+ { syntax: 'd', label: 'D' },
+ { syntax: 'dart', label: 'Dart' },
+ { syntax: 'delphi', variants: 'dpr, dfm, pas, pascal', label: 'Delphi' },
+ { syntax: 'diff', variants: 'patch', label: 'Diff' },
+ { syntax: 'django', variants: 'jinja', label: 'Django' },
+ { syntax: 'dns', variants: 'bind, zone', label: 'DNS Zone' },
+ { syntax: 'dockerfile', variants: 'docker', label: 'Dockerfile' },
+ { syntax: 'dos', variants: 'bat, cmd', label: 'Batch file (DOS)' },
+ { syntax: 'dsconfig', label: 'DSConfig' },
+ { syntax: 'dts', label: 'Device Tree' },
+ { syntax: 'dust', variants: 'dst', label: 'Dust' },
+ { syntax: 'ebnf', label: 'Extended Backus-Naur Form' },
+ { syntax: 'elixir', variants: 'ex, exs', label: 'Elixir' },
+ { syntax: 'elm', label: 'Elm' },
+ { syntax: 'erb', label: 'ERB' },
+ { syntax: 'erlang', variants: 'erl', label: 'Erlang' },
+ { syntax: 'erlang-repl', label: 'Erlang REPL' },
+ { syntax: 'excel', variants: 'xlsx, xls', label: 'Excel formulae' },
+ { syntax: 'fix', label: 'FIX' },
+ { syntax: 'flix', label: 'Flix' },
+ { syntax: 'fortran', variants: 'f90, f95', label: 'Fortran' },
+ { syntax: 'fsharp', variants: 'fs, f#', label: 'F#' },
+ { syntax: 'gams', variants: 'gms', label: 'GAMS' },
+ { syntax: 'gauss', variants: 'gss', label: 'GAUSS' },
+ { syntax: 'gcode', variants: 'nc', label: 'G-code (ISO 6983)' },
+ { syntax: 'gherkin', variants: 'feature', label: 'Gherkin' },
+ { syntax: 'glsl', label: 'GLSL' },
+ { syntax: 'gml', label: 'GML' },
+ { syntax: 'go', variants: 'golang', label: 'Go' },
+ { syntax: 'golo', label: 'Golo' },
+ { syntax: 'gradle', label: 'Gradle' },
+ { syntax: 'graphql', variants: 'gql', label: 'GraphQL' },
+ { syntax: 'groovy', label: 'Groovy' },
+ { syntax: 'haml', label: 'HAML' },
+ {
+ syntax: 'handlebars',
+ variants: 'hbs, html.hbs, html.handlebars, htmlbars',
+ label: 'Handlebars',
+ },
+ { syntax: 'haskell', variants: 'hs', label: 'Haskell' },
+ { syntax: 'haxe', variants: 'hx', label: 'Haxe' },
+ { syntax: 'hsp', label: 'HSP' },
+ { syntax: 'http', variants: 'https', label: 'HTTP' },
+ { syntax: 'hy', variants: 'hylang', label: 'Hy' },
+ { syntax: 'inform7', variants: 'i7', label: 'Inform 7' },
+ { syntax: 'ini', variants: 'toml', label: 'TOML, also INI' },
+ { syntax: 'irpf90', label: 'IRPF90' },
+ { syntax: 'isbl', label: 'ISBL' },
+ { syntax: 'java', variants: 'jsp', label: 'Java' },
+ { syntax: 'javascript', variants: 'js, jsx, mjs, cjs', label: 'Javascript' },
+ { syntax: 'jboss-cli', variants: 'wildfly-cli', label: 'JBoss CLI' },
+ { syntax: 'json', label: 'JSON' },
+ { syntax: 'julia', label: 'Julia' },
+ { syntax: 'julia-repl', variants: 'jldoctest', label: 'Julia REPL' },
+ { syntax: 'kotlin', variants: 'kt, kts', label: 'Kotlin' },
+ { syntax: 'lasso', variants: 'ls, lassoscript', label: 'Lasso' },
+ { syntax: 'latex', variants: 'tex', label: 'LaTeX' },
+ { syntax: 'ldif', label: 'LDIF' },
+ { syntax: 'leaf', label: 'Leaf' },
+ { syntax: 'less', label: 'Less' },
+ { syntax: 'lisp', label: 'Lisp' },
+ { syntax: 'livecodeserver', label: 'LiveCode' },
+ { syntax: 'livescript', variants: 'ls', label: 'LiveScript' },
+ { syntax: 'llvm', label: 'LLVM IR' },
+ { syntax: 'lsl', label: 'LSL (Linden Scripting Language)' },
+ { syntax: 'lua', label: 'Lua' },
+ { syntax: 'makefile', variants: 'mk, mak, make', label: 'Makefile' },
+ { syntax: 'markdown', variants: 'md, mkdown, mkd', label: 'Markdown' },
+ { syntax: 'mathematica', variants: 'mma, wl', label: 'Mathematica' },
+ { syntax: 'matlab', label: 'Matlab' },
+ { syntax: 'maxima', label: 'Maxima' },
+ { syntax: 'mel', label: 'MEL' },
+ { syntax: 'mercury', variants: 'm, moo', label: 'Mercury' },
+ { syntax: 'mipsasm', variants: 'mips', label: 'MIPS Assembly' },
+ { syntax: 'mizar', label: 'Mizar' },
+ { syntax: 'mojolicious', label: 'Mojolicious' },
+ { syntax: 'monkey', label: 'Monkey' },
+ { syntax: 'moonscript', variants: 'moon', label: 'MoonScript' },
+ { syntax: 'n1ql', label: 'N1QL' },
+ { syntax: 'nestedtext', variants: 'nt', label: 'Nested Text' },
+ { syntax: 'nginx', variants: 'nginxconf', label: 'Nginx config' },
+ { syntax: 'nim', label: 'Nim' },
+ { syntax: 'nix', variants: 'nixos', label: 'Nix' },
+ { syntax: 'node-repl', label: 'Node REPL' },
+ { syntax: 'nsis', label: 'NSIS' },
+ {
+ syntax: 'objectivec',
+ variants: 'mm, objc, obj-c, obj-c++, objective-c++',
+ label: 'Objective-C',
+ },
+ { syntax: 'ocaml', variants: 'ml', label: 'OCaml' },
+ { syntax: 'openscad', variants: 'scad', label: 'OpenSCAD' },
+ { syntax: 'oxygene', label: 'Oxygene' },
+ { syntax: 'parser3', label: 'Parser3' },
+ { syntax: 'perl', variants: 'pl, pm', label: 'Perl' },
+ { syntax: 'pf', variants: 'pf.conf', label: 'Packet Filter config' },
+ { syntax: 'pgsql', variants: 'postgres, postgresql', label: 'PostgreSQL' },
+ { syntax: 'php', label: 'PHP' },
+ { syntax: 'php-template', label: 'PHP template' },
+ { syntax: 'plaintext', variants: 'text, txt', label: 'Plain text' },
+ { syntax: 'pony', label: 'Pony' },
+ { syntax: 'powershell', variants: 'pwsh, ps, ps1', label: 'PowerShell' },
+ { syntax: 'processing', variants: 'pde', label: 'Processing' },
+ { syntax: 'profile', label: 'Python profiler' },
+ { syntax: 'prolog', label: 'Prolog' },
+ { syntax: 'properties', label: '.properties' },
+ { syntax: 'protobuf', label: 'Protocol Buffers' },
+ { syntax: 'puppet', variants: 'pp', label: 'Puppet' },
+ { syntax: 'purebasic', variants: 'pb, pbi', label: 'PureBASIC' },
+ { syntax: 'python', variants: 'py, gyp, ipython', label: 'Python' },
+ { syntax: 'python-repl', variants: 'pycon', label: 'Python REPL' },
+ { syntax: 'q', variants: 'k, kdb', label: 'Q' },
+ { syntax: 'qml', variants: 'qt', label: 'QML' },
+ { syntax: 'r', label: 'R' },
+ { syntax: 'reasonml', variants: 're', label: 'ReasonML' },
+ { syntax: 'rib', label: 'RenderMan RIB' },
+ { syntax: 'roboconf', variants: 'graph, instances', label: 'Roboconf' },
+ { syntax: 'routeros', variants: 'mikrotik', label: 'Microtik RouterOS script' },
+ { syntax: 'rsl', label: 'RenderMan RSL' },
+ { syntax: 'ruby', variants: 'rb, gemspec, podspec, thor, irb', label: 'Ruby' },
+ { syntax: 'ruleslanguage', label: 'Oracle Rules Language' },
+ { syntax: 'rust', variants: 'rs', label: 'Rust' },
+ { syntax: 'sas', label: 'SAS' },
+ { syntax: 'scala', label: 'Scala' },
+ { syntax: 'scheme', label: 'Scheme' },
+ { syntax: 'scilab', variants: 'sci', label: 'Scilab' },
+ { syntax: 'scss', label: 'SCSS' },
+ { syntax: 'shell', variants: 'console, shellsession', label: 'Shell Session' },
+ { syntax: 'smali', label: 'Smali' },
+ { syntax: 'smalltalk', variants: 'st', label: 'Smalltalk' },
+ { syntax: 'sml', variants: 'ml', label: 'SML (Standard ML)' },
+ { syntax: 'sqf', label: 'SQF' },
+ { syntax: 'sql', label: 'SQL' },
+ { syntax: 'stan', variants: 'stanfuncs', label: 'Stan' },
+ { syntax: 'stata', variants: 'do, ado', label: 'Stata' },
+ { syntax: 'step21', variants: 'p21, step, stp', label: 'STEP Part 21' },
+ { syntax: 'stylus', variants: 'styl', label: 'Stylus' },
+ { syntax: 'subunit', label: 'SubUnit' },
+ { syntax: 'swift', label: 'Swift' },
+ { syntax: 'taggerscript', label: 'Tagger Script' },
+ { syntax: 'tap', label: 'Test Anything Protocol' },
+ { syntax: 'tcl', variants: 'tk', label: 'Tcl' },
+ { syntax: 'thrift', label: 'Thrift' },
+ { syntax: 'tp', label: 'TP' },
+ { syntax: 'twig', variants: 'craftcms', label: 'Twig' },
+ { syntax: 'typescript', variants: 'ts, tsx', label: 'TypeScript' },
+ { syntax: 'vala', label: 'Vala' },
+ { syntax: 'vbnet', variants: 'vb', label: 'Visual Basic .NET' },
+ { syntax: 'vbscript', variants: 'vbs', label: 'VBScript' },
+ { syntax: 'vbscript-html', label: 'VBScript in HTML' },
+ { syntax: 'verilog', variants: 'v, sv, svh', label: 'Verilog' },
+ { syntax: 'vhdl', label: 'VHDL' },
+ { syntax: 'vim', label: 'Vim Script' },
+ { syntax: 'wasm', label: 'WebAssembly' },
+ { syntax: 'wren', label: 'Wren' },
+ { syntax: 'x86asm', label: 'Intel x86 Assembly' },
+ { syntax: 'xl', variants: 'tao', label: 'XL' },
+ {
+ syntax: 'xml',
+ variants: 'html, xhtml, rss, atom, xjb, xsd, xsl, plist, wsf, svg',
+ label: 'HTML, XML',
+ },
+ { syntax: 'xquery', variants: 'xpath, xq', label: 'XQuery' },
+ { syntax: 'yaml', variants: 'yml', label: 'YAML' },
+ { syntax: 'zephir', variants: 'zep', label: 'Zephir' },
+];
+/* eslint-enable @gitlab/require-i18n-strings */
+
+const codeBlockLanguageLoader = {
+ lowlight,
+
+ allLanguages: CODE_BLOCK_LANGUAGES,
+
+ findLanguageBySyntax(value) {
+ const lowercaseValue = value?.toLowerCase() || 'plaintext';
+ return (
+ this.allLanguages.find(
+ ({ syntax, variants }) =>
+ syntax === lowercaseValue || variants?.toLowerCase().split(', ').includes(lowercaseValue),
+ ) || {
+ syntax: lowercaseValue,
+ label: sprintf(__(`Custom (%{language})`), { language: lowercaseValue }),
+ }
+ );
+ },
+
+ filterLanguages(value) {
+ if (!value) return this.allLanguages;
+
+ const lowercaseValue = value?.toLowerCase() || '';
+ return this.allLanguages.filter(
+ ({ syntax, label, variants }) =>
+ syntax.toLowerCase().includes(lowercaseValue) ||
+ label.toLowerCase().includes(lowercaseValue) ||
+ variants?.toLowerCase().includes(lowercaseValue),
+ );
+ },
isLanguageLoaded(language) {
return this.lowlight.registered(language);
- }
+ },
loadLanguagesFromDOM(domTree) {
const languages = [];
@@ -15,7 +253,15 @@ export default class CodeBlockLanguageLoader {
});
return this.loadLanguages(languages);
- }
+ },
+
+ loadLanguageFromInputRule(match) {
+ const { syntax } = this.findLanguageBySyntax(match[1]);
+
+ this.loadLanguages([syntax]);
+
+ return { language: syntax };
+ },
loadLanguages(languageList = []) {
const loaders = languageList
@@ -31,5 +277,7 @@ export default class CodeBlockLanguageLoader {
});
return Promise.all(loaders);
- }
-}
+ },
+};
+
+export default codeBlockLanguageLoader;
diff --git a/app/assets/javascripts/content_editor/services/create_content_editor.js b/app/assets/javascripts/content_editor/services/create_content_editor.js
index 7ed62ee17fb..af19a0ab0e4 100644
--- a/app/assets/javascripts/content_editor/services/create_content_editor.js
+++ b/app/assets/javascripts/content_editor/services/create_content_editor.js
@@ -60,7 +60,7 @@ import { ContentEditor } from './content_editor';
import createMarkdownSerializer from './markdown_serializer';
import createMarkdownDeserializer from './markdown_deserializer';
import trackInputRulesAndShortcuts from './track_input_rules_and_shortcuts';
-import CodeBlockLanguageLoader from './code_block_language_loader';
+import languageLoader from './code_block_language_loader';
const createTiptapEditor = ({ extensions = [], ...options } = {}) =>
new Editor({
@@ -86,7 +86,6 @@ export const createContentEditor = ({
const eventHub = eventHubFactory();
- const languageLoader = new CodeBlockLanguageLoader(lowlight);
const builtInContentEditorExtensions = [
Attachment.configure({ uploadsPath, renderMarkdown, eventHub }),
Audio,
diff --git a/app/assets/javascripts/content_editor/services/track_input_rules_and_shortcuts.js b/app/assets/javascripts/content_editor/services/track_input_rules_and_shortcuts.js
index eb1e4885ba6..b844b414343 100644
--- a/app/assets/javascripts/content_editor/services/track_input_rules_and_shortcuts.js
+++ b/app/assets/javascripts/content_editor/services/track_input_rules_and_shortcuts.js
@@ -8,12 +8,12 @@ import {
INPUT_RULE_TRACKING_ACTION,
} from '../constants';
-const trackKeyboardShortcut = (contentType, commandFn, shortcut) => () => {
+const trackKeyboardShortcut = (contentType, commandFn, shortcut) => (...args) => {
Tracking.event(undefined, KEYBOARD_SHORTCUT_TRACKING_ACTION, {
label: CONTENT_EDITOR_TRACKING_LABEL,
property: `${contentType}.${shortcut}`,
});
- return commandFn();
+ return commandFn(...args);
};
const trackInputRule = (contentType, inputRule) => {
diff --git a/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue b/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue
index e2f3f9cad7b..a077c8ae3af 100644
--- a/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue
+++ b/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue
@@ -74,6 +74,7 @@ export default {
v-for="note in notesInGutter"
:key="note.id"
:img-src="note.author.avatar_url"
+ :size="24"
:tooltip-text="getTooltipText(note)"
lazy
class="diff-comment-avatar js-diff-comment-avatar"
diff --git a/app/assets/javascripts/invite_members/components/invite_members_modal.vue b/app/assets/javascripts/invite_members/components/invite_members_modal.vue
index 23225869636..a9aa0e9b760 100644
--- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue
+++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue
@@ -193,46 +193,28 @@ export default {
this.invalidFeedbackMessage = '';
const [usersToInviteByEmail, usersToAddById] = this.partitionNewUsersToInvite();
- const promises = [];
- const baseData = {
+
+ const apiAddByInvite = this.isProject
+ ? Api.inviteProjectMembers.bind(Api)
+ : Api.inviteGroupMembers.bind(Api);
+
+ const email = usersToInviteByEmail !== '' ? { email: usersToInviteByEmail } : {};
+ const userId = usersToAddById !== '' ? { user_id: usersToAddById } : {};
+
+ this.trackinviteMembersForTask();
+
+ apiAddByInvite(this.id, {
format: 'json',
expires_at: expiresAt,
access_level: accessLevel,
invite_source: this.source,
tasks_to_be_done: this.tasksToBeDoneForPost,
tasks_project_id: this.tasksProjectForPost,
- };
-
- if (usersToInviteByEmail !== '') {
- const apiInviteByEmail = this.isProject
- ? Api.inviteProjectMembersByEmail.bind(Api)
- : Api.inviteGroupMembersByEmail.bind(Api);
-
- promises.push(
- apiInviteByEmail(this.id, {
- ...baseData,
- email: usersToInviteByEmail,
- }),
- );
- }
-
- if (usersToAddById !== '') {
- const apiAddByUserId = this.isProject
- ? Api.addProjectMembersByUserId.bind(Api)
- : Api.addGroupMembersByUserId.bind(Api);
-
- promises.push(
- apiAddByUserId(this.id, {
- ...baseData,
- user_id: usersToAddById,
- }),
- );
- }
- this.trackinviteMembersForTask();
-
- Promise.all(promises)
- .then((responses) => {
- const message = responseMessageFromSuccess(responses);
+ ...email,
+ ...userId,
+ })
+ .then((response) => {
+ const message = responseMessageFromSuccess(response);
if (message) {
this.showInvalidFeedbackMessage({
diff --git a/app/assets/javascripts/invite_members/constants.js b/app/assets/javascripts/invite_members/constants.js
index cf2ee508184..3cd0bfc0181 100644
--- a/app/assets/javascripts/invite_members/constants.js
+++ b/app/assets/javascripts/invite_members/constants.js
@@ -1,4 +1,4 @@
-import { __, s__ } from '~/locale';
+import { s__ } from '~/locale';
export const SEARCH_DELAY = 200;
@@ -14,9 +14,6 @@ export const GROUP_FILTERS = {
DESCENDANT_GROUPS: 'descendant_groups',
};
-export const API_MESSAGES = {
- EMAIL_ALREADY_INVITED: __('Invite email has already been taken'),
-};
export const USERS_FILTER_ALL = 'all';
export const USERS_FILTER_SAML_PROVIDER_ID = 'saml_provider_id';
export const TRIGGER_ELEMENT_BUTTON = 'button';
diff --git a/app/assets/javascripts/invite_members/utils/response_message_parser.js b/app/assets/javascripts/invite_members/utils/response_message_parser.js
index 52ec3be3205..db8ac303dc4 100644
--- a/app/assets/javascripts/invite_members/utils/response_message_parser.js
+++ b/app/assets/javascripts/invite_members/utils/response_message_parser.js
@@ -1,28 +1,15 @@
import { isString } from 'lodash';
-import { API_MESSAGES } from '~/invite_members/constants';
function responseKeyedMessageParsed(keyedMessage) {
try {
const keys = Object.keys(keyedMessage);
const msg = keyedMessage[keys[0]];
- if (msg === API_MESSAGES.EMAIL_ALREADY_INVITED) {
- return '';
- }
return msg;
} catch {
return '';
}
}
-function responseMessageStringForMultiple(message) {
- return message.includes(':');
-}
-function responseMessageStringFirstPart(message) {
- const firstPart = message.split(':')[1];
- const firstMsg = firstPart.split(/ and [\w-]*$/)[0].trim();
-
- return firstMsg;
-}
export function responseMessageFromError(response) {
if (!response?.response?.data) {
@@ -33,36 +20,25 @@ export function responseMessageFromError(response) {
response: { data },
} = response;
- return (
- data.error ||
- data.message?.user?.[0] ||
- data.message?.access_level?.[0] ||
- data.message?.error ||
- data.message ||
- ''
- );
+ return data.error || data.message?.error || data.message || '';
}
export function responseMessageFromSuccess(response) {
- if (!response?.[0]?.data) {
+ if (!response?.data) {
return '';
}
- const { data } = response[0];
+ const { data } = response;
- if (data.message && !data.message.user) {
+ if (data.message) {
const { message } = data;
if (isString(message)) {
- if (responseMessageStringForMultiple(message)) {
- return responseMessageStringFirstPart(message);
- }
-
return message;
}
return responseKeyedMessageParsed(message);
}
- return data.message || data.message?.user || data.error || '';
+ return data.error || '';
}
diff --git a/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue b/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue
index ca60f876c6f..cb7b963b698 100644
--- a/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue
+++ b/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue
@@ -18,6 +18,8 @@ export default {
name: 'MembersFilteredSearchBar',
components: { FilteredSearchBar },
availableTokens: AVAILABLE_FILTERED_SEARCH_TOKENS,
+ searchButtonAttributes: { 'data-qa-selector': 'search_button' },
+ searchInputAttributes: { 'data-qa-selector': 'search_bar_input' },
inject: {
namespace: {},
sourceId: {},
@@ -127,8 +129,9 @@ export default {
:recent-searches-storage-key="filteredSearchBar.recentSearchesStorageKey"
:search-input-placeholder="filteredSearchBar.placeholder"
:initial-filter-value="initialFilterValue"
+ :search-button-attributes="$options.searchButtonAttributes"
+ :search-input-attributes="$options.searchInputAttributes"
data-testid="members-filtered-search-bar"
- data-qa-selector="members_filtered_search_bar_content"
@onFilter="handleFilter"
/>
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue
index 6a9f62a91c4..6638a5de62f 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue
@@ -79,6 +79,16 @@ export default {
required: false,
default: '',
},
+ searchButtonAttributes: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ searchInputAttributes: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
},
data() {
let selectedSortOption = this.sortOptions[0]?.sortDirection?.descending;
@@ -320,6 +330,8 @@ export default {
:available-tokens="tokens"
:history-items="filteredRecentSearches"
:suggestions-list-class="suggestionsListClass"
+ :search-button-attributes="searchButtonAttributes"
+ :search-input-attributes="searchInputAttributes"
class="flex-grow-1"
@history-item-selected="handleHistoryItemSelected"
@clear="onClear"
diff --git a/db/docs/application_settings.yml b/db/docs/application_settings.yml
index e0e43e090bf..578e8ad5c3d 100644
--- a/db/docs/application_settings.yml
+++ b/db/docs/application_settings.yml
@@ -9,6 +9,6 @@ feature_categories:
- pages
- service_ping
- source_code_management
-description: TODO
+description: GitLab application settings
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/commit/8589b4e137f50293952923bb07e2814257d7784d
milestone: '7.7'
diff --git a/db/docs/product_analytics_events_experimental.yml b/db/docs/product_analytics_events_experimental.yml
index 9b33cdc6b87..c295074b706 100644
--- a/db/docs/product_analytics_events_experimental.yml
+++ b/db/docs/product_analytics_events_experimental.yml
@@ -4,6 +4,6 @@ classes:
- ProductAnalyticsEvent
feature_categories:
- product_analytics
-description: TODO
-introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/commit/9af97ee69a36de1dc4e73f4030d6316d3f0a82c5
+description: Product analytic events, experimental feature.
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/commit/fc6c53e6f7b47dc22c8619a5a6fe491d29778d3f
milestone: '13.2'
diff --git a/doc/development/documentation/index.md b/doc/development/documentation/index.md
index 66d6beb821f..c6afcdbddd0 100644
--- a/doc/development/documentation/index.md
+++ b/doc/development/documentation/index.md
@@ -20,7 +20,7 @@ In addition to this page, the following resources can help you craft and contrib
## Source files and rendered web locations
-Documentation for GitLab, GitLab Runner, Omnibus GitLab, and Charts is published to . Documentation for GitLab is also published within the application at `/help` on the domain of the GitLab instance.
+Documentation for GitLab, GitLab Runner, GitLab Operator, Omnibus GitLab, and Charts is published to . Documentation for GitLab is also published within the application at `/help` on the domain of the GitLab instance.
At `/help`, only help for your current edition and version is included. Help for other versions is available at .
The source of the documentation exists within the codebase of each GitLab application in the following repository locations:
@@ -31,6 +31,7 @@ The source of the documentation exists within the codebase of each GitLab applic
| [GitLab Runner](https://gitlab.com/gitlab-org/gitlab-runner/) | [`/docs`](https://gitlab.com/gitlab-org/gitlab-runner/-/tree/main/docs) |
| [Omnibus GitLab](https://gitlab.com/gitlab-org/omnibus-gitlab/) | [`/doc`](https://gitlab.com/gitlab-org/omnibus-gitlab/tree/master/doc) |
| [Charts](https://gitlab.com/gitlab-org/charts/gitlab) | [`/doc`](https://gitlab.com/gitlab-org/charts/gitlab/tree/master/doc) |
+| [GitLab Operator](https://gitlab.com/gitlab-org/cloud-native/gitlab-operator) | [`/doc`](https://gitlab.com/gitlab-org/cloud-native/gitlab-operator/-/tree/master/doc) |
Documentation issues and merge requests are part of their respective repositories and all have the label `Documentation`.
diff --git a/doc/user/infrastructure/iac/terraform_state.md b/doc/user/infrastructure/iac/terraform_state.md
index 39a57b60787..60f97f522cf 100644
--- a/doc/user/infrastructure/iac/terraform_state.md
+++ b/doc/user/infrastructure/iac/terraform_state.md
@@ -454,29 +454,6 @@ query ProjectTerraformStates {
For those new to the GitLab GraphQL API, read
[Getting started with GitLab GraphQL API](../../../api/graphql/getting_started.md).
-## Troubleshooting
+## Related topics
-### Unable to lock Terraform state files in CI jobs for `terraform apply` using a plan created in a previous job
-
-When passing `-backend-config=` to `terraform init`, Terraform persists these values inside the plan
-cache file. This includes the `password` value.
-
-As a result, to create a plan and later use the same plan in another CI job, you might get the error
-`Error: Error acquiring the state lock` errors when using `-backend-config=password=$CI_JOB_TOKEN`.
-This happens because the value of `$CI_JOB_TOKEN` is only valid for the duration of the current job.
-
-As a workaround, use [http backend configuration variables](https://www.terraform.io/docs/language/settings/backends/http.html#configuration-variables) in your CI job,
-which is what happens behind the scenes when following the
-[Get started using GitLab CI](#get-started-using-gitlab-ci) instructions.
-
-### Error: "address": required field is not set
-
-By default, we set `TF_ADDRESS` to `${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/terraform/state/${TF_STATE_NAME}`.
-If you don't set `TF_STATE_NAME` or `TF_ADDRESS` in your job, the job fails with the error message
-`Error: "address": required field is not set`.
-
-To resolve this, ensure that either `TF_ADDRESS` or `TF_STATE_NAME` is accessible in the
-job that returned the error:
-
-1. Configure the [CI/CD environment scope](../../../ci/variables/#add-a-cicd-variable-to-a-project) for the job.
-1. Set the job's [environment](../../../ci/yaml/#environment), matching the environment scope from the previous step.
+- [Troubleshooting GitLab-managed Terraform state](troubleshooting.md).
diff --git a/doc/user/infrastructure/iac/troubleshooting.md b/doc/user/infrastructure/iac/troubleshooting.md
index ecefa20db99..bc0aa39bc70 100644
--- a/doc/user/infrastructure/iac/troubleshooting.md
+++ b/doc/user/infrastructure/iac/troubleshooting.md
@@ -66,3 +66,30 @@ with better Terraform-specific names. To resolve the syntax error, you can:
my-Terraform-job:
extends: .terraform:init # The updated name.
```
+
+## Troubleshooting Terraform state
+
+### Unable to lock Terraform state files in CI jobs for `terraform apply` using a plan created in a previous job
+
+When passing `-backend-config=` to `terraform init`, Terraform persists these values inside the plan
+cache file. This includes the `password` value.
+
+As a result, to create a plan and later use the same plan in another CI job, you might get the error
+`Error: Error acquiring the state lock` errors when using `-backend-config=password=$CI_JOB_TOKEN`.
+This happens because the value of `$CI_JOB_TOKEN` is only valid for the duration of the current job.
+
+As a workaround, use [http backend configuration variables](https://www.terraform.io/docs/language/settings/backends/http.html#configuration-variables) in your CI job,
+which is what happens behind the scenes when following the
+[Get started using GitLab CI](terraform_state.md#get-started-using-gitlab-ci) instructions.
+
+### Error: "address": required field is not set
+
+By default, we set `TF_ADDRESS` to `${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/terraform/state/${TF_STATE_NAME}`.
+If you don't set `TF_STATE_NAME` or `TF_ADDRESS` in your job, the job fails with the error message
+`Error: "address": required field is not set`.
+
+To resolve this, ensure that either `TF_ADDRESS` or `TF_STATE_NAME` is accessible in the
+job that returned the error:
+
+1. Configure the [CI/CD environment scope](../../../ci/variables/#add-a-cicd-variable-to-a-project) for the job.
+1. Set the job's [environment](../../../ci/yaml/#environment), matching the environment scope from the previous step.
diff --git a/lib/gitlab/task_helpers.rb b/lib/gitlab/task_helpers.rb
index 6a98fa12903..54db31ffd6c 100644
--- a/lib/gitlab/task_helpers.rb
+++ b/lib/gitlab/task_helpers.rb
@@ -198,3 +198,4 @@ module Gitlab
end
end
end
+# rubocop:enable Rails/Output
diff --git a/lib/gitlab/usage_data_counters/known_events/epic_events.yml b/lib/gitlab/usage_data_counters/known_events/epic_events.yml
index b2096cbfc70..82787b7bf29 100644
--- a/lib/gitlab/usage_data_counters/known_events/epic_events.yml
+++ b/lib/gitlab/usage_data_counters/known_events/epic_events.yml
@@ -212,3 +212,9 @@
redis_slot: project_management
aggregation: daily
feature_flag: track_epics_activity
+
+- name: g_project_management_epic_blocked_added
+ category: epics_usage
+ redis_slot: project_management
+ aggregation: daily
+ feature_flag: track_epics_activity
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 24fb2d883c1..f6e44bc8de3 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -10969,6 +10969,9 @@ msgstr ""
msgid "CurrentUser|Start an Ultimate trial"
msgstr ""
+msgid "Custom (%{language})"
+msgstr ""
+
msgid "Custom Attributes"
msgstr ""
@@ -11965,6 +11968,9 @@ msgstr ""
msgid "Delete badge"
msgstr ""
+msgid "Delete code block"
+msgstr ""
+
msgid "Delete column"
msgstr ""
@@ -16506,6 +16512,9 @@ msgstr ""
msgid "Geo|Edit %{nodeType} site"
msgstr ""
+msgid "Geo|Edit your search and try again."
+msgstr ""
+
msgid "Geo|Failed"
msgstr ""
@@ -16581,6 +16590,9 @@ msgstr ""
msgid "Geo|Next sync scheduled at"
msgstr ""
+msgid "Geo|No Geo site found"
+msgstr ""
+
msgid "Geo|No available replication slots"
msgstr ""
@@ -20605,9 +20617,6 @@ msgstr ""
msgid "Invite a group"
msgstr ""
-msgid "Invite email has already been taken"
-msgstr ""
-
msgid "Invite members"
msgstr ""
diff --git a/qa/qa/page/admin/settings/component/performance_bar.rb b/qa/qa/page/admin/settings/component/performance_bar.rb
index 9e92fa362fb..ebf0e744b5e 100644
--- a/qa/qa/page/admin/settings/component/performance_bar.rb
+++ b/qa/qa/page/admin/settings/component/performance_bar.rb
@@ -12,7 +12,7 @@ module QA
end
def enable_performance_bar
- check_element(:enable_performance_bar_checkbox)
+ check_element(:enable_performance_bar_checkbox, true)
Capybara.current_session.driver.browser.manage.add_cookie(name: 'perf_bar_enabled', value: 'true')
end
diff --git a/qa/qa/page/component/members_filter.rb b/qa/qa/page/component/members_filter.rb
index ac07fe7e9fa..fce4560d255 100644
--- a/qa/qa/page/component/members_filter.rb
+++ b/qa/qa/page/component/members_filter.rb
@@ -10,15 +10,14 @@ module QA
super
base.view 'app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue' do
- element :members_filtered_search_bar_content
+ element :search_bar_input
+ element :search_button
end
end
def search_member(username)
- # TODO: Update the two actions below to use direct qa selectors once this is implemented:
- # https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1688
- find_element(:members_filtered_search_bar_content).find('input').set(username)
- find('.gl-search-box-by-click-search-button').click
+ fill_element :search_bar_input, username
+ click_element :search_button
end
end
end
diff --git a/spec/features/groups/members/manage_members_spec.rb b/spec/features/groups/members/manage_members_spec.rb
index 5bcd1ed11f9..468001c3be6 100644
--- a/spec/features/groups/members/manage_members_spec.rb
+++ b/spec/features/groups/members/manage_members_spec.rb
@@ -42,27 +42,6 @@ RSpec.describe 'Groups > Members > Manage members' do
end
end
- it 'add user to group', :js, :snowplow, :aggregate_failures do
- group.add_owner(user1)
-
- visit group_group_members_path(group)
-
- invite_member(user2.name, role: 'Reporter')
-
- page.within(second_row) do
- expect(page).to have_content(user2.name)
- expect(page).to have_button('Reporter')
- end
-
- expect_snowplow_event(
- category: 'Members::CreateService',
- action: 'create_member',
- label: 'group-members-page',
- property: 'existing_user',
- user: user1
- )
- end
-
it 'remove user from group', :js do
group.add_owner(user1)
group.add_developer(user2)
@@ -87,43 +66,29 @@ RSpec.describe 'Groups > Members > Manage members' do
end
end
- it 'add yourself to group when already an owner', :js, :aggregate_failures do
- group.add_owner(user1)
+ context 'when inviting' do
+ it 'add yourself to group when already an owner', :js do
+ group.add_owner(user1)
- visit group_group_members_path(group)
+ visit group_group_members_path(group)
- invite_member(user1.name, role: 'Reporter')
+ invite_member(user1.name, role: 'Reporter', refresh: false)
- page.within(first_row) do
- expect(page).to have_content(user1.name)
- expect(page).to have_content('Owner')
- end
- end
+ expect(page).to have_selector(invite_modal_selector)
+ expect(page).to have_content("not authorized to update member")
- it 'invite user to group', :js, :snowplow do
- group.add_owner(user1)
+ page.refresh
- visit group_group_members_path(group)
-
- invite_member('test@example.com', role: 'Reporter')
-
- expect(page).to have_link 'Invited'
- click_link 'Invited'
-
- aggregate_failures do
- page.within(members_table) do
- expect(page).to have_content('test@example.com')
- expect(page).to have_content('Invited')
- expect(page).to have_button('Reporter')
+ page.within find_member_row(user1) do
+ expect(page).to have_content('Owner')
end
+ end
- expect_snowplow_event(
- category: 'Members::InviteService',
- action: 'create_member',
- label: 'group-members-page',
- property: 'net_new_user',
- user: user1
- )
+ it_behaves_like 'inviting members', 'group-members-page' do
+ let_it_be(:entity) { group }
+ let_it_be(:members_page_path) { group_group_members_path(entity) }
+ let_it_be(:subentity) { create(:group, parent: group) }
+ let_it_be(:subentity_members_page_path) { group_group_members_path(subentity) }
end
end
diff --git a/spec/features/projects/members/manage_members_spec.rb b/spec/features/projects/members/manage_members_spec.rb
index f259098ce71..0f4120e88e0 100644
--- a/spec/features/projects/members/manage_members_spec.rb
+++ b/spec/features/projects/members/manage_members_spec.rb
@@ -48,24 +48,6 @@ RSpec.describe 'Projects > Members > Manage members', :js do
end
end
- it 'add user to project', :snowplow, :aggregate_failures do
- visit_members_page
-
- invite_member(user2.name, role: 'Reporter')
-
- page.within find_member_row(user2) do
- expect(page).to have_button('Reporter')
- end
-
- expect_snowplow_event(
- category: 'Members::CreateService',
- action: 'create_member',
- label: 'project-members-page',
- property: 'existing_user',
- user: user1
- )
- end
-
it 'uses ProjectMember access_level_roles for the invite members modal access option', :aggregate_failures do
visit_members_page
@@ -104,24 +86,11 @@ RSpec.describe 'Projects > Members > Manage members', :js do
expect(members_table).not_to have_content(other_user.name)
end
- it 'invite user to project', :snowplow, :aggregate_failures do
- visit_members_page
-
- invite_member('test@example.com', role: 'Reporter')
-
- click_link 'Invited'
-
- page.within find_invited_member_row('test@example.com') do
- expect(page).to have_button('Reporter')
- end
-
- expect_snowplow_event(
- category: 'Members::InviteService',
- action: 'create_member',
- label: 'project-members-page',
- property: 'net_new_user',
- user: user1
- )
+ it_behaves_like 'inviting members', 'project-members-page' do
+ let_it_be(:entity) { project }
+ let_it_be(:members_page_path) { project_project_members_path(entity) }
+ let_it_be(:subentity) { project }
+ let_it_be(:subentity_members_page_path) { project_project_members_path(entity) }
end
describe 'member search results' do
diff --git a/spec/frontend/api_spec.js b/spec/frontend/api_spec.js
index c303d470c7b..85332bf21d8 100644
--- a/spec/frontend/api_spec.js
+++ b/spec/frontend/api_spec.js
@@ -187,36 +187,15 @@ describe('Api', () => {
});
});
- describe('addGroupMembersByUserId', () => {
- it('adds an existing User as a new Group Member by User ID', () => {
- const groupId = 1;
- const expectedUserId = 2;
- const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/1/members`;
- const params = {
- user_id: expectedUserId,
- access_level: 10,
- expires_at: undefined,
- };
-
- mock.onPost(expectedUrl).reply(200, {
- id: expectedUserId,
- state: 'active',
- });
-
- return Api.addGroupMembersByUserId(groupId, params).then(({ data }) => {
- expect(data.id).toBe(expectedUserId);
- expect(data.state).toBe('active');
- });
- });
- });
-
- describe('inviteGroupMembersByEmail', () => {
+ describe('inviteGroupMembers', () => {
it('invites a new email address to create a new User and become a Group Member', () => {
const groupId = 1;
const email = 'email@example.com';
+ const userId = '1';
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/1/invitations`;
const params = {
email,
+ userId,
access_level: 10,
expires_at: undefined,
};
@@ -225,7 +204,7 @@ describe('Api', () => {
status: 'success',
});
- return Api.inviteGroupMembersByEmail(groupId, params).then(({ data }) => {
+ return Api.inviteGroupMembers(groupId, params).then(({ data }) => {
expect(data.status).toBe('success');
});
});
@@ -543,36 +522,15 @@ describe('Api', () => {
});
});
- describe('addProjectMembersByUserId', () => {
- it('adds an existing User as a new Project Member by User ID', () => {
- const projectId = 1;
- const expectedUserId = 2;
- const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/1/members`;
- const params = {
- user_id: expectedUserId,
- access_level: 10,
- expires_at: undefined,
- };
-
- mock.onPost(expectedUrl).reply(200, {
- id: expectedUserId,
- state: 'active',
- });
-
- return Api.addProjectMembersByUserId(projectId, params).then(({ data }) => {
- expect(data.id).toBe(expectedUserId);
- expect(data.state).toBe('active');
- });
- });
- });
-
- describe('inviteProjectMembersByEmail', () => {
+ describe('inviteProjectMembers', () => {
it('invites a new email address to create a new User and become a Project Member', () => {
const projectId = 1;
- const expectedEmail = 'email@example.com';
+ const email = 'email@example.com';
+ const userId = '1';
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/1/invitations`;
const params = {
- email: expectedEmail,
+ email,
+ userId,
access_level: 10,
expires_at: undefined,
};
@@ -581,7 +539,7 @@ describe('Api', () => {
status: 'success',
});
- return Api.inviteProjectMembersByEmail(projectId, params).then(({ data }) => {
+ return Api.inviteProjectMembers(projectId, params).then(({ data }) => {
expect(data.status).toBe('success');
});
});
diff --git a/spec/frontend/content_editor/components/code_block_bubble_menu_spec.js b/spec/frontend/content_editor/components/code_block_bubble_menu_spec.js
new file mode 100644
index 00000000000..074c311495f
--- /dev/null
+++ b/spec/frontend/content_editor/components/code_block_bubble_menu_spec.js
@@ -0,0 +1,142 @@
+import { BubbleMenu } from '@tiptap/vue-2';
+import { GlButton, GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui';
+import Vue from 'vue';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import CodeBlockBubbleMenu from '~/content_editor/components/code_block_bubble_menu.vue';
+import eventHubFactory from '~/helpers/event_hub_factory';
+import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight';
+import codeBlockLanguageLoader from '~/content_editor/services/code_block_language_loader';
+import { createTestEditor, emitEditorEvent } from '../test_utils';
+
+describe('content_editor/components/code_block_bubble_menu', () => {
+ let wrapper;
+ let tiptapEditor;
+ let bubbleMenu;
+ let eventHub;
+
+ const buildEditor = () => {
+ tiptapEditor = createTestEditor({ extensions: [CodeBlockHighlight] });
+ eventHub = eventHubFactory();
+ };
+
+ const buildWrapper = () => {
+ wrapper = mountExtended(CodeBlockBubbleMenu, {
+ provide: {
+ tiptapEditor,
+ eventHub,
+ },
+ });
+ };
+
+ const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
+ const findDropdownItemsData = () =>
+ findDropdownItems().wrappers.map((x) => ({
+ text: x.text(),
+ visible: x.isVisible(),
+ checked: x.props('isChecked'),
+ }));
+
+ beforeEach(() => {
+ buildEditor();
+ buildWrapper();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders bubble menu component', async () => {
+ tiptapEditor.commands.insertContent('test
');
+ bubbleMenu = wrapper.findComponent(BubbleMenu);
+
+ await emitEditorEvent({ event: 'transaction', tiptapEditor });
+
+ expect(bubbleMenu.props('editor')).toBe(tiptapEditor);
+ expect(bubbleMenu.classes()).toEqual(['gl-shadow', 'gl-rounded-base']);
+ });
+
+ it('selects plaintext language by default', async () => {
+ tiptapEditor.commands.insertContent('test
');
+ bubbleMenu = wrapper.findComponent(BubbleMenu);
+
+ await emitEditorEvent({ event: 'transaction', tiptapEditor });
+
+ expect(wrapper.findComponent(GlDropdown).props('text')).toBe('Plain text');
+ });
+
+ it('selects appropriate language based on the code block', async () => {
+ tiptapEditor.commands.insertContent('var a = 2;
');
+ bubbleMenu = wrapper.findComponent(BubbleMenu);
+
+ await emitEditorEvent({ event: 'transaction', tiptapEditor });
+
+ expect(wrapper.findComponent(GlDropdown).props('text')).toBe('Javascript');
+ });
+
+ it("selects Custom (syntax) if the language doesn't exist in the list", async () => {
+ tiptapEditor.commands.insertContent('test
');
+ bubbleMenu = wrapper.findComponent(BubbleMenu);
+
+ await emitEditorEvent({ event: 'transaction', tiptapEditor });
+
+ expect(wrapper.findComponent(GlDropdown).props('text')).toBe('Custom (nomnoml)');
+ });
+
+ it('delete button deletes the code block', async () => {
+ tiptapEditor.commands.insertContent('var a = 2;
');
+
+ await wrapper.findComponent(GlButton).vm.$emit('click');
+
+ expect(tiptapEditor.getText()).toBe('');
+ });
+
+ describe('when opened and search is changed', () => {
+ beforeEach(async () => {
+ tiptapEditor.commands.insertContent('var a = 2;
');
+
+ wrapper.findComponent(GlSearchBoxByType).vm.$emit('input', 'js');
+
+ await Vue.nextTick();
+ });
+
+ it('shows dropdown items', () => {
+ expect(findDropdownItemsData()).toEqual([
+ { text: 'Javascript', visible: true, checked: true },
+ { text: 'Java', visible: true, checked: false },
+ { text: 'Javascript', visible: false, checked: false },
+ { text: 'JSON', visible: true, checked: false },
+ ]);
+ });
+
+ describe('when dropdown item is clicked', () => {
+ beforeEach(async () => {
+ jest.spyOn(codeBlockLanguageLoader, 'loadLanguages').mockResolvedValue();
+
+ findDropdownItems().at(1).vm.$emit('click');
+
+ await Vue.nextTick();
+ });
+
+ it('loads language', () => {
+ expect(codeBlockLanguageLoader.loadLanguages).toHaveBeenCalledWith(['java']);
+ });
+
+ it('sets code block', () => {
+ expect(tiptapEditor.getJSON()).toMatchObject({
+ content: [
+ {
+ type: 'codeBlock',
+ attrs: {
+ language: 'java',
+ },
+ },
+ ],
+ });
+ });
+
+ it('updates selected dropdown', () => {
+ expect(wrapper.findComponent(GlDropdown).props('text')).toBe('Java');
+ });
+ });
+ });
+});
diff --git a/spec/frontend/content_editor/components/formatting_bubble_menu_spec.js b/spec/frontend/content_editor/components/formatting_bubble_menu_spec.js
index e44a7fa4ddb..192ddee78c6 100644
--- a/spec/frontend/content_editor/components/formatting_bubble_menu_spec.js
+++ b/spec/frontend/content_editor/components/formatting_bubble_menu_spec.js
@@ -9,7 +9,7 @@ import {
} from '~/content_editor/constants';
import { createTestEditor } from '../test_utils';
-describe('content_editor/components/top_toolbar', () => {
+describe('content_editor/components/formatting_bubble_menu', () => {
let wrapper;
let trackingSpy;
let tiptapEditor;
diff --git a/spec/frontend/content_editor/extensions/frontmatter_spec.js b/spec/frontend/content_editor/extensions/frontmatter_spec.js
index a8cbad6ef81..4f80c2cb81a 100644
--- a/spec/frontend/content_editor/extensions/frontmatter_spec.js
+++ b/spec/frontend/content_editor/extensions/frontmatter_spec.js
@@ -23,7 +23,7 @@ describe('content_editor/extensions/frontmatter', () => {
});
it('does not insert a frontmatter block when executing code block input rule', () => {
- const expectedDoc = doc(codeBlock(''));
+ const expectedDoc = doc(codeBlock({ language: 'plaintext' }, ''));
const inputRuleText = '``` ';
triggerNodeInputRule({ tiptapEditor, inputRuleText });
diff --git a/spec/frontend/content_editor/services/code_block_language_loader_spec.js b/spec/frontend/content_editor/services/code_block_language_loader_spec.js
index bb97c9afa41..905c1685b94 100644
--- a/spec/frontend/content_editor/services/code_block_language_loader_spec.js
+++ b/spec/frontend/content_editor/services/code_block_language_loader_spec.js
@@ -1,4 +1,6 @@
-import CodeBlockLanguageBlocker from '~/content_editor/services/code_block_language_loader';
+import codeBlockLanguageBlocker from '~/content_editor/services/code_block_language_loader';
+import waitForPromises from 'helpers/wait_for_promises';
+import { backtickInputRegex } from '~/content_editor/extensions/code_block_highlight';
describe('content_editor/services/code_block_language_loader', () => {
let languageLoader;
@@ -12,7 +14,43 @@ describe('content_editor/services/code_block_language_loader', () => {
.mockImplementation((language) => lowlight.languages.push(language)),
registered: jest.fn().mockImplementation((language) => lowlight.languages.includes(language)),
};
- languageLoader = new CodeBlockLanguageBlocker(lowlight);
+ languageLoader = codeBlockLanguageBlocker;
+ languageLoader.lowlight = lowlight;
+ });
+
+ describe('findLanguageBySyntax', () => {
+ it.each`
+ syntax | language
+ ${'javascript'} | ${{ syntax: 'javascript', label: 'Javascript' }}
+ ${'js'} | ${{ syntax: 'javascript', label: 'Javascript' }}
+ ${'jsx'} | ${{ syntax: 'javascript', label: 'Javascript' }}
+ `('returns a language by syntax and its variants', ({ syntax, language }) => {
+ expect(languageLoader.findLanguageBySyntax(syntax)).toMatchObject(language);
+ });
+
+ it('returns Custom (syntax) if the language does not exist', () => {
+ expect(languageLoader.findLanguageBySyntax('foobar')).toMatchObject({
+ syntax: 'foobar',
+ label: 'Custom (foobar)',
+ });
+ });
+
+ it('returns plaintext if no syntax is passed', () => {
+ expect(languageLoader.findLanguageBySyntax('')).toMatchObject({
+ syntax: 'plaintext',
+ label: 'Plain text',
+ });
+ });
+ });
+
+ describe('filterLanguages', () => {
+ it('filters languages by the given search term', () => {
+ expect(languageLoader.filterLanguages('ts')).toEqual([
+ { label: 'Device Tree', syntax: 'dts' },
+ { label: 'Kotlin', syntax: 'kotlin', variants: 'kt, kts' },
+ { label: 'TypeScript', syntax: 'typescript', variants: 'ts, tsx' },
+ ]);
+ });
});
describe('loadLanguages', () => {
@@ -56,6 +94,18 @@ describe('content_editor/services/code_block_language_loader', () => {
});
});
+ describe('loadLanguageFromInputRule', () => {
+ it('loads highlight.js language packages identified from the input rule', async () => {
+ const match = new RegExp(backtickInputRegex).exec('```js ');
+ const attrs = languageLoader.loadLanguageFromInputRule(match);
+
+ await waitForPromises();
+
+ expect(attrs).toEqual({ language: 'javascript' });
+ expect(lowlight.registerLanguage).toHaveBeenCalledWith('javascript', expect.any(Function));
+ });
+ });
+
describe('isLanguageLoaded', () => {
it('returns true when a language is registered', async () => {
const language = 'javascript';
diff --git a/spec/frontend/invite_members/components/invite_members_modal_spec.js b/spec/frontend/invite_members/components/invite_members_modal_spec.js
index 1bf44fbed71..84317da39e6 100644
--- a/spec/frontend/invite_members/components/invite_members_modal_spec.js
+++ b/spec/frontend/invite_members/components/invite_members_modal_spec.js
@@ -23,7 +23,7 @@ import ContentTransition from '~/vue_shared/components/content_transition.vue';
import axios from '~/lib/utils/axios_utils';
import httpStatus from '~/lib/utils/http_status';
import { getParameterValues } from '~/lib/utils/url_utility';
-import { apiPaths, membersApiResponse, invitationsApiResponse } from '../mock_data/api_responses';
+import { GROUPS_INVITATIONS_PATH, invitationsApiResponse } from '../mock_data/api_responses';
import {
propsData,
inviteSource,
@@ -301,11 +301,8 @@ describe('InviteMembersModal', () => {
});
describe('submitting the invite form', () => {
- const mockMembersApi = (code, data) => {
- mock.onPost(apiPaths.GROUPS_MEMBERS).reply(code, data);
- };
const mockInvitationsApi = (code, data) => {
- mock.onPost(apiPaths.GROUPS_INVITATIONS).reply(code, data);
+ mock.onPost(GROUPS_INVITATIONS_PATH).reply(code, data);
};
const expectedEmailRestrictedError =
@@ -329,7 +326,7 @@ describe('InviteMembersModal', () => {
await triggerMembersTokenSelect([user1, user2]);
wrapper.vm.$toast = { show: jest.fn() };
- jest.spyOn(Api, 'addGroupMembersByUserId').mockResolvedValue({ data: postData });
+ jest.spyOn(Api, 'inviteGroupMembers').mockResolvedValue({ data: postData });
});
describe('when triggered from regular mounting', () => {
@@ -337,12 +334,8 @@ describe('InviteMembersModal', () => {
clickInviteButton();
});
- it('sets isLoading on the Invite button when it is clicked', () => {
- expect(findModal().props('actionPrimary').attributes.loading).toBe(true);
- });
-
- it('calls Api addGroupMembersByUserId with the correct params', () => {
- expect(Api.addGroupMembersByUserId).toHaveBeenCalledWith(propsData.id, postData);
+ it('calls Api inviteGroupMembers with the correct params', () => {
+ expect(Api.inviteGroupMembers).toHaveBeenCalledWith(propsData.id, postData);
});
it('displays the successful toastMessage', () => {
@@ -372,21 +365,9 @@ describe('InviteMembersModal', () => {
await triggerMembersTokenSelect([user1]);
});
- it('displays "Member already exists" api message for http status conflict', async () => {
- mockMembersApi(httpStatus.CONFLICT, membersApiResponse.MEMBER_ALREADY_EXISTS);
-
- clickInviteButton();
-
- await waitForPromises();
-
- expect(membersFormGroupInvalidFeedback()).toBe('Member already exists');
- expect(findMembersSelect().props('validationState')).toBe(false);
- expect(findModal().props('actionPrimary').attributes.loading).toBe(false);
- });
-
describe('clearing the invalid state and message', () => {
beforeEach(async () => {
- mockMembersApi(httpStatus.CONFLICT, membersApiResponse.MEMBER_ALREADY_EXISTS);
+ mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.EMAIL_TAKEN);
clickInviteButton();
@@ -394,7 +375,9 @@ describe('InviteMembersModal', () => {
});
it('clears the error when the list of members to invite is cleared', async () => {
- expect(membersFormGroupInvalidFeedback()).toBe('Member already exists');
+ expect(membersFormGroupInvalidFeedback()).toBe(
+ Object.values(invitationsApiResponse.EMAIL_TAKEN.message)[0],
+ );
expect(findMembersSelect().props('validationState')).toBe(false);
findMembersSelect().vm.$emit('clear');
@@ -425,13 +408,15 @@ describe('InviteMembersModal', () => {
});
it('clears the invalid state and message once the list of members to invite is cleared', async () => {
- mockMembersApi(httpStatus.CONFLICT, membersApiResponse.MEMBER_ALREADY_EXISTS);
+ mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.EMAIL_TAKEN);
clickInviteButton();
await waitForPromises();
- expect(membersFormGroupInvalidFeedback()).toBe('Member already exists');
+ expect(membersFormGroupInvalidFeedback()).toBe(
+ Object.values(invitationsApiResponse.EMAIL_TAKEN.message)[0],
+ );
expect(findMembersSelect().props('validationState')).toBe(false);
expect(findModal().props('actionPrimary').attributes.loading).toBe(false);
@@ -445,7 +430,10 @@ describe('InviteMembersModal', () => {
});
it('displays the generic error for http server error', async () => {
- mockMembersApi(httpStatus.INTERNAL_SERVER_ERROR, 'Request failed with status code 500');
+ mockInvitationsApi(
+ httpStatus.INTERNAL_SERVER_ERROR,
+ 'Request failed with status code 500',
+ );
clickInviteButton();
@@ -455,7 +443,7 @@ describe('InviteMembersModal', () => {
});
it('displays the restricted user api message for response with bad request', async () => {
- mockMembersApi(httpStatus.BAD_REQUEST, membersApiResponse.SINGLE_USER_RESTRICTED);
+ mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.EMAIL_RESTRICTED);
clickInviteButton();
@@ -465,7 +453,7 @@ describe('InviteMembersModal', () => {
});
it('displays the first part of the error when multiple existing users are restricted by email', async () => {
- mockMembersApi(httpStatus.CREATED, membersApiResponse.MULTIPLE_USERS_RESTRICTED);
+ mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.MULTIPLE_RESTRICTED);
clickInviteButton();
@@ -476,19 +464,6 @@ describe('InviteMembersModal', () => {
);
expect(findMembersSelect().props('validationState')).toBe(false);
});
-
- it('displays an access_level error message received for the existing user', async () => {
- mockMembersApi(httpStatus.BAD_REQUEST, membersApiResponse.SINGLE_USER_ACCESS_LEVEL);
-
- clickInviteButton();
-
- await waitForPromises();
-
- expect(membersFormGroupInvalidFeedback()).toBe(
- 'should be greater than or equal to Owner inherited membership from group Gitlab Org',
- );
- expect(findMembersSelect().props('validationState')).toBe(false);
- });
});
});
@@ -509,7 +484,7 @@ describe('InviteMembersModal', () => {
await triggerMembersTokenSelect([user3]);
wrapper.vm.$toast = { show: jest.fn() };
- jest.spyOn(Api, 'inviteGroupMembersByEmail').mockResolvedValue({ data: postData });
+ jest.spyOn(Api, 'inviteGroupMembers').mockResolvedValue({ data: postData });
});
describe('when triggered from regular mounting', () => {
@@ -517,8 +492,8 @@ describe('InviteMembersModal', () => {
clickInviteButton();
});
- it('calls Api inviteGroupMembersByEmail with the correct params', () => {
- expect(Api.inviteGroupMembersByEmail).toHaveBeenCalledWith(propsData.id, postData);
+ it('calls Api inviteGroupMembers with the correct params', () => {
+ expect(Api.inviteGroupMembers).toHaveBeenCalledWith(propsData.id, postData);
});
it('displays the successful toastMessage', () => {
@@ -558,20 +533,8 @@ describe('InviteMembersModal', () => {
expect(findModal().props('actionPrimary').attributes.loading).toBe(false);
});
- it('displays the successful toast message when email has already been invited', async () => {
- mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.EMAIL_TAKEN);
- wrapper.vm.$toast = { show: jest.fn() };
-
- clickInviteButton();
-
- await waitForPromises();
-
- expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('Members were successfully added');
- expect(findMembersSelect().props('validationState')).toBe(null);
- });
-
it('displays the first error message when multiple emails return a restricted error message', async () => {
- mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.MULTIPLE_EMAIL_RESTRICTED);
+ mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.MULTIPLE_RESTRICTED);
clickInviteButton();
@@ -618,19 +581,17 @@ describe('InviteMembersModal', () => {
format: 'json',
tasks_to_be_done: [],
tasks_project_id: '',
+ user_id: '1',
+ email: 'email@example.com',
};
- const emailPostData = { ...postData, email: 'email@example.com' };
- const idPostData = { ...postData, user_id: '1' };
-
describe('when invites are sent successfully', () => {
beforeEach(async () => {
createComponent();
await triggerMembersTokenSelect([user1, user3]);
wrapper.vm.$toast = { show: jest.fn() };
- jest.spyOn(Api, 'inviteGroupMembersByEmail').mockResolvedValue({ data: postData });
- jest.spyOn(Api, 'addGroupMembersByUserId').mockResolvedValue({ data: postData });
+ jest.spyOn(Api, 'inviteGroupMembers').mockResolvedValue({ data: postData });
});
describe('when triggered from regular mounting', () => {
@@ -638,12 +599,8 @@ describe('InviteMembersModal', () => {
clickInviteButton();
});
- it('calls Api inviteGroupMembersByEmail with the correct params', () => {
- expect(Api.inviteGroupMembersByEmail).toHaveBeenCalledWith(propsData.id, emailPostData);
- });
-
- it('calls Api addGroupMembersByUserId with the correct params', () => {
- expect(Api.addGroupMembersByUserId).toHaveBeenCalledWith(propsData.id, idPostData);
+ it('calls Api inviteGroupMembers with the correct params', () => {
+ expect(Api.inviteGroupMembers).toHaveBeenCalledWith(propsData.id, postData);
});
it('displays the successful toastMessage', () => {
@@ -656,12 +613,8 @@ describe('InviteMembersModal', () => {
clickInviteButton();
- expect(Api.inviteGroupMembersByEmail).toHaveBeenCalledWith(propsData.id, {
- ...emailPostData,
- invite_source: '_invite_source_',
- });
- expect(Api.addGroupMembersByUserId).toHaveBeenCalledWith(propsData.id, {
- ...idPostData,
+ expect(Api.inviteGroupMembers).toHaveBeenCalledWith(propsData.id, {
+ ...postData,
invite_source: '_invite_source_',
});
});
@@ -674,7 +627,6 @@ describe('InviteMembersModal', () => {
await triggerMembersTokenSelect([user1, user3]);
mockInvitationsApi(httpStatus.BAD_REQUEST, invitationsApiResponse.EMAIL_INVALID);
- mockMembersApi(httpStatus.OK, '200 OK');
clickInviteButton();
});
@@ -693,7 +645,7 @@ describe('InviteMembersModal', () => {
await triggerMembersTokenSelect([user3]);
wrapper.vm.$toast = { show: jest.fn() };
- jest.spyOn(Api, 'inviteGroupMembersByEmail').mockResolvedValue({});
+ jest.spyOn(Api, 'inviteGroupMembers').mockResolvedValue({});
});
it('tracks the view for learn_gitlab source', () => {
diff --git a/spec/frontend/invite_members/mock_data/api_responses.js b/spec/frontend/invite_members/mock_data/api_responses.js
index a3e426376d8..4ad3b6aeb66 100644
--- a/spec/frontend/invite_members/mock_data/api_responses.js
+++ b/spec/frontend/invite_members/mock_data/api_responses.js
@@ -1,12 +1,12 @@
-const INVITATIONS_API_EMAIL_INVALID = {
+const EMAIL_INVALID = {
message: { error: 'email contains an invalid email address' },
};
-const INVITATIONS_API_ERROR_EMAIL_INVALID = {
+const ERROR_EMAIL_INVALID = {
error: 'email contains an invalid email address',
};
-const INVITATIONS_API_EMAIL_RESTRICTED = {
+const EMAIL_RESTRICTED = {
message: {
'email@example.com':
"The member's email address is not allowed for this project. Go to the Admin area > Sign-up restrictions, and check Allowed domains for sign-ups.",
@@ -14,65 +14,31 @@ const INVITATIONS_API_EMAIL_RESTRICTED = {
status: 'error',
};
-const INVITATIONS_API_MULTIPLE_EMAIL_RESTRICTED = {
+const MULTIPLE_RESTRICTED = {
message: {
'email@example.com':
"The member's email address is not allowed for this project. Go to the Admin area > Sign-up restrictions, and check Allowed domains for sign-ups.",
'email4@example.com':
"The member's email address is not allowed for this project. Go to the Admin area > Sign-up restrictions, and check the Domain denylist.",
- },
- status: 'error',
-};
-
-const INVITATIONS_API_EMAIL_TAKEN = {
- message: {
- 'email@example.org': 'Invite email has already been taken',
- },
- status: 'error',
-};
-
-const MEMBERS_API_MEMBER_ALREADY_EXISTS = {
- message: 'Member already exists',
-};
-
-const MEMBERS_API_SINGLE_USER_RESTRICTED = {
- message: {
- user: [
+ root:
"The member's email address is not allowed for this project. Go to the Admin area > Sign-up restrictions, and check Allowed domains for sign-ups.",
- ],
},
-};
-
-const MEMBERS_API_SINGLE_USER_ACCESS_LEVEL = {
- message: {
- access_level: [
- 'should be greater than or equal to Owner inherited membership from group Gitlab Org',
- ],
- },
-};
-
-const MEMBERS_API_MULTIPLE_USERS_RESTRICTED = {
- message:
- "root: The member's email address is not allowed for this project. Go to the Admin area > Sign-up restrictions, and check Allowed domains for sign-ups. and user18: The member's email address is not allowed for this project. Go to the Admin area > Sign-up restrictions, and check the Domain denylist. and john_doe31: The member's email address is not allowed for this project. Go to the Admin area > Sign-up restrictions, and check Email restrictions for sign-ups.",
status: 'error',
};
-export const apiPaths = {
- GROUPS_MEMBERS: '/api/v4/groups/1/members',
- GROUPS_INVITATIONS: '/api/v4/groups/1/invitations',
+const EMAIL_TAKEN = {
+ message: {
+ 'email@example.org': "The member's email address has already been taken",
+ },
+ status: 'error',
};
-export const membersApiResponse = {
- MEMBER_ALREADY_EXISTS: MEMBERS_API_MEMBER_ALREADY_EXISTS,
- SINGLE_USER_ACCESS_LEVEL: MEMBERS_API_SINGLE_USER_ACCESS_LEVEL,
- SINGLE_USER_RESTRICTED: MEMBERS_API_SINGLE_USER_RESTRICTED,
- MULTIPLE_USERS_RESTRICTED: MEMBERS_API_MULTIPLE_USERS_RESTRICTED,
-};
+export const GROUPS_INVITATIONS_PATH = '/api/v4/groups/1/invitations';
export const invitationsApiResponse = {
- EMAIL_INVALID: INVITATIONS_API_EMAIL_INVALID,
- ERROR_EMAIL_INVALID: INVITATIONS_API_ERROR_EMAIL_INVALID,
- EMAIL_RESTRICTED: INVITATIONS_API_EMAIL_RESTRICTED,
- MULTIPLE_EMAIL_RESTRICTED: INVITATIONS_API_MULTIPLE_EMAIL_RESTRICTED,
- EMAIL_TAKEN: INVITATIONS_API_EMAIL_TAKEN,
+ EMAIL_INVALID,
+ ERROR_EMAIL_INVALID,
+ EMAIL_RESTRICTED,
+ MULTIPLE_RESTRICTED,
+ EMAIL_TAKEN,
};
diff --git a/spec/frontend/invite_members/utils/response_message_parser_spec.js b/spec/frontend/invite_members/utils/response_message_parser_spec.js
index e2cc87c8547..8b2064df374 100644
--- a/spec/frontend/invite_members/utils/response_message_parser_spec.js
+++ b/spec/frontend/invite_members/utils/response_message_parser_spec.js
@@ -2,23 +2,19 @@ import {
responseMessageFromSuccess,
responseMessageFromError,
} from '~/invite_members/utils/response_message_parser';
-import { membersApiResponse, invitationsApiResponse } from '../mock_data/api_responses';
+import { invitationsApiResponse } from '../mock_data/api_responses';
describe('Response message parser', () => {
const expectedMessage = 'expected display and message.';
describe('parse message from successful response', () => {
const exampleKeyedMsg = { 'email@example.com': expectedMessage };
- const exampleFirstPartMultiple = 'username1: expected display and message.';
- const exampleUserMsgMultiple =
- ' and username2: id not found and restricted email. and username3: email is restricted.';
it.each([
- [[{ data: { message: expectedMessage } }]],
- [[{ data: { message: exampleFirstPartMultiple + exampleUserMsgMultiple } }]],
- [[{ data: { error: expectedMessage } }]],
- [[{ data: { message: [expectedMessage] } }]],
- [[{ data: { message: exampleKeyedMsg } }]],
+ [{ data: { message: expectedMessage } }],
+ [{ data: { error: expectedMessage } }],
+ [{ data: { message: [expectedMessage] } }],
+ [{ data: { message: exampleKeyedMsg } }],
])(`returns "${expectedMessage}" from success response: %j`, (successResponse) => {
expect(responseMessageFromSuccess(successResponse)).toBe(expectedMessage);
});
@@ -27,8 +23,6 @@ describe('Response message parser', () => {
describe('message from error response', () => {
it.each([
[{ response: { data: { error: expectedMessage } } }],
- [{ response: { data: { message: { user: [expectedMessage] } } } }],
- [{ response: { data: { message: { access_level: [expectedMessage] } } } }],
[{ response: { data: { message: { error: expectedMessage } } } }],
[{ response: { data: { message: expectedMessage } } }],
])(`returns "${expectedMessage}" from error response: %j`, (errorResponse) => {
@@ -41,18 +35,10 @@ describe('Response message parser', () => {
"The member's email address is not allowed for this project. Go to the Admin area > Sign-up restrictions, and check Allowed domains for sign-ups.";
it.each([
- [[{ data: membersApiResponse.MULTIPLE_USERS_RESTRICTED }]],
- [[{ data: invitationsApiResponse.MULTIPLE_EMAIL_RESTRICTED }]],
- [[{ data: invitationsApiResponse.EMAIL_RESTRICTED }]],
+ [{ data: invitationsApiResponse.MULTIPLE_RESTRICTED }],
+ [{ data: invitationsApiResponse.EMAIL_RESTRICTED }],
])(`returns "${expectedMessage}" from success response: %j`, (restrictedResponse) => {
expect(responseMessageFromSuccess(restrictedResponse)).toBe(expected);
});
-
- it.each([[{ response: { data: membersApiResponse.SINGLE_USER_RESTRICTED } }]])(
- `returns "${expectedMessage}" from error response: %j`,
- (singleRestrictedResponse) => {
- expect(responseMessageFromError(singleRestrictedResponse)).toBe(expected);
- },
- );
});
});
diff --git a/spec/support/helpers/features/invite_members_modal_helper.rb b/spec/support/helpers/features/invite_members_modal_helper.rb
index a0850060eed..7ed64615020 100644
--- a/spec/support/helpers/features/invite_members_modal_helper.rb
+++ b/spec/support/helpers/features/invite_members_modal_helper.rb
@@ -5,19 +5,22 @@ module Spec
module Helpers
module Features
module InviteMembersModalHelper
- def invite_member(name, role: 'Guest', expires_at: nil)
+ def invite_member(names, role: 'Guest', expires_at: nil, refresh: true)
click_on 'Invite members'
page.within invite_modal_selector do
- find(member_dropdown_selector).set(name)
+ Array.wrap(names).each do |name|
+ find(member_dropdown_selector).set(name)
+
+ wait_for_requests
+ click_button name
+ end
- wait_for_requests
- click_button name
choose_options(role, expires_at)
click_button 'Invite'
- page.refresh
+ page.refresh if refresh
end
end
diff --git a/spec/support/shared_examples/features/content_editor_shared_examples.rb b/spec/support/shared_examples/features/content_editor_shared_examples.rb
index 2332285540a..5c44cb7f04b 100644
--- a/spec/support/shared_examples/features/content_editor_shared_examples.rb
+++ b/spec/support/shared_examples/features/content_editor_shared_examples.rb
@@ -1,14 +1,48 @@
# frozen_string_literal: true
RSpec.shared_examples 'edits content using the content editor' do
- it 'formats text as bold using bubble menu' do
- content_editor_testid = '[data-testid="content-editor"] [contenteditable]'
+ content_editor_testid = '[data-testid="content-editor"] [contenteditable].ProseMirror'
- expect(page).to have_css(content_editor_testid)
+ describe 'formatting bubble menu' do
+ it 'shows a formatting bubble menu for a regular paragraph' do
+ expect(page).to have_css(content_editor_testid)
- find(content_editor_testid).send_keys 'Typing text in the content editor'
- find(content_editor_testid).send_keys [:shift, :left]
+ find(content_editor_testid).send_keys 'Typing text in the content editor'
+ find(content_editor_testid).send_keys [:shift, :left]
- expect(page).to have_css('[data-testid="formatting-bubble-menu"]')
+ expect(page).to have_css('[data-testid="formatting-bubble-menu"]')
+ end
+
+ it 'does not show a formatting bubble menu for code' do
+ find(content_editor_testid).send_keys 'This is a `code`'
+ find(content_editor_testid).send_keys [:shift, :left]
+
+ expect(page).not_to have_css('[data-testid="formatting-bubble-menu"]')
+ end
+ end
+
+ describe 'code block bubble menu' do
+ it 'shows a code block bubble menu for a code block' do
+ find(content_editor_testid).send_keys '```js ' # trigger input rule
+ find(content_editor_testid).send_keys 'var a = 0'
+ find(content_editor_testid).send_keys [:shift, :left]
+
+ expect(page).not_to have_css('[data-testid="formatting-bubble-menu"]')
+ expect(page).to have_css('[data-testid="code-block-bubble-menu"]')
+ end
+
+ it 'sets code block type to "javascript" for `js`' do
+ find(content_editor_testid).send_keys '```js '
+ find(content_editor_testid).send_keys 'var a = 0'
+
+ expect(find('[data-testid="code-block-bubble-menu"]')).to have_text('Javascript')
+ end
+
+ it 'sets code block type to "Custom (nomnoml)" for `nomnoml`' do
+ find(content_editor_testid).send_keys '```nomnoml '
+ find(content_editor_testid).send_keys 'test'
+
+ expect(find('[data-testid="code-block-bubble-menu"]')).to have_text('Custom (nomnoml)')
+ end
end
end
diff --git a/spec/support/shared_examples/features/inviting_members_shared_examples.rb b/spec/support/shared_examples/features/inviting_members_shared_examples.rb
new file mode 100644
index 00000000000..58357b262f5
--- /dev/null
+++ b/spec/support/shared_examples/features/inviting_members_shared_examples.rb
@@ -0,0 +1,175 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'inviting members' do |snowplow_invite_label|
+ before_all do
+ group.add_owner(user1)
+ end
+
+ it 'adds user as member', :js, :snowplow, :aggregate_failures do
+ visit members_page_path
+
+ invite_member(user2.name, role: 'Reporter')
+
+ page.within find_member_row(user2) do
+ expect(page).to have_button('Reporter')
+ end
+
+ expect_snowplow_event(
+ category: 'Members::InviteService',
+ action: 'create_member',
+ label: snowplow_invite_label,
+ property: 'existing_user',
+ user: user1
+ )
+ end
+
+ it 'invites user by email', :js, :snowplow, :aggregate_failures do
+ visit members_page_path
+
+ invite_member('test@example.com', role: 'Reporter')
+
+ click_link 'Invited'
+
+ page.within find_invited_member_row('test@example.com') do
+ expect(page).to have_button('Reporter')
+ end
+
+ expect_snowplow_event(
+ category: 'Members::InviteService',
+ action: 'create_member',
+ label: snowplow_invite_label,
+ property: 'net_new_user',
+ user: user1
+ )
+ end
+
+ it 'invites user by username and invites user by email', :js, :aggregate_failures do
+ visit members_page_path
+
+ invite_member([user2.name, 'test@example.com'], role: 'Reporter')
+
+ page.within find_member_row(user2) do
+ expect(page).to have_button('Reporter')
+ end
+
+ click_link 'Invited'
+
+ page.within find_invited_member_row('test@example.com') do
+ expect(page).to have_button('Reporter')
+ end
+ end
+
+ context 'when member is already a member by username' do
+ it 'updates the member for that user', :js do
+ visit members_page_path
+
+ invite_member(user2.name, role: 'Developer')
+
+ invite_member(user2.name, role: 'Reporter', refresh: false)
+
+ expect(page).not_to have_selector(invite_modal_selector)
+
+ page.refresh
+
+ page.within find_invited_member_row(user2.name) do
+ expect(page).to have_button('Reporter')
+ end
+ end
+ end
+
+ context 'when member is already a member by email' do
+ it 'fails with an error', :js do
+ visit members_page_path
+
+ invite_member('test@example.com', role: 'Developer')
+
+ invite_member('test@example.com', role: 'Reporter', refresh: false)
+
+ expect(page).to have_selector(invite_modal_selector)
+ expect(page).to have_content("The member's email address has already been taken")
+
+ page.refresh
+
+ click_link 'Invited'
+
+ page.within find_invited_member_row('test@example.com') do
+ expect(page).to have_button('Developer')
+ end
+ end
+ end
+
+ context 'when inviting a parent group member to the sub-entity' do
+ before_all do
+ group.add_owner(user1)
+ group.add_developer(user2)
+ end
+
+ context 'when role is higher than parent group membership' do
+ let(:role) { 'Maintainer' }
+
+ it 'adds the user as a member on sub-entity with higher access level', :js do
+ visit subentity_members_page_path
+
+ invite_member(user2.name, role: role, refresh: false)
+
+ expect(page).not_to have_selector(invite_modal_selector)
+
+ page.refresh
+
+ page.within find_invited_member_row(user2.name) do
+ expect(page).to have_button(role)
+ end
+ end
+ end
+
+ context 'when role is lower than parent group membership' do
+ let(:role) { 'Reporter' }
+
+ it 'fails with an error', :js do
+ visit subentity_members_page_path
+
+ invite_member(user2.name, role: role, refresh: false)
+
+ expect(page).to have_selector(invite_modal_selector)
+ expect(page).to have_content "Access level should be greater than or equal to Developer inherited membership " \
+ "from group #{group.name}"
+
+ page.refresh
+
+ page.within find_invited_member_row(user2.name) do
+ expect(page).to have_content('Developer')
+ expect(page).not_to have_button('Developer')
+ end
+ end
+
+ context 'when there are multiple users invited with errors' do
+ let_it_be(:user3) { create(:user) }
+
+ before do
+ group.add_maintainer(user3)
+ end
+
+ it 'only shows the first user error', :js do
+ visit subentity_members_page_path
+
+ invite_member([user2.name, user3.name], role: role, refresh: false)
+
+ expect(page).to have_selector(invite_modal_selector)
+ expect(page).to have_text("Access level should be greater than or equal to", count: 1)
+
+ page.refresh
+
+ page.within find_invited_member_row(user2.name) do
+ expect(page).to have_content('Developer')
+ expect(page).not_to have_button('Developer')
+ end
+
+ page.within find_invited_member_row(user3.name) do
+ expect(page).to have_content('Maintainer')
+ expect(page).not_to have_button('Maintainer')
+ end
+ end
+ end
+ end
+ end
+end