Add latest changes from gitlab-org/gitlab@master
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
import createEventHub from '~/helpers/event_hub_factory';
|
||||
|
||||
export default createEventHub();
|
||||
|
||||
export const EVENT_OPEN_DELETE_USER_MODAL = Symbol('OPEN');
|
|
@ -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>
|
|
@ -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';
|
||||
|
|
|
@ -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();
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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 });
|
||||
};
|
||||
|
|
|
@ -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(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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]) => {
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
})();
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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';
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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: [],
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
fragment WorkItem on WorkItem {
|
||||
id
|
||||
title
|
||||
workItemType {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -503,6 +503,7 @@
|
|||
|
||||
&.dropdown-menu-user-link::before {
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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'
|
||||
|
||||
|
|
|
@ -15,5 +15,3 @@
|
|||
= render @identities
|
||||
- else
|
||||
%h4= _('This user has no identities')
|
||||
|
||||
= render partial: 'admin/users/modals'
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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.')
|
||||
|
|
@ -68,5 +68,3 @@
|
|||
= gl_loading_icon(size: 'lg', css_class: 'gl-my-7')
|
||||
|
||||
= paginate_collection @users
|
||||
|
||||
= render partial: 'admin/users/modals'
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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]
|
||||
|
||||
|
|
|
@ -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' } }
|
||||
|
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 21 KiB |
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 16 KiB |
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 20 KiB |
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 12 KiB |
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 9.6 KiB |
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 16 KiB |
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 6.6 KiB |
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 6.4 KiB |
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 14 KiB |
|
@ -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 ""
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
`;
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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', () => {
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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 },
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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
|
||||
}
|
||||
|
|