Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2022-04-19 21:09:48 +00:00
parent cd40e11c57
commit 3007cf75a9
37 changed files with 1042 additions and 455 deletions

View File

@ -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);

View File

@ -0,0 +1,146 @@
<script>
import {
GlButton,
GlButtonGroup,
GlDropdown,
GlDropdownItem,
GlSearchBoxByType,
GlTooltipDirective as GlTooltip,
} from '@gitlab/ui';
import { BubbleMenu } from '@tiptap/vue-2';
import codeBlockLanguageLoader from '../services/code_block_language_loader';
import CodeBlockHighlight from '../extensions/code_block_highlight';
import Diagram from '../extensions/diagram';
import Frontmatter from '../extensions/frontmatter';
import EditorStateObserver from './editor_state_observer.vue';
const CODE_BLOCK_NODE_TYPES = [CodeBlockHighlight.name, Diagram.name, Frontmatter.name];
export default {
components: {
BubbleMenu,
GlButton,
GlButtonGroup,
GlDropdown,
GlDropdownItem,
GlSearchBoxByType,
EditorStateObserver,
},
directives: {
GlTooltip,
},
inject: ['tiptapEditor'],
data() {
return {
selectedLanguage: {},
filterTerm: '',
filteredLanguages: [],
};
},
watch: {
filterTerm: {
handler(val) {
this.filteredLanguages = codeBlockLanguageLoader.filterLanguages(val);
},
immediate: true,
},
},
methods: {
shouldShow: ({ editor }) => {
return CODE_BLOCK_NODE_TYPES.some((type) => editor.isActive(type));
},
getSelectedLanguage() {
const { language } = this.tiptapEditor.getAttributes(this.getCodeBlockType());
this.selectedLanguage = codeBlockLanguageLoader.findLanguageBySyntax(language);
},
async setSelectedLanguage(language) {
this.selectedLanguage = language;
await codeBlockLanguageLoader.loadLanguages([language.syntax]);
this.tiptapEditor.commands.setCodeBlock({ language: this.selectedLanguage.syntax });
},
tippyOnBeforeUpdate(tippy, props) {
if (props.getReferenceClientRect) {
// eslint-disable-next-line no-param-reassign
props.getReferenceClientRect = () => {
const { view } = this.tiptapEditor;
const { from } = this.tiptapEditor.state.selection;
for (let { node } = view.domAtPos(from); node; node = node.parentElement) {
if (node.nodeName?.toLowerCase() === 'pre') {
return node.getBoundingClientRect();
}
}
return new DOMRect(-1000, -1000, 0, 0);
};
}
},
deleteCodeBlock() {
this.tiptapEditor.chain().focus().deleteNode(this.getCodeBlockType()).run();
},
getCodeBlockType() {
return (
CODE_BLOCK_NODE_TYPES.find((type) => this.tiptapEditor.isActive(type)) ||
CodeBlockHighlight.name
);
},
},
};
</script>
<template>
<bubble-menu
data-testid="code-block-bubble-menu"
class="gl-shadow gl-rounded-base"
:editor="tiptapEditor"
plugin-key="bubbleMenuCodeBlock"
:should-show="shouldShow"
:tippy-options="{ onBeforeUpdate: tippyOnBeforeUpdate }"
>
<editor-state-observer @transaction="getSelectedLanguage">
<gl-button-group>
<gl-dropdown contenteditable="false" boundary="viewport" :text="selectedLanguage.label">
<template #header>
<gl-search-box-by-type
v-model="filterTerm"
:clear-button-title="__('Clear')"
:placeholder="__('Search')"
/>
</template>
<template #highlighted-items>
<gl-dropdown-item :key="selectedLanguage.syntax" is-check-item :is-checked="true">
{{ selectedLanguage.label }}
</gl-dropdown-item>
</template>
<gl-dropdown-item
v-for="language in filteredLanguages"
v-show="selectedLanguage.syntax !== language.syntax"
:key="language.syntax"
@click="setSelectedLanguage(language)"
>
{{ language.label }}
</gl-dropdown-item>
</gl-dropdown>
<gl-button
v-gl-tooltip
variant="default"
category="primary"
size="medium"
:aria-label="__('Delete code block')"
:title="__('Delete code block')"
icon="remove"
@click="deleteCodeBlock"
/>
</gl-button-group>
</editor-state-observer>
</bubble-menu>
</template>

View File

@ -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 {
<top-toolbar ref="toolbar" class="gl-mb-4" />
<div class="gl-relative">
<formatting-bubble-menu />
<code-block-bubble-menu />
<tiptap-editor-content class="md" :editor="contentEditor.tiptapEditor" />
<loading-indicator />
</div>

View File

@ -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));
},
},
};
</script>
@ -24,6 +36,7 @@ export default {
data-testid="formatting-bubble-menu"
class="gl-shadow gl-rounded-base"
:editor="tiptapEditor"
:should-show="shouldShow"
>
<gl-button-group>
<toolbar-button

View File

@ -1,30 +1,19 @@
import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight';
import { textblockTypeInputRule } from '@tiptap/core';
import { isFunction } from 'lodash';
import codeBlockLanguageLoader from '../services/code_block_language_loader';
const extractLanguage = (element) => 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],
];

View File

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

View File

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

View File

@ -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) => {

View File

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

View File

@ -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({

View File

@ -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';

View File

@ -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 || '';
}

View File

@ -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"
/>
</template>

View File

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

View File

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

View File

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

View File

@ -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 <https://docs.gitlab.com>. 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 <https://docs.gitlab.com>. 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 <https://docs.gitlab.com/archives/>.
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`.

View File

@ -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).

View File

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

View File

@ -198,3 +198,4 @@ module Gitlab
end
end
end
# rubocop:enable Rails/Output

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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');
});
});

View File

@ -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('<pre>test</pre>');
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('<pre>test</pre>');
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('<pre lang="javascript">var a = 2;</pre>');
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('<pre lang="nomnoml">test</pre>');
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('<pre lang="javascript">var a = 2;</pre>');
await wrapper.findComponent(GlButton).vm.$emit('click');
expect(tiptapEditor.getText()).toBe('');
});
describe('when opened and search is changed', () => {
beforeEach(async () => {
tiptapEditor.commands.insertContent('<pre lang="javascript">var a = 2;</pre>');
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');
});
});
});
});

View File

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

View File

@ -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 });

View File

@ -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';

View File

@ -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', () => {

View File

@ -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,
};

View File

@ -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);
},
);
});
});

View File

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

View File

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

View File

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