Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
cd40e11c57
commit
3007cf75a9
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 || '';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -198,3 +198,4 @@ module Gitlab
|
|||
end
|
||||
end
|
||||
end
|
||||
# rubocop:enable Rails/Output
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Reference in New Issue