Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2022-03-23 12:07:27 +00:00
parent 52192e0f19
commit 3e68d38487
75 changed files with 954 additions and 967 deletions

View File

@ -1,9 +1,11 @@
<script>
import SharedDeleteAction from './shared/shared_delete_action.vue';
import { GlDropdownItem } from '@gitlab/ui';
import { s__ } from '~/locale';
import eventHub, { EVENT_OPEN_DELETE_USER_MODAL } from '../modals/delete_user_modal_event_hub';
export default {
components: {
SharedDeleteAction,
GlDropdownItem,
},
props: {
username: {
@ -20,17 +22,32 @@ export default {
default: () => [],
},
},
methods: {
onClick() {
const { username, paths, userDeletionObstacles } = this;
eventHub.$emit(EVENT_OPEN_DELETE_USER_MODAL, {
username,
blockPath: paths.block,
deletePath: paths.delete,
userDeletionObstacles,
i18n: {
title: s__('AdminUsers|Delete User %{username}?'),
primaryButtonLabel: s__('AdminUsers|Delete user'),
messageBody: s__(`AdminUsers|You are about to permanently delete the user %{username}. Issues, merge requests,
and groups linked to them will be transferred to a system-wide "Ghost-user". To avoid data loss,
consider using the %{strongStart}block user%{strongEnd} feature instead. Once you %{strongStart}Delete user%{strongEnd},
it cannot be undone or recovered.`),
},
});
},
},
};
</script>
<template>
<shared-delete-action
modal-type="delete"
:username="username"
:paths="paths"
:delete-path="paths.delete"
:user-deletion-obstacles="userDeletionObstacles"
>
<slot></slot>
</shared-delete-action>
<gl-dropdown-item @click="onClick">
<span class="gl-text-red-500">
<slot></slot>
</span>
</gl-dropdown-item>
</template>

View File

@ -1,9 +1,11 @@
<script>
import SharedDeleteAction from './shared/shared_delete_action.vue';
import { GlDropdownItem } from '@gitlab/ui';
import { s__ } from '~/locale';
import eventHub, { EVENT_OPEN_DELETE_USER_MODAL } from '../modals/delete_user_modal_event_hub';
export default {
components: {
SharedDeleteAction,
GlDropdownItem,
},
props: {
username: {
@ -20,17 +22,32 @@ export default {
default: () => [],
},
},
methods: {
onClick() {
const { username, paths, userDeletionObstacles } = this;
eventHub.$emit(EVENT_OPEN_DELETE_USER_MODAL, {
username,
blockPath: paths.block,
deletePath: paths.deleteWithContributions,
userDeletionObstacles,
i18n: {
title: s__('AdminUsers|Delete User %{username} and contributions?'),
primaryButtonLabel: s__('AdminUsers|Delete user and contributions'),
messageBody: s__(`AdminUsers|You are about to permanently delete the user %{username}. This will delete all of the issues,
merge requests, and groups linked to them. To avoid data loss,
consider using the %{strongStart}block user%{strongEnd} feature instead. Once you %{strongStart}Delete user%{strongEnd},
it cannot be undone or recovered.`),
},
});
},
},
};
</script>
<template>
<shared-delete-action
modal-type="delete-with-contributions"
:username="username"
:paths="paths"
:delete-path="paths.deleteWithContributions"
:user-deletion-obstacles="userDeletionObstacles"
>
<slot></slot>
</shared-delete-action>
<gl-dropdown-item @click="onClick">
<span class="gl-text-red-500">
<slot></slot>
</span>
</gl-dropdown-item>
</template>

View File

@ -1,52 +0,0 @@
<script>
import { GlDropdownItem } from '@gitlab/ui';
export default {
components: {
GlDropdownItem,
},
props: {
username: {
type: String,
required: true,
},
paths: {
type: Object,
required: true,
},
deletePath: {
type: String,
required: true,
},
modalType: {
type: String,
required: true,
},
userDeletionObstacles: {
type: Array,
required: true,
},
},
computed: {
modalAttributes() {
return {
'data-block-user-url': this.paths.block,
'data-delete-user-url': this.deletePath,
'data-gl-modal-action': this.modalType,
'data-username': this.username,
'data-user-deletion-obstacles': JSON.stringify(this.userDeletionObstacles),
};
},
},
};
</script>
<template>
<div class="js-delete-user-modal-button" v-bind="{ ...modalAttributes }">
<gl-dropdown-item>
<span class="gl-text-red-500">
<slot></slot>
</span>
</gl-dropdown-item>
</div>
</template>

View File

@ -1,8 +1,8 @@
<script>
import { GlModal, GlButton, GlFormInput, GlSprintf } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import { s__, sprintf } from '~/locale';
import UserDeletionObstaclesList from '~/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue';
import eventHub, { EVENT_OPEN_DELETE_USER_MODAL } from './delete_user_modal_event_hub';
export default {
components: {
@ -13,47 +13,23 @@ export default {
UserDeletionObstaclesList,
},
props: {
title: {
type: String,
required: true,
},
content: {
type: String,
required: true,
},
action: {
type: String,
required: true,
},
secondaryAction: {
type: String,
required: true,
},
deleteUserUrl: {
type: String,
required: true,
},
blockUserUrl: {
type: String,
required: true,
},
username: {
type: String,
required: true,
},
csrfToken: {
type: String,
required: true,
},
userDeletionObstacles: {
type: String,
required: false,
default: '[]',
},
},
data() {
return {
enteredUsername: '',
username: '',
blockPath: '',
deletePath: '',
userDeletionObstacles: [],
i18n: {
title: '',
primaryButtonLabel: '',
messageBody: '',
},
};
},
computed: {
@ -61,75 +37,80 @@ export default {
return this.username.trim();
},
modalTitle() {
return sprintf(this.title, { username: this.trimmedUsername }, false);
return sprintf(this.i18n.title, { username: this.trimmedUsername }, false);
},
canSubmit() {
return this.enteredUsername && this.enteredUsername === this.trimmedUsername;
},
secondaryButtonLabel() {
return s__('AdminUsers|Block user');
},
canSubmit() {
return this.enteredUsername === this.trimmedUsername;
},
obstacles() {
try {
return JSON.parse(this.userDeletionObstacles);
} catch (e) {
Sentry.captureException(e);
}
return [];
},
},
mounted() {
eventHub.$on(EVENT_OPEN_DELETE_USER_MODAL, this.onOpenEvent);
},
destroyed() {
eventHub.$off(EVENT_OPEN_DELETE_USER_MODAL, this.onOpenEvent);
},
methods: {
show() {
onOpenEvent({ username, blockPath, deletePath, userDeletionObstacles, i18n }) {
this.username = username;
this.blockPath = blockPath;
this.deletePath = deletePath;
this.userDeletionObstacles = userDeletionObstacles;
this.i18n = i18n;
this.openModal();
},
openModal() {
this.$refs.modal.show();
},
onSubmit() {
this.$refs.form.submit();
this.enteredUsername = '';
},
onCancel() {
this.enteredUsername = '';
this.$refs.modal.hide();
},
onSecondaryAction() {
const { form } = this.$refs;
form.action = this.blockUserUrl;
form.action = this.blockPath;
this.$refs.method.value = 'put';
form.submit();
},
onSubmit() {
this.$refs.form.submit();
this.enteredUsername = '';
},
},
};
</script>
<template>
<gl-modal ref="modal" modal-id="delete-user-modal" :title="modalTitle" kind="danger">
<p>
<gl-sprintf :message="content">
<gl-sprintf :message="i18n.messageBody">
<template #username>
<strong>{{ trimmedUsername }}</strong>
<strong data-testid="message-username">{{ trimmedUsername }}</strong>
</template>
<template #strong="props">
<strong>{{ props.content }}</strong>
<template #strong="{ content }">
<strong>{{ content }}</strong>
</template>
</gl-sprintf>
</p>
<user-deletion-obstacles-list
v-if="obstacles.length"
:obstacles="obstacles"
v-if="userDeletionObstacles.length"
:obstacles="userDeletionObstacles"
:user-name="trimmedUsername"
/>
<p>
<gl-sprintf :message="s__('AdminUsers|To confirm, type %{username}')">
<template #username>
<code class="gl-white-space-pre-wrap">{{ trimmedUsername }}</code>
<code data-testid="confirm-username" class="gl-white-space-pre-wrap">{{
trimmedUsername
}}</code>
</template>
</gl-sprintf>
</p>
<form ref="form" :action="deleteUserUrl" method="post" @submit.prevent>
<form ref="form" :action="deletePath" method="post" @submit.prevent>
<input ref="method" type="hidden" name="_method" value="delete" />
<input :value="csrfToken" type="hidden" name="authenticity_token" />
<gl-form-input
@ -140,6 +121,7 @@ export default {
autocomplete="off"
/>
</form>
<template #modal-footer>
<gl-button @click="onCancel">{{ __('Cancel') }}</gl-button>
<gl-button
@ -148,10 +130,10 @@ export default {
variant="danger"
@click="onSecondaryAction"
>
{{ secondaryAction }}
{{ secondaryButtonLabel }}
</gl-button>
<gl-button :disabled="!canSubmit" category="primary" variant="danger" @click="onSubmit">{{
action
i18n.primaryButtonLabel
}}</gl-button>
</template>
</gl-modal>

View File

@ -0,0 +1,5 @@
import createEventHub from '~/helpers/event_hub_factory';
export default createEventHub();
export const EVENT_OPEN_DELETE_USER_MODAL = Symbol('OPEN');

View File

@ -1,77 +0,0 @@
<script>
import DeleteUserModal from './delete_user_modal.vue';
export default {
components: { DeleteUserModal },
props: {
modalConfiguration: {
required: true,
type: Object,
},
csrfToken: {
required: true,
type: String,
},
selector: {
required: true,
type: String,
},
},
data() {
return {
currentModalData: null,
};
},
computed: {
activeModal() {
return Boolean(this.currentModalData);
},
modalProps() {
const { glModalAction: requestedAction } = this.currentModalData;
return {
...this.modalConfiguration[requestedAction],
...this.currentModalData,
csrfToken: this.csrfToken,
};
},
},
mounted() {
/*
* Here we're looking for every button that needs to launch a modal
* on click, and then attaching a click event handler to show the modal
* if it's correctly configured.
*
* TODO: Replace this with integrated modal components https://gitlab.com/gitlab-org/gitlab/-/issues/320922
*/
document.querySelectorAll(this.selector).forEach((button) => {
button.addEventListener('click', (e) => {
if (!button.dataset.glModalAction) return;
e.preventDefault();
this.show(button.dataset);
});
});
},
methods: {
show(modalData) {
const { glModalAction: requestedAction } = modalData;
if (!this.modalConfiguration[requestedAction]) {
throw new Error(`Modal action ${requestedAction} has no configuration in HTML`);
}
this.currentModalData = modalData;
return this.$nextTick().then(() => {
this.$refs.modal.show();
});
},
},
};
</script>
<template>
<delete-user-modal v-if="activeModal" ref="modal" v-bind="modalProps" />
</template>

View File

@ -20,9 +20,3 @@ export const I18N_USER_ACTIONS = {
ban: s__('AdminUsers|Ban user'),
unban: s__('AdminUsers|Unban user'),
};
export const CONFIRM_DELETE_BUTTON_SELECTOR = '.js-delete-user-modal-button';
export const MODAL_TEXTS_CONTAINER_SELECTOR = '#js-modal-texts';
export const MODAL_MANAGER_SELECTOR = '#js-delete-user-modal';

View File

@ -4,13 +4,8 @@ import createDefaultClient from '~/lib/graphql';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import csrf from '~/lib/utils/csrf';
import AdminUsersApp from './components/app.vue';
import ModalManager from './components/modals/user_modal_manager.vue';
import DeleteUserModal from './components/modals/delete_user_modal.vue';
import UserActions from './components/user_actions.vue';
import {
CONFIRM_DELETE_BUTTON_SELECTOR,
MODAL_TEXTS_CONTAINER_SELECTOR,
MODAL_MANAGER_SELECTOR,
} from './constants';
Vue.use(VueApollo);
@ -46,43 +41,13 @@ export const initAdminUserActions = (el = document.querySelector('#js-admin-user
initApp(el, UserActions, 'user', { showButtonLabels: true });
export const initDeleteUserModals = () => {
const modalsMountElement = document.querySelector(MODAL_TEXTS_CONTAINER_SELECTOR);
if (!modalsMountElement) {
return;
}
const modalConfiguration = Array.from(modalsMountElement.children).reduce((accumulator, node) => {
const { modal, ...config } = node.dataset;
return {
...accumulator,
[modal]: {
title: node.dataset.title,
...config,
content: node.innerHTML,
},
};
}, {});
// eslint-disable-next-line no-new
new Vue({
el: MODAL_MANAGER_SELECTOR,
return new Vue({
functional: true,
methods: {
show(...args) {
this.$refs.manager.show(...args);
},
},
render(h) {
return h(ModalManager, {
ref: 'manager',
render: (createElement) =>
createElement(DeleteUserModal, {
props: {
selector: CONFIRM_DELETE_BUTTON_SELECTOR,
modalConfiguration,
csrfToken: csrf.token,
},
});
},
});
}),
}).$mount();
};

View File

@ -30,6 +30,7 @@ export default {
>
<div
v-if="isLoading"
data-testid="content-editor-loading-indicator"
class="gl-w-full gl-display-flex gl-justify-content-center gl-align-items-center gl-absolute gl-top-0 gl-bottom-0"
>
<div class="gl-bg-white gl-absolute gl-w-full gl-h-full gl-opacity-3"></div>

View File

@ -1,11 +1,33 @@
import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight';
import { lowlight } from 'lowlight/lib/all';
import { textblockTypeInputRule } from '@tiptap/core';
import { isFunction } from 'lodash';
const extractLanguage = (element) => element.getAttribute('lang');
const backtickInputRegex = /^```([a-z]+)?[\s\n]$/;
const tildeInputRegex = /^~~~([a-z]+)?[\s\n]$/;
const loadLanguageFromInputRule = (languageLoader) => (match) => {
const language = match[1];
if (isFunction(languageLoader?.loadLanguages)) {
languageLoader.loadLanguages([language]);
}
return {
language,
};
};
export default CodeBlockLowlight.extend({
isolating: true,
addOptions() {
return {
...this.parent?.(),
languageLoader: {},
};
},
addAttributes() {
return {
language: {
@ -18,6 +40,22 @@ export default CodeBlockLowlight.extend({
},
};
},
addInputRules() {
const { languageLoader } = this.options;
return [
textblockTypeInputRule({
find: backtickInputRegex,
type: this.type,
getAttributes: loadLanguageFromInputRule(languageLoader),
}),
textblockTypeInputRule({
find: tildeInputRegex,
type: this.type,
getAttributes: loadLanguageFromInputRule(languageLoader),
}),
];
},
renderHTML({ HTMLAttributes }) {
return [
'pre',
@ -28,6 +66,4 @@ export default CodeBlockLowlight.extend({
['code', {}, 0],
];
},
}).configure({
lowlight,
});

View File

@ -0,0 +1,35 @@
export default class CodeBlockLanguageLoader {
constructor(lowlight) {
this.lowlight = lowlight;
}
isLanguageLoaded(language) {
return this.lowlight.registered(language);
}
loadLanguagesFromDOM(domTree) {
const languages = [];
domTree.querySelectorAll('pre').forEach((preElement) => {
languages.push(preElement.getAttribute('lang'));
});
return this.loadLanguages(languages);
}
loadLanguages(languageList = []) {
const loaders = languageList
.filter((languageName) => !this.isLanguageLoaded(languageName))
.map((languageName) => {
return import(
/* webpackChunkName: 'highlight.language.js' */ `highlight.js/lib/languages/${languageName}`
)
.then(({ default: language }) => {
this.lowlight.registerLanguage(languageName, language);
})
.catch(() => false);
});
return Promise.all(loaders);
}
}

View File

@ -3,11 +3,12 @@ import { LOADING_CONTENT_EVENT, LOADING_SUCCESS_EVENT, LOADING_ERROR_EVENT } fro
/* eslint-disable no-underscore-dangle */
export class ContentEditor {
constructor({ tiptapEditor, serializer, deserializer, eventHub }) {
constructor({ tiptapEditor, serializer, deserializer, eventHub, languageLoader }) {
this._tiptapEditor = tiptapEditor;
this._serializer = serializer;
this._deserializer = deserializer;
this._eventHub = eventHub;
this._languageLoader = languageLoader;
}
get tiptapEditor() {
@ -34,23 +35,33 @@ export class ContentEditor {
}
async setSerializedContent(serializedContent) {
const { _tiptapEditor: editor, _deserializer: deserializer, _eventHub: eventHub } = this;
const {
_tiptapEditor: editor,
_deserializer: deserializer,
_eventHub: eventHub,
_languageLoader: languageLoader,
} = this;
const { doc, tr } = editor.state;
const selection = TextSelection.create(doc, 0, doc.content.size);
try {
eventHub.$emit(LOADING_CONTENT_EVENT);
const { document } = await deserializer.deserialize({
const result = await deserializer.deserialize({
schema: editor.schema,
content: serializedContent,
});
if (document) {
if (Object.keys(result).length !== 0) {
const { document, dom } = result;
await languageLoader.loadLanguagesFromDOM(dom);
tr.setSelection(selection)
.replaceSelectionWith(document, false)
.setMeta('preventUpdate', true);
editor.view.dispatch(tr);
}
eventHub.$emit(LOADING_SUCCESS_EVENT);
} catch (e) {
eventHub.$emit(LOADING_ERROR_EVENT, e);

View File

@ -1,5 +1,6 @@
import { Editor } from '@tiptap/vue-2';
import { isFunction } from 'lodash';
import { lowlight } from 'lowlight/lib/core';
import eventHubFactory from '~/helpers/event_hub_factory';
import { PROVIDE_SERIALIZER_OR_RENDERER_ERROR } from '../constants';
import Attachment from '../extensions/attachment';
@ -58,6 +59,7 @@ import { ContentEditor } from './content_editor';
import createMarkdownSerializer from './markdown_serializer';
import createMarkdownDeserializer from './markdown_deserializer';
import trackInputRulesAndShortcuts from './track_input_rules_and_shortcuts';
import CodeBlockLanguageLoader from './code_block_language_loader';
const createTiptapEditor = ({ extensions = [], ...options } = {}) =>
new Editor({
@ -83,6 +85,7 @@ export const createContentEditor = ({
const eventHub = eventHubFactory();
const languageLoader = new CodeBlockLanguageLoader(lowlight);
const builtInContentEditorExtensions = [
Attachment.configure({ uploadsPath, renderMarkdown, eventHub }),
Audio,
@ -91,7 +94,7 @@ export const createContentEditor = ({
BulletList,
Code,
ColorChip,
CodeBlockHighlight,
CodeBlockHighlight.configure({ lowlight, languageLoader }),
DescriptionItem,
DescriptionList,
Details,
@ -105,7 +108,7 @@ export const createContentEditor = ({
FootnoteDefinition,
FootnoteReference,
FootnotesSection,
Frontmatter,
Frontmatter.configure({ lowlight }),
Gapcursor,
HardBreak,
Heading,
@ -144,5 +147,5 @@ export const createContentEditor = ({
const serializer = createMarkdownSerializer({ serializerConfig });
const deserializer = createMarkdownDeserializer({ render: renderMarkdown });
return new ContentEditor({ tiptapEditor, serializer, eventHub, deserializer });
return new ContentEditor({ tiptapEditor, serializer, eventHub, deserializer, languageLoader });
};

View File

@ -2,8 +2,6 @@ import { GlBreakpointInstance as bp, breakpoints } from '@gitlab/ui/dist/utils';
import $ from 'jquery';
import { debounce } from 'lodash';
import { getCookie, setCookie, parseBoolean } from '~/lib/utils/common_utils';
import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger';
export const SIDEBAR_COLLAPSED_CLASS = 'js-sidebar-collapsed';
@ -114,7 +112,26 @@ export default class ContextualSidebar {
this.toggleCollapsedSidebar(collapse, true);
}
initInviteMembersModal();
initInviteMembersTrigger();
const modalEl = document.querySelector('.js-invite-members-modal');
if (modalEl) {
import(
/* webpackChunkName: 'initInviteMembersModal' */ '~/invite_members/init_invite_members_modal'
)
.then(({ default: initInviteMembersModal }) => {
initInviteMembersModal();
})
.catch(() => {});
const inviteTriggers = document.querySelectorAll('.js-invite-members-trigger');
if (inviteTriggers) {
import(
/* webpackChunkName: 'initInviteMembersTrigger' */ '~/invite_members/init_invite_members_trigger'
)
.then(({ default: initInviteMembersTrigger }) => {
initInviteMembersTrigger();
})
.catch(() => {});
}
}
}
}

View File

@ -3,10 +3,10 @@ import terminalModule from '../modules/terminal';
function getPathsFromData(el) {
return {
webTerminalSvgPath: el.dataset.eeWebTerminalSvgPath,
webTerminalHelpPath: el.dataset.eeWebTerminalHelpPath,
webTerminalConfigHelpPath: el.dataset.eeWebTerminalConfigHelpPath,
webTerminalRunnersHelpPath: el.dataset.eeWebTerminalRunnersHelpPath,
webTerminalSvgPath: el.dataset.webTerminalSvgPath,
webTerminalHelpPath: el.dataset.webTerminalHelpPath,
webTerminalConfigHelpPath: el.dataset.webTerminalConfigHelpPath,
webTerminalRunnersHelpPath: el.dataset.webTerminalRunnersHelpPath,
};
}

View File

@ -1,9 +1,12 @@
import Vue from 'vue';
import { GlToast } from '@gitlab/ui';
import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils';
import IntegrationForm from './components/integration_form.vue';
import { createStore } from './store';
Vue.use(GlToast);
function parseBooleanInData(data) {
const result = {};
Object.entries(data).forEach(([key, value]) => {

View File

@ -44,6 +44,10 @@ export default {
type: String,
required: true,
},
rootId: {
type: String,
required: true,
},
isProject: {
type: Boolean,
required: true,
@ -290,6 +294,8 @@ export default {
:submit-disabled="inviteDisabled"
:invalid-feedback-message="invalidFeedbackMessage"
:is-loading="isLoading"
:new-users-to-invite="newUsersToInvite"
:root-group-id="rootId"
@reset="resetFields"
@submit="sendInvite"
@access-level="onAccessLevelUpdate"

View File

@ -5,45 +5,42 @@ import { parseBoolean } from '~/lib/utils/common_utils';
Vue.use(GlToast);
let initedInviteMembersModal;
export default (function initInviteMembersModal() {
let inviteMembersModal;
export default function initInviteMembersModal() {
if (initedInviteMembersModal) {
// if we already loaded this in another part of the dom, we don't want to do it again
// else we will stack the modals
return false;
}
return () => {
if (!inviteMembersModal) {
// https://gitlab.com/gitlab-org/gitlab/-/issues/344955
// bug lying in wait here for someone to put group and project invite in same screen
// once that happens we'll need to mount these differently, perhaps split
// group/project to each mount one, with many ways to open it.
const el = document.querySelector('.js-invite-members-modal');
// https://gitlab.com/gitlab-org/gitlab/-/issues/344955
// bug lying in wait here for someone to put group and project invite in same screen
// once that happens we'll need to mount these differently, perhaps split
// group/project to each mount one, with many ways to open it.
const el = document.querySelector('.js-invite-members-modal');
if (!el) {
return false;
}
if (!el) {
return false;
}
initedInviteMembersModal = true;
return new Vue({
el,
name: 'InviteMembersModalRoot',
provide: {
newProjectPath: el.dataset.newProjectPath,
},
render: (createElement) =>
createElement(InviteMembersModal, {
props: {
...el.dataset,
isProject: parseBoolean(el.dataset.isProject),
accessLevels: JSON.parse(el.dataset.accessLevels),
defaultAccessLevel: parseInt(el.dataset.defaultAccessLevel, 10),
tasksToBeDoneOptions: JSON.parse(el.dataset.tasksToBeDoneOptions || '[]'),
projects: JSON.parse(el.dataset.projects || '[]'),
usersFilter: el.dataset.usersFilter,
filterId: parseInt(el.dataset.filterId, 10),
inviteMembersModal = new Vue({
el,
name: 'InviteMembersModalRoot',
provide: {
newProjectPath: el.dataset.newProjectPath,
},
}),
});
}
render: (createElement) =>
createElement(InviteMembersModal, {
props: {
...el.dataset,
isProject: parseBoolean(el.dataset.isProject),
accessLevels: JSON.parse(el.dataset.accessLevels),
defaultAccessLevel: parseInt(el.dataset.defaultAccessLevel, 10),
tasksToBeDoneOptions: JSON.parse(el.dataset.tasksToBeDoneOptions || '[]'),
projects: JSON.parse(el.dataset.projects || '[]'),
usersFilter: el.dataset.usersFilter,
filterId: parseInt(el.dataset.filterId, 10),
},
}),
});
}
return inviteMembersModal;
};
})();

View File

@ -43,7 +43,7 @@ export default class CreateMergeRequestDropdown {
this.refInput = this.wrapperEl.querySelector('.js-ref');
this.refMessage = this.wrapperEl.querySelector('.js-ref-message');
this.unavailableButton = this.wrapperEl.querySelector('.unavailable');
this.unavailableButtonSpinner = this.unavailableButton.querySelector('.gl-spinner');
this.unavailableButtonSpinner = this.unavailableButton.querySelector('.js-create-mr-spinner');
this.unavailableButtonText = this.unavailableButton.querySelector('.text');
this.branchCreated = false;
@ -462,10 +462,10 @@ export default class CreateMergeRequestDropdown {
setUnavailableButtonState(isLoading = true) {
if (isLoading) {
this.unavailableButtonSpinner.classList.remove('hide');
this.unavailableButtonSpinner.classList.remove('gl-display-none');
this.unavailableButtonText.textContent = __('Checking branch availability...');
} else {
this.unavailableButtonSpinner.classList.add('hide');
this.unavailableButtonSpinner.classList.add('gl-display-none');
this.unavailableButtonText.textContent = __('New branch unavailable');
}
}

View File

@ -234,9 +234,6 @@ export default {
closeWorkItemDetailModal() {
this.workItemId = null;
},
handleWorkItemDetailModalError(message) {
createFlash({ message });
},
handleCreateTask(description) {
this.$emit('updateDescription', description);
this.closeCreateTaskModal();
@ -298,7 +295,6 @@ export default {
:visible="showWorkItemDetailModal"
:work-item-id="workItemId"
@close="closeWorkItemDetailModal"
@error="handleWorkItemDetailModalError"
/>
<template v-if="workItemsEnabled">
<gl-tooltip v-for="item in taskButtons" :key="item" :target="item">

View File

@ -1,5 +1,6 @@
import Vue from 'vue';
import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import LearnGitlab from '../components/learn_gitlab.vue';
@ -24,5 +25,7 @@ function initLearnGitlab() {
});
}
initLearnGitlab();
initInviteMembersModal();
initInviteMembersTrigger();
initLearnGitlab();

View File

@ -2,10 +2,7 @@
import { escape } from 'lodash';
import { __ } from '~/locale';
import { WI_TITLE_TRACK_LABEL } from '../constants';
export default {
WI_TITLE_TRACK_LABEL,
props: {
initialTitle: {
type: String,
@ -50,7 +47,6 @@ export default {
<h2
class="gl-font-weight-normal gl-sm-font-weight-bold gl-my-5 gl-display-inline-block"
:class="{ 'gl-cursor-not-allowed': disabled }"
data-testid="title"
aria-labelledby="item-title"
>
<span
@ -59,7 +55,6 @@ export default {
role="textbox"
:aria-label="__('Title')"
:data-placeholder="placeholder"
:data-track-label="$options.WI_TITLE_TRACK_LABEL"
:contenteditable="!disabled"
class="gl-pseudo-placeholder"
@blur="handleBlur"

View File

@ -1,14 +1,15 @@
<script>
import { GlModal, GlLoadingIcon } from '@gitlab/ui';
import { s__ } from '~/locale';
import { GlAlert, GlModal } from '@gitlab/ui';
import { i18n } from '../constants';
import workItemQuery from '../graphql/work_item.query.graphql';
import ItemTitle from './item_title.vue';
import WorkItemTitle from './work_item_title.vue';
export default {
i18n,
components: {
GlAlert,
GlModal,
GlLoadingIcon,
ItemTitle,
WorkItemTitle,
},
props: {
visible: {
@ -23,6 +24,7 @@ export default {
},
data() {
return {
error: undefined,
workItem: {},
};
},
@ -34,23 +36,17 @@ export default {
id: this.workItemId,
};
},
update(data) {
return data.workItem;
},
skip() {
return !this.workItemId;
},
error() {
this.$emit(
'error',
s__('WorkItem|Something went wrong when fetching the work item. Please try again.'),
);
this.error = this.$options.i18n.fetchError;
},
},
},
computed: {
workItemTitle() {
return this.workItem?.title;
workItemType() {
return this.workItem.workItemType?.name;
},
},
};
@ -58,7 +54,16 @@ export default {
<template>
<gl-modal hide-footer modal-id="work-item-detail-modal" :visible="visible" @hide="$emit('close')">
<gl-loading-icon v-if="$apollo.queries.workItem.loading" size="md" />
<item-title v-else class="gl-m-0!" :initial-title="workItemTitle" />
<gl-alert v-if="error" variant="danger" @dismiss="error = false">
{{ error }}
</gl-alert>
<work-item-title
:loading="$apollo.queries.workItem.loading"
:work-item-id="workItem.id"
:work-item-title="workItem.title"
:work-item-type="workItemType"
@error="error = $event"
/>
</gl-modal>
</template>

View File

@ -0,0 +1,73 @@
<script>
import { GlLoadingIcon } from '@gitlab/ui';
import Tracking from '~/tracking';
import { i18n } from '../constants';
import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
import ItemTitle from './item_title.vue';
export default {
components: {
GlLoadingIcon,
ItemTitle,
},
mixins: [Tracking.mixin()],
props: {
loading: {
type: Boolean,
required: false,
default: false,
},
workItemId: {
type: String,
required: false,
default: '',
},
workItemTitle: {
type: String,
required: false,
default: '',
},
workItemType: {
type: String,
required: false,
default: '',
},
},
computed: {
tracking() {
return {
category: 'workItems:show',
label: 'item_title',
property: `type_${this.workItemType}`,
};
},
},
methods: {
async updateWorkItem(updatedTitle) {
if (updatedTitle === this.workItemTitle) {
return;
}
try {
await this.$apollo.mutate({
mutation: updateWorkItemMutation,
variables: {
input: {
id: this.workItemId,
title: updatedTitle,
},
},
});
this.track('updated_title');
} catch {
this.$emit('error', i18n.updateError);
}
},
},
};
</script>
<template>
<gl-loading-icon v-if="loading" class="gl-mt-3" size="md" />
<item-title v-else :initial-title="workItemTitle" @title-changed="updateWorkItem" />
</template>

View File

@ -1,5 +1,10 @@
import { s__ } from '~/locale';
export const i18n = {
fetchError: s__('WorkItem|Something went wrong when fetching the work item. Please try again.'),
updateError: s__('WorkItem|Something went wrong while updating the work item. Please try again.'),
};
export const widgetTypes = {
title: 'TITLE',
};
export const WI_TITLE_TRACK_LABEL = 'item_title';

View File

@ -1,18 +1,9 @@
#import './widget.fragment.graphql'
#import "./work_item.fragment.graphql"
mutation createWorkItem($input: WorkItemCreateInput!) {
workItemCreate(input: $input) {
workItem {
id
title
workItemType {
id
}
widgets @client {
nodes {
...WidgetBase
}
}
...WorkItem
}
}
}

View File

@ -23,12 +23,16 @@ export function createApolloProvider() {
id: 'gid://gitlab/WorkItem/1',
},
data: {
localWorkItem: {
__typename: 'LocalWorkItem',
workItem: {
__typename: 'WorkItem',
id: 'gid://gitlab/WorkItem/1',
type: 'FEATURE',
// eslint-disable-next-line @gitlab/require-i18n-strings
title: 'Test Work Item',
workItemType: {
__typename: 'WorkItemType',
id: 'work-item-type-1',
name: 'Type', // eslint-disable-line @gitlab/require-i18n-strings
},
widgets: {
__typename: 'LocalWorkItemWidgetConnection',
nodes: [],

View File

@ -1,18 +1,9 @@
#import './widget.fragment.graphql'
#import "./work_item.fragment.graphql"
mutation workItemUpdate($input: WorkItemUpdateInput!) {
workItemUpdate(input: $input) {
workItem {
id
title
workItemType {
id
}
widgets @client {
nodes {
...WidgetBase
}
}
...WorkItem
}
}
}

View File

@ -0,0 +1,8 @@
fragment WorkItem on WorkItem {
id
title
workItemType {
id
name
}
}

View File

@ -1,16 +1,7 @@
#import './widget.fragment.graphql'
#import "./work_item.fragment.graphql"
query WorkItem($id: ID!) {
query workItem($id: ID!) {
workItem(id: $id) {
id
title
workItemType {
id
}
widgets @client {
nodes {
...WidgetBase
}
}
...WorkItem
}
}

View File

@ -1,23 +1,17 @@
<script>
import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
import { GlAlert } from '@gitlab/ui';
import { TYPE_WORK_ITEM } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import Tracking from '~/tracking';
import { i18n } from '../constants';
import workItemQuery from '../graphql/work_item.query.graphql';
import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
import { WI_TITLE_TRACK_LABEL } from '../constants';
import ItemTitle from '../components/item_title.vue';
const trackingMixin = Tracking.mixin();
import WorkItemTitle from '../components/work_item_title.vue';
export default {
titleUpdatedEvent: 'updated_title',
i18n,
components: {
ItemTitle,
GlAlert,
GlLoadingIcon,
WorkItemTitle,
},
mixins: [trackingMixin],
props: {
id: {
type: String,
@ -27,7 +21,7 @@ export default {
data() {
return {
workItem: {},
error: false,
error: undefined,
};
},
apollo: {
@ -38,37 +32,17 @@ export default {
id: this.gid,
};
},
error() {
this.error = this.$options.i18n.fetchError;
},
},
},
computed: {
tracking() {
return {
category: 'workItems:show',
action: 'updated_title',
label: WI_TITLE_TRACK_LABEL,
property: '[type_work_item]',
};
},
gid() {
return convertToGraphQLId('WorkItem', this.id);
return convertToGraphQLId(TYPE_WORK_ITEM, this.id);
},
},
methods: {
async updateWorkItem(updatedTitle) {
try {
await this.$apollo.mutate({
mutation: updateWorkItemMutation,
variables: {
input: {
id: this.gid,
title: updatedTitle,
},
},
});
this.track();
} catch {
this.error = true;
}
workItemType() {
return this.workItem.workItemType?.name;
},
},
};
@ -76,23 +50,16 @@ export default {
<template>
<section>
<gl-alert v-if="error" variant="danger" @dismiss="error = false">{{
__('Something went wrong while updating work item. Please try again')
}}</gl-alert>
<!-- Title widget placeholder -->
<div>
<gl-loading-icon
v-if="$apollo.queries.workItem.loading"
size="md"
data-testid="loading-types"
/>
<template v-else>
<item-title
:initial-title="workItem.title"
data-testid="title"
@title-changed="updateWorkItem"
/>
</template>
</div>
<gl-alert v-if="error" variant="danger" @dismiss="error = false">
{{ error }}
</gl-alert>
<work-item-title
:loading="$apollo.queries.workItem.loading"
:work-item-id="workItem.id"
:work-item-title="workItem.title"
:work-item-type="workItemType"
@error="error = $event"
/>
</section>
</template>

View File

@ -503,6 +503,7 @@
&.dropdown-menu-user-link::before {
top: 50%;
transform: translateY(-50%);
}
}

View File

@ -20,7 +20,11 @@ module IdeHelper
'fork-info' => @fork_info&.to_json,
'project' => convert_to_project_entity_json(@project),
'enable-environments-guidance' => enable_environments_guidance?.to_s,
'preview-markdown-path' => @project && preview_markdown_path(@project)
'preview-markdown-path' => @project && preview_markdown_path(@project),
'web-terminal-svg-path' => image_path('illustrations/web-ide_promotion.svg'),
'web-terminal-help-path' => help_page_path('user/project/web_ide/index.md', anchor: 'interactive-web-terminals-for-the-web-ide'),
'web-terminal-config-help-path' => help_page_path('user/project/web_ide/index.md', anchor: 'web-ide-configuration-file'),
'web-terminal-runners-help-path' => help_page_path('user/project/web_ide/index.md', anchor: 'runner-configuration')
}
end
@ -44,5 +48,3 @@ module IdeHelper
current_user.dismissed_callout?(feature_name: 'web_ide_ci_environments_guidance')
end
end
::IdeHelper.prepend_mod_with('IdeHelper')

View File

@ -48,6 +48,7 @@ module InviteMembersHelper
def common_invite_modal_dataset(source)
dataset = {
id: source.id,
root_id: source&.root_ancestor&.id,
name: source.name,
default_access_level: Gitlab::Access::GUEST
}

View File

@ -3,7 +3,7 @@
module Clusters
module Applications
class Runner < ApplicationRecord
VERSION = '0.37.1'
VERSION = '0.39.0'
self.table_name = 'clusters_applications_runners'

View File

@ -15,5 +15,3 @@
= render @identities
- else
%h4= _('This user has no identities')
= render partial: 'admin/users/modals'

View File

@ -28,5 +28,3 @@
impersonation: true,
active_tokens: @active_impersonation_tokens,
revoke_route_helper: ->(token) { revoke_admin_user_impersonation_token_path(token.user, token) }
= render partial: 'admin/users/modals'

View File

@ -1,20 +0,0 @@
#js-delete-user-modal
#js-modal-texts.hidden{ "hidden": true, "aria-hidden": "true" }
%div{ data: { modal: "delete",
title: s_("AdminUsers|Delete User %{username}?"),
action: s_('AdminUsers|Delete user'),
'secondary-action': s_('AdminUsers|Block user') } }
= s_('AdminUsers|You are about to permanently delete the user %{username}. Issues, merge requests,
and groups linked to them will be transferred to a system-wide "Ghost-user". To avoid data loss,
consider using the %{strongStart}block user%{strongEnd} feature instead. Once you %{strongStart}Delete user%{strongEnd},
it cannot be undone or recovered.')
%div{ data: { modal: "delete-with-contributions",
title: s_("AdminUsers|Delete User %{username} and contributions?"),
action: s_('AdminUsers|Delete user and contributions') ,
'secondary-action': s_('AdminUsers|Block user') } }
= s_('AdminUsers|You are about to permanently delete the user %{username}. This will delete all of the issues,
merge requests, and groups linked to them. To avoid data loss,
consider using the %{strongStart}block user%{strongEnd} feature instead. Once you %{strongStart}Delete user%{strongEnd},
it cannot be undone or recovered.')

View File

@ -68,5 +68,3 @@
= gl_loading_icon(size: 'lg', css_class: 'gl-my-7')
= paginate_collection @users
= render partial: 'admin/users/modals'

View File

@ -3,4 +3,3 @@
- page_title _("SSH Keys"), @user.name, _("Users")
= render 'admin/users/head'
= render 'profiles/keys/key_table', admin: true
= render partial: 'admin/users/modals'

View File

@ -48,5 +48,3 @@
- if member.respond_to? :project
= link_to project_project_member_path(project, member), data: { confirm: remove_member_message(member), confirm_btn_variant: 'danger' }, aria: { label: _('Remove') }, remote: true, method: :delete, class: "btn btn-sm btn-danger gl-button btn-icon gl-ml-3", title: _('Remove user from project') do
= sprite_icon('remove', size: 16, css_class: 'gl-icon')
= render partial: 'admin/users/modals'

View File

@ -146,4 +146,3 @@
.col-md-6.gl-display-none.gl-md-display-block
= render 'admin/users/profile', user: @user
= render 'admin/users/user_detail_note'
= render partial: 'admin/users/modals'

View File

@ -15,7 +15,10 @@
#{ dropzone_text.html_safe }
%br
.dropzone-alerts.gl-alert.gl-alert-danger.gl-mb-5.data{ style: "display:none" }
= render 'shared/global_alert',
variant: :danger,
alert_class: 'dropzone-alerts gl-alert gl-alert-danger gl-mb-5 data gl-display-none',
dismissible: false
= render 'shared/new_commit_form', placeholder: placeholder, ref: local_assigns[:ref]

View File

@ -13,13 +13,13 @@
.create-mr-dropdown-wrap.d-inline-block.full-width-mobile.js-create-mr{ data: { project_path: @project.full_path, project_id: @project.id, can_create_path: can_create_path, create_mr_path: create_mr_path, create_branch_path: create_branch_path, refs_path: refs_path, is_confidential: can_create_confidential_merge_request?.to_s } }
.btn-group.unavailable
%button.gl-button.btn{ type: 'button', disabled: 'disabled' }
.gl-spinner.align-text-bottom.gl-button-icon.hide
= gl_loading_icon(inline: true, css_class: 'js-create-mr-spinner gl-button-icon gl-display-none')
%span.text
Checking branch availability…
.btn-group.available.hidden
%button.gl-button.btn.js-create-merge-request.btn-confirm{ type: 'button', data: { action: data_action } }
.gl-spinner.js-spinner.gl-mr-2.gl-display-none
= gl_loading_icon(css_class: 'js-create-mr-spinner js-spinner gl-mr-2 gl-display-none')
= value
%button.gl-button.btn.btn-confirm.btn-icon.dropdown-toggle.create-merge-request-dropdown-toggle.js-dropdown-toggle{ type: 'button', data: { dropdown: { trigger: '#create-merge-request-dropdown' }, display: 'static' } }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -35056,9 +35056,6 @@ msgstr ""
msgid "Something went wrong while updating assignees"
msgstr ""
msgid "Something went wrong while updating work item. Please try again"
msgstr ""
msgid "Something went wrong while updating your list settings"
msgstr ""
@ -42389,6 +42386,9 @@ msgstr ""
msgid "WorkItem|Something went wrong when fetching work item types. Please try again"
msgstr ""
msgid "WorkItem|Something went wrong while updating the work item. Please try again."
msgstr ""
msgid "WorkItem|Type"
msgstr ""

View File

@ -30,7 +30,7 @@ module QA
errors = ["Correlation Id: #{correlation_id}"]
errors << "Sentry Url: #{sentry_uri}&query=correlation_id%3A%22#{correlation_id}%22" if sentry_uri
errors << "Kibana Url: #{kibana_uri}app/discover#/?_a=(query:(language:kuery,query:'json.correlation_id%20:%20#{correlation_id}'))" if kibana_uri
errors << "Kibana Url: #{kibana_uri}app/discover#/?_a=(query:(language:kuery,query:'json.correlation_id%20:%20#{correlation_id}'))&_g=(time:(from:now-24h%2Fh,to:now))" if kibana_uri
errors.join("\n")
end

View File

@ -156,7 +156,7 @@ RSpec.describe QA::Resource::ApiFabricator do
Fabrication of FooBarResource using the API failed (400) with `#{raw_post}`.
Correlation Id: foobar
Sentry Url: https://sentry.gitlab.net/gitlab/staginggitlabcom/?environment=gstg-cny&query=correlation_id%3A%22foobar%22
Kibana Url: https://nonprod-log.gitlab.net/app/discover#/?_a=(query:(language:kuery,query:'json.correlation_id%20:%20foobar'))
Kibana Url: https://nonprod-log.gitlab.net/app/discover#/?_a=(query:(language:kuery,query:'json.correlation_id%20:%20foobar'))&_g=(time:(from:now-24h%2Fh,to:now))
ERROR
end
end

View File

@ -28,7 +28,7 @@ RSpec.describe QA::Support::Loglinking do
expect(QA::Support::Loglinking.failure_metadata('foo123')).to eql(<<~ERROR.chomp)
Correlation Id: foo123
Kibana Url: https://kibana.address/app/discover#/?_a=(query:(language:kuery,query:'json.correlation_id%20:%20foo123'))
Kibana Url: https://kibana.address/app/discover#/?_a=(query:(language:kuery,query:'json.correlation_id%20:%20foo123'))&_g=(time:(from:now-24h%2Fh,to:now))
ERROR
end
end

View File

@ -55,6 +55,7 @@ class StaticAnalysis
Task.new(%w[yarn run internal:stylelint], 8),
Task.new(%w[scripts/lint-conflicts.sh], 1),
Task.new(%w[yarn run block-dependencies], 1),
Task.new(%w[yarn run check-dependencies], 1),
Task.new(%w[scripts/lint-rugged], 1),
Task.new(%w[scripts/gemfile_lock_changed.sh], 1)
].compact.freeze

View File

@ -1,9 +1,9 @@
import { GlDropdownItem } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import { kebabCase } from 'lodash';
import Actions from '~/admin/users/components/actions';
import SharedDeleteAction from '~/admin/users/components/actions/shared/shared_delete_action.vue';
import eventHub, {
EVENT_OPEN_DELETE_USER_MODAL,
} from '~/admin/users/components/modals/delete_user_modal_event_hub';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
import { OBSTACLE_TYPES } from '~/vue_shared/components/user_deletion_obstacles/constants';
import { CONFIRMATION_ACTIONS, DELETE_ACTIONS } from '../../constants';
@ -14,12 +14,11 @@ describe('Action components', () => {
const findDropdownItem = () => wrapper.find(GlDropdownItem);
const initComponent = ({ component, props, stubs = {} } = {}) => {
const initComponent = ({ component, props } = {}) => {
wrapper = shallowMount(component, {
propsData: {
...props,
},
stubs,
});
};
@ -29,7 +28,7 @@ describe('Action components', () => {
});
describe('CONFIRMATION_ACTIONS', () => {
it.each(CONFIRMATION_ACTIONS)('renders a dropdown item for "%s"', async (action) => {
it.each(CONFIRMATION_ACTIONS)('renders a dropdown item for "%s"', (action) => {
initComponent({
component: Actions[capitalizeFirstCharacter(action)],
props: {
@ -38,20 +37,23 @@ describe('Action components', () => {
},
});
await nextTick();
expect(findDropdownItem().exists()).toBe(true);
});
});
describe('DELETE_ACTION_COMPONENTS', () => {
beforeEach(() => {
jest.spyOn(eventHub, '$emit').mockImplementation();
});
const userDeletionObstacles = [
{ name: 'schedule1', type: OBSTACLE_TYPES.oncallSchedules },
{ name: 'policy1', type: OBSTACLE_TYPES.escalationPolicies },
];
it.each(DELETE_ACTIONS.map((action) => [action, paths[action]]))(
'renders a dropdown item for "%s"',
async (action, expectedPath) => {
it.each(DELETE_ACTIONS)(
'renders a dropdown item that opens the delete user modal when clicked for "%s"',
async (action) => {
initComponent({
component: Actions[capitalizeFirstCharacter(action)],
props: {
@ -59,21 +61,19 @@ describe('Action components', () => {
paths,
userDeletionObstacles,
},
stubs: { SharedDeleteAction },
});
await nextTick();
const sharedAction = wrapper.find(SharedDeleteAction);
await findDropdownItem().vm.$emit('click');
expect(sharedAction.attributes('data-block-user-url')).toBe(paths.block);
expect(sharedAction.attributes('data-delete-user-url')).toBe(expectedPath);
expect(sharedAction.attributes('data-gl-modal-action')).toBe(kebabCase(action));
expect(sharedAction.attributes('data-username')).toBe('John Doe');
expect(sharedAction.attributes('data-user-deletion-obstacles')).toBe(
JSON.stringify(userDeletionObstacles),
expect(eventHub.$emit).toHaveBeenCalledWith(
EVENT_OPEN_DELETE_USER_MODAL,
expect.objectContaining({
username: 'John Doe',
blockPath: paths.block,
deletePath: paths[action],
userDeletionObstacles,
}),
);
expect(findDropdownItem().exists()).toBe(true);
},
);
});

View File

@ -1,160 +1,28 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`User Operation confirmation modal renders modal with form included 1`] = `
<div>
<p>
<gl-sprintf-stub
message="content"
/>
</p>
<user-deletion-obstacles-list-stub
obstacles="schedule1,policy1"
username="username"
exports[`Delete user modal renders modal with form included 1`] = `
<form
action=""
method="post"
>
<input
name="_method"
type="hidden"
value="delete"
/>
<p>
<gl-sprintf-stub
message="To confirm, type %{username}"
/>
</p>
<form
action="delete-url"
method="post"
>
<input
name="_method"
type="hidden"
value="delete"
/>
<input
name="authenticity_token"
type="hidden"
value="csrf"
/>
<gl-form-input-stub
autocomplete="off"
autofocus=""
name="username"
type="text"
value=""
/>
</form>
<gl-button-stub
buttontextclasses=""
category="primary"
icon=""
size="medium"
variant="default"
>
Cancel
</gl-button-stub>
<gl-button-stub
buttontextclasses=""
category="secondary"
disabled="true"
icon=""
size="medium"
variant="danger"
>
secondaryAction
</gl-button-stub>
<gl-button-stub
buttontextclasses=""
category="primary"
disabled="true"
icon=""
size="medium"
variant="danger"
>
action
</gl-button-stub>
</div>
`;
exports[`User Operation confirmation modal when user's name has leading and trailing whitespace displays user's name without whitespace 1`] = `
<div>
<p>
content
</p>
<user-deletion-obstacles-list-stub
obstacles="schedule1,policy1"
username="John Smith"
<input
name="authenticity_token"
type="hidden"
value="csrf"
/>
<p>
To confirm, type
<code
class="gl-white-space-pre-wrap"
>
John Smith
</code>
</p>
<form
action="delete-url"
method="post"
>
<input
name="_method"
type="hidden"
value="delete"
/>
<input
name="authenticity_token"
type="hidden"
value="csrf"
/>
<gl-form-input-stub
autocomplete="off"
autofocus=""
name="username"
type="text"
value=""
/>
</form>
<gl-button-stub
buttontextclasses=""
category="primary"
icon=""
size="medium"
variant="default"
>
Cancel
</gl-button-stub>
<gl-button-stub
buttontextclasses=""
category="secondary"
disabled="true"
icon=""
size="medium"
variant="danger"
>
secondaryAction
</gl-button-stub>
<gl-button-stub
buttontextclasses=""
category="primary"
disabled="true"
icon=""
size="medium"
variant="danger"
>
action
</gl-button-stub>
</div>
<gl-form-input-stub
autocomplete="off"
autofocus=""
name="username"
type="text"
value=""
/>
</form>
`;

View File

@ -1,6 +1,8 @@
import { GlButton, GlFormInput, GlSprintf } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import eventHub, {
EVENT_OPEN_DELETE_USER_MODAL,
} from '~/admin/users/components/modals/delete_user_modal_event_hub';
import DeleteUserModal from '~/admin/users/components/modals/delete_user_modal.vue';
import UserDeletionObstaclesList from '~/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue';
import ModalStub from './stubs/modal_stub';
@ -9,7 +11,7 @@ const TEST_DELETE_USER_URL = 'delete-url';
const TEST_BLOCK_USER_URL = 'block-url';
const TEST_CSRF = 'csrf';
describe('User Operation confirmation modal', () => {
describe('Delete user modal', () => {
let wrapper;
let formSubmitSpy;
@ -27,28 +29,36 @@ describe('User Operation confirmation modal', () => {
const getMethodParam = () => new FormData(findForm().element).get('_method');
const getFormAction = () => findForm().attributes('action');
const findUserDeletionObstaclesList = () => wrapper.findComponent(UserDeletionObstaclesList);
const findMessageUsername = () => wrapper.findByTestId('message-username');
const findConfirmUsername = () => wrapper.findByTestId('confirm-username');
const emitOpenModalEvent = (modalData) => {
return eventHub.$emit(EVENT_OPEN_DELETE_USER_MODAL, modalData);
};
const setUsername = (username) => {
findUsernameInput().vm.$emit('input', username);
return findUsernameInput().vm.$emit('input', username);
};
const username = 'username';
const badUsername = 'bad_username';
const userDeletionObstacles = '["schedule1", "policy1"]';
const userDeletionObstacles = ['schedule1', 'policy1'];
const createComponent = (props = {}, stubs = {}) => {
wrapper = shallowMount(DeleteUserModal, {
const mockModalData = {
username,
blockPath: TEST_BLOCK_USER_URL,
deletePath: TEST_DELETE_USER_URL,
userDeletionObstacles,
i18n: {
title: 'Modal for %{username}',
primaryButtonLabel: 'Delete user',
messageBody: 'Delete %{username} or rather %{strongStart}block user%{strongEnd}?',
},
};
const createComponent = (stubs = {}) => {
wrapper = shallowMountExtended(DeleteUserModal, {
propsData: {
username,
title: 'title',
content: 'content',
action: 'action',
secondaryAction: 'secondaryAction',
deleteUserUrl: TEST_DELETE_USER_URL,
blockUserUrl: TEST_BLOCK_USER_URL,
csrfToken: TEST_CSRF,
userDeletionObstacles,
...props,
},
stubs: {
GlModal: ModalStub,
@ -68,7 +78,7 @@ describe('User Operation confirmation modal', () => {
it('renders modal with form included', () => {
createComponent();
expect(wrapper.element).toMatchSnapshot();
expect(findForm().element).toMatchSnapshot();
});
describe('on created', () => {
@ -83,11 +93,11 @@ describe('User Operation confirmation modal', () => {
});
describe('with incorrect username', () => {
beforeEach(async () => {
beforeEach(() => {
createComponent();
setUsername(badUsername);
emitOpenModalEvent(mockModalData);
await nextTick();
return setUsername(badUsername);
});
it('shows incorrect username', () => {
@ -101,11 +111,11 @@ describe('User Operation confirmation modal', () => {
});
describe('with correct username', () => {
beforeEach(async () => {
beforeEach(() => {
createComponent();
setUsername(username);
emitOpenModalEvent(mockModalData);
await nextTick();
return setUsername(username);
});
it('shows correct username', () => {
@ -117,11 +127,9 @@ describe('User Operation confirmation modal', () => {
expect(findSecondaryButton().attributes('disabled')).toBeFalsy();
});
describe('when primary action is submitted', () => {
beforeEach(async () => {
findPrimaryButton().vm.$emit('click');
await nextTick();
describe('when primary action is clicked', () => {
beforeEach(() => {
return findPrimaryButton().vm.$emit('click');
});
it('clears the input', () => {
@ -136,11 +144,9 @@ describe('User Operation confirmation modal', () => {
});
});
describe('when secondary action is submitted', () => {
beforeEach(async () => {
findSecondaryButton().vm.$emit('click');
await nextTick();
describe('when secondary action is clicked', () => {
beforeEach(() => {
return findSecondaryButton().vm.$emit('click');
});
it('has correct form attributes and calls submit', () => {
@ -154,22 +160,23 @@ describe('User Operation confirmation modal', () => {
describe("when user's name has leading and trailing whitespace", () => {
beforeEach(() => {
createComponent(
{
username: ' John Smith ',
},
{ GlSprintf },
);
createComponent({ GlSprintf });
return emitOpenModalEvent({ ...mockModalData, username: ' John Smith ' });
});
it("displays user's name without whitespace", () => {
expect(wrapper.element).toMatchSnapshot();
expect(findMessageUsername().text()).toBe('John Smith');
expect(findConfirmUsername().text()).toBe('John Smith');
});
it('passes user name without whitespace to the obstacles', () => {
expect(findUserDeletionObstaclesList().props()).toMatchObject({
userName: 'John Smith',
});
});
it("shows enabled buttons when user's name is entered without whitespace", async () => {
setUsername('John Smith');
await nextTick();
await setUsername('John Smith');
expect(findPrimaryButton().attributes('disabled')).toBeUndefined();
expect(findSecondaryButton().attributes('disabled')).toBeUndefined();
@ -177,17 +184,20 @@ describe('User Operation confirmation modal', () => {
});
describe('Related user-deletion-obstacles list', () => {
it('does NOT render the list when user has no related obstacles', () => {
createComponent({ userDeletionObstacles: '[]' });
it('does NOT render the list when user has no related obstacles', async () => {
createComponent();
await emitOpenModalEvent({ ...mockModalData, userDeletionObstacles: [] });
expect(findUserDeletionObstaclesList().exists()).toBe(false);
});
it('renders the list when user has related obstalces', () => {
it('renders the list when user has related obstalces', async () => {
createComponent();
await emitOpenModalEvent(mockModalData);
const obstacles = findUserDeletionObstaclesList();
expect(obstacles.exists()).toBe(true);
expect(obstacles.props('obstacles')).toEqual(JSON.parse(userDeletionObstacles));
expect(obstacles.props('obstacles')).toEqual(userDeletionObstacles);
});
});
});

View File

@ -1,126 +0,0 @@
import { mount } from '@vue/test-utils';
import { nextTick } from 'vue';
import UserModalManager from '~/admin/users/components/modals/user_modal_manager.vue';
import ModalStub from './stubs/modal_stub';
describe('Users admin page Modal Manager', () => {
let wrapper;
const modalConfiguration = {
action1: {
title: 'action1',
content: 'Action Modal 1',
},
action2: {
title: 'action2',
content: 'Action Modal 2',
},
};
const findModal = () => wrapper.find({ ref: 'modal' });
const createComponent = (props = {}) => {
wrapper = mount(UserModalManager, {
propsData: {
selector: '.js-delete-user-modal-button',
modalConfiguration,
csrfToken: 'dummyCSRF',
...props,
},
stubs: {
DeleteUserModal: ModalStub,
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('render behavior', () => {
it('does not renders modal when initialized', () => {
createComponent();
expect(findModal().exists()).toBeFalsy();
});
it('throws if action has no proper configuration', () => {
createComponent({
modalConfiguration: {},
});
expect(() => wrapper.vm.show({ glModalAction: 'action1' })).toThrow();
});
it('renders modal with expected props when valid configuration is passed', async () => {
createComponent();
wrapper.vm.show({
glModalAction: 'action1',
extraProp: 'extraPropValue',
});
await nextTick();
const modal = findModal();
expect(modal.exists()).toBeTruthy();
expect(modal.vm.$attrs.csrfToken).toEqual('dummyCSRF');
expect(modal.vm.$attrs.extraProp).toEqual('extraPropValue');
expect(modal.vm.showWasCalled).toBeTruthy();
});
});
describe('click handling', () => {
let button;
let button2;
const createButtons = () => {
button = document.createElement('button');
button2 = document.createElement('button');
button.setAttribute('class', 'js-delete-user-modal-button');
button.setAttribute('data-username', 'foo');
button.setAttribute('data-gl-modal-action', 'action1');
button.setAttribute('data-block-user-url', '/block');
button.setAttribute('data-delete-user-url', '/delete');
document.body.appendChild(button);
document.body.appendChild(button2);
};
const removeButtons = () => {
button.remove();
button = null;
button2.remove();
button2 = null;
};
beforeEach(() => {
createButtons();
createComponent();
});
afterEach(() => {
removeButtons();
});
it('renders the modal when the button is clicked', async () => {
button.click();
await nextTick();
expect(findModal().exists()).toBe(true);
});
it('does not render the modal when a misconfigured button is clicked', async () => {
button.removeAttribute('data-gl-modal-action');
button.click();
await nextTick();
expect(findModal().exists()).toBe(false);
});
it('does not render the modal when a button without the selector class is clicked', async () => {
button2.click();
await nextTick();
expect(findModal().exists()).toBe(false);
});
});
});

View File

@ -1,5 +1,5 @@
import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight';
import { createTestEditor } from '../test_utils';
import { createTestEditor, createDocBuilder, triggerNodeInputRule } from '../test_utils';
const CODE_BLOCK_HTML = `<pre class="code highlight js-syntax-highlight language-javascript" lang="javascript" v-pre="true">
<code>
@ -12,34 +12,78 @@ const CODE_BLOCK_HTML = `<pre class="code highlight js-syntax-highlight language
describe('content_editor/extensions/code_block_highlight', () => {
let parsedCodeBlockHtmlFixture;
let tiptapEditor;
let doc;
let codeBlock;
let languageLoader;
const parseHTML = (html) => new DOMParser().parseFromString(html, 'text/html');
const preElement = () => parsedCodeBlockHtmlFixture.querySelector('pre');
beforeEach(() => {
tiptapEditor = createTestEditor({ extensions: [CodeBlockHighlight] });
parsedCodeBlockHtmlFixture = parseHTML(CODE_BLOCK_HTML);
languageLoader = { loadLanguages: jest.fn() };
tiptapEditor = createTestEditor({
extensions: [CodeBlockHighlight.configure({ languageLoader })],
});
tiptapEditor.commands.setContent(CODE_BLOCK_HTML);
({
builders: { doc, codeBlock },
} = createDocBuilder({
tiptapEditor,
names: {
codeBlock: { nodeType: CodeBlockHighlight.name },
},
}));
});
it('extracts language and params attributes from Markdown API output', () => {
const language = preElement().getAttribute('lang');
describe('when parsing HTML', () => {
beforeEach(() => {
parsedCodeBlockHtmlFixture = parseHTML(CODE_BLOCK_HTML);
expect(tiptapEditor.getJSON().content[0].attrs).toMatchObject({
language,
tiptapEditor.commands.setContent(CODE_BLOCK_HTML);
});
it('extracts language and params attributes from Markdown API output', () => {
const language = preElement().getAttribute('lang');
expect(tiptapEditor.getJSON().content[0].attrs).toMatchObject({
language,
});
});
it('adds code, highlight, and js-syntax-highlight to code block element', () => {
const editorHtmlOutput = parseHTML(tiptapEditor.getHTML()).querySelector('pre');
expect(editorHtmlOutput.classList.toString()).toContain('code highlight js-syntax-highlight');
});
it('adds content-editor-code-block class to the pre element', () => {
const editorHtmlOutput = parseHTML(tiptapEditor.getHTML()).querySelector('pre');
expect(editorHtmlOutput.classList.toString()).toContain('content-editor-code-block');
});
});
it('adds code, highlight, and js-syntax-highlight to code block element', () => {
const editorHtmlOutput = parseHTML(tiptapEditor.getHTML()).querySelector('pre');
describe.each`
inputRule
${'```'}
${'~~~'}
`('when typing $inputRule input rule', ({ inputRule }) => {
const language = 'javascript';
expect(editorHtmlOutput.classList.toString()).toContain('code highlight js-syntax-highlight');
});
beforeEach(() => {
triggerNodeInputRule({
tiptapEditor,
inputRuleText: `${inputRule}${language} `,
});
});
it('adds content-editor-code-block class to the pre element', () => {
const editorHtmlOutput = parseHTML(tiptapEditor.getHTML()).querySelector('pre');
it('creates a new code block and loads related language', () => {
const expectedDoc = doc(codeBlock({ language }));
expect(editorHtmlOutput.classList.toString()).toContain('content-editor-code-block');
expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON());
});
it('loads language when language loader is available', () => {
expect(languageLoader.loadLanguages).toHaveBeenCalledWith([language]);
});
});
});

View File

@ -0,0 +1,70 @@
import CodeBlockLanguageBlocker from '~/content_editor/services/code_block_language_loader';
describe('content_editor/services/code_block_language_loader', () => {
let languageLoader;
let lowlight;
beforeEach(() => {
lowlight = {
languages: [],
registerLanguage: jest
.fn()
.mockImplementation((language) => lowlight.languages.push(language)),
registered: jest.fn().mockImplementation((language) => lowlight.languages.includes(language)),
};
languageLoader = new CodeBlockLanguageBlocker(lowlight);
});
describe('loadLanguages', () => {
it('loads highlight.js language packages identified by a list of languages', async () => {
const languages = ['javascript', 'ruby'];
await languageLoader.loadLanguages(languages);
languages.forEach((language) => {
expect(lowlight.registerLanguage).toHaveBeenCalledWith(language, expect.any(Function));
});
});
describe('when language is already registered', () => {
it('does not load the language again', async () => {
const languages = ['javascript'];
await languageLoader.loadLanguages(languages);
await languageLoader.loadLanguages(languages);
expect(lowlight.registerLanguage).toHaveBeenCalledTimes(1);
});
});
});
describe('loadLanguagesFromDOM', () => {
it('loads highlight.js language packages identified by pre tags in a DOM fragment', async () => {
const parser = new DOMParser();
const { body } = parser.parseFromString(
`
<pre lang="javascript"></pre>
<pre lang="ruby"></pre>
`,
'text/html',
);
await languageLoader.loadLanguagesFromDOM(body);
expect(lowlight.registerLanguage).toHaveBeenCalledWith('javascript', expect.any(Function));
expect(lowlight.registerLanguage).toHaveBeenCalledWith('ruby', expect.any(Function));
});
});
describe('isLanguageLoaded', () => {
it('returns true when a language is registered', async () => {
const language = 'javascript';
expect(languageLoader.isLanguageLoaded(language)).toBe(false);
await languageLoader.loadLanguages([language]);
expect(languageLoader.isLanguageLoaded(language)).toBe(true);
});
});
});

View File

@ -11,6 +11,7 @@ describe('content_editor/services/content_editor', () => {
let contentEditor;
let serializer;
let deserializer;
let languageLoader;
let eventHub;
let doc;
let p;
@ -27,8 +28,15 @@ describe('content_editor/services/content_editor', () => {
serializer = { deserialize: jest.fn() };
deserializer = { deserialize: jest.fn() };
languageLoader = { loadLanguagesFromDOM: jest.fn() };
eventHub = eventHubFactory();
contentEditor = new ContentEditor({ tiptapEditor, serializer, deserializer, eventHub });
contentEditor = new ContentEditor({
tiptapEditor,
serializer,
deserializer,
eventHub,
languageLoader,
});
});
describe('.dispose', () => {
@ -43,10 +51,12 @@ describe('content_editor/services/content_editor', () => {
describe('when setSerializedContent succeeds', () => {
let document;
const dom = {};
const testMarkdown = '**bold text**';
beforeEach(() => {
document = doc(p('document'));
deserializer.deserialize.mockResolvedValueOnce({ document });
deserializer.deserialize.mockResolvedValueOnce({ document, dom });
});
it('emits loadingContent and loadingSuccess event in the eventHub', () => {
@ -59,14 +69,20 @@ describe('content_editor/services/content_editor', () => {
expect(loadingContentEmitted).toBe(true);
});
contentEditor.setSerializedContent('**bold text**');
contentEditor.setSerializedContent(testMarkdown);
});
it('sets the deserialized document in the tiptap editor object', async () => {
await contentEditor.setSerializedContent('**bold text**');
await contentEditor.setSerializedContent(testMarkdown);
expect(contentEditor.tiptapEditor.state.doc.toJSON()).toEqual(document.toJSON());
});
it('passes deserialized DOM document to language loader', async () => {
await contentEditor.setSerializedContent(testMarkdown);
expect(languageLoader.loadLanguagesFromDOM).toHaveBeenCalledWith(dom);
});
});
describe('when setSerializedContent fails', () => {

View File

@ -6,10 +6,10 @@ import { SET_BRANCH_WORKING_REFERENCE } from '~/ide/stores/mutation_types';
import createTerminalPlugin from '~/ide/stores/plugins/terminal';
const TEST_DATASET = {
eeWebTerminalSvgPath: `${TEST_HOST}/web/terminal/svg`,
eeWebTerminalHelpPath: `${TEST_HOST}/web/terminal/help`,
eeWebTerminalConfigHelpPath: `${TEST_HOST}/web/terminal/config/help`,
eeWebTerminalRunnersHelpPath: `${TEST_HOST}/web/terminal/runners/help`,
webTerminalSvgPath: `${TEST_HOST}/web/terminal/svg`,
webTerminalHelpPath: `${TEST_HOST}/web/terminal/help`,
webTerminalConfigHelpPath: `${TEST_HOST}/web/terminal/config/help`,
webTerminalRunnersHelpPath: `${TEST_HOST}/web/terminal/runners/help`,
};
Vue.use(Vuex);
@ -40,10 +40,10 @@ describe('ide/stores/extend', () => {
it('dispatches terminal/setPaths', () => {
expect(store.dispatch).toHaveBeenCalledWith('terminal/setPaths', {
webTerminalSvgPath: TEST_DATASET.eeWebTerminalSvgPath,
webTerminalHelpPath: TEST_DATASET.eeWebTerminalHelpPath,
webTerminalConfigHelpPath: TEST_DATASET.eeWebTerminalConfigHelpPath,
webTerminalRunnersHelpPath: TEST_DATASET.eeWebTerminalRunnersHelpPath,
webTerminalSvgPath: TEST_DATASET.webTerminalSvgPath,
webTerminalHelpPath: TEST_DATASET.webTerminalHelpPath,
webTerminalConfigHelpPath: TEST_DATASET.webTerminalConfigHelpPath,
webTerminalRunnersHelpPath: TEST_DATASET.webTerminalRunnersHelpPath,
});
});

View File

@ -1,5 +1,6 @@
export const propsData = {
id: '1',
rootId: '1',
name: 'test name',
isProject: false,
accessLevels: { Guest: 10, Reporter: 20, Developer: 30, Maintainer: 40, Owner: 50 },

View File

@ -15,7 +15,7 @@ describe('CreateMergeRequestDropdown', () => {
<div id="dummy-wrapper-element">
<div class="available"></div>
<div class="unavailable">
<div class="gl-spinner"></div>
<div class="js-create-mr-spinner"></div>
<div class="text"></div>
</div>
<div class="js-ref"></div>

View File

@ -6,7 +6,6 @@ import { stubComponent } from 'helpers/stub_component';
import { TEST_HOST } from 'helpers/test_constants';
import { mockTracking } from 'helpers/tracking_helper';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import createFlash from '~/flash';
import Description from '~/issues/show/components/description.vue';
import TaskList from '~/task_list';
import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue';
@ -317,15 +316,6 @@ describe('Description component', () => {
expect(findWorkItemDetailModal().props('visible')).toBe(false);
});
it('shows error on error', async () => {
const message = 'I am error';
await findTaskLink().trigger('click');
findWorkItemDetailModal().vm.$emit('error', message);
expect(createFlash).toHaveBeenCalledWith({ message });
});
it('tracks when opened', async () => {
const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);

View File

@ -1,11 +1,12 @@
import { GlModal, GlLoadingIcon } from '@gitlab/ui';
import { GlAlert, GlModal } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import WorkItemTitle from '~/work_items/components/item_title.vue';
import WorkItemTitle from '~/work_items/components/work_item_title.vue';
import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue';
import { i18n } from '~/work_items/constants';
import workItemQuery from '~/work_items/graphql/work_item.query.graphql';
import { workItemQueryResponse } from '../mock_data';
@ -13,10 +14,11 @@ describe('WorkItemDetailModal component', () => {
let wrapper;
Vue.use(VueApollo);
const successHandler = jest.fn().mockResolvedValue(workItemQueryResponse);
const successHandler = jest.fn().mockResolvedValue({ data: workItemQueryResponse });
const findAlert = () => wrapper.findComponent(GlAlert);
const findModal = () => wrapper.findComponent(GlModal);
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findWorkItemTitle = () => wrapper.findComponent(WorkItemTitle);
const createComponent = ({ workItemId = '1', handler = successHandler } = {}) => {
@ -41,10 +43,6 @@ describe('WorkItemDetailModal component', () => {
createComponent({ workItemId: null });
});
it('renders empty title when there is no `workItemId` prop', () => {
expect(findWorkItemTitle().exists()).toBe(true);
});
it('skips the work item query', () => {
expect(successHandler).not.toHaveBeenCalled();
});
@ -55,12 +53,10 @@ describe('WorkItemDetailModal component', () => {
createComponent();
});
it('renders loading spinner', () => {
expect(findLoadingIcon().exists()).toBe(true);
});
it('renders WorkItemTitle in loading state', () => {
createComponent();
it('does not render title', () => {
expect(findWorkItemTitle().exists()).toBe(false);
expect(findWorkItemTitle().props('loading')).toBe(true);
});
});
@ -70,23 +66,26 @@ describe('WorkItemDetailModal component', () => {
return waitForPromises();
});
it('does not render loading spinner', () => {
expect(findLoadingIcon().exists()).toBe(false);
});
it('renders title', () => {
expect(findWorkItemTitle().exists()).toBe(true);
it('does not render WorkItemTitle in loading state', () => {
expect(findWorkItemTitle().props('loading')).toBe(false);
});
});
it('emits an error if query has errored', async () => {
it('shows an error message when the work item query was unsuccessful', async () => {
const errorHandler = jest.fn().mockRejectedValue('Oops');
createComponent({ handler: errorHandler });
await waitForPromises();
expect(errorHandler).toHaveBeenCalled();
expect(findAlert().text()).toBe(i18n.fetchError);
});
it('shows an error message when WorkItemTitle emits an `error` event', async () => {
createComponent();
findWorkItemTitle().vm.$emit('error', i18n.updateError);
await waitForPromises();
expect(wrapper.emitted('error')).toEqual([
['Something went wrong when fetching the work item. Please try again.'],
]);
expect(findAlert().text()).toBe(i18n.updateError);
});
});

View File

@ -0,0 +1,111 @@
import { GlLoadingIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import { mockTracking } from 'helpers/tracking_helper';
import waitForPromises from 'helpers/wait_for_promises';
import ItemTitle from '~/work_items/components/item_title.vue';
import WorkItemTitle from '~/work_items/components/work_item_title.vue';
import { i18n } from '~/work_items/constants';
import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
import { updateWorkItemMutationResponse, workItemQueryResponse } from '../mock_data';
describe('WorkItemTitle component', () => {
let wrapper;
Vue.use(VueApollo);
const mutationSuccessHandler = jest.fn().mockResolvedValue(updateWorkItemMutationResponse);
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findItemTitle = () => wrapper.findComponent(ItemTitle);
const createComponent = ({ loading = false, mutationHandler = mutationSuccessHandler } = {}) => {
wrapper = shallowMount(WorkItemTitle, {
apolloProvider: createMockApollo([[updateWorkItemMutation, mutationHandler]]),
propsData: {
loading,
workItemId: workItemQueryResponse.workItem.id,
workItemTitle: workItemQueryResponse.workItem.title,
workItemType: workItemQueryResponse.workItem.workItemType.name,
},
});
};
afterEach(() => {
wrapper.destroy();
});
describe('when loading', () => {
beforeEach(() => {
createComponent({ loading: true });
});
it('renders loading spinner', () => {
expect(findLoadingIcon().exists()).toBe(true);
});
it('does not render title', () => {
expect(findItemTitle().exists()).toBe(false);
});
});
describe('when loaded', () => {
beforeEach(() => {
createComponent({ loading: false });
});
it('does not render loading spinner', () => {
expect(findLoadingIcon().exists()).toBe(false);
});
it('renders title', () => {
expect(findItemTitle().props('initialTitle')).toBe(workItemQueryResponse.workItem.title);
});
});
describe('when updating the title', () => {
it('calls a mutation', () => {
const title = 'new title!';
createComponent();
findItemTitle().vm.$emit('title-changed', title);
expect(mutationSuccessHandler).toHaveBeenCalledWith({ input: { id: '1', title } });
});
it('does not call a mutation when the title has not changed', () => {
createComponent();
findItemTitle().vm.$emit('title-changed', workItemQueryResponse.workItem.title);
expect(mutationSuccessHandler).not.toHaveBeenCalled();
});
it('emits an error message when the mutation was unsuccessful', async () => {
createComponent({ mutationHandler: jest.fn().mockRejectedValue('Error!') });
findItemTitle().vm.$emit('title-changed', 'new title');
await waitForPromises();
expect(wrapper.emitted('error')).toEqual([[i18n.updateError]]);
});
it('tracks editing the title', async () => {
const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
createComponent();
findItemTitle().vm.$emit('title-changed', 'new title');
await waitForPromises();
expect(trackingSpy).toHaveBeenCalledWith('workItems:show', 'updated_title', {
category: 'workItems:show',
label: 'item_title',
property: 'type_Task',
});
});
});
});

View File

@ -6,6 +6,7 @@ export const workItemQueryResponse = {
workItemType: {
__typename: 'WorkItemType',
id: 'work-item-type-1',
name: 'Task',
},
widgets: {
__typename: 'LocalWorkItemWidgetConnection',
@ -31,6 +32,7 @@ export const updateWorkItemMutationResponse = {
workItemType: {
__typename: 'WorkItemType',
id: 'work-item-type-1',
name: 'Task',
},
widgets: {
__typename: 'LocalWorkItemWidgetConnection',
@ -73,6 +75,7 @@ export const createWorkItemMutationResponse = {
workItemType: {
__typename: 'WorkItemType',
id: 'work-item-type-1',
name: 'Task',
},
},
},

View File

@ -1,108 +1,78 @@
import Vue from 'vue';
import { GlAlert } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
import workItemQuery from '~/work_items/graphql/work_item.query.graphql';
import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
import WorkItemsRoot from '~/work_items/pages/work_item_root.vue';
import ItemTitle from '~/work_items/components/item_title.vue';
import { resolvers } from '~/work_items/graphql/resolvers';
import { workItemQueryResponse, updateWorkItemMutationResponse } from '../mock_data';
import WorkItemTitle from '~/work_items/components/work_item_title.vue';
import { i18n } from '~/work_items/constants';
import { workItemQueryResponse } from '../mock_data';
Vue.use(VueApollo);
const WORK_ITEM_ID = '1';
const WORK_ITEM_GID = `gid://gitlab/WorkItem/${WORK_ITEM_ID}`;
describe('Work items root component', () => {
const mockUpdatedTitle = 'Updated title';
let wrapper;
let fakeApollo;
const findTitle = () => wrapper.findComponent(ItemTitle);
const successHandler = jest.fn().mockResolvedValue({ data: workItemQueryResponse });
const createComponent = ({ queryResponse = workItemQueryResponse } = {}) => {
fakeApollo = createMockApollo(
[[updateWorkItemMutation, jest.fn().mockResolvedValue(updateWorkItemMutationResponse)]],
resolvers,
{
possibleTypes: {
LocalWorkItemWidget: ['LocalTitleWidget'],
},
},
);
fakeApollo.clients.defaultClient.cache.writeQuery({
query: workItemQuery,
variables: {
id: WORK_ITEM_GID,
},
data: queryResponse,
});
const findAlert = () => wrapper.findComponent(GlAlert);
const findWorkItemTitle = () => wrapper.findComponent(WorkItemTitle);
const createComponent = ({ handler = successHandler } = {}) => {
wrapper = shallowMount(WorkItemsRoot, {
apolloProvider: createMockApollo([[workItemQuery, handler]]),
propsData: {
id: WORK_ITEM_ID,
},
apolloProvider: fakeApollo,
});
};
afterEach(() => {
wrapper.destroy();
fakeApollo = null;
});
it('renders the title', () => {
createComponent();
expect(findTitle().exists()).toBe(true);
expect(findTitle().props('initialTitle')).toBe('Test');
});
it('updates the title when it is edited', async () => {
createComponent();
jest.spyOn(wrapper.vm.$apollo, 'mutate');
await findTitle().vm.$emit('title-changed', mockUpdatedTitle);
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: updateWorkItemMutation,
variables: {
input: {
id: WORK_ITEM_GID,
title: mockUpdatedTitle,
},
},
});
});
describe('tracking', () => {
let trackingSpy;
describe('when loading', () => {
beforeEach(() => {
trackingSpy = mockTracking('_category_', undefined, jest.spyOn);
createComponent();
});
afterEach(() => {
unmockTracking();
});
it('renders WorkItemTitle in loading state', () => {
createComponent();
it('tracks item title updates', async () => {
await findTitle().vm.$emit('title-changed', mockUpdatedTitle);
await waitForPromises();
expect(trackingSpy).toHaveBeenCalledTimes(1);
expect(trackingSpy).toHaveBeenCalledWith('workItems:show', undefined, {
action: 'updated_title',
category: 'workItems:show',
label: 'item_title',
property: '[type_work_item]',
});
expect(findWorkItemTitle().props('loading')).toBe(true);
});
});
describe('when loaded', () => {
beforeEach(() => {
createComponent();
return waitForPromises();
});
it('does not render WorkItemTitle in loading state', () => {
expect(findWorkItemTitle().props('loading')).toBe(false);
});
});
it('shows an error message when the work item query was unsuccessful', async () => {
const errorHandler = jest.fn().mockRejectedValue('Oops');
createComponent({ handler: errorHandler });
await waitForPromises();
expect(errorHandler).toHaveBeenCalled();
expect(findAlert().text()).toBe(i18n.fetchError);
});
it('shows an error message when WorkItemTitle emits an `error` event', async () => {
createComponent();
findWorkItemTitle().vm.$emit('error', i18n.updateError);
await waitForPromises();
expect(findAlert().text()).toBe(i18n.updateError);
});
});

View File

@ -0,0 +1,63 @@
import { nextTick } from 'vue';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import { ContentEditor } from '~/content_editor';
/**
* This spec exercises some workflows in the Content Editor without mocking
* any component.
*
*/
describe('content_editor', () => {
let wrapper;
let renderMarkdown;
let contentEditorService;
const buildWrapper = () => {
renderMarkdown = jest.fn();
wrapper = mountExtended(ContentEditor, {
propsData: {
renderMarkdown,
uploadsPath: '/',
},
listeners: {
initialized(contentEditor) {
contentEditorService = contentEditor;
},
},
});
};
describe('when loading initial content', () => {
describe('when the initial content is empty', () => {
it('still hides the loading indicator', async () => {
buildWrapper();
renderMarkdown.mockResolvedValue('');
await contentEditorService.setSerializedContent('');
await nextTick();
expect(wrapper.findByTestId('content-editor-loading-indicator').exists()).toBe(false);
});
});
describe('when the initial content is not empty', () => {
const initialContent = '<p><strong>bold text</strong></p>';
beforeEach(async () => {
buildWrapper();
renderMarkdown.mockResolvedValue(initialContent);
await contentEditorService.setSerializedContent('**bold text**');
await nextTick();
});
it('hides the loading indicator', async () => {
expect(wrapper.findByTestId('content-editor-loading-indicator').exists()).toBe(false);
});
it('displays the initial content', async () => {
expect(wrapper.html()).toContain(initialContent);
});
});
});
});

View File

@ -35,6 +35,7 @@ RSpec.describe InviteMembersHelper do
it 'has expected common attributes' do
attributes = {
id: project.id,
root_id: project.root_ancestor.id,
name: project.name,
default_access_level: Gitlab::Access::GUEST
}