summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2022-03-23 12:07:27 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2022-03-23 12:07:27 +0000
commit3e68d3848770b492d314f8e2967c37f7fdd5d143 (patch)
tree01bd69a759c55ddf4ea1e5549a253cb0fd564854
parent52192e0f19ca790dc9f44bc45730434100f83d90 (diff)
downloadgitlab-ce-3e68d3848770b492d314f8e2967c37f7fdd5d143.tar.gz
Add latest changes from gitlab-org/gitlab@master
-rw-r--r--app/assets/javascripts/admin/users/components/actions/delete.vue39
-rw-r--r--app/assets/javascripts/admin/users/components/actions/delete_with_contributions.vue39
-rw-r--r--app/assets/javascripts/admin/users/components/actions/shared/shared_delete_action.vue52
-rw-r--r--app/assets/javascripts/admin/users/components/modals/delete_user_modal.vue112
-rw-r--r--app/assets/javascripts/admin/users/components/modals/delete_user_modal_event_hub.js5
-rw-r--r--app/assets/javascripts/admin/users/components/modals/user_modal_manager.vue77
-rw-r--r--app/assets/javascripts/admin/users/constants.js6
-rw-r--r--app/assets/javascripts/admin/users/index.js47
-rw-r--r--app/assets/javascripts/content_editor/components/loading_indicator.vue1
-rw-r--r--app/assets/javascripts/content_editor/extensions/code_block_highlight.js42
-rw-r--r--app/assets/javascripts/content_editor/services/code_block_language_loader.js35
-rw-r--r--app/assets/javascripts/content_editor/services/content_editor.js19
-rw-r--r--app/assets/javascripts/content_editor/services/create_content_editor.js9
-rw-r--r--app/assets/javascripts/contextual_sidebar.js25
-rw-r--r--app/assets/javascripts/ide/stores/plugins/terminal.js8
-rw-r--r--app/assets/javascripts/integrations/edit/index.js3
-rw-r--r--app/assets/javascripts/invite_members/components/invite_members_modal.vue6
-rw-r--r--app/assets/javascripts/invite_members/init_invite_members_modal.js73
-rw-r--r--app/assets/javascripts/issues/create_merge_request_dropdown.js6
-rw-r--r--app/assets/javascripts/issues/show/components/description.vue4
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/index/index.js5
-rw-r--r--app/assets/javascripts/work_items/components/item_title.vue5
-rw-r--r--app/assets/javascripts/work_items/components/work_item_detail_modal.vue37
-rw-r--r--app/assets/javascripts/work_items/components/work_item_title.vue73
-rw-r--r--app/assets/javascripts/work_items/constants.js9
-rw-r--r--app/assets/javascripts/work_items/graphql/create_work_item.mutation.graphql13
-rw-r--r--app/assets/javascripts/work_items/graphql/provider.js10
-rw-r--r--app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql13
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item.fragment.graphql8
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item.query.graphql15
-rw-r--r--app/assets/javascripts/work_items/pages/work_item_root.vue81
-rw-r--r--app/assets/stylesheets/framework/dropdowns.scss1
-rw-r--r--app/helpers/ide_helper.rb8
-rw-r--r--app/helpers/invite_members_helper.rb1
-rw-r--r--app/models/clusters/applications/runner.rb2
-rw-r--r--app/views/admin/identities/index.html.haml2
-rw-r--r--app/views/admin/impersonation_tokens/index.html.haml2
-rw-r--r--app/views/admin/users/_modals.html.haml20
-rw-r--r--app/views/admin/users/_users.html.haml2
-rw-r--r--app/views/admin/users/keys.html.haml1
-rw-r--r--app/views/admin/users/projects.html.haml2
-rw-r--r--app/views/admin/users/show.html.haml1
-rw-r--r--app/views/projects/blob/_upload.html.haml5
-rw-r--r--app/views/projects/issues/_new_branch.html.haml4
-rw-r--r--doc/development/img/merge_request_reports_v14_7.pngbin66876 -> 21202 bytes
-rw-r--r--doc/development/img/merge_widget_v14_7.pngbin56335 -> 16526 bytes
-rw-r--r--doc/operations/incident_management/img/incident_metrics_tab_text_link_modal_v14_9.pngbin53167 -> 20989 bytes
-rw-r--r--doc/user/group/epics/img/related_epic_block_v14_9.pngbin31319 -> 11800 bytes
-rw-r--r--doc/user/group/epics/img/related_epics_add_v14_9.pngbin27785 -> 9862 bytes
-rw-r--r--doc/user/project/integrations/img/failed_badges.pngbin46485 -> 15999 bytes
-rw-r--r--doc/user/project/integrations/img/failed_banner.pngbin17440 -> 6783 bytes
-rw-r--r--doc/user/project/merge_requests/img/ff_merge_rebase_v14_9.pngbin17903 -> 6552 bytes
-rw-r--r--doc/user/search/img/code_search_git_blame_v14_9.pngbin40505 -> 13872 bytes
-rw-r--r--locale/gitlab.pot6
-rw-r--r--qa/qa/support/loglinking.rb2
-rw-r--r--qa/spec/resource/api_fabricator_spec.rb2
-rw-r--r--qa/spec/support/loglinking_spec.rb2
-rwxr-xr-xscripts/static-analysis1
-rw-r--r--spec/frontend/admin/users/components/actions/actions_spec.js42
-rw-r--r--spec/frontend/admin/users/components/modals/__snapshots__/delete_user_modal_spec.js.snap174
-rw-r--r--spec/frontend/admin/users/components/modals/delete_user_modal_spec.js104
-rw-r--r--spec/frontend/admin/users/components/modals/user_modal_manager_spec.js126
-rw-r--r--spec/frontend/content_editor/extensions/code_block_highlight_spec.js74
-rw-r--r--spec/frontend/content_editor/services/code_block_language_loader_spec.js70
-rw-r--r--spec/frontend/content_editor/services/content_editor_spec.js24
-rw-r--r--spec/frontend/ide/stores/plugins/terminal_spec.js16
-rw-r--r--spec/frontend/invite_members/mock_data/member_modal.js1
-rw-r--r--spec/frontend/issues/create_merge_request_dropdown_spec.js2
-rw-r--r--spec/frontend/issues/show/components/description_spec.js10
-rw-r--r--spec/frontend/work_items/components/work_item_detail_modal_spec.js (renamed from spec/frontend/work_items/components/work_item_detail_spec.js)45
-rw-r--r--spec/frontend/work_items/components/work_item_title_spec.js111
-rw-r--r--spec/frontend/work_items/mock_data.js3
-rw-r--r--spec/frontend/work_items/pages/work_item_root_spec.js104
-rw-r--r--spec/frontend_integration/content_editor/content_editor_integration_spec.js63
-rw-r--r--spec/helpers/invite_members_helper_spec.rb1
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
index 282d6f96aa6..1c06e7f4fd0 100644
--- a/doc/development/img/merge_request_reports_v14_7.png
+++ b/doc/development/img/merge_request_reports_v14_7.png
Binary files differ
diff --git a/doc/development/img/merge_widget_v14_7.png b/doc/development/img/merge_widget_v14_7.png
index d5e8ed8df52..86bc11802d1 100644
--- a/doc/development/img/merge_widget_v14_7.png
+++ b/doc/development/img/merge_widget_v14_7.png
Binary files differ
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
index 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
Binary files differ
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
index 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
Binary files differ
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
index 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
Binary files differ
diff --git a/doc/user/project/integrations/img/failed_badges.png b/doc/user/project/integrations/img/failed_badges.png
index d44415a8687..5a1f481e54c 100644
--- a/doc/user/project/integrations/img/failed_badges.png
+++ b/doc/user/project/integrations/img/failed_badges.png
Binary files differ
diff --git a/doc/user/project/integrations/img/failed_banner.png b/doc/user/project/integrations/img/failed_banner.png
index ba40c1301d6..4384ce07873 100644
--- a/doc/user/project/integrations/img/failed_banner.png
+++ b/doc/user/project/integrations/img/failed_banner.png
Binary files differ
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
index 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
Binary files differ
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
index 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
Binary files differ
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
}