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) {
 | 
					  inviteGroupMembers(id, data) {
 | 
				
			||||||
    const url = Api.buildUrl(this.groupMembersPath).replace(':id', encodeURIComponent(id));
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    return axios.post(url, data);
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  inviteGroupMembersByEmail(id, data) {
 | 
					 | 
				
			||||||
    const url = Api.buildUrl(this.groupInvitationsPath).replace(':id', encodeURIComponent(id));
 | 
					    const url = Api.buildUrl(this.groupInvitationsPath).replace(':id', encodeURIComponent(id));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return axios.post(url, data);
 | 
					    return axios.post(url, data);
 | 
				
			||||||
| 
						 | 
					@ -258,13 +252,7 @@ const Api = {
 | 
				
			||||||
      .then(({ data }) => data);
 | 
					      .then(({ data }) => data);
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  addProjectMembersByUserId(id, data) {
 | 
					  inviteProjectMembers(id, data) {
 | 
				
			||||||
    const url = Api.buildUrl(this.projectMembersPath).replace(':id', encodeURIComponent(id));
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    return axios.post(url, data);
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  inviteProjectMembersByEmail(id, data) {
 | 
					 | 
				
			||||||
    const url = Api.buildUrl(this.projectInvitationsPath).replace(':id', encodeURIComponent(id));
 | 
					    const url = Api.buildUrl(this.projectInvitationsPath).replace(':id', encodeURIComponent(id));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return axios.post(url, data);
 | 
					    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 ContentEditorProvider from './content_editor_provider.vue';
 | 
				
			||||||
import EditorStateObserver from './editor_state_observer.vue';
 | 
					import EditorStateObserver from './editor_state_observer.vue';
 | 
				
			||||||
import FormattingBubbleMenu from './formatting_bubble_menu.vue';
 | 
					import FormattingBubbleMenu from './formatting_bubble_menu.vue';
 | 
				
			||||||
 | 
					import CodeBlockBubbleMenu from './code_block_bubble_menu.vue';
 | 
				
			||||||
import TopToolbar from './top_toolbar.vue';
 | 
					import TopToolbar from './top_toolbar.vue';
 | 
				
			||||||
import LoadingIndicator from './loading_indicator.vue';
 | 
					import LoadingIndicator from './loading_indicator.vue';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -16,6 +17,7 @@ export default {
 | 
				
			||||||
    TiptapEditorContent,
 | 
					    TiptapEditorContent,
 | 
				
			||||||
    TopToolbar,
 | 
					    TopToolbar,
 | 
				
			||||||
    FormattingBubbleMenu,
 | 
					    FormattingBubbleMenu,
 | 
				
			||||||
 | 
					    CodeBlockBubbleMenu,
 | 
				
			||||||
    EditorStateObserver,
 | 
					    EditorStateObserver,
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  props: {
 | 
					  props: {
 | 
				
			||||||
| 
						 | 
					@ -89,6 +91,7 @@ export default {
 | 
				
			||||||
        <top-toolbar ref="toolbar" class="gl-mb-4" />
 | 
					        <top-toolbar ref="toolbar" class="gl-mb-4" />
 | 
				
			||||||
        <div class="gl-relative">
 | 
					        <div class="gl-relative">
 | 
				
			||||||
          <formatting-bubble-menu />
 | 
					          <formatting-bubble-menu />
 | 
				
			||||||
 | 
					          <code-block-bubble-menu />
 | 
				
			||||||
          <tiptap-editor-content class="md" :editor="contentEditor.tiptapEditor" />
 | 
					          <tiptap-editor-content class="md" :editor="contentEditor.tiptapEditor" />
 | 
				
			||||||
          <loading-indicator />
 | 
					          <loading-indicator />
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -3,6 +3,10 @@ import { GlButtonGroup } from '@gitlab/ui';
 | 
				
			||||||
import { BubbleMenu } from '@tiptap/vue-2';
 | 
					import { BubbleMenu } from '@tiptap/vue-2';
 | 
				
			||||||
import { BUBBLE_MENU_TRACKING_ACTION } from '../constants';
 | 
					import { BUBBLE_MENU_TRACKING_ACTION } from '../constants';
 | 
				
			||||||
import trackUIControl from '../services/track_ui_control';
 | 
					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';
 | 
					import ToolbarButton from './toolbar_button.vue';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default {
 | 
					export default {
 | 
				
			||||||
| 
						 | 
					@ -16,6 +20,14 @@ export default {
 | 
				
			||||||
    trackToolbarControlExecution({ contentType, value }) {
 | 
					    trackToolbarControlExecution({ contentType, value }) {
 | 
				
			||||||
      trackUIControl({ action: BUBBLE_MENU_TRACKING_ACTION, property: 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>
 | 
					</script>
 | 
				
			||||||
| 
						 | 
					@ -24,6 +36,7 @@ export default {
 | 
				
			||||||
    data-testid="formatting-bubble-menu"
 | 
					    data-testid="formatting-bubble-menu"
 | 
				
			||||||
    class="gl-shadow gl-rounded-base"
 | 
					    class="gl-shadow gl-rounded-base"
 | 
				
			||||||
    :editor="tiptapEditor"
 | 
					    :editor="tiptapEditor"
 | 
				
			||||||
 | 
					    :should-show="shouldShow"
 | 
				
			||||||
  >
 | 
					  >
 | 
				
			||||||
    <gl-button-group>
 | 
					    <gl-button-group>
 | 
				
			||||||
      <toolbar-button
 | 
					      <toolbar-button
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,30 +1,19 @@
 | 
				
			||||||
import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight';
 | 
					import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight';
 | 
				
			||||||
import { textblockTypeInputRule } from '@tiptap/core';
 | 
					import { textblockTypeInputRule } from '@tiptap/core';
 | 
				
			||||||
import { isFunction } from 'lodash';
 | 
					import codeBlockLanguageLoader from '../services/code_block_language_loader';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const extractLanguage = (element) => element.getAttribute('lang');
 | 
					const extractLanguage = (element) => element.getAttribute('lang');
 | 
				
			||||||
const backtickInputRegex = /^```([a-z]+)?[\s\n]$/;
 | 
					export const backtickInputRegex = /^```([a-z]+)?[\s\n]$/;
 | 
				
			||||||
const tildeInputRegex = /^~~~([a-z]+)?[\s\n]$/;
 | 
					export const tildeInputRegex = /^~~~([a-z]+)?[\s\n]$/;
 | 
				
			||||||
 | 
					 | 
				
			||||||
const loadLanguageFromInputRule = (languageLoader) => (match) => {
 | 
					 | 
				
			||||||
  const language = match[1];
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  if (isFunction(languageLoader?.loadLanguages)) {
 | 
					 | 
				
			||||||
    languageLoader.loadLanguages([language]);
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  return {
 | 
					 | 
				
			||||||
    language,
 | 
					 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default CodeBlockLowlight.extend({
 | 
					export default CodeBlockLowlight.extend({
 | 
				
			||||||
  isolating: true,
 | 
					  isolating: true,
 | 
				
			||||||
 | 
					  exitOnArrowDown: false,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  addOptions() {
 | 
					  addOptions() {
 | 
				
			||||||
    return {
 | 
					    return {
 | 
				
			||||||
      ...this.parent?.(),
 | 
					      ...this.parent?.(),
 | 
				
			||||||
      languageLoader: {},
 | 
					      languageLoader: codeBlockLanguageLoader,
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -42,26 +31,36 @@ export default CodeBlockLowlight.extend({
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  addInputRules() {
 | 
					  addInputRules() {
 | 
				
			||||||
    const { languageLoader } = this.options;
 | 
					    const { languageLoader } = this.options;
 | 
				
			||||||
 | 
					    const getAttributes = (match) => languageLoader?.loadLanguageFromInputRule(match) || {};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return [
 | 
					    return [
 | 
				
			||||||
      textblockTypeInputRule({
 | 
					      textblockTypeInputRule({
 | 
				
			||||||
        find: backtickInputRegex,
 | 
					        find: backtickInputRegex,
 | 
				
			||||||
        type: this.type,
 | 
					        type: this.type,
 | 
				
			||||||
        getAttributes: loadLanguageFromInputRule(languageLoader),
 | 
					        getAttributes,
 | 
				
			||||||
      }),
 | 
					      }),
 | 
				
			||||||
      textblockTypeInputRule({
 | 
					      textblockTypeInputRule({
 | 
				
			||||||
        find: tildeInputRegex,
 | 
					        find: tildeInputRegex,
 | 
				
			||||||
        type: this.type,
 | 
					        type: this.type,
 | 
				
			||||||
        getAttributes: loadLanguageFromInputRule(languageLoader),
 | 
					        getAttributes,
 | 
				
			||||||
      }),
 | 
					      }),
 | 
				
			||||||
    ];
 | 
					    ];
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
 | 
					  parseHTML() {
 | 
				
			||||||
 | 
					    return [
 | 
				
			||||||
 | 
					      ...(this.parent?.() || []),
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        tag: 'div.markdown-code-block',
 | 
				
			||||||
 | 
					        skip: true,
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					    ];
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
  renderHTML({ HTMLAttributes }) {
 | 
					  renderHTML({ HTMLAttributes }) {
 | 
				
			||||||
    return [
 | 
					    return [
 | 
				
			||||||
      'pre',
 | 
					      'pre',
 | 
				
			||||||
      {
 | 
					      {
 | 
				
			||||||
        ...HTMLAttributes,
 | 
					        ...HTMLAttributes,
 | 
				
			||||||
        class: `content-editor-code-block ${HTMLAttributes.class}`,
 | 
					        class: `content-editor-code-block ${gon.user_color_scheme} ${HTMLAttributes.class}`,
 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
      ['code', {}, 0],
 | 
					      ['code', {}, 0],
 | 
				
			||||||
    ];
 | 
					    ];
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,11 +1,249 @@
 | 
				
			||||||
export default class CodeBlockLanguageLoader {
 | 
					import { lowlight } from 'lowlight/lib/core';
 | 
				
			||||||
  constructor(lowlight) {
 | 
					import { __, sprintf } from '~/locale';
 | 
				
			||||||
    this.lowlight = lowlight;
 | 
					
 | 
				
			||||||
  }
 | 
					/* 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) {
 | 
					  isLanguageLoaded(language) {
 | 
				
			||||||
    return this.lowlight.registered(language);
 | 
					    return this.lowlight.registered(language);
 | 
				
			||||||
  }
 | 
					  },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  loadLanguagesFromDOM(domTree) {
 | 
					  loadLanguagesFromDOM(domTree) {
 | 
				
			||||||
    const languages = [];
 | 
					    const languages = [];
 | 
				
			||||||
| 
						 | 
					@ -15,7 +253,15 @@ export default class CodeBlockLanguageLoader {
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return this.loadLanguages(languages);
 | 
					    return this.loadLanguages(languages);
 | 
				
			||||||
  }
 | 
					  },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  loadLanguageFromInputRule(match) {
 | 
				
			||||||
 | 
					    const { syntax } = this.findLanguageBySyntax(match[1]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    this.loadLanguages([syntax]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return { language: syntax };
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  loadLanguages(languageList = []) {
 | 
					  loadLanguages(languageList = []) {
 | 
				
			||||||
    const loaders = languageList
 | 
					    const loaders = languageList
 | 
				
			||||||
| 
						 | 
					@ -31,5 +277,7 @@ export default class CodeBlockLanguageLoader {
 | 
				
			||||||
      });
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return Promise.all(loaders);
 | 
					    return Promise.all(loaders);
 | 
				
			||||||
  }
 | 
					  },
 | 
				
			||||||
}
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default codeBlockLanguageLoader;
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -60,7 +60,7 @@ import { ContentEditor } from './content_editor';
 | 
				
			||||||
import createMarkdownSerializer from './markdown_serializer';
 | 
					import createMarkdownSerializer from './markdown_serializer';
 | 
				
			||||||
import createMarkdownDeserializer from './markdown_deserializer';
 | 
					import createMarkdownDeserializer from './markdown_deserializer';
 | 
				
			||||||
import trackInputRulesAndShortcuts from './track_input_rules_and_shortcuts';
 | 
					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 } = {}) =>
 | 
					const createTiptapEditor = ({ extensions = [], ...options } = {}) =>
 | 
				
			||||||
  new Editor({
 | 
					  new Editor({
 | 
				
			||||||
| 
						 | 
					@ -86,7 +86,6 @@ export const createContentEditor = ({
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const eventHub = eventHubFactory();
 | 
					  const eventHub = eventHubFactory();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const languageLoader = new CodeBlockLanguageLoader(lowlight);
 | 
					 | 
				
			||||||
  const builtInContentEditorExtensions = [
 | 
					  const builtInContentEditorExtensions = [
 | 
				
			||||||
    Attachment.configure({ uploadsPath, renderMarkdown, eventHub }),
 | 
					    Attachment.configure({ uploadsPath, renderMarkdown, eventHub }),
 | 
				
			||||||
    Audio,
 | 
					    Audio,
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -8,12 +8,12 @@ import {
 | 
				
			||||||
  INPUT_RULE_TRACKING_ACTION,
 | 
					  INPUT_RULE_TRACKING_ACTION,
 | 
				
			||||||
} from '../constants';
 | 
					} from '../constants';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const trackKeyboardShortcut = (contentType, commandFn, shortcut) => () => {
 | 
					const trackKeyboardShortcut = (contentType, commandFn, shortcut) => (...args) => {
 | 
				
			||||||
  Tracking.event(undefined, KEYBOARD_SHORTCUT_TRACKING_ACTION, {
 | 
					  Tracking.event(undefined, KEYBOARD_SHORTCUT_TRACKING_ACTION, {
 | 
				
			||||||
    label: CONTENT_EDITOR_TRACKING_LABEL,
 | 
					    label: CONTENT_EDITOR_TRACKING_LABEL,
 | 
				
			||||||
    property: `${contentType}.${shortcut}`,
 | 
					    property: `${contentType}.${shortcut}`,
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
  return commandFn();
 | 
					  return commandFn(...args);
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const trackInputRule = (contentType, inputRule) => {
 | 
					const trackInputRule = (contentType, inputRule) => {
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -74,6 +74,7 @@ export default {
 | 
				
			||||||
        v-for="note in notesInGutter"
 | 
					        v-for="note in notesInGutter"
 | 
				
			||||||
        :key="note.id"
 | 
					        :key="note.id"
 | 
				
			||||||
        :img-src="note.author.avatar_url"
 | 
					        :img-src="note.author.avatar_url"
 | 
				
			||||||
 | 
					        :size="24"
 | 
				
			||||||
        :tooltip-text="getTooltipText(note)"
 | 
					        :tooltip-text="getTooltipText(note)"
 | 
				
			||||||
        lazy
 | 
					        lazy
 | 
				
			||||||
        class="diff-comment-avatar js-diff-comment-avatar"
 | 
					        class="diff-comment-avatar js-diff-comment-avatar"
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -193,46 +193,28 @@ export default {
 | 
				
			||||||
      this.invalidFeedbackMessage = '';
 | 
					      this.invalidFeedbackMessage = '';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      const [usersToInviteByEmail, usersToAddById] = this.partitionNewUsersToInvite();
 | 
					      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',
 | 
					        format: 'json',
 | 
				
			||||||
        expires_at: expiresAt,
 | 
					        expires_at: expiresAt,
 | 
				
			||||||
        access_level: accessLevel,
 | 
					        access_level: accessLevel,
 | 
				
			||||||
        invite_source: this.source,
 | 
					        invite_source: this.source,
 | 
				
			||||||
        tasks_to_be_done: this.tasksToBeDoneForPost,
 | 
					        tasks_to_be_done: this.tasksToBeDoneForPost,
 | 
				
			||||||
        tasks_project_id: this.tasksProjectForPost,
 | 
					        tasks_project_id: this.tasksProjectForPost,
 | 
				
			||||||
      };
 | 
					        ...email,
 | 
				
			||||||
 | 
					        ...userId,
 | 
				
			||||||
      if (usersToInviteByEmail !== '') {
 | 
					      })
 | 
				
			||||||
        const apiInviteByEmail = this.isProject
 | 
					        .then((response) => {
 | 
				
			||||||
          ? Api.inviteProjectMembersByEmail.bind(Api)
 | 
					          const message = responseMessageFromSuccess(response);
 | 
				
			||||||
          : 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);
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
          if (message) {
 | 
					          if (message) {
 | 
				
			||||||
            this.showInvalidFeedbackMessage({
 | 
					            this.showInvalidFeedbackMessage({
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,4 +1,4 @@
 | 
				
			||||||
import { __, s__ } from '~/locale';
 | 
					import { s__ } from '~/locale';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const SEARCH_DELAY = 200;
 | 
					export const SEARCH_DELAY = 200;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -14,9 +14,6 @@ export const GROUP_FILTERS = {
 | 
				
			||||||
  DESCENDANT_GROUPS: 'descendant_groups',
 | 
					  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_ALL = 'all';
 | 
				
			||||||
export const USERS_FILTER_SAML_PROVIDER_ID = 'saml_provider_id';
 | 
					export const USERS_FILTER_SAML_PROVIDER_ID = 'saml_provider_id';
 | 
				
			||||||
export const TRIGGER_ELEMENT_BUTTON = 'button';
 | 
					export const TRIGGER_ELEMENT_BUTTON = 'button';
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,28 +1,15 @@
 | 
				
			||||||
import { isString } from 'lodash';
 | 
					import { isString } from 'lodash';
 | 
				
			||||||
import { API_MESSAGES } from '~/invite_members/constants';
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
function responseKeyedMessageParsed(keyedMessage) {
 | 
					function responseKeyedMessageParsed(keyedMessage) {
 | 
				
			||||||
  try {
 | 
					  try {
 | 
				
			||||||
    const keys = Object.keys(keyedMessage);
 | 
					    const keys = Object.keys(keyedMessage);
 | 
				
			||||||
    const msg = keyedMessage[keys[0]];
 | 
					    const msg = keyedMessage[keys[0]];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (msg === API_MESSAGES.EMAIL_ALREADY_INVITED) {
 | 
					 | 
				
			||||||
      return '';
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    return msg;
 | 
					    return msg;
 | 
				
			||||||
  } catch {
 | 
					  } catch {
 | 
				
			||||||
    return '';
 | 
					    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) {
 | 
					export function responseMessageFromError(response) {
 | 
				
			||||||
  if (!response?.response?.data) {
 | 
					  if (!response?.response?.data) {
 | 
				
			||||||
| 
						 | 
					@ -33,36 +20,25 @@ export function responseMessageFromError(response) {
 | 
				
			||||||
    response: { data },
 | 
					    response: { data },
 | 
				
			||||||
  } = response;
 | 
					  } = response;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return (
 | 
					  return data.error || data.message?.error || data.message || '';
 | 
				
			||||||
    data.error ||
 | 
					 | 
				
			||||||
    data.message?.user?.[0] ||
 | 
					 | 
				
			||||||
    data.message?.access_level?.[0] ||
 | 
					 | 
				
			||||||
    data.message?.error ||
 | 
					 | 
				
			||||||
    data.message ||
 | 
					 | 
				
			||||||
    ''
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function responseMessageFromSuccess(response) {
 | 
					export function responseMessageFromSuccess(response) {
 | 
				
			||||||
  if (!response?.[0]?.data) {
 | 
					  if (!response?.data) {
 | 
				
			||||||
    return '';
 | 
					    return '';
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const { data } = response[0];
 | 
					  const { data } = response;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  if (data.message && !data.message.user) {
 | 
					  if (data.message) {
 | 
				
			||||||
    const { message } = data;
 | 
					    const { message } = data;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (isString(message)) {
 | 
					    if (isString(message)) {
 | 
				
			||||||
      if (responseMessageStringForMultiple(message)) {
 | 
					 | 
				
			||||||
        return responseMessageStringFirstPart(message);
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      return message;
 | 
					      return message;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return responseKeyedMessageParsed(message);
 | 
					    return responseKeyedMessageParsed(message);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return data.message || data.message?.user || data.error || '';
 | 
					  return data.error || '';
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -18,6 +18,8 @@ export default {
 | 
				
			||||||
  name: 'MembersFilteredSearchBar',
 | 
					  name: 'MembersFilteredSearchBar',
 | 
				
			||||||
  components: { FilteredSearchBar },
 | 
					  components: { FilteredSearchBar },
 | 
				
			||||||
  availableTokens: AVAILABLE_FILTERED_SEARCH_TOKENS,
 | 
					  availableTokens: AVAILABLE_FILTERED_SEARCH_TOKENS,
 | 
				
			||||||
 | 
					  searchButtonAttributes: { 'data-qa-selector': 'search_button' },
 | 
				
			||||||
 | 
					  searchInputAttributes: { 'data-qa-selector': 'search_bar_input' },
 | 
				
			||||||
  inject: {
 | 
					  inject: {
 | 
				
			||||||
    namespace: {},
 | 
					    namespace: {},
 | 
				
			||||||
    sourceId: {},
 | 
					    sourceId: {},
 | 
				
			||||||
| 
						 | 
					@ -127,8 +129,9 @@ export default {
 | 
				
			||||||
    :recent-searches-storage-key="filteredSearchBar.recentSearchesStorageKey"
 | 
					    :recent-searches-storage-key="filteredSearchBar.recentSearchesStorageKey"
 | 
				
			||||||
    :search-input-placeholder="filteredSearchBar.placeholder"
 | 
					    :search-input-placeholder="filteredSearchBar.placeholder"
 | 
				
			||||||
    :initial-filter-value="initialFilterValue"
 | 
					    :initial-filter-value="initialFilterValue"
 | 
				
			||||||
 | 
					    :search-button-attributes="$options.searchButtonAttributes"
 | 
				
			||||||
 | 
					    :search-input-attributes="$options.searchInputAttributes"
 | 
				
			||||||
    data-testid="members-filtered-search-bar"
 | 
					    data-testid="members-filtered-search-bar"
 | 
				
			||||||
    data-qa-selector="members_filtered_search_bar_content"
 | 
					 | 
				
			||||||
    @onFilter="handleFilter"
 | 
					    @onFilter="handleFilter"
 | 
				
			||||||
  />
 | 
					  />
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -79,6 +79,16 @@ export default {
 | 
				
			||||||
      required: false,
 | 
					      required: false,
 | 
				
			||||||
      default: '',
 | 
					      default: '',
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
 | 
					    searchButtonAttributes: {
 | 
				
			||||||
 | 
					      type: Object,
 | 
				
			||||||
 | 
					      required: false,
 | 
				
			||||||
 | 
					      default: () => ({}),
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    searchInputAttributes: {
 | 
				
			||||||
 | 
					      type: Object,
 | 
				
			||||||
 | 
					      required: false,
 | 
				
			||||||
 | 
					      default: () => ({}),
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  data() {
 | 
					  data() {
 | 
				
			||||||
    let selectedSortOption = this.sortOptions[0]?.sortDirection?.descending;
 | 
					    let selectedSortOption = this.sortOptions[0]?.sortDirection?.descending;
 | 
				
			||||||
| 
						 | 
					@ -320,6 +330,8 @@ export default {
 | 
				
			||||||
      :available-tokens="tokens"
 | 
					      :available-tokens="tokens"
 | 
				
			||||||
      :history-items="filteredRecentSearches"
 | 
					      :history-items="filteredRecentSearches"
 | 
				
			||||||
      :suggestions-list-class="suggestionsListClass"
 | 
					      :suggestions-list-class="suggestionsListClass"
 | 
				
			||||||
 | 
					      :search-button-attributes="searchButtonAttributes"
 | 
				
			||||||
 | 
					      :search-input-attributes="searchInputAttributes"
 | 
				
			||||||
      class="flex-grow-1"
 | 
					      class="flex-grow-1"
 | 
				
			||||||
      @history-item-selected="handleHistoryItemSelected"
 | 
					      @history-item-selected="handleHistoryItemSelected"
 | 
				
			||||||
      @clear="onClear"
 | 
					      @clear="onClear"
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -9,6 +9,6 @@ feature_categories:
 | 
				
			||||||
- pages
 | 
					- pages
 | 
				
			||||||
- service_ping
 | 
					- service_ping
 | 
				
			||||||
- source_code_management
 | 
					- source_code_management
 | 
				
			||||||
description: TODO
 | 
					description: GitLab application settings
 | 
				
			||||||
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/commit/8589b4e137f50293952923bb07e2814257d7784d
 | 
					introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/commit/8589b4e137f50293952923bb07e2814257d7784d
 | 
				
			||||||
milestone: '7.7'
 | 
					milestone: '7.7'
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -4,6 +4,6 @@ classes:
 | 
				
			||||||
- ProductAnalyticsEvent
 | 
					- ProductAnalyticsEvent
 | 
				
			||||||
feature_categories:
 | 
					feature_categories:
 | 
				
			||||||
- product_analytics
 | 
					- product_analytics
 | 
				
			||||||
description: TODO
 | 
					description: Product analytic events, experimental feature.
 | 
				
			||||||
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/commit/9af97ee69a36de1dc4e73f4030d6316d3f0a82c5
 | 
					introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/commit/fc6c53e6f7b47dc22c8619a5a6fe491d29778d3f
 | 
				
			||||||
milestone: '13.2'
 | 
					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
 | 
					## 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/>.
 | 
					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:
 | 
					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) |
 | 
					| [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) |
 | 
					| [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) |
 | 
					| [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`.
 | 
					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
 | 
					For those new to the GitLab GraphQL API, read
 | 
				
			||||||
[Getting started with GitLab GraphQL API](../../../api/graphql/getting_started.md).
 | 
					[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
 | 
					- [Troubleshooting GitLab-managed Terraform state](troubleshooting.md).
 | 
				
			||||||
 | 
					 | 
				
			||||||
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.
 | 
					 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -66,3 +66,30 @@ with better Terraform-specific names. To resolve the syntax error, you can:
 | 
				
			||||||
  my-Terraform-job:
 | 
					  my-Terraform-job:
 | 
				
			||||||
    extends: .terraform:init  # The updated name.
 | 
					    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
 | 
					  end
 | 
				
			||||||
end
 | 
					end
 | 
				
			||||||
 | 
					# rubocop:enable Rails/Output
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -212,3 +212,9 @@
 | 
				
			||||||
  redis_slot: project_management
 | 
					  redis_slot: project_management
 | 
				
			||||||
  aggregation: daily
 | 
					  aggregation: daily
 | 
				
			||||||
  feature_flag: track_epics_activity
 | 
					  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"
 | 
					msgid "CurrentUser|Start an Ultimate trial"
 | 
				
			||||||
msgstr ""
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					msgid "Custom (%{language})"
 | 
				
			||||||
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
msgid "Custom Attributes"
 | 
					msgid "Custom Attributes"
 | 
				
			||||||
msgstr ""
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -11965,6 +11968,9 @@ msgstr ""
 | 
				
			||||||
msgid "Delete badge"
 | 
					msgid "Delete badge"
 | 
				
			||||||
msgstr ""
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					msgid "Delete code block"
 | 
				
			||||||
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
msgid "Delete column"
 | 
					msgid "Delete column"
 | 
				
			||||||
msgstr ""
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -16506,6 +16512,9 @@ msgstr ""
 | 
				
			||||||
msgid "Geo|Edit %{nodeType} site"
 | 
					msgid "Geo|Edit %{nodeType} site"
 | 
				
			||||||
msgstr ""
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					msgid "Geo|Edit your search and try again."
 | 
				
			||||||
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
msgid "Geo|Failed"
 | 
					msgid "Geo|Failed"
 | 
				
			||||||
msgstr ""
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -16581,6 +16590,9 @@ msgstr ""
 | 
				
			||||||
msgid "Geo|Next sync scheduled at"
 | 
					msgid "Geo|Next sync scheduled at"
 | 
				
			||||||
msgstr ""
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					msgid "Geo|No Geo site found"
 | 
				
			||||||
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
msgid "Geo|No available replication slots"
 | 
					msgid "Geo|No available replication slots"
 | 
				
			||||||
msgstr ""
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -20605,9 +20617,6 @@ msgstr ""
 | 
				
			||||||
msgid "Invite a group"
 | 
					msgid "Invite a group"
 | 
				
			||||||
msgstr ""
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
msgid "Invite email has already been taken"
 | 
					 | 
				
			||||||
msgstr ""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
msgid "Invite members"
 | 
					msgid "Invite members"
 | 
				
			||||||
msgstr ""
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -12,7 +12,7 @@ module QA
 | 
				
			||||||
            end
 | 
					            end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            def enable_performance_bar
 | 
					            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')
 | 
					              Capybara.current_session.driver.browser.manage.add_cookie(name: 'perf_bar_enabled', value: 'true')
 | 
				
			||||||
            end
 | 
					            end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -10,15 +10,14 @@ module QA
 | 
				
			||||||
          super
 | 
					          super
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          base.view 'app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue' do
 | 
					          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
 | 
				
			||||||
        end
 | 
					        end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        def search_member(username)
 | 
					        def search_member(username)
 | 
				
			||||||
          # TODO: Update the two actions below to use direct qa selectors once this is implemented:
 | 
					          fill_element :search_bar_input, username
 | 
				
			||||||
          # https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1688
 | 
					          click_element :search_button
 | 
				
			||||||
          find_element(:members_filtered_search_bar_content).find('input').set(username)
 | 
					 | 
				
			||||||
          find('.gl-search-box-by-click-search-button').click
 | 
					 | 
				
			||||||
        end
 | 
					        end
 | 
				
			||||||
      end
 | 
					      end
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -42,27 +42,6 @@ RSpec.describe 'Groups > Members > Manage members' do
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
  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
 | 
					  it 'remove user from group', :js do
 | 
				
			||||||
    group.add_owner(user1)
 | 
					    group.add_owner(user1)
 | 
				
			||||||
    group.add_developer(user2)
 | 
					    group.add_developer(user2)
 | 
				
			||||||
| 
						 | 
					@ -87,43 +66,29 @@ RSpec.describe 'Groups > Members > Manage members' do
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  it 'add yourself to group when already an owner', :js, :aggregate_failures do
 | 
					  context 'when inviting' do
 | 
				
			||||||
    group.add_owner(user1)
 | 
					    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_selector(invite_modal_selector)
 | 
				
			||||||
      expect(page).to have_content(user1.name)
 | 
					      expect(page).to have_content("not authorized to update member")
 | 
				
			||||||
      expect(page).to have_content('Owner')
 | 
					 | 
				
			||||||
    end
 | 
					 | 
				
			||||||
  end
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  it 'invite user to group', :js, :snowplow do
 | 
					      page.refresh
 | 
				
			||||||
    group.add_owner(user1)
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    visit group_group_members_path(group)
 | 
					      page.within find_member_row(user1) do
 | 
				
			||||||
 | 
					        expect(page).to have_content('Owner')
 | 
				
			||||||
    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')
 | 
					 | 
				
			||||||
      end
 | 
					      end
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      expect_snowplow_event(
 | 
					    it_behaves_like 'inviting members', 'group-members-page' do
 | 
				
			||||||
        category: 'Members::InviteService',
 | 
					      let_it_be(:entity) { group }
 | 
				
			||||||
        action: 'create_member',
 | 
					      let_it_be(:members_page_path) { group_group_members_path(entity) }
 | 
				
			||||||
        label: 'group-members-page',
 | 
					      let_it_be(:subentity) { create(:group, parent: group) }
 | 
				
			||||||
        property: 'net_new_user',
 | 
					      let_it_be(:subentity_members_page_path) { group_group_members_path(subentity) }
 | 
				
			||||||
        user: user1
 | 
					 | 
				
			||||||
      )
 | 
					 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -48,24 +48,6 @@ RSpec.describe 'Projects > Members > Manage members', :js do
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
  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
 | 
					  it 'uses ProjectMember access_level_roles for the invite members modal access option', :aggregate_failures do
 | 
				
			||||||
    visit_members_page
 | 
					    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)
 | 
					    expect(members_table).not_to have_content(other_user.name)
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  it 'invite user to project', :snowplow, :aggregate_failures do
 | 
					  it_behaves_like 'inviting members', 'project-members-page' do
 | 
				
			||||||
    visit_members_page
 | 
					    let_it_be(:entity) { project }
 | 
				
			||||||
 | 
					    let_it_be(:members_page_path) { project_project_members_path(entity) }
 | 
				
			||||||
    invite_member('test@example.com', role: 'Reporter')
 | 
					    let_it_be(:subentity) { project }
 | 
				
			||||||
 | 
					    let_it_be(:subentity_members_page_path) { project_project_members_path(entity) }
 | 
				
			||||||
    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
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  describe 'member search results' do
 | 
					  describe 'member search results' do
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -187,36 +187,15 @@ describe('Api', () => {
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  describe('addGroupMembersByUserId', () => {
 | 
					  describe('inviteGroupMembers', () => {
 | 
				
			||||||
    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', () => {
 | 
					 | 
				
			||||||
    it('invites a new email address to create a new User and become a Group Member', () => {
 | 
					    it('invites a new email address to create a new User and become a Group Member', () => {
 | 
				
			||||||
      const groupId = 1;
 | 
					      const groupId = 1;
 | 
				
			||||||
      const email = 'email@example.com';
 | 
					      const email = 'email@example.com';
 | 
				
			||||||
 | 
					      const userId = '1';
 | 
				
			||||||
      const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/1/invitations`;
 | 
					      const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/1/invitations`;
 | 
				
			||||||
      const params = {
 | 
					      const params = {
 | 
				
			||||||
        email,
 | 
					        email,
 | 
				
			||||||
 | 
					        userId,
 | 
				
			||||||
        access_level: 10,
 | 
					        access_level: 10,
 | 
				
			||||||
        expires_at: undefined,
 | 
					        expires_at: undefined,
 | 
				
			||||||
      };
 | 
					      };
 | 
				
			||||||
| 
						 | 
					@ -225,7 +204,7 @@ describe('Api', () => {
 | 
				
			||||||
        status: 'success',
 | 
					        status: 'success',
 | 
				
			||||||
      });
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      return Api.inviteGroupMembersByEmail(groupId, params).then(({ data }) => {
 | 
					      return Api.inviteGroupMembers(groupId, params).then(({ data }) => {
 | 
				
			||||||
        expect(data.status).toBe('success');
 | 
					        expect(data.status).toBe('success');
 | 
				
			||||||
      });
 | 
					      });
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
| 
						 | 
					@ -543,36 +522,15 @@ describe('Api', () => {
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  describe('addProjectMembersByUserId', () => {
 | 
					  describe('inviteProjectMembers', () => {
 | 
				
			||||||
    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', () => {
 | 
					 | 
				
			||||||
    it('invites a new email address to create a new User and become a Project Member', () => {
 | 
					    it('invites a new email address to create a new User and become a Project Member', () => {
 | 
				
			||||||
      const projectId = 1;
 | 
					      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 expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/1/invitations`;
 | 
				
			||||||
      const params = {
 | 
					      const params = {
 | 
				
			||||||
        email: expectedEmail,
 | 
					        email,
 | 
				
			||||||
 | 
					        userId,
 | 
				
			||||||
        access_level: 10,
 | 
					        access_level: 10,
 | 
				
			||||||
        expires_at: undefined,
 | 
					        expires_at: undefined,
 | 
				
			||||||
      };
 | 
					      };
 | 
				
			||||||
| 
						 | 
					@ -581,7 +539,7 @@ describe('Api', () => {
 | 
				
			||||||
        status: 'success',
 | 
					        status: 'success',
 | 
				
			||||||
      });
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      return Api.inviteProjectMembersByEmail(projectId, params).then(({ data }) => {
 | 
					      return Api.inviteProjectMembers(projectId, params).then(({ data }) => {
 | 
				
			||||||
        expect(data.status).toBe('success');
 | 
					        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';
 | 
					} from '~/content_editor/constants';
 | 
				
			||||||
import { createTestEditor } from '../test_utils';
 | 
					import { createTestEditor } from '../test_utils';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
describe('content_editor/components/top_toolbar', () => {
 | 
					describe('content_editor/components/formatting_bubble_menu', () => {
 | 
				
			||||||
  let wrapper;
 | 
					  let wrapper;
 | 
				
			||||||
  let trackingSpy;
 | 
					  let trackingSpy;
 | 
				
			||||||
  let tiptapEditor;
 | 
					  let tiptapEditor;
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -23,7 +23,7 @@ describe('content_editor/extensions/frontmatter', () => {
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  it('does not insert a frontmatter block when executing code block input rule', () => {
 | 
					  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 = '``` ';
 | 
					    const inputRuleText = '``` ';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    triggerNodeInputRule({ tiptapEditor, 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', () => {
 | 
					describe('content_editor/services/code_block_language_loader', () => {
 | 
				
			||||||
  let languageLoader;
 | 
					  let languageLoader;
 | 
				
			||||||
| 
						 | 
					@ -12,7 +14,43 @@ describe('content_editor/services/code_block_language_loader', () => {
 | 
				
			||||||
        .mockImplementation((language) => lowlight.languages.push(language)),
 | 
					        .mockImplementation((language) => lowlight.languages.push(language)),
 | 
				
			||||||
      registered: jest.fn().mockImplementation((language) => lowlight.languages.includes(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', () => {
 | 
					  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', () => {
 | 
					  describe('isLanguageLoaded', () => {
 | 
				
			||||||
    it('returns true when a language is registered', async () => {
 | 
					    it('returns true when a language is registered', async () => {
 | 
				
			||||||
      const language = 'javascript';
 | 
					      const language = 'javascript';
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -23,7 +23,7 @@ import ContentTransition from '~/vue_shared/components/content_transition.vue';
 | 
				
			||||||
import axios from '~/lib/utils/axios_utils';
 | 
					import axios from '~/lib/utils/axios_utils';
 | 
				
			||||||
import httpStatus from '~/lib/utils/http_status';
 | 
					import httpStatus from '~/lib/utils/http_status';
 | 
				
			||||||
import { getParameterValues } from '~/lib/utils/url_utility';
 | 
					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 {
 | 
					import {
 | 
				
			||||||
  propsData,
 | 
					  propsData,
 | 
				
			||||||
  inviteSource,
 | 
					  inviteSource,
 | 
				
			||||||
| 
						 | 
					@ -301,11 +301,8 @@ describe('InviteMembersModal', () => {
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  describe('submitting the invite form', () => {
 | 
					  describe('submitting the invite form', () => {
 | 
				
			||||||
    const mockMembersApi = (code, data) => {
 | 
					 | 
				
			||||||
      mock.onPost(apiPaths.GROUPS_MEMBERS).reply(code, data);
 | 
					 | 
				
			||||||
    };
 | 
					 | 
				
			||||||
    const mockInvitationsApi = (code, data) => {
 | 
					    const mockInvitationsApi = (code, data) => {
 | 
				
			||||||
      mock.onPost(apiPaths.GROUPS_INVITATIONS).reply(code, data);
 | 
					      mock.onPost(GROUPS_INVITATIONS_PATH).reply(code, data);
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const expectedEmailRestrictedError =
 | 
					    const expectedEmailRestrictedError =
 | 
				
			||||||
| 
						 | 
					@ -329,7 +326,7 @@ describe('InviteMembersModal', () => {
 | 
				
			||||||
          await triggerMembersTokenSelect([user1, user2]);
 | 
					          await triggerMembersTokenSelect([user1, user2]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          wrapper.vm.$toast = { show: jest.fn() };
 | 
					          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', () => {
 | 
					        describe('when triggered from regular mounting', () => {
 | 
				
			||||||
| 
						 | 
					@ -337,12 +334,8 @@ describe('InviteMembersModal', () => {
 | 
				
			||||||
            clickInviteButton();
 | 
					            clickInviteButton();
 | 
				
			||||||
          });
 | 
					          });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          it('sets isLoading on the Invite button when it is clicked', () => {
 | 
					          it('calls Api inviteGroupMembers with the correct params', () => {
 | 
				
			||||||
            expect(findModal().props('actionPrimary').attributes.loading).toBe(true);
 | 
					            expect(Api.inviteGroupMembers).toHaveBeenCalledWith(propsData.id, postData);
 | 
				
			||||||
          });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
          it('calls Api addGroupMembersByUserId with the correct params', () => {
 | 
					 | 
				
			||||||
            expect(Api.addGroupMembersByUserId).toHaveBeenCalledWith(propsData.id, postData);
 | 
					 | 
				
			||||||
          });
 | 
					          });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          it('displays the successful toastMessage', () => {
 | 
					          it('displays the successful toastMessage', () => {
 | 
				
			||||||
| 
						 | 
					@ -372,21 +365,9 @@ describe('InviteMembersModal', () => {
 | 
				
			||||||
          await triggerMembersTokenSelect([user1]);
 | 
					          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', () => {
 | 
					        describe('clearing the invalid state and message', () => {
 | 
				
			||||||
          beforeEach(async () => {
 | 
					          beforeEach(async () => {
 | 
				
			||||||
            mockMembersApi(httpStatus.CONFLICT, membersApiResponse.MEMBER_ALREADY_EXISTS);
 | 
					            mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.EMAIL_TAKEN);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            clickInviteButton();
 | 
					            clickInviteButton();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -394,7 +375,9 @@ describe('InviteMembersModal', () => {
 | 
				
			||||||
          });
 | 
					          });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          it('clears the error when the list of members to invite is cleared', async () => {
 | 
					          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);
 | 
					            expect(findMembersSelect().props('validationState')).toBe(false);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            findMembersSelect().vm.$emit('clear');
 | 
					            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 () => {
 | 
					        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();
 | 
					          clickInviteButton();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          await waitForPromises();
 | 
					          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(findMembersSelect().props('validationState')).toBe(false);
 | 
				
			||||||
          expect(findModal().props('actionPrimary').attributes.loading).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 () => {
 | 
					        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();
 | 
					          clickInviteButton();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -455,7 +443,7 @@ describe('InviteMembersModal', () => {
 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        it('displays the restricted user api message for response with bad request', async () => {
 | 
					        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();
 | 
					          clickInviteButton();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -465,7 +453,7 @@ describe('InviteMembersModal', () => {
 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        it('displays the first part of the error when multiple existing users are restricted by email', async () => {
 | 
					        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();
 | 
					          clickInviteButton();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -476,19 +464,6 @@ describe('InviteMembersModal', () => {
 | 
				
			||||||
          );
 | 
					          );
 | 
				
			||||||
          expect(findMembersSelect().props('validationState')).toBe(false);
 | 
					          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]);
 | 
					          await triggerMembersTokenSelect([user3]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          wrapper.vm.$toast = { show: jest.fn() };
 | 
					          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', () => {
 | 
					        describe('when triggered from regular mounting', () => {
 | 
				
			||||||
| 
						 | 
					@ -517,8 +492,8 @@ describe('InviteMembersModal', () => {
 | 
				
			||||||
            clickInviteButton();
 | 
					            clickInviteButton();
 | 
				
			||||||
          });
 | 
					          });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          it('calls Api inviteGroupMembersByEmail with the correct params', () => {
 | 
					          it('calls Api inviteGroupMembers with the correct params', () => {
 | 
				
			||||||
            expect(Api.inviteGroupMembersByEmail).toHaveBeenCalledWith(propsData.id, postData);
 | 
					            expect(Api.inviteGroupMembers).toHaveBeenCalledWith(propsData.id, postData);
 | 
				
			||||||
          });
 | 
					          });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          it('displays the successful toastMessage', () => {
 | 
					          it('displays the successful toastMessage', () => {
 | 
				
			||||||
| 
						 | 
					@ -558,20 +533,8 @@ describe('InviteMembersModal', () => {
 | 
				
			||||||
          expect(findModal().props('actionPrimary').attributes.loading).toBe(false);
 | 
					          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 () => {
 | 
					        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();
 | 
					          clickInviteButton();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -618,19 +581,17 @@ describe('InviteMembersModal', () => {
 | 
				
			||||||
        format: 'json',
 | 
					        format: 'json',
 | 
				
			||||||
        tasks_to_be_done: [],
 | 
					        tasks_to_be_done: [],
 | 
				
			||||||
        tasks_project_id: '',
 | 
					        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', () => {
 | 
					      describe('when invites are sent successfully', () => {
 | 
				
			||||||
        beforeEach(async () => {
 | 
					        beforeEach(async () => {
 | 
				
			||||||
          createComponent();
 | 
					          createComponent();
 | 
				
			||||||
          await triggerMembersTokenSelect([user1, user3]);
 | 
					          await triggerMembersTokenSelect([user1, user3]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          wrapper.vm.$toast = { show: jest.fn() };
 | 
					          wrapper.vm.$toast = { show: jest.fn() };
 | 
				
			||||||
          jest.spyOn(Api, 'inviteGroupMembersByEmail').mockResolvedValue({ data: postData });
 | 
					          jest.spyOn(Api, 'inviteGroupMembers').mockResolvedValue({ data: postData });
 | 
				
			||||||
          jest.spyOn(Api, 'addGroupMembersByUserId').mockResolvedValue({ data: postData });
 | 
					 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        describe('when triggered from regular mounting', () => {
 | 
					        describe('when triggered from regular mounting', () => {
 | 
				
			||||||
| 
						 | 
					@ -638,12 +599,8 @@ describe('InviteMembersModal', () => {
 | 
				
			||||||
            clickInviteButton();
 | 
					            clickInviteButton();
 | 
				
			||||||
          });
 | 
					          });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          it('calls Api inviteGroupMembersByEmail with the correct params', () => {
 | 
					          it('calls Api inviteGroupMembers with the correct params', () => {
 | 
				
			||||||
            expect(Api.inviteGroupMembersByEmail).toHaveBeenCalledWith(propsData.id, emailPostData);
 | 
					            expect(Api.inviteGroupMembers).toHaveBeenCalledWith(propsData.id, postData);
 | 
				
			||||||
          });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
          it('calls Api addGroupMembersByUserId with the correct params', () => {
 | 
					 | 
				
			||||||
            expect(Api.addGroupMembersByUserId).toHaveBeenCalledWith(propsData.id, idPostData);
 | 
					 | 
				
			||||||
          });
 | 
					          });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          it('displays the successful toastMessage', () => {
 | 
					          it('displays the successful toastMessage', () => {
 | 
				
			||||||
| 
						 | 
					@ -656,12 +613,8 @@ describe('InviteMembersModal', () => {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          clickInviteButton();
 | 
					          clickInviteButton();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          expect(Api.inviteGroupMembersByEmail).toHaveBeenCalledWith(propsData.id, {
 | 
					          expect(Api.inviteGroupMembers).toHaveBeenCalledWith(propsData.id, {
 | 
				
			||||||
            ...emailPostData,
 | 
					            ...postData,
 | 
				
			||||||
            invite_source: '_invite_source_',
 | 
					 | 
				
			||||||
          });
 | 
					 | 
				
			||||||
          expect(Api.addGroupMembersByUserId).toHaveBeenCalledWith(propsData.id, {
 | 
					 | 
				
			||||||
            ...idPostData,
 | 
					 | 
				
			||||||
            invite_source: '_invite_source_',
 | 
					            invite_source: '_invite_source_',
 | 
				
			||||||
          });
 | 
					          });
 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
| 
						 | 
					@ -674,7 +627,6 @@ describe('InviteMembersModal', () => {
 | 
				
			||||||
          await triggerMembersTokenSelect([user1, user3]);
 | 
					          await triggerMembersTokenSelect([user1, user3]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          mockInvitationsApi(httpStatus.BAD_REQUEST, invitationsApiResponse.EMAIL_INVALID);
 | 
					          mockInvitationsApi(httpStatus.BAD_REQUEST, invitationsApiResponse.EMAIL_INVALID);
 | 
				
			||||||
          mockMembersApi(httpStatus.OK, '200 OK');
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
          clickInviteButton();
 | 
					          clickInviteButton();
 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
| 
						 | 
					@ -693,7 +645,7 @@ describe('InviteMembersModal', () => {
 | 
				
			||||||
        await triggerMembersTokenSelect([user3]);
 | 
					        await triggerMembersTokenSelect([user3]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        wrapper.vm.$toast = { show: jest.fn() };
 | 
					        wrapper.vm.$toast = { show: jest.fn() };
 | 
				
			||||||
        jest.spyOn(Api, 'inviteGroupMembersByEmail').mockResolvedValue({});
 | 
					        jest.spyOn(Api, 'inviteGroupMembers').mockResolvedValue({});
 | 
				
			||||||
      });
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      it('tracks the view for learn_gitlab source', () => {
 | 
					      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' },
 | 
					  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',
 | 
					  error: 'email contains an invalid email address',
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const INVITATIONS_API_EMAIL_RESTRICTED = {
 | 
					const EMAIL_RESTRICTED = {
 | 
				
			||||||
  message: {
 | 
					  message: {
 | 
				
			||||||
    'email@example.com':
 | 
					    '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.",
 | 
					      "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',
 | 
					  status: 'error',
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const INVITATIONS_API_MULTIPLE_EMAIL_RESTRICTED = {
 | 
					const MULTIPLE_RESTRICTED = {
 | 
				
			||||||
  message: {
 | 
					  message: {
 | 
				
			||||||
    'email@example.com':
 | 
					    '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.",
 | 
					      "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':
 | 
					    '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.",
 | 
					      "The member's email address is not allowed for this project. Go to the Admin area > Sign-up restrictions, and check the Domain denylist.",
 | 
				
			||||||
  },
 | 
					    root:
 | 
				
			||||||
  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: [
 | 
					 | 
				
			||||||
      "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.",
 | 
					      "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',
 | 
					  status: 'error',
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const apiPaths = {
 | 
					const EMAIL_TAKEN = {
 | 
				
			||||||
  GROUPS_MEMBERS: '/api/v4/groups/1/members',
 | 
					  message: {
 | 
				
			||||||
  GROUPS_INVITATIONS: '/api/v4/groups/1/invitations',
 | 
					    'email@example.org': "The member's email address has already been taken",
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  status: 'error',
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const membersApiResponse = {
 | 
					export const GROUPS_INVITATIONS_PATH = '/api/v4/groups/1/invitations';
 | 
				
			||||||
  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 invitationsApiResponse = {
 | 
					export const invitationsApiResponse = {
 | 
				
			||||||
  EMAIL_INVALID: INVITATIONS_API_EMAIL_INVALID,
 | 
					  EMAIL_INVALID,
 | 
				
			||||||
  ERROR_EMAIL_INVALID: INVITATIONS_API_ERROR_EMAIL_INVALID,
 | 
					  ERROR_EMAIL_INVALID,
 | 
				
			||||||
  EMAIL_RESTRICTED: INVITATIONS_API_EMAIL_RESTRICTED,
 | 
					  EMAIL_RESTRICTED,
 | 
				
			||||||
  MULTIPLE_EMAIL_RESTRICTED: INVITATIONS_API_MULTIPLE_EMAIL_RESTRICTED,
 | 
					  MULTIPLE_RESTRICTED,
 | 
				
			||||||
  EMAIL_TAKEN: INVITATIONS_API_EMAIL_TAKEN,
 | 
					  EMAIL_TAKEN,
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -2,23 +2,19 @@ import {
 | 
				
			||||||
  responseMessageFromSuccess,
 | 
					  responseMessageFromSuccess,
 | 
				
			||||||
  responseMessageFromError,
 | 
					  responseMessageFromError,
 | 
				
			||||||
} from '~/invite_members/utils/response_message_parser';
 | 
					} 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', () => {
 | 
					describe('Response message parser', () => {
 | 
				
			||||||
  const expectedMessage = 'expected display and message.';
 | 
					  const expectedMessage = 'expected display and message.';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  describe('parse message from successful response', () => {
 | 
					  describe('parse message from successful response', () => {
 | 
				
			||||||
    const exampleKeyedMsg = { 'email@example.com': expectedMessage };
 | 
					    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([
 | 
					    it.each([
 | 
				
			||||||
      [[{ data: { message: expectedMessage } }]],
 | 
					      [{ data: { message: expectedMessage } }],
 | 
				
			||||||
      [[{ data: { message: exampleFirstPartMultiple + exampleUserMsgMultiple } }]],
 | 
					      [{ data: { error: expectedMessage } }],
 | 
				
			||||||
      [[{ data: { error: expectedMessage } }]],
 | 
					      [{ data: { message: [expectedMessage] } }],
 | 
				
			||||||
      [[{ data: { message: [expectedMessage] } }]],
 | 
					      [{ data: { message: exampleKeyedMsg } }],
 | 
				
			||||||
      [[{ data: { message: exampleKeyedMsg } }]],
 | 
					 | 
				
			||||||
    ])(`returns "${expectedMessage}" from success response: %j`, (successResponse) => {
 | 
					    ])(`returns "${expectedMessage}" from success response: %j`, (successResponse) => {
 | 
				
			||||||
      expect(responseMessageFromSuccess(successResponse)).toBe(expectedMessage);
 | 
					      expect(responseMessageFromSuccess(successResponse)).toBe(expectedMessage);
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
| 
						 | 
					@ -27,8 +23,6 @@ describe('Response message parser', () => {
 | 
				
			||||||
  describe('message from error response', () => {
 | 
					  describe('message from error response', () => {
 | 
				
			||||||
    it.each([
 | 
					    it.each([
 | 
				
			||||||
      [{ response: { data: { error: expectedMessage } } }],
 | 
					      [{ response: { data: { error: expectedMessage } } }],
 | 
				
			||||||
      [{ response: { data: { message: { user: [expectedMessage] } } } }],
 | 
					 | 
				
			||||||
      [{ response: { data: { message: { access_level: [expectedMessage] } } } }],
 | 
					 | 
				
			||||||
      [{ response: { data: { message: { error: expectedMessage } } } }],
 | 
					      [{ response: { data: { message: { error: expectedMessage } } } }],
 | 
				
			||||||
      [{ response: { data: { message: expectedMessage } } }],
 | 
					      [{ response: { data: { message: expectedMessage } } }],
 | 
				
			||||||
    ])(`returns "${expectedMessage}" from error response: %j`, (errorResponse) => {
 | 
					    ])(`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.";
 | 
					      "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([
 | 
					    it.each([
 | 
				
			||||||
      [[{ data: membersApiResponse.MULTIPLE_USERS_RESTRICTED }]],
 | 
					      [{ data: invitationsApiResponse.MULTIPLE_RESTRICTED }],
 | 
				
			||||||
      [[{ data: invitationsApiResponse.MULTIPLE_EMAIL_RESTRICTED }]],
 | 
					      [{ data: invitationsApiResponse.EMAIL_RESTRICTED }],
 | 
				
			||||||
      [[{ data: invitationsApiResponse.EMAIL_RESTRICTED }]],
 | 
					 | 
				
			||||||
    ])(`returns "${expectedMessage}" from success response: %j`, (restrictedResponse) => {
 | 
					    ])(`returns "${expectedMessage}" from success response: %j`, (restrictedResponse) => {
 | 
				
			||||||
      expect(responseMessageFromSuccess(restrictedResponse)).toBe(expected);
 | 
					      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 Helpers
 | 
				
			||||||
      module Features
 | 
					      module Features
 | 
				
			||||||
        module InviteMembersModalHelper
 | 
					        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'
 | 
					            click_on 'Invite members'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            page.within invite_modal_selector do
 | 
					            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)
 | 
					              choose_options(role, expires_at)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
              click_button 'Invite'
 | 
					              click_button 'Invite'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
              page.refresh
 | 
					              page.refresh if refresh
 | 
				
			||||||
            end
 | 
					            end
 | 
				
			||||||
          end
 | 
					          end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,14 +1,48 @@
 | 
				
			||||||
# frozen_string_literal: true
 | 
					# frozen_string_literal: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
RSpec.shared_examples 'edits content using the content editor' do
 | 
					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].ProseMirror'
 | 
				
			||||||
    content_editor_testid = '[data-testid="content-editor"] [contenteditable]'
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    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 'Typing text in the content editor'
 | 
				
			||||||
    find(content_editor_testid).send_keys [:shift, :left]
 | 
					      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
 | 
				
			||||||
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