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 @@ + + 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