diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2022-03-23 12:07:27 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2022-03-23 12:07:27 +0000 |
commit | 3e68d3848770b492d314f8e2967c37f7fdd5d143 (patch) | |
tree | 01bd69a759c55ddf4ea1e5549a253cb0fd564854 | |
parent | 52192e0f19ca790dc9f44bc45730434100f83d90 (diff) | |
download | gitlab-ce-3e68d3848770b492d314f8e2967c37f7fdd5d143.tar.gz |
Add latest changes from gitlab-org/gitlab@master
75 files changed, 949 insertions, 962 deletions
diff --git a/app/assets/javascripts/admin/users/components/actions/delete.vue b/app/assets/javascripts/admin/users/components/actions/delete.vue index e6dde5898e7..ae0c6731271 100644 --- a/app/assets/javascripts/admin/users/components/actions/delete.vue +++ b/app/assets/javascripts/admin/users/components/actions/delete.vue @@ -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> diff --git a/app/assets/javascripts/admin/users/components/actions/delete_with_contributions.vue b/app/assets/javascripts/admin/users/components/actions/delete_with_contributions.vue index bd920a91516..a39df1cbfb6 100644 --- a/app/assets/javascripts/admin/users/components/actions/delete_with_contributions.vue +++ b/app/assets/javascripts/admin/users/components/actions/delete_with_contributions.vue @@ -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> diff --git a/app/assets/javascripts/admin/users/components/actions/shared/shared_delete_action.vue b/app/assets/javascripts/admin/users/components/actions/shared/shared_delete_action.vue deleted file mode 100644 index c9f29b55dbf..00000000000 --- a/app/assets/javascripts/admin/users/components/actions/shared/shared_delete_action.vue +++ /dev/null @@ -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> diff --git a/app/assets/javascripts/admin/users/components/modals/delete_user_modal.vue b/app/assets/javascripts/admin/users/components/modals/delete_user_modal.vue index d7c08096376..31fe86775ee 100644 --- a/app/assets/javascripts/admin/users/components/modals/delete_user_modal.vue +++ b/app/assets/javascripts/admin/users/components/modals/delete_user_modal.vue @@ -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); - }, - secondaryButtonLabel() { - return s__('AdminUsers|Block user'); + return sprintf(this.i18n.title, { username: this.trimmedUsername }, false); }, canSubmit() { - return this.enteredUsername === this.trimmedUsername; + return this.enteredUsername && this.enteredUsername === this.trimmedUsername; }, - obstacles() { - try { - return JSON.parse(this.userDeletionObstacles); - } catch (e) { - Sentry.captureException(e); - } - return []; + secondaryButtonLabel() { + return s__('AdminUsers|Block user'); }, }, + 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> diff --git a/app/assets/javascripts/admin/users/components/modals/delete_user_modal_event_hub.js b/app/assets/javascripts/admin/users/components/modals/delete_user_modal_event_hub.js new file mode 100644 index 00000000000..001061dcc6b --- /dev/null +++ b/app/assets/javascripts/admin/users/components/modals/delete_user_modal_event_hub.js @@ -0,0 +1,5 @@ +import createEventHub from '~/helpers/event_hub_factory'; + +export default createEventHub(); + +export const EVENT_OPEN_DELETE_USER_MODAL = Symbol('OPEN'); diff --git a/app/assets/javascripts/admin/users/components/modals/user_modal_manager.vue b/app/assets/javascripts/admin/users/components/modals/user_modal_manager.vue deleted file mode 100644 index 1dfea3f1e7b..00000000000 --- a/app/assets/javascripts/admin/users/components/modals/user_modal_manager.vue +++ /dev/null @@ -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> diff --git a/app/assets/javascripts/admin/users/constants.js b/app/assets/javascripts/admin/users/constants.js index 4636c8705a5..9cd61d6b1db 100644 --- a/app/assets/javascripts/admin/users/constants.js +++ b/app/assets/javascripts/admin/users/constants.js @@ -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'; diff --git a/app/assets/javascripts/admin/users/index.js b/app/assets/javascripts/admin/users/index.js index 0c485d2a239..2bd37d3fffe 100644 --- a/app/assets/javascripts/admin/users/index.js +++ b/app/assets/javascripts/admin/users/index.js @@ -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(); }; diff --git a/app/assets/javascripts/content_editor/components/loading_indicator.vue b/app/assets/javascripts/content_editor/components/loading_indicator.vue index 5b9383d6e11..620324adb06 100644 --- a/app/assets/javascripts/content_editor/components/loading_indicator.vue +++ b/app/assets/javascripts/content_editor/components/loading_indicator.vue @@ -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> diff --git a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js index 204ac07d401..74f620b57b6 100644 --- a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js +++ b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js @@ -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, }); diff --git a/app/assets/javascripts/content_editor/services/code_block_language_loader.js b/app/assets/javascripts/content_editor/services/code_block_language_loader.js new file mode 100644 index 00000000000..3c12cf614a5 --- /dev/null +++ b/app/assets/javascripts/content_editor/services/code_block_language_loader.js @@ -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); + } +} diff --git a/app/assets/javascripts/content_editor/services/content_editor.js b/app/assets/javascripts/content_editor/services/content_editor.js index c5638da2daf..56badf965ee 100644 --- a/app/assets/javascripts/content_editor/services/content_editor.js +++ b/app/assets/javascripts/content_editor/services/content_editor.js @@ -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); diff --git a/app/assets/javascripts/content_editor/services/create_content_editor.js b/app/assets/javascripts/content_editor/services/create_content_editor.js index d9d39a387d0..5b637eee176 100644 --- a/app/assets/javascripts/content_editor/services/create_content_editor.js +++ b/app/assets/javascripts/content_editor/services/create_content_editor.js @@ -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 }); }; diff --git a/app/assets/javascripts/contextual_sidebar.js b/app/assets/javascripts/contextual_sidebar.js index d1a68e80608..f2ff77daf02 100644 --- a/app/assets/javascripts/contextual_sidebar.js +++ b/app/assets/javascripts/contextual_sidebar.js @@ -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(() => {}); + } + } } } diff --git a/app/assets/javascripts/ide/stores/plugins/terminal.js b/app/assets/javascripts/ide/stores/plugins/terminal.js index 94139d5bdf0..f7ed3075b0c 100644 --- a/app/assets/javascripts/ide/stores/plugins/terminal.js +++ b/app/assets/javascripts/ide/stores/plugins/terminal.js @@ -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, }; } diff --git a/app/assets/javascripts/integrations/edit/index.js b/app/assets/javascripts/integrations/edit/index.js index 3e58dd0be99..4860b288b0f 100644 --- a/app/assets/javascripts/integrations/edit/index.js +++ b/app/assets/javascripts/integrations/edit/index.js @@ -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]) => { diff --git a/app/assets/javascripts/invite_members/components/invite_members_modal.vue b/app/assets/javascripts/invite_members/components/invite_members_modal.vue index be48a58d838..da0c7860932 100644 --- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue +++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue @@ -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" diff --git a/app/assets/javascripts/invite_members/init_invite_members_modal.js b/app/assets/javascripts/invite_members/init_invite_members_modal.js index e9d620cedf0..cb05798bb9d 100644 --- a/app/assets/javascripts/invite_members/init_invite_members_modal.js +++ b/app/assets/javascripts/invite_members/init_invite_members_modal.js @@ -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; + }; +})(); diff --git a/app/assets/javascripts/issues/create_merge_request_dropdown.js b/app/assets/javascripts/issues/create_merge_request_dropdown.js index 247f8dd0bd6..949f0033b18 100644 --- a/app/assets/javascripts/issues/create_merge_request_dropdown.js +++ b/app/assets/javascripts/issues/create_merge_request_dropdown.js @@ -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'); } } diff --git a/app/assets/javascripts/issues/show/components/description.vue b/app/assets/javascripts/issues/show/components/description.vue index f885ca94c45..996bf307b8c 100644 --- a/app/assets/javascripts/issues/show/components/description.vue +++ b/app/assets/javascripts/issues/show/components/description.vue @@ -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"> diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/index/index.js b/app/assets/javascripts/pages/projects/learn_gitlab/index/index.js index 63357ea9c72..af4a6f8a0c9 100644 --- a/app/assets/javascripts/pages/projects/learn_gitlab/index/index.js +++ b/app/assets/javascripts/pages/projects/learn_gitlab/index/index.js @@ -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(); diff --git a/app/assets/javascripts/work_items/components/item_title.vue b/app/assets/javascripts/work_items/components/item_title.vue index 79840cc4f0f..92025c363e5 100644 --- a/app/assets/javascripts/work_items/components/item_title.vue +++ b/app/assets/javascripts/work_items/components/item_title.vue @@ -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" diff --git a/app/assets/javascripts/work_items/components/work_item_detail_modal.vue b/app/assets/javascripts/work_items/components/work_item_detail_modal.vue index d5687d26499..4d804ad3918 100644 --- a/app/assets/javascripts/work_items/components/work_item_detail_modal.vue +++ b/app/assets/javascripts/work_items/components/work_item_detail_modal.vue @@ -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> diff --git a/app/assets/javascripts/work_items/components/work_item_title.vue b/app/assets/javascripts/work_items/components/work_item_title.vue new file mode 100644 index 00000000000..47927d46672 --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_title.vue @@ -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> diff --git a/app/assets/javascripts/work_items/constants.js b/app/assets/javascripts/work_items/constants.js index 995c02a2c5b..22f24ad8af6 100644 --- a/app/assets/javascripts/work_items/constants.js +++ b/app/assets/javascripts/work_items/constants.js @@ -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'; diff --git a/app/assets/javascripts/work_items/graphql/create_work_item.mutation.graphql b/app/assets/javascripts/work_items/graphql/create_work_item.mutation.graphql index 9312d1c582b..7f9aaf43068 100644 --- a/app/assets/javascripts/work_items/graphql/create_work_item.mutation.graphql +++ b/app/assets/javascripts/work_items/graphql/create_work_item.mutation.graphql @@ -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 } } } diff --git a/app/assets/javascripts/work_items/graphql/provider.js b/app/assets/javascripts/work_items/graphql/provider.js index 28328a840cf..c15382874d8 100644 --- a/app/assets/javascripts/work_items/graphql/provider.js +++ b/app/assets/javascripts/work_items/graphql/provider.js @@ -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: [], diff --git a/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql b/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql index efb1ed8d6df..c0b6e856411 100644 --- a/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql +++ b/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql @@ -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 } } } diff --git a/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql new file mode 100644 index 00000000000..2707d6bb790 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql @@ -0,0 +1,8 @@ +fragment WorkItem on WorkItem { + id + title + workItemType { + id + name + } +} diff --git a/app/assets/javascripts/work_items/graphql/work_item.query.graphql b/app/assets/javascripts/work_items/graphql/work_item.query.graphql index b32cb4f28fb..1d3dae0649d 100644 --- a/app/assets/javascripts/work_items/graphql/work_item.query.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item.query.graphql @@ -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 } } diff --git a/app/assets/javascripts/work_items/pages/work_item_root.vue b/app/assets/javascripts/work_items/pages/work_item_root.vue index 32b6fc231a8..f29d2d8e59a 100644 --- a/app/assets/javascripts/work_items/pages/work_item_root.vue +++ b/app/assets/javascripts/work_items/pages/work_item_root.vue @@ -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> diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index ef17600b2d2..5c6d9266f7c 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -503,6 +503,7 @@ &.dropdown-menu-user-link::before { top: 50%; + transform: translateY(-50%); } } diff --git a/app/helpers/ide_helper.rb b/app/helpers/ide_helper.rb index bd1571f3956..4b463b9971d 100644 --- a/app/helpers/ide_helper.rb +++ b/app/helpers/ide_helper.rb @@ -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') diff --git a/app/helpers/invite_members_helper.rb b/app/helpers/invite_members_helper.rb index a2dde29e25d..c4fbf639d40 100644 --- a/app/helpers/invite_members_helper.rb +++ b/app/helpers/invite_members_helper.rb @@ -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 } diff --git a/app/models/clusters/applications/runner.rb b/app/models/clusters/applications/runner.rb index 07eaca87fad..e62b6fa5fc5 100644 --- a/app/models/clusters/applications/runner.rb +++ b/app/models/clusters/applications/runner.rb @@ -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' diff --git a/app/views/admin/identities/index.html.haml b/app/views/admin/identities/index.html.haml index 3b3042b5506..a4f1ce4afc0 100644 --- a/app/views/admin/identities/index.html.haml +++ b/app/views/admin/identities/index.html.haml @@ -15,5 +15,3 @@ = render @identities - else %h4= _('This user has no identities') - -= render partial: 'admin/users/modals' diff --git a/app/views/admin/impersonation_tokens/index.html.haml b/app/views/admin/impersonation_tokens/index.html.haml index 26fbba83a32..2c526bb38d8 100644 --- a/app/views/admin/impersonation_tokens/index.html.haml +++ b/app/views/admin/impersonation_tokens/index.html.haml @@ -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' diff --git a/app/views/admin/users/_modals.html.haml b/app/views/admin/users/_modals.html.haml deleted file mode 100644 index 0890990f476..00000000000 --- a/app/views/admin/users/_modals.html.haml +++ /dev/null @@ -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.') - diff --git a/app/views/admin/users/_users.html.haml b/app/views/admin/users/_users.html.haml index ad7ce57ebda..ad6e1668e2f 100644 --- a/app/views/admin/users/_users.html.haml +++ b/app/views/admin/users/_users.html.haml @@ -68,5 +68,3 @@ = gl_loading_icon(size: 'lg', css_class: 'gl-my-7') = paginate_collection @users - -= render partial: 'admin/users/modals' diff --git a/app/views/admin/users/keys.html.haml b/app/views/admin/users/keys.html.haml index 28024ae084f..5f9d11af7c1 100644 --- a/app/views/admin/users/keys.html.haml +++ b/app/views/admin/users/keys.html.haml @@ -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' diff --git a/app/views/admin/users/projects.html.haml b/app/views/admin/users/projects.html.haml index 580cfe9f956..2f6c08f123e 100644 --- a/app/views/admin/users/projects.html.haml +++ b/app/views/admin/users/projects.html.haml @@ -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' diff --git a/app/views/admin/users/show.html.haml b/app/views/admin/users/show.html.haml index 94542af3b96..889c17a3689 100644 --- a/app/views/admin/users/show.html.haml +++ b/app/views/admin/users/show.html.haml @@ -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' diff --git a/app/views/projects/blob/_upload.html.haml b/app/views/projects/blob/_upload.html.haml index 1d3bec1ad44..3d7b9e21d5d 100644 --- a/app/views/projects/blob/_upload.html.haml +++ b/app/views/projects/blob/_upload.html.haml @@ -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] diff --git a/app/views/projects/issues/_new_branch.html.haml b/app/views/projects/issues/_new_branch.html.haml index f6ed6c26752..630abd20d03 100644 --- a/app/views/projects/issues/_new_branch.html.haml +++ b/app/views/projects/issues/_new_branch.html.haml @@ -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' } } diff --git a/doc/development/img/merge_request_reports_v14_7.png b/doc/development/img/merge_request_reports_v14_7.png Binary files differindex 282d6f96aa6..1c06e7f4fd0 100644 --- a/doc/development/img/merge_request_reports_v14_7.png +++ b/doc/development/img/merge_request_reports_v14_7.png diff --git a/doc/development/img/merge_widget_v14_7.png b/doc/development/img/merge_widget_v14_7.png Binary files differindex d5e8ed8df52..86bc11802d1 100644 --- a/doc/development/img/merge_widget_v14_7.png +++ b/doc/development/img/merge_widget_v14_7.png diff --git a/doc/operations/incident_management/img/incident_metrics_tab_text_link_modal_v14_9.png b/doc/operations/incident_management/img/incident_metrics_tab_text_link_modal_v14_9.png Binary files differindex 1b045a13fc5..bae1e5ad1b4 100644 --- a/doc/operations/incident_management/img/incident_metrics_tab_text_link_modal_v14_9.png +++ b/doc/operations/incident_management/img/incident_metrics_tab_text_link_modal_v14_9.png diff --git a/doc/user/group/epics/img/related_epic_block_v14_9.png b/doc/user/group/epics/img/related_epic_block_v14_9.png Binary files differindex 7b5824b84d1..20fdce0151d 100644 --- a/doc/user/group/epics/img/related_epic_block_v14_9.png +++ b/doc/user/group/epics/img/related_epic_block_v14_9.png diff --git a/doc/user/group/epics/img/related_epics_add_v14_9.png b/doc/user/group/epics/img/related_epics_add_v14_9.png Binary files differindex 3da6eeaff43..112b900f2e3 100644 --- a/doc/user/group/epics/img/related_epics_add_v14_9.png +++ b/doc/user/group/epics/img/related_epics_add_v14_9.png diff --git a/doc/user/project/integrations/img/failed_badges.png b/doc/user/project/integrations/img/failed_badges.png Binary files differindex d44415a8687..5a1f481e54c 100644 --- a/doc/user/project/integrations/img/failed_badges.png +++ b/doc/user/project/integrations/img/failed_badges.png diff --git a/doc/user/project/integrations/img/failed_banner.png b/doc/user/project/integrations/img/failed_banner.png Binary files differindex ba40c1301d6..4384ce07873 100644 --- a/doc/user/project/integrations/img/failed_banner.png +++ b/doc/user/project/integrations/img/failed_banner.png diff --git a/doc/user/project/merge_requests/img/ff_merge_rebase_v14_9.png b/doc/user/project/merge_requests/img/ff_merge_rebase_v14_9.png Binary files differindex f4330549a57..17ce42e7a69 100644 --- a/doc/user/project/merge_requests/img/ff_merge_rebase_v14_9.png +++ b/doc/user/project/merge_requests/img/ff_merge_rebase_v14_9.png diff --git a/doc/user/search/img/code_search_git_blame_v14_9.png b/doc/user/search/img/code_search_git_blame_v14_9.png Binary files differindex 33d4e77e3f5..eb8d14de4a4 100644 --- a/doc/user/search/img/code_search_git_blame_v14_9.png +++ b/doc/user/search/img/code_search_git_blame_v14_9.png diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 0618fc0a840..9f0c6dcbc33 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -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 "" diff --git a/qa/qa/support/loglinking.rb b/qa/qa/support/loglinking.rb index 89519e9537c..ab38faeb887 100644 --- a/qa/qa/support/loglinking.rb +++ b/qa/qa/support/loglinking.rb @@ -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 diff --git a/qa/spec/resource/api_fabricator_spec.rb b/qa/spec/resource/api_fabricator_spec.rb index ec9907916eb..581236e5ac5 100644 --- a/qa/spec/resource/api_fabricator_spec.rb +++ b/qa/spec/resource/api_fabricator_spec.rb @@ -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 diff --git a/qa/spec/support/loglinking_spec.rb b/qa/spec/support/loglinking_spec.rb index cba8a139767..3a112ef36d7 100644 --- a/qa/spec/support/loglinking_spec.rb +++ b/qa/spec/support/loglinking_spec.rb @@ -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 diff --git a/scripts/static-analysis b/scripts/static-analysis index 9c6a948adc1..317652eb075 100755 --- a/scripts/static-analysis +++ b/scripts/static-analysis @@ -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 diff --git a/spec/frontend/admin/users/components/actions/actions_spec.js b/spec/frontend/admin/users/components/actions/actions_spec.js index fa485e73999..b758c15a91a 100644 --- a/spec/frontend/admin/users/components/actions/actions_spec.js +++ b/spec/frontend/admin/users/components/actions/actions_spec.js @@ -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); }, ); }); diff --git a/spec/frontend/admin/users/components/modals/__snapshots__/delete_user_modal_spec.js.snap b/spec/frontend/admin/users/components/modals/__snapshots__/delete_user_modal_spec.js.snap index 7a17ef2cc6c..265569ac0e3 100644 --- a/spec/frontend/admin/users/components/modals/__snapshots__/delete_user_modal_spec.js.snap +++ b/spec/frontend/admin/users/components/modals/__snapshots__/delete_user_modal_spec.js.snap @@ -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> `; diff --git a/spec/frontend/admin/users/components/modals/delete_user_modal_spec.js b/spec/frontend/admin/users/components/modals/delete_user_modal_spec.js index f875cd24ee1..09a345ac826 100644 --- a/spec/frontend/admin/users/components/modals/delete_user_modal_spec.js +++ b/spec/frontend/admin/users/components/modals/delete_user_modal_spec.js @@ -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 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 = (props = {}, stubs = {}) => { - wrapper = shallowMount(DeleteUserModal, { + 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("shows enabled buttons when user's name is entered without whitespace", async () => { - setUsername('John Smith'); + it('passes user name without whitespace to the obstacles', () => { + expect(findUserDeletionObstaclesList().props()).toMatchObject({ + userName: 'John Smith', + }); + }); - await nextTick(); + it("shows enabled buttons when user's name is entered without whitespace", async () => { + 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); }); }); }); diff --git a/spec/frontend/admin/users/components/modals/user_modal_manager_spec.js b/spec/frontend/admin/users/components/modals/user_modal_manager_spec.js deleted file mode 100644 index 4786357faa1..00000000000 --- a/spec/frontend/admin/users/components/modals/user_modal_manager_spec.js +++ /dev/null @@ -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); - }); - }); -}); diff --git a/spec/frontend/content_editor/extensions/code_block_highlight_spec.js b/spec/frontend/content_editor/extensions/code_block_highlight_spec.js index 05fa0f79ef0..02e5b1dc271 100644 --- a/spec/frontend/content_editor/extensions/code_block_highlight_spec.js +++ b/spec/frontend/content_editor/extensions/code_block_highlight_spec.js @@ -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 code, highlight, and js-syntax-highlight to code block element', () => { - const editorHtmlOutput = parseHTML(tiptapEditor.getHTML()).querySelector('pre'); + it('adds content-editor-code-block class to the pre element', () => { + const editorHtmlOutput = parseHTML(tiptapEditor.getHTML()).querySelector('pre'); - expect(editorHtmlOutput.classList.toString()).toContain('code highlight js-syntax-highlight'); + expect(editorHtmlOutput.classList.toString()).toContain('content-editor-code-block'); + }); }); - it('adds content-editor-code-block class to the pre element', () => { - const editorHtmlOutput = parseHTML(tiptapEditor.getHTML()).querySelector('pre'); + describe.each` + inputRule + ${'```'} + ${'~~~'} + `('when typing $inputRule input rule', ({ inputRule }) => { + const language = 'javascript'; + + beforeEach(() => { + triggerNodeInputRule({ + tiptapEditor, + inputRuleText: `${inputRule}${language} `, + }); + }); + + 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]); + }); }); }); diff --git a/spec/frontend/content_editor/services/code_block_language_loader_spec.js b/spec/frontend/content_editor/services/code_block_language_loader_spec.js new file mode 100644 index 00000000000..bb97c9afa41 --- /dev/null +++ b/spec/frontend/content_editor/services/code_block_language_loader_spec.js @@ -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); + }); + }); +}); diff --git a/spec/frontend/content_editor/services/content_editor_spec.js b/spec/frontend/content_editor/services/content_editor_spec.js index 3bc72b13302..5b7a27b501d 100644 --- a/spec/frontend/content_editor/services/content_editor_spec.js +++ b/spec/frontend/content_editor/services/content_editor_spec.js @@ -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', () => { diff --git a/spec/frontend/ide/stores/plugins/terminal_spec.js b/spec/frontend/ide/stores/plugins/terminal_spec.js index 912de88cb39..193300540fd 100644 --- a/spec/frontend/ide/stores/plugins/terminal_spec.js +++ b/spec/frontend/ide/stores/plugins/terminal_spec.js @@ -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, }); }); diff --git a/spec/frontend/invite_members/mock_data/member_modal.js b/spec/frontend/invite_members/mock_data/member_modal.js index 590502909b2..1b0cc57fb5b 100644 --- a/spec/frontend/invite_members/mock_data/member_modal.js +++ b/spec/frontend/invite_members/mock_data/member_modal.js @@ -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 }, diff --git a/spec/frontend/issues/create_merge_request_dropdown_spec.js b/spec/frontend/issues/create_merge_request_dropdown_spec.js index c2cfb16fdf7..9691456dc3f 100644 --- a/spec/frontend/issues/create_merge_request_dropdown_spec.js +++ b/spec/frontend/issues/create_merge_request_dropdown_spec.js @@ -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> diff --git a/spec/frontend/issues/show/components/description_spec.js b/spec/frontend/issues/show/components/description_spec.js index 85b9bc098a1..6f6f50b89bc 100644 --- a/spec/frontend/issues/show/components/description_spec.js +++ b/spec/frontend/issues/show/components/description_spec.js @@ -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); diff --git a/spec/frontend/work_items/components/work_item_detail_spec.js b/spec/frontend/work_items/components/work_item_detail_modal_spec.js index c403680ba23..8112be8edec 100644 --- a/spec/frontend/work_items/components/work_item_detail_spec.js +++ b/spec/frontend/work_items/components/work_item_detail_modal_spec.js @@ -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); }); }); diff --git a/spec/frontend/work_items/components/work_item_title_spec.js b/spec/frontend/work_items/components/work_item_title_spec.js new file mode 100644 index 00000000000..cfd51d60617 --- /dev/null +++ b/spec/frontend/work_items/components/work_item_title_spec.js @@ -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', + }); + }); + }); +}); diff --git a/spec/frontend/work_items/mock_data.js b/spec/frontend/work_items/mock_data.js index fc732a6c06f..9d79fd49894 100644 --- a/spec/frontend/work_items/mock_data.js +++ b/spec/frontend/work_items/mock_data.js @@ -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', }, }, }, diff --git a/spec/frontend/work_items/pages/work_item_root_spec.js b/spec/frontend/work_items/pages/work_item_root_spec.js index 728495e0e23..332c2fe646b 100644 --- a/spec/frontend/work_items/pages/work_item_root_spec.js +++ b/spec/frontend/work_items/pages/work_item_root_spec.js @@ -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 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 successHandler = jest.fn().mockResolvedValue({ data: workItemQueryResponse }); + + 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'); + describe('when loading', () => { + beforeEach(() => { + createComponent(); + }); - await findTitle().vm.$emit('title-changed', mockUpdatedTitle); + it('renders WorkItemTitle in loading state', () => { + createComponent(); - expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ - mutation: updateWorkItemMutation, - variables: { - input: { - id: WORK_ITEM_GID, - title: mockUpdatedTitle, - }, - }, + expect(findWorkItemTitle().props('loading')).toBe(true); }); }); - describe('tracking', () => { - let trackingSpy; - + describe('when loaded', () => { beforeEach(() => { - trackingSpy = mockTracking('_category_', undefined, jest.spyOn); - createComponent(); + return waitForPromises(); }); - afterEach(() => { - unmockTracking(); + it('does not render WorkItemTitle in loading state', () => { + expect(findWorkItemTitle().props('loading')).toBe(false); }); + }); - it('tracks item title updates', async () => { - await findTitle().vm.$emit('title-changed', mockUpdatedTitle); + it('shows an error message when the work item query was unsuccessful', async () => { + const errorHandler = jest.fn().mockRejectedValue('Oops'); + createComponent({ handler: errorHandler }); + await waitForPromises(); - await waitForPromises(); + expect(errorHandler).toHaveBeenCalled(); + expect(findAlert().text()).toBe(i18n.fetchError); + }); - expect(trackingSpy).toHaveBeenCalledTimes(1); - expect(trackingSpy).toHaveBeenCalledWith('workItems:show', undefined, { - action: 'updated_title', - category: 'workItems:show', - label: 'item_title', - property: '[type_work_item]', - }); - }); + 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); }); }); diff --git a/spec/frontend_integration/content_editor/content_editor_integration_spec.js b/spec/frontend_integration/content_editor/content_editor_integration_spec.js new file mode 100644 index 00000000000..1b45c0d43a3 --- /dev/null +++ b/spec/frontend_integration/content_editor/content_editor_integration_spec.js @@ -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); + }); + }); + }); +}); diff --git a/spec/helpers/invite_members_helper_spec.rb b/spec/helpers/invite_members_helper_spec.rb index 796d68e290e..8ef24f13c03 100644 --- a/spec/helpers/invite_members_helper_spec.rb +++ b/spec/helpers/invite_members_helper_spec.rb @@ -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 } |