Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
e40c68997d
commit
c4b4a75c35
|
@ -626,7 +626,7 @@ lib/gitlab/checks/** @proglottis @toon @zj-gitlab
|
|||
/doc/raketasks/x509_signatures.md @aqualls
|
||||
/doc/security/ @eread
|
||||
/doc/ssh/index.md @eread
|
||||
/doc/subscriptions/ @sselhorn
|
||||
/doc/subscriptions/ @fneill
|
||||
/doc/system_hooks/system_hooks.md @kpaizee
|
||||
/doc/topics/authentication/index.md @eread
|
||||
/doc/topics/autodevops/customize.md @marcia
|
||||
|
@ -791,5 +791,5 @@ lib/gitlab/checks/** @proglottis @toon @zj-gitlab
|
|||
/doc/user/snippets.md @aqualls
|
||||
/doc/user/tasks.md @msedlakjakubowski
|
||||
/doc/user/todos.md @msedlakjakubowski
|
||||
/doc/user/usage_quotas.md @sselhorn
|
||||
/doc/user/usage_quotas.md @fneill
|
||||
/doc/user/workspace/index.md @fneill
|
||||
|
|
|
@ -1 +1 @@
|
|||
63abf93ad828f7a7924f3e0bb1fea8ea43d7c6af
|
||||
c35fba1c073deed91d1a1f9f11dd668856841d80
|
||||
|
|
|
@ -1,31 +0,0 @@
|
|||
import $ from 'jquery';
|
||||
|
||||
export default class AjaxLoadingSpinner {
|
||||
static init() {
|
||||
const $elements = $('.js-ajax-loading-spinner');
|
||||
$elements.on('ajax:beforeSend', AjaxLoadingSpinner.ajaxBeforeSend);
|
||||
}
|
||||
|
||||
static ajaxBeforeSend(e) {
|
||||
const button = e.target;
|
||||
const newButton = document.createElement('button');
|
||||
newButton.classList.add('btn', 'btn-default', 'disabled', 'gl-button');
|
||||
newButton.setAttribute('disabled', 'disabled');
|
||||
|
||||
const spinner = document.createElement('span');
|
||||
spinner.classList.add('align-text-bottom', 'gl-spinner', 'gl-spinner-sm', 'gl-spinner-orange');
|
||||
newButton.appendChild(spinner);
|
||||
|
||||
button.classList.add('hidden');
|
||||
button.parentNode.insertBefore(newButton, button.nextSibling);
|
||||
|
||||
$(button).one('ajax:error', () => {
|
||||
newButton.remove();
|
||||
button.classList.remove('hidden');
|
||||
});
|
||||
|
||||
$(button).one('ajax:success', () => {
|
||||
$(button).off('ajax:beforeSend', AjaxLoadingSpinner.ajaxBeforeSend);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -1,7 +1,6 @@
|
|||
<script>
|
||||
import { GlLoadingIcon } from '@gitlab/ui';
|
||||
import { EditorContent as TiptapEditorContent } from '@tiptap/vue-2';
|
||||
import { LOADING_CONTENT_EVENT, LOADING_SUCCESS_EVENT, LOADING_ERROR_EVENT } from '../constants';
|
||||
import { createContentEditor } from '../services/create_content_editor';
|
||||
import ContentEditorAlert from './content_editor_alert.vue';
|
||||
import ContentEditorProvider from './content_editor_provider.vue';
|
||||
|
@ -55,17 +54,12 @@ export default {
|
|||
extensions,
|
||||
serializerConfig,
|
||||
});
|
||||
|
||||
this.contentEditor.on(LOADING_CONTENT_EVENT, this.displayLoadingIndicator);
|
||||
this.contentEditor.on(LOADING_SUCCESS_EVENT, this.hideLoadingIndicator);
|
||||
this.contentEditor.on(LOADING_ERROR_EVENT, this.hideLoadingIndicator);
|
||||
},
|
||||
mounted() {
|
||||
this.$emit('initialized', this.contentEditor);
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.contentEditor.dispose();
|
||||
this.contentEditor.off(LOADING_CONTENT_EVENT, this.displayLoadingIndicator);
|
||||
this.contentEditor.off(LOADING_SUCCESS_EVENT, this.hideLoadingIndicator);
|
||||
this.contentEditor.off(LOADING_ERROR_EVENT, this.hideLoadingIndicator);
|
||||
},
|
||||
methods: {
|
||||
displayLoadingIndicator() {
|
||||
|
@ -91,7 +85,14 @@ export default {
|
|||
<template>
|
||||
<content-editor-provider :content-editor="contentEditor">
|
||||
<div>
|
||||
<editor-state-observer @docUpdate="notifyChange" @focus="focus" @blur="blur" />
|
||||
<editor-state-observer
|
||||
@loading="displayLoadingIndicator"
|
||||
@loadingSuccess="hideLoadingIndicator"
|
||||
@loadingError="hideLoadingIndicator"
|
||||
@docUpdate="notifyChange"
|
||||
@focus="focus"
|
||||
@blur="blur"
|
||||
/>
|
||||
<content-editor-alert />
|
||||
<div
|
||||
data-testid="content-editor"
|
||||
|
|
|
@ -8,6 +8,7 @@ export default {
|
|||
|
||||
return {
|
||||
contentEditor,
|
||||
eventHub: contentEditor.eventHub,
|
||||
tiptapEditor: contentEditor.tiptapEditor,
|
||||
};
|
||||
},
|
||||
|
|
|
@ -1,5 +1,11 @@
|
|||
<script>
|
||||
import { debounce } from 'lodash';
|
||||
import {
|
||||
LOADING_CONTENT_EVENT,
|
||||
LOADING_SUCCESS_EVENT,
|
||||
LOADING_ERROR_EVENT,
|
||||
ALERT_EVENT,
|
||||
} from '../constants';
|
||||
|
||||
export const tiptapToComponentMap = {
|
||||
update: 'docUpdate',
|
||||
|
@ -7,30 +13,48 @@ export const tiptapToComponentMap = {
|
|||
transaction: 'transaction',
|
||||
focus: 'focus',
|
||||
blur: 'blur',
|
||||
alert: 'alert',
|
||||
};
|
||||
|
||||
export const eventHubEvents = [
|
||||
ALERT_EVENT,
|
||||
LOADING_CONTENT_EVENT,
|
||||
LOADING_SUCCESS_EVENT,
|
||||
LOADING_ERROR_EVENT,
|
||||
];
|
||||
|
||||
const getComponentEventName = (tiptapEventName) => tiptapToComponentMap[tiptapEventName];
|
||||
|
||||
export default {
|
||||
inject: ['tiptapEditor'],
|
||||
inject: ['tiptapEditor', 'eventHub'],
|
||||
created() {
|
||||
this.disposables = [];
|
||||
|
||||
Object.keys(tiptapToComponentMap).forEach((tiptapEvent) => {
|
||||
const eventHandler = debounce((params) => this.handleTipTapEvent(tiptapEvent, params), 100);
|
||||
const eventHandler = debounce(
|
||||
(params) => this.bubbleEvent(getComponentEventName(tiptapEvent), params),
|
||||
100,
|
||||
);
|
||||
|
||||
this.tiptapEditor?.on(tiptapEvent, eventHandler);
|
||||
|
||||
this.disposables.push(() => this.tiptapEditor?.off(tiptapEvent, eventHandler));
|
||||
});
|
||||
|
||||
eventHubEvents.forEach((event) => {
|
||||
const handler = (...params) => {
|
||||
this.bubbleEvent(event, ...params);
|
||||
};
|
||||
|
||||
this.eventHub.$on(event, handler);
|
||||
this.disposables.push(() => this.eventHub?.$off(event, handler));
|
||||
});
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.disposables.forEach((dispose) => dispose());
|
||||
},
|
||||
methods: {
|
||||
handleTipTapEvent(tiptapEvent, params) {
|
||||
this.$emit(getComponentEventName(tiptapEvent), params);
|
||||
bubbleEvent(eventHubEvent, params) {
|
||||
this.$emit(eventHubEvent, params);
|
||||
},
|
||||
},
|
||||
render() {
|
||||
|
|
|
@ -42,9 +42,10 @@ export const TEXT_STYLE_DROPDOWN_ITEMS = [
|
|||
},
|
||||
];
|
||||
|
||||
export const LOADING_CONTENT_EVENT = 'loadingContent';
|
||||
export const LOADING_CONTENT_EVENT = 'loading';
|
||||
export const LOADING_SUCCESS_EVENT = 'loadingSuccess';
|
||||
export const LOADING_ERROR_EVENT = 'loadingError';
|
||||
export const ALERT_EVENT = 'alert';
|
||||
|
||||
export const PARSE_HTML_PRIORITY_LOWEST = 1;
|
||||
export const PARSE_HTML_PRIORITY_DEFAULT = 50;
|
||||
|
@ -56,3 +57,4 @@ export const EXTENSION_PRIORITY_LOWER = 75;
|
|||
* https://tiptap.dev/guide/custom-extensions/#priority
|
||||
*/
|
||||
export const EXTENSION_PRIORITY_DEFAULT = 100;
|
||||
export const EXTENSION_PRIORITY_HIGHEST = 200;
|
||||
|
|
|
@ -9,15 +9,22 @@ export default Extension.create({
|
|||
return {
|
||||
uploadsPath: null,
|
||||
renderMarkdown: null,
|
||||
eventHub: null,
|
||||
};
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
uploadAttachment: ({ file }) => () => {
|
||||
const { uploadsPath, renderMarkdown } = this.options;
|
||||
const { uploadsPath, renderMarkdown, eventHub } = this.options;
|
||||
|
||||
return handleFileEvent({ file, uploadsPath, renderMarkdown, editor: this.editor });
|
||||
return handleFileEvent({
|
||||
file,
|
||||
uploadsPath,
|
||||
renderMarkdown,
|
||||
editor: this.editor,
|
||||
eventHub,
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
|
@ -29,23 +36,25 @@ export default Extension.create({
|
|||
key: new PluginKey('attachment'),
|
||||
props: {
|
||||
handlePaste: (_, event) => {
|
||||
const { uploadsPath, renderMarkdown } = this.options;
|
||||
const { uploadsPath, renderMarkdown, eventHub } = this.options;
|
||||
|
||||
return handleFileEvent({
|
||||
editor,
|
||||
file: event.clipboardData.files[0],
|
||||
uploadsPath,
|
||||
renderMarkdown,
|
||||
eventHub,
|
||||
});
|
||||
},
|
||||
handleDrop: (_, event) => {
|
||||
const { uploadsPath, renderMarkdown } = this.options;
|
||||
const { uploadsPath, renderMarkdown, eventHub } = this.options;
|
||||
|
||||
return handleFileEvent({
|
||||
editor,
|
||||
file: event.dataTransfer.files[0],
|
||||
uploadsPath,
|
||||
renderMarkdown,
|
||||
eventHub,
|
||||
});
|
||||
},
|
||||
},
|
||||
|
|
|
@ -1,17 +1,20 @@
|
|||
import eventHubFactory from '~/helpers/event_hub_factory';
|
||||
import { LOADING_CONTENT_EVENT, LOADING_SUCCESS_EVENT, LOADING_ERROR_EVENT } from '../constants';
|
||||
/* eslint-disable no-underscore-dangle */
|
||||
export class ContentEditor {
|
||||
constructor({ tiptapEditor, serializer }) {
|
||||
constructor({ tiptapEditor, serializer, eventHub }) {
|
||||
this._tiptapEditor = tiptapEditor;
|
||||
this._serializer = serializer;
|
||||
this._eventHub = eventHubFactory();
|
||||
this._eventHub = eventHub;
|
||||
}
|
||||
|
||||
get tiptapEditor() {
|
||||
return this._tiptapEditor;
|
||||
}
|
||||
|
||||
get eventHub() {
|
||||
return this._eventHub;
|
||||
}
|
||||
|
||||
get empty() {
|
||||
const doc = this.tiptapEditor?.state.doc;
|
||||
|
||||
|
@ -23,39 +26,23 @@ export class ContentEditor {
|
|||
this.tiptapEditor.destroy();
|
||||
}
|
||||
|
||||
once(type, handler) {
|
||||
this._eventHub.$once(type, handler);
|
||||
}
|
||||
|
||||
on(type, handler) {
|
||||
this._eventHub.$on(type, handler);
|
||||
}
|
||||
|
||||
emit(type, params = {}) {
|
||||
this._eventHub.$emit(type, params);
|
||||
}
|
||||
|
||||
off(type, handler) {
|
||||
this._eventHub.$off(type, handler);
|
||||
}
|
||||
|
||||
disposeAllEvents() {
|
||||
this._eventHub.dispose();
|
||||
}
|
||||
|
||||
async setSerializedContent(serializedContent) {
|
||||
const { _tiptapEditor: editor, _serializer: serializer } = this;
|
||||
const { _tiptapEditor: editor, _serializer: serializer, _eventHub: eventHub } = this;
|
||||
|
||||
try {
|
||||
this._eventHub.$emit(LOADING_CONTENT_EVENT);
|
||||
eventHub.$emit(LOADING_CONTENT_EVENT);
|
||||
const document = await serializer.deserialize({
|
||||
schema: editor.schema,
|
||||
content: serializedContent,
|
||||
});
|
||||
editor.commands.setContent(document);
|
||||
this._eventHub.$emit(LOADING_SUCCESS_EVENT);
|
||||
eventHub.$emit(LOADING_SUCCESS_EVENT);
|
||||
} catch (e) {
|
||||
this._eventHub.$emit(LOADING_ERROR_EVENT, e);
|
||||
eventHub.$emit(LOADING_ERROR_EVENT, e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { Editor } from '@tiptap/vue-2';
|
||||
import { isFunction } from 'lodash';
|
||||
import eventHubFactory from '~/helpers/event_hub_factory';
|
||||
import { PROVIDE_SERIALIZER_OR_RENDERER_ERROR } from '../constants';
|
||||
import Attachment from '../extensions/attachment';
|
||||
import Audio from '../extensions/audio';
|
||||
|
@ -78,8 +79,10 @@ export const createContentEditor = ({
|
|||
throw new Error(PROVIDE_SERIALIZER_OR_RENDERER_ERROR);
|
||||
}
|
||||
|
||||
const eventHub = eventHubFactory();
|
||||
|
||||
const builtInContentEditorExtensions = [
|
||||
Attachment.configure({ uploadsPath, renderMarkdown }),
|
||||
Attachment.configure({ uploadsPath, renderMarkdown, eventHub }),
|
||||
Audio,
|
||||
Blockquote,
|
||||
Bold,
|
||||
|
@ -137,5 +140,5 @@ export const createContentEditor = ({
|
|||
const tiptapEditor = createTiptapEditor({ extensions: trackedExtensions, ...tiptapOptions });
|
||||
const serializer = createMarkdownSerializer({ render: renderMarkdown, serializerConfig });
|
||||
|
||||
return new ContentEditor({ tiptapEditor, serializer });
|
||||
return new ContentEditor({ tiptapEditor, serializer, eventHub });
|
||||
};
|
||||
|
|
|
@ -49,7 +49,7 @@ export const uploadFile = async ({ uploadsPath, renderMarkdown, file }) => {
|
|||
return extractAttachmentLinkUrl(rendered);
|
||||
};
|
||||
|
||||
const uploadImage = async ({ editor, file, uploadsPath, renderMarkdown }) => {
|
||||
const uploadImage = async ({ editor, file, uploadsPath, renderMarkdown, eventHub }) => {
|
||||
const encodedSrc = await readFileAsDataURL(file);
|
||||
const { view } = editor;
|
||||
|
||||
|
@ -72,14 +72,14 @@ const uploadImage = async ({ editor, file, uploadsPath, renderMarkdown }) => {
|
|||
);
|
||||
} catch (e) {
|
||||
editor.commands.deleteRange({ from: position, to: position + 1 });
|
||||
editor.emit('alert', {
|
||||
eventHub.$emit('alert', {
|
||||
message: __('An error occurred while uploading the image. Please try again.'),
|
||||
variant: 'danger',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const uploadAttachment = async ({ editor, file, uploadsPath, renderMarkdown }) => {
|
||||
const uploadAttachment = async ({ editor, file, uploadsPath, renderMarkdown, eventHub }) => {
|
||||
await Promise.resolve();
|
||||
|
||||
const { view } = editor;
|
||||
|
@ -103,23 +103,23 @@ const uploadAttachment = async ({ editor, file, uploadsPath, renderMarkdown }) =
|
|||
);
|
||||
} catch (e) {
|
||||
editor.commands.deleteRange({ from, to: from + 1 });
|
||||
editor.emit('alert', {
|
||||
eventHub.$emit('alert', {
|
||||
message: __('An error occurred while uploading the file. Please try again.'),
|
||||
variant: 'danger',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const handleFileEvent = ({ editor, file, uploadsPath, renderMarkdown }) => {
|
||||
export const handleFileEvent = ({ editor, file, uploadsPath, renderMarkdown, eventHub }) => {
|
||||
if (!file) return false;
|
||||
|
||||
if (acceptedMimes.image.includes(file?.type)) {
|
||||
uploadImage({ editor, file, uploadsPath, renderMarkdown });
|
||||
uploadImage({ editor, file, uploadsPath, renderMarkdown, eventHub });
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
uploadAttachment({ editor, file, uploadsPath, renderMarkdown });
|
||||
uploadAttachment({ editor, file, uploadsPath, renderMarkdown, eventHub });
|
||||
|
||||
return true;
|
||||
};
|
||||
|
|
|
@ -288,7 +288,6 @@ export default {
|
|||
'sidebar-collapsed': !isSidebarOpen,
|
||||
'has-archived-block': job.archived,
|
||||
}"
|
||||
:erase-path="job.erase_path"
|
||||
:size="jobLogSize"
|
||||
:raw-path="job.raw_path"
|
||||
:is-scroll-bottom-disabled="isScrollBottomDisabled"
|
||||
|
@ -325,6 +324,7 @@ export default {
|
|||
'right-sidebar-expanded': isSidebarOpen,
|
||||
'right-sidebar-collapsed': !isSidebarOpen,
|
||||
}"
|
||||
:erase-path="job.erase_path"
|
||||
:artifact-help-url="artifactHelpUrl"
|
||||
data-testid="job-sidebar"
|
||||
/>
|
||||
|
|
|
@ -5,7 +5,6 @@ import { __, s__, sprintf } from '~/locale';
|
|||
|
||||
export default {
|
||||
i18n: {
|
||||
eraseLogButtonLabel: s__('Job|Erase job log and artifacts'),
|
||||
scrollToBottomButtonLabel: s__('Job|Scroll to bottom'),
|
||||
scrollToTopButtonLabel: s__('Job|Scroll to top'),
|
||||
showRawButtonLabel: s__('Job|Show complete raw'),
|
||||
|
@ -18,11 +17,6 @@ export default {
|
|||
GlTooltip: GlTooltipDirective,
|
||||
},
|
||||
props: {
|
||||
erasePath: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
size: {
|
||||
type: Number,
|
||||
required: true,
|
||||
|
@ -97,20 +91,6 @@ export default {
|
|||
data-testid="job-raw-link-controller"
|
||||
icon="doc-text"
|
||||
/>
|
||||
|
||||
<gl-button
|
||||
v-if="erasePath"
|
||||
v-gl-tooltip.body
|
||||
:title="$options.i18n.eraseLogButtonLabel"
|
||||
:aria-label="$options.i18n.eraseLogButtonLabel"
|
||||
:href="erasePath"
|
||||
:data-confirm="__('Are you sure you want to erase this build?')"
|
||||
class="gl-ml-3"
|
||||
data-testid="job-log-erase-link"
|
||||
data-confirm-btn-variant="danger"
|
||||
data-method="post"
|
||||
icon="remove"
|
||||
/>
|
||||
<!-- eo links -->
|
||||
|
||||
<!-- scroll buttons -->
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<script>
|
||||
import { GlButton, GlLink, GlModalDirective } from '@gitlab/ui';
|
||||
import { GlButton, GlModalDirective } from '@gitlab/ui';
|
||||
import { mapGetters } from 'vuex';
|
||||
import { JOB_SIDEBAR } from '../constants';
|
||||
|
||||
|
@ -10,7 +10,6 @@ export default {
|
|||
},
|
||||
components: {
|
||||
GlButton,
|
||||
GlLink,
|
||||
},
|
||||
directives: {
|
||||
GlModal: GlModalDirective,
|
||||
|
@ -37,9 +36,18 @@ export default {
|
|||
:aria-label="$options.i18n.retryLabel"
|
||||
category="primary"
|
||||
variant="confirm"
|
||||
>{{ $options.i18n.retryLabel }}</gl-button
|
||||
>
|
||||
<gl-link v-else :href="href" class="btn gl-button btn-confirm" data-method="post" rel="nofollow"
|
||||
>{{ $options.i18n.retryLabel }}
|
||||
</gl-link>
|
||||
icon="retry"
|
||||
data-testid="retry-job-button"
|
||||
/>
|
||||
|
||||
<gl-button
|
||||
v-else
|
||||
:href="href"
|
||||
:aria-label="$options.i18n.retryLabel"
|
||||
category="primary"
|
||||
variant="confirm"
|
||||
icon="retry"
|
||||
data-method="post"
|
||||
data-testid="retry-job-link"
|
||||
/>
|
||||
</template>
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
<script>
|
||||
import { GlButton, GlIcon } from '@gitlab/ui';
|
||||
import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui';
|
||||
import { isEmpty } from 'lodash';
|
||||
import { mapActions, mapGetters, mapState } from 'vuex';
|
||||
import { s__ } from '~/locale';
|
||||
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
|
||||
import { JOB_SIDEBAR } from '../constants';
|
||||
import ArtifactsBlock from './artifacts_block.vue';
|
||||
|
@ -18,10 +19,17 @@ export const forwardDeploymentFailureModalId = 'forward-deployment-failure';
|
|||
export default {
|
||||
name: 'JobSidebar',
|
||||
i18n: {
|
||||
eraseLogButtonLabel: s__('Job|Erase job log and artifacts'),
|
||||
eraseLogConfirmText: s__('Job|Are you sure you want to erase this job log and artifacts?'),
|
||||
cancelJobButtonLabel: s__('Job|Cancel'),
|
||||
retryJobButtonLabel: s__('Job|Retry'),
|
||||
...JOB_SIDEBAR,
|
||||
},
|
||||
borderTopClass: ['gl-border-t-solid', 'gl-border-t-1', 'gl-border-t-gray-100'],
|
||||
forwardDeploymentFailureModalId,
|
||||
directives: {
|
||||
GlTooltip: GlTooltipDirective,
|
||||
},
|
||||
components: {
|
||||
ArtifactsBlock,
|
||||
CommitBlock,
|
||||
|
@ -41,6 +49,11 @@ export default {
|
|||
required: false,
|
||||
default: '',
|
||||
},
|
||||
erasePath: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapGetters(['hasForwardDeploymentFailure']),
|
||||
|
@ -81,8 +94,24 @@ export default {
|
|||
</h4>
|
||||
</tooltip-on-truncate>
|
||||
<div class="gl-flex-grow-1 gl-flex-shrink-0 gl-text-right">
|
||||
<gl-button
|
||||
v-if="erasePath"
|
||||
v-gl-tooltip.left
|
||||
:title="$options.i18n.eraseLogButtonLabel"
|
||||
:aria-label="$options.i18n.eraseLogButtonLabel"
|
||||
:href="erasePath"
|
||||
:data-confirm="$options.i18n.eraseLogConfirmText"
|
||||
class="gl-mr-2"
|
||||
data-testid="job-log-erase-link"
|
||||
data-confirm-btn-variant="danger"
|
||||
data-method="post"
|
||||
icon="remove"
|
||||
/>
|
||||
<job-sidebar-retry-button
|
||||
v-if="job.retry_path"
|
||||
v-gl-tooltip.left
|
||||
:title="$options.i18n.retryJobButtonLabel"
|
||||
:aria-label="$options.i18n.retryJobButtonLabel"
|
||||
:category="retryButtonCategory"
|
||||
:href="job.retry_path"
|
||||
:modal-id="$options.forwardDeploymentFailureModalId"
|
||||
|
@ -92,12 +121,15 @@ export default {
|
|||
/>
|
||||
<gl-button
|
||||
v-if="job.cancel_path"
|
||||
v-gl-tooltip.left
|
||||
:title="$options.i18n.cancelJobButtonLabel"
|
||||
:aria-label="$options.i18n.cancelJobButtonLabel"
|
||||
:href="job.cancel_path"
|
||||
icon="cancel"
|
||||
data-method="post"
|
||||
data-testid="cancel-button"
|
||||
rel="nofollow"
|
||||
>{{ $options.i18n.cancel }}
|
||||
</gl-button>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<gl-button
|
||||
|
|
|
@ -1,12 +1,9 @@
|
|||
import initDeprecatedRemoveRowBehavior from '~/behaviors/deprecated_remove_row_behavior';
|
||||
import AjaxLoadingSpinner from '~/branches/ajax_loading_spinner';
|
||||
import BranchSortDropdown from '~/branches/branch_sort_dropdown';
|
||||
import initDiverganceGraph from '~/branches/divergence_graph';
|
||||
import initDeleteBranchButton from '~/branches/init_delete_branch_button';
|
||||
import initDeleteBranchModal from '~/branches/init_delete_branch_modal';
|
||||
|
||||
AjaxLoadingSpinner.init();
|
||||
|
||||
const { divergingCountsEndpoint, defaultBranch } = document.querySelector(
|
||||
'.js-branch-list',
|
||||
).dataset;
|
||||
|
|
|
@ -0,0 +1,195 @@
|
|||
<script>
|
||||
import { uniqueId } from 'lodash';
|
||||
import { GlButton, GlFormGroup, GlFormInputGroup } from '@gitlab/ui';
|
||||
import { s__ } from '~/locale';
|
||||
|
||||
const VALIDATION_STATE = {
|
||||
NO_VALIDATION: null,
|
||||
INVALID: false,
|
||||
VALID: true,
|
||||
};
|
||||
|
||||
export const i18n = {
|
||||
addStepButtonLabel: s__('PipelineWizardListWidget|add another step'),
|
||||
removeStepButtonLabel: s__('PipelineWizardListWidget|remove step'),
|
||||
invalidFeedback: s__('PipelineWizardInputValidation|This value is not valid'),
|
||||
errors: {
|
||||
needsAnyValueError: s__('PipelineWizardInputValidation|At least one entry is required'),
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
i18n,
|
||||
name: 'ListWidget',
|
||||
components: {
|
||||
GlButton,
|
||||
GlFormGroup,
|
||||
GlFormInputGroup,
|
||||
},
|
||||
props: {
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
default: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
invalidFeedback: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: i18n.invalidFeedback,
|
||||
},
|
||||
id: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: () => uniqueId('listWidget-'),
|
||||
},
|
||||
pattern: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
required: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
validate: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
touched: false,
|
||||
value: this.default ? this.default.map(this.getAsValueEntry) : [this.getAsValueEntry(null)],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
sanitizedValue() {
|
||||
// Filter out empty steps
|
||||
return this.value.filter(({ value }) => Boolean(value)).map(({ value }) => value) || [];
|
||||
},
|
||||
hasAnyValue() {
|
||||
return this.value.some(({ value }) => Boolean(value));
|
||||
},
|
||||
needsAnyValue() {
|
||||
return this.required && !this.value.some(({ value }) => Boolean(value));
|
||||
},
|
||||
inputFieldStates() {
|
||||
return this.value.map(this.getValidationStateForValue);
|
||||
},
|
||||
inputGroupState() {
|
||||
return this.showValidationState
|
||||
? this.inputFieldStates.every((v) => v !== VALIDATION_STATE.INVALID)
|
||||
: VALIDATION_STATE.NO_VALIDATION;
|
||||
},
|
||||
showValidationState() {
|
||||
return this.touched || this.validate;
|
||||
},
|
||||
feedback() {
|
||||
return this.needsAnyValue
|
||||
? this.$options.i18n.errors.needsAnyValueError
|
||||
: this.invalidFeedback;
|
||||
},
|
||||
},
|
||||
async created() {
|
||||
if (this.default) {
|
||||
// emit an updated default value
|
||||
await this.$nextTick();
|
||||
this.$emit('input', this.sanitizedValue);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
addInputField() {
|
||||
this.value.push(this.getAsValueEntry(null));
|
||||
},
|
||||
getAsValueEntry(value) {
|
||||
return {
|
||||
id: uniqueId('listValue-'),
|
||||
value,
|
||||
};
|
||||
},
|
||||
getValidationStateForValue({ value }, fieldIndex) {
|
||||
// If we require a value to be set, mark the first
|
||||
// field as invalid, but not all of them.
|
||||
if (this.needsAnyValue && fieldIndex === 0) return VALIDATION_STATE.INVALID;
|
||||
if (!value) return VALIDATION_STATE.NO_VALIDATION;
|
||||
return this.passesPatternValidation(value)
|
||||
? VALIDATION_STATE.VALID
|
||||
: VALIDATION_STATE.INVALID;
|
||||
},
|
||||
passesPatternValidation(v) {
|
||||
return !this.pattern || new RegExp(this.pattern).test(v);
|
||||
},
|
||||
async onValueUpdate() {
|
||||
await this.$nextTick();
|
||||
this.$emit('input', this.sanitizedValue);
|
||||
},
|
||||
onTouch() {
|
||||
this.touched = true;
|
||||
},
|
||||
removeValue(index) {
|
||||
this.value.splice(index, 1);
|
||||
this.onValueUpdate();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="gl-mb-6">
|
||||
<gl-form-group
|
||||
:invalid-feedback="feedback"
|
||||
:label="label"
|
||||
:label-description="description"
|
||||
:state="inputGroupState"
|
||||
class="gl-mb-2"
|
||||
>
|
||||
<gl-form-input-group
|
||||
v-for="(item, i) in value"
|
||||
:key="item.id"
|
||||
v-model.trim="value[i].value"
|
||||
:placeholder="i === 0 ? placeholder : undefined"
|
||||
:state="inputFieldStates[i]"
|
||||
class="gl-mb-2"
|
||||
type="text"
|
||||
@blur="onTouch"
|
||||
@input="onValueUpdate"
|
||||
>
|
||||
<template v-if="value.length > 1" #append>
|
||||
<gl-button
|
||||
:aria-label="$options.i18n.removeStepButtonLabel"
|
||||
category="secondary"
|
||||
data-testid="remove-step-button"
|
||||
icon="remove"
|
||||
@click="removeValue"
|
||||
/>
|
||||
</template>
|
||||
</gl-form-input-group>
|
||||
</gl-form-group>
|
||||
<gl-button
|
||||
category="tertiary"
|
||||
data-testid="add-step-button"
|
||||
icon="plus"
|
||||
size="small"
|
||||
variant="confirm"
|
||||
@click="addInputField"
|
||||
>
|
||||
{{ $options.i18n.addStepButtonLabel }}
|
||||
</gl-button>
|
||||
</div>
|
||||
</template>
|
|
@ -31,15 +31,21 @@ class Namespace
|
|||
# ActiveRecord. https://github.com/rails/rails/issues/13496
|
||||
# Ideally it would be:
|
||||
# `incorrect_traversal_ids.update_all('traversal_ids = cte.traversal_ids')`
|
||||
sql = """
|
||||
UPDATE namespaces
|
||||
SET traversal_ids = cte.traversal_ids
|
||||
FROM (#{recursive_traversal_ids}) as cte
|
||||
WHERE namespaces.id = cte.id
|
||||
AND namespaces.traversal_ids::bigint[] <> cte.traversal_ids
|
||||
"""
|
||||
sql = <<-SQL
|
||||
UPDATE namespaces
|
||||
SET traversal_ids = cte.traversal_ids
|
||||
FROM (#{recursive_traversal_ids}) as cte
|
||||
WHERE namespaces.id = cte.id
|
||||
AND namespaces.traversal_ids::bigint[] <> cte.traversal_ids
|
||||
SQL
|
||||
|
||||
Namespace.transaction do
|
||||
@root.lock!
|
||||
if Feature.enabled?(:for_no_key_update_lock, default_enabled: :yaml)
|
||||
@root.lock!("FOR NO KEY UPDATE")
|
||||
else
|
||||
@root.lock!
|
||||
end
|
||||
|
||||
Namespace.connection.exec_query(sql)
|
||||
end
|
||||
rescue ActiveRecord::Deadlocked
|
||||
|
|
|
@ -60,7 +60,7 @@ module ErrorTracking
|
|||
end
|
||||
|
||||
def actor
|
||||
return event['transaction'] if event['transaction']
|
||||
return event['transaction'] if event['transaction'].present?
|
||||
|
||||
# Some SDKs do not have a transaction attribute.
|
||||
# So we build it by combining function name and module name from
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
name: for_no_key_update_lock
|
||||
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/81239
|
||||
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/353619
|
||||
milestone: '14.9'
|
||||
type: development
|
||||
group: group::workspaces
|
||||
default_enabled: false
|
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
name: use_received_header_for_incoming_emails
|
||||
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/81489
|
||||
rollout_issue_url:
|
||||
milestone: '14.9'
|
||||
type: development
|
||||
group: group::certify
|
||||
default_enabled: true
|
|
@ -121,8 +121,8 @@ Then create policies that allow you to read these secrets (one for each secret):
|
|||
$ vault policy write myproject-staging - <<EOF
|
||||
# Policy name: myproject-staging
|
||||
#
|
||||
# Read-only permission on 'secret/data/myproject/staging/*' path
|
||||
path "secret/data/myproject/staging/*" {
|
||||
# Read-only permission on 'secret/myproject/staging/*' path
|
||||
path "secret/myproject/staging/*" {
|
||||
capabilities = [ "read" ]
|
||||
}
|
||||
EOF
|
||||
|
@ -131,8 +131,8 @@ Success! Uploaded policy: myproject-staging
|
|||
$ vault policy write myproject-production - <<EOF
|
||||
# Policy name: myproject-production
|
||||
#
|
||||
# Read-only permission on 'secret/data/myproject/production/*' path
|
||||
path "secret/data/myproject/production/*" {
|
||||
# Read-only permission on 'secret/myproject/production/*' path
|
||||
path "secret/myproject/production/*" {
|
||||
capabilities = [ "read" ]
|
||||
}
|
||||
EOF
|
||||
|
|
|
@ -140,7 +140,7 @@ to its **Pipelines** tab.
|
|||
|
||||
![Pipelines index page](img/pipelines_index_v13_0.png)
|
||||
|
||||
Click a pipeline to open the **Pipeline Details** page and show
|
||||
Select a pipeline to open the **Pipeline Details** page and show
|
||||
the jobs that were run for that pipeline. From here you can cancel a running pipeline,
|
||||
retry jobs on a failed pipeline, or [delete a pipeline](#delete-a-pipeline).
|
||||
|
||||
|
@ -246,7 +246,7 @@ For each `var` or `file_var`, a key and value are required.
|
|||
[Manual jobs](../jobs/job_control.md#create-a-job-that-must-be-run-manually),
|
||||
allow you to require manual interaction before moving forward in the pipeline.
|
||||
|
||||
You can do this straight from the pipeline graph. Just click the play button
|
||||
You can do this straight from the pipeline graph. Just select the play button
|
||||
to execute that particular job.
|
||||
|
||||
For example, your pipeline can start automatically, but require a manual action to
|
||||
|
@ -259,8 +259,8 @@ In the example below, the `production` stage has a job with a manual action:
|
|||
|
||||
> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/27188) in GitLab 11.11.
|
||||
|
||||
Multiple manual actions in a single stage can be started at the same time using the "Play all manual" button.
|
||||
After you click this button, each individual manual action is triggered and refreshed
|
||||
Multiple manual actions in a single stage can be started at the same time using the "Play all manual"
|
||||
After you select this action, each individual manual action is triggered and refreshed
|
||||
to an updated status.
|
||||
|
||||
This functionality is only available:
|
||||
|
@ -283,9 +283,9 @@ pipelines.
|
|||
|
||||
Users with the Owner role for a project can delete a pipeline
|
||||
by clicking on the pipeline in the **CI/CD > Pipelines** to get to the **Pipeline Details**
|
||||
page, then using the **Delete** button.
|
||||
page, then selecting **Delete**.
|
||||
|
||||
![Pipeline Delete Button](img/pipeline-delete.png)
|
||||
![Pipeline Delete](img/pipeline-delete.png)
|
||||
|
||||
WARNING:
|
||||
Deleting a pipeline expires all pipeline caches, and deletes all related objects,
|
||||
|
@ -314,7 +314,7 @@ sensitive information like deployment credentials and tokens.
|
|||
**Runners** marked as **protected** can run jobs only on protected
|
||||
branches, preventing untrusted code from executing on the protected runner and
|
||||
preserving deployment keys and other credentials from being unintentionally
|
||||
accessed. In order to ensure that jobs intended to be executed on protected
|
||||
accessed. To ensure that jobs intended to be executed on protected
|
||||
runners do not use regular runners, they must be tagged accordingly.
|
||||
|
||||
### How pipeline duration is calculated
|
||||
|
@ -434,7 +434,7 @@ fix it.
|
|||
|
||||
Pipeline mini graphs only display jobs by stage.
|
||||
|
||||
Stages in pipeline mini graphs are collapsible. Hover your mouse over them and click to expand their jobs.
|
||||
Stages in pipeline mini graphs are expandable. Hover your mouse over each stage to see the name and status, and select a stage to expand its jobs list.
|
||||
|
||||
| Mini graph | Mini graph expanded |
|
||||
|:-------------------------------------------------------------|:---------------------------------------------------------------|
|
||||
|
|
|
@ -1191,7 +1191,7 @@ has a longer discussion explaining the potential problems.
|
|||
|
||||
To prevent writes to the Git repository data, there are two possible approaches:
|
||||
|
||||
- Use [maintenance mode](../administration/maintenance_mode/index.md) **(PREMIUM SELF)** to place GitLab in a read-only state.
|
||||
- Use [maintenance mode](../administration/maintenance_mode/index.md) to place GitLab in a read-only state.
|
||||
- Create explicit downtime by stopping all Gitaly services before backing up the repositories:
|
||||
|
||||
```shell
|
||||
|
@ -1354,15 +1354,13 @@ To prepare the new server:
|
|||
|
||||
```shell
|
||||
sudo rm -f /var/opt/gitlab/redis/dump.rdb
|
||||
sudo chown <your-linux-username> /var/opt/gitlab/redis
|
||||
sudo mkdir /var/opt/gitlab/backups
|
||||
sudo chown <your-linux-username> /var/opt/gitlab/backups
|
||||
sudo chown <your-linux-username> /var/opt/gitlab/redis /var/opt/gitlab/backups
|
||||
```
|
||||
|
||||
### Prepare and transfer content from the old server
|
||||
|
||||
1. Ensure you have an up-to-date system-level backup or snapshot of the old server.
|
||||
1. Enable [maintenance mode](../administration/maintenance_mode/index.md) **(PREMIUM SELF)**,
|
||||
1. Enable [maintenance mode](../administration/maintenance_mode/index.md),
|
||||
if supported by your GitLab edition.
|
||||
1. Block new CI/CD jobs from starting:
|
||||
1. Edit `/etc/gitlab/gitlab.rb`, and set the following:
|
||||
|
@ -1465,7 +1463,7 @@ To prepare the new server:
|
|||
1. While still under the Sidekiq dashboard, select **Cron** and then **Enable All**
|
||||
to re-enable periodic background jobs.
|
||||
1. Test that read-only operations on the GitLab instance work as expected. For example, browse through project repository files, merge requests, and issues.
|
||||
1. Disable [Maintenance Mode](../administration/maintenance_mode/index.md) **(PREMIUM SELF)**, if previously enabled.
|
||||
1. Disable [Maintenance Mode](../administration/maintenance_mode/index.md), if previously enabled.
|
||||
1. Test that the GitLab instance is working as expected.
|
||||
1. If applicable, re-enable [incoming email](../administration/incoming_email.md) and test it is working as expected.
|
||||
1. Update your DNS or load balancer to point at the new server.
|
||||
|
|
|
@ -1,34 +1,9 @@
|
|||
---
|
||||
stage: Enablement
|
||||
group: Distribution
|
||||
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments
|
||||
redirect_to: 'index.md'
|
||||
remove_date: '2022-05-24'
|
||||
---
|
||||
|
||||
# Namespaces **(FREE SELF)**
|
||||
This document was moved to [another location](index.md).
|
||||
|
||||
This Rake task enables [namespaces](../user/group/index.md#namespaces) for projects.
|
||||
|
||||
## Enable usernames and namespaces for user projects
|
||||
|
||||
This command enables the namespaces feature. It moves every project in its
|
||||
namespace folder.
|
||||
|
||||
The **repository location changes as part of this task**, so you must **update all your Git URLs** to
|
||||
point to the new location.
|
||||
|
||||
To change your username:
|
||||
|
||||
1. In the top-right corner, select your avatar.
|
||||
1. Select **Edit profile**.
|
||||
1. On the left sidebar, select **Account**.
|
||||
1. In the **Change username** section, type the new username.
|
||||
1. Select **Update username**.
|
||||
|
||||
For example:
|
||||
|
||||
- Old path: `git@example.org:myrepo.git`.
|
||||
- New path: `git@example.org:username/myrepo.git` or `git@example.org:groupname/myrepo.git`.
|
||||
|
||||
```shell
|
||||
bundle exec rake gitlab:enable_namespaces RAILS_ENV=production
|
||||
```
|
||||
<!-- This redirect file can be deleted after <2022-05-24>. -->
|
||||
<!-- Before deletion, see: https://docs.gitlab.com/ee/development/documentation/#move-or-rename-a-page -->
|
||||
|
|
|
@ -12,12 +12,49 @@ instance entirely offline.
|
|||
## Installation
|
||||
|
||||
NOTE:
|
||||
This guide assumes the server is Ubuntu 18.04. Instructions for other servers may vary.
|
||||
This guide also assumes the server host resolves as `my-host`, which you should replace with your
|
||||
server's name.
|
||||
This guide assumes the server is Ubuntu 20.04 using the [Omnibus installation method](https://docs.gitlab.com/omnibus/) and will be running GitLab [Enterprise Edition](https://about.gitlab.com/install/ce-or-ee/). Instructions for other servers may vary.
|
||||
This guide also assumes the server host resolves as `my-host.internal`, which you should replace with your
|
||||
server's FQDN, and that you have acess to a different server with Internet access to download the required package files.
|
||||
|
||||
Follow the installation instructions [as outlined in the omnibus install
|
||||
guide](https://about.gitlab.com/install/#ubuntu), but make sure to specify an `http`
|
||||
<i class="fa fa-youtube-play youtube" aria-hidden="true"></i>
|
||||
For a video walkthrough of this process, see [Offline GitLab Installation: Downloading & Installing](https://www.youtube.com/watch?v=TJaq4ua2Prw).
|
||||
|
||||
### Download the GitLab package
|
||||
|
||||
You should [manually download the GitLab package](../../update/package/index.md#upgrade-using-a-manually-downloaded-package) and relevant dependencies using a server of the same operating system type that has access to the Internet.
|
||||
|
||||
If your offline environment has no local network access, you must manually transport across the relevant package files through physical media, such as a USB drive or writable DVD.
|
||||
|
||||
In Ubuntu, this can be performed on a server with Internet access using the following commands:
|
||||
|
||||
```shell
|
||||
# Download the bash script to prepare the repository
|
||||
curl --silent "https://packages.gitlab.com/install/repositories/gitlab/gitlab-ee/script.deb.sh" | sudo bash
|
||||
|
||||
# Download the gitlab-ee package and dependencies to /var/cache/apt/archives
|
||||
sudo apt-get install --download-only gitlab-ee
|
||||
|
||||
# Copy the contents of the apt download folder to a mounted media device
|
||||
sudo cp /var/cache/apt/archives/*.deb /path/to/mount
|
||||
```
|
||||
|
||||
### Install the GitLab package
|
||||
|
||||
Prerequisites:
|
||||
|
||||
- Before installing the GitLab package on your offline environment, ensure that you have installed all required dependencies first.
|
||||
|
||||
If you are using Ubuntu, you can install the dependency `.deb` packages you copied across with `dpkg`. Do not install the GitLab package yet.
|
||||
|
||||
```shell
|
||||
# Navigate to the physical media device
|
||||
sudo cd /path/to/mount
|
||||
|
||||
# Install the dependency packages
|
||||
sudo dpkg -i <package_name>.deb
|
||||
```
|
||||
|
||||
[Use the relevant commands for your operating system to install the package](../../update/package/index.md#upgrade-using-a-manually-downloaded-package) but make sure to specify an `http`
|
||||
URL for the `EXTERNAL_URL` installation step. Once installed, we can manually
|
||||
configure the SSL ourselves.
|
||||
|
||||
|
@ -25,8 +62,10 @@ It is strongly recommended to setup a domain for IP resolution rather than bind
|
|||
to the server's IP address. This better ensures a stable target for our certs' CN
|
||||
and makes long-term resolution simpler.
|
||||
|
||||
The following example for Ubuntu specifies the `EXTERNAL_URL` using HTTP and installs the GitLab package:
|
||||
|
||||
```shell
|
||||
sudo EXTERNAL_URL="http://my-host.internal" apt-get install gitlab-ee
|
||||
sudo EXTERNAL_URL="http://my-host.internal" dpkg -i <gitlab_package_name>.deb
|
||||
```
|
||||
|
||||
## Enabling SSL
|
||||
|
@ -38,7 +77,7 @@ Follow these steps to enable SSL for your fresh instance. Note that these steps
|
|||
|
||||
```ruby
|
||||
# Update external_url from "http" to "https"
|
||||
external_url "https://gitlab.example.com"
|
||||
external_url "https://my-host.internal"
|
||||
|
||||
# Set Let's Encrypt to false
|
||||
letsencrypt['enable'] = false
|
||||
|
|
|
@ -75,4 +75,9 @@ Access our [permissions](../../permissions.md) page for more information.
|
|||
When you [add a linked issue](#add-a-linked-issue), you can show that it **blocks** or
|
||||
**is blocked by** another issue.
|
||||
|
||||
Issues that block other issues have an icon (**{issue-block}**) shown in the issue lists and [boards](../issue_board.md).
|
||||
Issues that block other issues have an icon (**{issue-block}**) next to their title, shown in the
|
||||
issue lists and [boards](../issue_board.md).
|
||||
The icon disappears when the blocking issue is closed or their relationship is changed or
|
||||
[removed](#remove-a-linked-issue).
|
||||
|
||||
If you try to close a blocked issue using the "Close issue" button, a confirmation message appears.
|
||||
|
|
|
@ -8,6 +8,8 @@ module Gitlab
|
|||
class Receiver
|
||||
include Gitlab::Utils::StrongMemoize
|
||||
|
||||
RECEIVED_HEADER_REGEX = /for\s+\<(.+)\>/.freeze
|
||||
|
||||
def initialize(raw)
|
||||
@raw = raw
|
||||
end
|
||||
|
@ -37,6 +39,8 @@ module Gitlab
|
|||
delivered_to: delivered_to.map(&:value),
|
||||
envelope_to: envelope_to.map(&:value),
|
||||
x_envelope_to: x_envelope_to.map(&:value),
|
||||
# reduced down to what looks like an email in the received headers
|
||||
received_recipients: recipients_from_received_headers,
|
||||
meta: {
|
||||
client_id: "email/#{mail.from.first}",
|
||||
project: handler&.project&.full_path
|
||||
|
@ -82,7 +86,8 @@ module Gitlab
|
|||
find_key_from_references ||
|
||||
find_key_from_delivered_to_header ||
|
||||
find_key_from_envelope_to_header ||
|
||||
find_key_from_x_envelope_to_header
|
||||
find_key_from_x_envelope_to_header ||
|
||||
find_first_key_from_received_headers
|
||||
end
|
||||
|
||||
def ensure_references_array(references)
|
||||
|
@ -117,6 +122,10 @@ module Gitlab
|
|||
Array(mail[:x_envelope_to])
|
||||
end
|
||||
|
||||
def received
|
||||
Array(mail[:received])
|
||||
end
|
||||
|
||||
def find_key_from_delivered_to_header
|
||||
delivered_to.find do |header|
|
||||
key = email_class.key_from_address(header.value)
|
||||
|
@ -138,6 +147,21 @@ module Gitlab
|
|||
end
|
||||
end
|
||||
|
||||
def find_first_key_from_received_headers
|
||||
return unless ::Feature.enabled?(:use_received_header_for_incoming_emails, default_enabled: :yaml)
|
||||
|
||||
recipients_from_received_headers.find do |email|
|
||||
key = email_class.key_from_address(email)
|
||||
break key if key
|
||||
end
|
||||
end
|
||||
|
||||
def recipients_from_received_headers
|
||||
strong_memoize :emails_from_received_headers do
|
||||
received.map { |header| header.value[RECEIVED_HEADER_REGEX, 1] }.compact
|
||||
end
|
||||
end
|
||||
|
||||
def ignore_auto_reply!
|
||||
if auto_submitted? || auto_replied?
|
||||
raise AutoGeneratedEmailError
|
||||
|
|
|
@ -4738,9 +4738,6 @@ msgstr ""
|
|||
msgid "Are you sure you want to discard your changes?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Are you sure you want to erase this build?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Are you sure you want to import %d repository?"
|
||||
msgid_plural "Are you sure you want to import %d repositories?"
|
||||
msgstr[0] ""
|
||||
|
@ -21031,9 +21028,15 @@ msgstr ""
|
|||
msgid "Jobs|You're about to retry a job that failed because it attempted to deploy code that is older than the latest deployment. Retrying this job could result in overwriting the environment with the older source code."
|
||||
msgstr ""
|
||||
|
||||
msgid "Job|Are you sure you want to erase this job log and artifacts?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Job|Browse"
|
||||
msgstr ""
|
||||
|
||||
msgid "Job|Cancel"
|
||||
msgstr ""
|
||||
|
||||
msgid "Job|Complete Raw"
|
||||
msgstr ""
|
||||
|
||||
|
@ -21061,6 +21064,9 @@ msgstr ""
|
|||
msgid "Job|Pipeline"
|
||||
msgstr ""
|
||||
|
||||
msgid "Job|Retry"
|
||||
msgstr ""
|
||||
|
||||
msgid "Job|Scroll to bottom"
|
||||
msgstr ""
|
||||
|
||||
|
@ -26813,12 +26819,21 @@ msgstr ""
|
|||
msgid "PipelineWizardDefaultCommitMessage|Update %{filename}"
|
||||
msgstr ""
|
||||
|
||||
msgid "PipelineWizardInputValidation|At least one entry is required"
|
||||
msgstr ""
|
||||
|
||||
msgid "PipelineWizardInputValidation|This field is required"
|
||||
msgstr ""
|
||||
|
||||
msgid "PipelineWizardInputValidation|This value is not valid"
|
||||
msgstr ""
|
||||
|
||||
msgid "PipelineWizardListWidget|add another step"
|
||||
msgstr ""
|
||||
|
||||
msgid "PipelineWizardListWidget|remove step"
|
||||
msgstr ""
|
||||
|
||||
msgid "PipelineWizard|Commit"
|
||||
msgstr ""
|
||||
|
||||
|
|
|
@ -313,7 +313,7 @@ RSpec.describe 'Jobs', :clean_gitlab_redis_shared_state do
|
|||
|
||||
context 'job is cancelable' do
|
||||
it 'shows cancel button' do
|
||||
click_link 'Cancel'
|
||||
find('[data-testid="cancel-button"]').click
|
||||
|
||||
expect(page.current_path).to eq(job_url)
|
||||
end
|
||||
|
@ -1031,7 +1031,7 @@ RSpec.describe 'Jobs', :clean_gitlab_redis_shared_state do
|
|||
end
|
||||
|
||||
it 'loads the page and shows all needed controls' do
|
||||
expect(page).to have_content 'Retry'
|
||||
expect(page).to have_selector('[data-testid="retry-button"')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -1049,7 +1049,7 @@ RSpec.describe 'Jobs', :clean_gitlab_redis_shared_state do
|
|||
|
||||
it 'shows the right status and buttons' do
|
||||
page.within('aside.right-sidebar') do
|
||||
expect(page).to have_content 'Cancel'
|
||||
expect(page).to have_selector('[data-testid="cancel-button"')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,35 @@
|
|||
Return-Path: <jake@example.com>
|
||||
Received: from myserver.example.com ([unix socket]) by myserver (Cyrus v2.2.13-Debian-2.2.13-19+squeeze3) with LMTPA; Thu, 13 Jun 2013 17:03:50 -0400
|
||||
Received: by 10.0.0.1 with HTTP; Thu, 13 Jun 2013 14:03:48 -0700
|
||||
Received: from blabla.google.com (blabla.google.com. [1.1.1.1])
|
||||
by bla.google.com with SMTPS id something.1.1.1.1.1.1.1
|
||||
for <incoming+gitlabhq/gitlabhq+auth_token@appmail.example.com>
|
||||
(Google Transport Security);
|
||||
Mon, 21 Feb 2022 14:41:58 -0800 (PST)
|
||||
Received: from mail.example.com (mail.example.com [IPv6:2607:f8b0:4001:c03::234]) by myserver.example.com (8.14.3/8.14.3/Debian-9.4) with ESMTP id r5DL3nFJ016967 (version=TLSv1/SSLv3 cipher=RC4-SHA bits=128 verify=NOT) for <incoming+gitlabhq/gitlabhq@example.com>; Thu, 13 Jun 2013 17:03:50 -0400
|
||||
From: "jake@example.com" <jake@example.com>
|
||||
To: "support@example.com" <support@example.com>
|
||||
Subject: Insert hilarious subject line here
|
||||
Date: Tue, 26 Nov 2019 14:22:41 +0000
|
||||
Message-ID: <7e2296f83dbf4de388cbf5f56f52c11f@EXDAG29-1.EXCHANGE.INT>
|
||||
Accept-Language: de-DE, en-US
|
||||
Content-Language: de-DE
|
||||
X-MS-Has-Attach:
|
||||
X-MS-TNEF-Correlator:
|
||||
x-ms-exchange-transport-fromentityheader: Hosted
|
||||
x-originating-ip: [62.96.54.178]
|
||||
Content-Type: multipart/alternative;
|
||||
boundary="_000_7e2296f83dbf4de388cbf5f56f52c11fEXDAG291EXCHANGEINT_"
|
||||
MIME-Version: 1.0
|
||||
|
||||
--_000_7e2296f83dbf4de388cbf5f56f52c11fEXDAG291EXCHANGEINT_
|
||||
Content-Type: text/plain; charset="iso-8859-1"
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
|
||||
|
||||
|
||||
--_000_7e2296f83dbf4de388cbf5f56f52c11fEXDAG291EXCHANGEINT_
|
||||
Content-Type: text/html; charset="iso-8859-1"
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
|
||||
Look, a message with no Delivered-To header! Let's fallback to Received: in case it's there.
|
|
@ -1,6 +1,6 @@
|
|||
Return-Path: <jake@adventuretime.ooo>
|
||||
Received: from iceking.adventuretime.ooo ([unix socket]) by iceking (Cyrus v2.2.13-Debian-2.2.13-19+squeeze3) with LMTPA; Thu, 13 Jun 2013 17:03:50 -0400
|
||||
Received: from mail-ie0-x234.google.com (mail-ie0-x234.google.com [IPv6:2607:f8b0:4001:c03::234]) by iceking.adventuretime.ooo (8.14.3/8.14.3/Debian-9.4) with ESMTP id r5DL3nFJ016967 (version=TLSv1/SSLv3 cipher=RC4-SHA bits=128 verify=NOT) for <incoming+gitlabhq/gitlabhq@appmail.adventuretime.ooo>; Thu, 13 Jun 2013 17:03:50 -0400
|
||||
Received: from mail-ie0-x234.google.com (mail-ie0-x234.google.com [IPv6:2607:f8b0:4001:c03::234]) by iceking.adventuretime.ooo (8.14.3/8.14.3/Debian-9.4) with ESMTP id r5DL3nFJ016967 (version=TLSv1/SSLv3 cipher=RC4-SHA bits=128 verify=NOT) for <incoming+gitlabhq-gitlabhq-project_id-auth_token-issue-issue_iid@appmail.adventuretime.ooo>; Thu, 13 Jun 2013 17:03:50 -0400
|
||||
Received: by mail-ie0-f180.google.com with SMTP id f4so21977375iea.25 for <incoming+gitlabhq-gitlabhq-project_id-auth_token-issue-issue_iid@appmail.adventuretime.ooo>; Thu, 13 Jun 2013 14:03:48 -0700
|
||||
Received: by 10.0.0.1 with HTTP; Thu, 13 Jun 2013 14:03:48 -0700
|
||||
Date: Thu, 13 Jun 2013 17:03:48 -0400
|
||||
|
|
|
@ -0,0 +1,45 @@
|
|||
{
|
||||
"event_id": "dquJXuPF9sP1fMy5RpKo979xUALjNDQB",
|
||||
"timestamp": 1645191605.123456,
|
||||
"platform": "php",
|
||||
"sdk": {
|
||||
"name": "sentry.php",
|
||||
"version": "3.3.7"
|
||||
},
|
||||
"logger": "php",
|
||||
"transaction": "",
|
||||
"server_name": "oAjA5zTgIjqP",
|
||||
"release": "C0FFEE",
|
||||
"environment": "Development/Berlin",
|
||||
"exception": {
|
||||
"values": [
|
||||
{
|
||||
"type": "TestException",
|
||||
"value": "Sentry test exception",
|
||||
"stacktrace": {
|
||||
"frames": [
|
||||
{
|
||||
"filename": "/src/Path/To/Class.php",
|
||||
"lineno": 3,
|
||||
"in_app": true,
|
||||
"abs_path": "/var/www/html/src/Path/To/Class.php",
|
||||
"function": "Path\\To\\Class::method",
|
||||
"raw_function": "Path\\To\\Class::method",
|
||||
"pre_context": [
|
||||
"// Pre-context"
|
||||
],
|
||||
"context_line": "throw new TestException('Sentry test exception');",
|
||||
"post_context": [
|
||||
"// Post-context"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"mechanism": {
|
||||
"type": "generic",
|
||||
"handled": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
|
@ -1,32 +0,0 @@
|
|||
import AjaxLoadingSpinner from '~/branches/ajax_loading_spinner';
|
||||
|
||||
describe('Ajax Loading Spinner', () => {
|
||||
let ajaxLoadingSpinnerElement;
|
||||
let fauxEvent;
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = `
|
||||
<div>
|
||||
<a class="js-ajax-loading-spinner"
|
||||
data-remote
|
||||
href="http://goesnowhere.nothing/whereami">
|
||||
Remove me
|
||||
</a></div>`;
|
||||
AjaxLoadingSpinner.init();
|
||||
ajaxLoadingSpinnerElement = document.querySelector('.js-ajax-loading-spinner');
|
||||
fauxEvent = { target: ajaxLoadingSpinnerElement };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('`ajaxBeforeSend` event handler sets current icon to spinner and disables link', () => {
|
||||
expect(ajaxLoadingSpinnerElement.parentNode.querySelector('.gl-spinner')).toBeNull();
|
||||
expect(ajaxLoadingSpinnerElement.classList.contains('hidden')).toBe(false);
|
||||
|
||||
AjaxLoadingSpinner.ajaxBeforeSend(fauxEvent);
|
||||
|
||||
expect(ajaxLoadingSpinnerElement.parentNode.querySelector('.gl-spinner')).not.toBeNull();
|
||||
expect(ajaxLoadingSpinnerElement.classList.contains('hidden')).toBe(true);
|
||||
});
|
||||
});
|
|
@ -3,20 +3,25 @@ import { nextTick } from 'vue';
|
|||
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
|
||||
import ContentEditorAlert from '~/content_editor/components/content_editor_alert.vue';
|
||||
import EditorStateObserver from '~/content_editor/components/editor_state_observer.vue';
|
||||
import { createTestEditor, emitEditorEvent } from '../test_utils';
|
||||
import eventHubFactory from '~/helpers/event_hub_factory';
|
||||
import { ALERT_EVENT } from '~/content_editor/constants';
|
||||
import { createTestEditor } from '../test_utils';
|
||||
|
||||
describe('content_editor/components/content_editor_alert', () => {
|
||||
let wrapper;
|
||||
let tiptapEditor;
|
||||
let eventHub;
|
||||
|
||||
const findErrorAlert = () => wrapper.findComponent(GlAlert);
|
||||
|
||||
const createWrapper = async () => {
|
||||
tiptapEditor = createTestEditor();
|
||||
eventHub = eventHubFactory();
|
||||
|
||||
wrapper = shallowMountExtended(ContentEditorAlert, {
|
||||
provide: {
|
||||
tiptapEditor,
|
||||
eventHub,
|
||||
},
|
||||
stubs: {
|
||||
EditorStateObserver,
|
||||
|
@ -37,7 +42,9 @@ describe('content_editor/components/content_editor_alert', () => {
|
|||
async ({ message, variant }) => {
|
||||
createWrapper();
|
||||
|
||||
await emitEditorEvent({ tiptapEditor, event: 'alert', params: { message, variant } });
|
||||
eventHub.$emit(ALERT_EVENT, { message, variant });
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(findErrorAlert().text()).toBe(message);
|
||||
expect(findErrorAlert().attributes().variant).toBe(variant);
|
||||
|
@ -48,11 +55,9 @@ describe('content_editor/components/content_editor_alert', () => {
|
|||
const message = 'error message';
|
||||
|
||||
createWrapper();
|
||||
|
||||
await emitEditorEvent({ tiptapEditor, event: 'alert', params: { message } });
|
||||
|
||||
eventHub.$emit(ALERT_EVENT, { message });
|
||||
await nextTick();
|
||||
findErrorAlert().vm.$emit('dismiss');
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(findErrorAlert().exists()).toBe(false);
|
||||
|
|
|
@ -121,7 +121,7 @@ describe('ContentEditor', () => {
|
|||
beforeEach(async () => {
|
||||
createWrapper();
|
||||
|
||||
contentEditor.emit(LOADING_CONTENT_EVENT);
|
||||
contentEditor.eventHub.$emit(LOADING_CONTENT_EVENT);
|
||||
|
||||
await nextTick();
|
||||
});
|
||||
|
@ -143,9 +143,9 @@ describe('ContentEditor', () => {
|
|||
beforeEach(async () => {
|
||||
createWrapper();
|
||||
|
||||
contentEditor.emit(LOADING_CONTENT_EVENT);
|
||||
contentEditor.eventHub.$emit(LOADING_CONTENT_EVENT);
|
||||
await nextTick();
|
||||
contentEditor.emit(LOADING_SUCCESS_EVENT);
|
||||
contentEditor.eventHub.$emit(LOADING_SUCCESS_EVENT);
|
||||
await nextTick();
|
||||
});
|
||||
|
||||
|
@ -164,9 +164,9 @@ describe('ContentEditor', () => {
|
|||
beforeEach(async () => {
|
||||
createWrapper();
|
||||
|
||||
contentEditor.emit(LOADING_CONTENT_EVENT);
|
||||
contentEditor.eventHub.$emit(LOADING_CONTENT_EVENT);
|
||||
await nextTick();
|
||||
contentEditor.emit(LOADING_ERROR_EVENT, error);
|
||||
contentEditor.eventHub.$emit(LOADING_ERROR_EVENT, error);
|
||||
await nextTick();
|
||||
});
|
||||
|
||||
|
|
|
@ -3,6 +3,13 @@ import { each } from 'lodash';
|
|||
import EditorStateObserver, {
|
||||
tiptapToComponentMap,
|
||||
} from '~/content_editor/components/editor_state_observer.vue';
|
||||
import eventHubFactory from '~/helpers/event_hub_factory';
|
||||
import {
|
||||
LOADING_CONTENT_EVENT,
|
||||
LOADING_SUCCESS_EVENT,
|
||||
LOADING_ERROR_EVENT,
|
||||
ALERT_EVENT,
|
||||
} from '~/content_editor/constants';
|
||||
import { createTestEditor } from '../test_utils';
|
||||
|
||||
describe('content_editor/components/editor_state_observer', () => {
|
||||
|
@ -11,19 +18,29 @@ describe('content_editor/components/editor_state_observer', () => {
|
|||
let onDocUpdateListener;
|
||||
let onSelectionUpdateListener;
|
||||
let onTransactionListener;
|
||||
let onLoadingContentListener;
|
||||
let onLoadingSuccessListener;
|
||||
let onLoadingErrorListener;
|
||||
let onAlertListener;
|
||||
let eventHub;
|
||||
|
||||
const buildEditor = () => {
|
||||
tiptapEditor = createTestEditor();
|
||||
eventHub = eventHubFactory();
|
||||
jest.spyOn(tiptapEditor, 'on');
|
||||
};
|
||||
|
||||
const buildWrapper = () => {
|
||||
wrapper = shallowMount(EditorStateObserver, {
|
||||
provide: { tiptapEditor },
|
||||
provide: { tiptapEditor, eventHub },
|
||||
listeners: {
|
||||
docUpdate: onDocUpdateListener,
|
||||
selectionUpdate: onSelectionUpdateListener,
|
||||
transaction: onTransactionListener,
|
||||
[ALERT_EVENT]: onAlertListener,
|
||||
[LOADING_CONTENT_EVENT]: onLoadingContentListener,
|
||||
[LOADING_SUCCESS_EVENT]: onLoadingSuccessListener,
|
||||
[LOADING_ERROR_EVENT]: onLoadingErrorListener,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
@ -32,8 +49,11 @@ describe('content_editor/components/editor_state_observer', () => {
|
|||
onDocUpdateListener = jest.fn();
|
||||
onSelectionUpdateListener = jest.fn();
|
||||
onTransactionListener = jest.fn();
|
||||
onAlertListener = jest.fn();
|
||||
onLoadingSuccessListener = jest.fn();
|
||||
onLoadingContentListener = jest.fn();
|
||||
onLoadingErrorListener = jest.fn();
|
||||
buildEditor();
|
||||
buildWrapper();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
@ -44,6 +64,8 @@ describe('content_editor/components/editor_state_observer', () => {
|
|||
it('emits update, selectionUpdate, and transaction events', () => {
|
||||
const content = '<p>My paragraph</p>';
|
||||
|
||||
buildWrapper();
|
||||
|
||||
tiptapEditor.commands.insertContent(content);
|
||||
|
||||
expect(onDocUpdateListener).toHaveBeenCalledWith(
|
||||
|
@ -58,10 +80,27 @@ describe('content_editor/components/editor_state_observer', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it.each`
|
||||
event | listener
|
||||
${ALERT_EVENT} | ${() => onAlertListener}
|
||||
${LOADING_CONTENT_EVENT} | ${() => onLoadingContentListener}
|
||||
${LOADING_SUCCESS_EVENT} | ${() => onLoadingSuccessListener}
|
||||
${LOADING_ERROR_EVENT} | ${() => onLoadingErrorListener}
|
||||
`('listens to $event event in the eventBus object', ({ event, listener }) => {
|
||||
const args = {};
|
||||
|
||||
buildWrapper();
|
||||
|
||||
eventHub.$emit(event, args);
|
||||
expect(listener()).toHaveBeenCalledWith(args);
|
||||
});
|
||||
|
||||
describe('when component is destroyed', () => {
|
||||
it('removes onTiptapDocUpdate and onTiptapSelectionUpdate hooks', () => {
|
||||
jest.spyOn(tiptapEditor, 'off');
|
||||
|
||||
buildWrapper();
|
||||
|
||||
wrapper.destroy();
|
||||
|
||||
each(tiptapToComponentMap, (_, tiptapEvent) => {
|
||||
|
@ -71,5 +110,25 @@ describe('content_editor/components/editor_state_observer', () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
it.each`
|
||||
event
|
||||
${ALERT_EVENT}
|
||||
${LOADING_CONTENT_EVENT}
|
||||
${LOADING_SUCCESS_EVENT}
|
||||
${LOADING_ERROR_EVENT}
|
||||
`('removes $event event hook from eventHub', ({ event }) => {
|
||||
jest.spyOn(eventHub, '$off');
|
||||
jest.spyOn(eventHub, '$on');
|
||||
|
||||
buildWrapper();
|
||||
|
||||
wrapper.destroy();
|
||||
|
||||
expect(eventHub.$off).toHaveBeenCalledWith(
|
||||
event,
|
||||
eventHub.$on.mock.calls.find(([eventName]) => eventName === event)[1],
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -2,6 +2,7 @@ import { GlButton } from '@gitlab/ui';
|
|||
import { shallowMount } from '@vue/test-utils';
|
||||
import EditorStateObserver from '~/content_editor/components/editor_state_observer.vue';
|
||||
import ToolbarButton from '~/content_editor/components/toolbar_button.vue';
|
||||
import eventHubFactory from '~/helpers/event_hub_factory';
|
||||
import { createTestEditor, mockChainedCommands, emitEditorEvent } from '../test_utils';
|
||||
|
||||
describe('content_editor/components/toolbar_button', () => {
|
||||
|
@ -25,6 +26,7 @@ describe('content_editor/components/toolbar_button', () => {
|
|||
},
|
||||
provide: {
|
||||
tiptapEditor,
|
||||
eventHub: eventHubFactory(),
|
||||
},
|
||||
propsData: {
|
||||
contentType: CONTENT_TYPE,
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { GlDropdown, GlButton, GlFormInputGroup } from '@gitlab/ui';
|
||||
import { mountExtended } from 'helpers/vue_test_utils_helper';
|
||||
import ToolbarLinkButton from '~/content_editor/components/toolbar_link_button.vue';
|
||||
import eventHubFactory from '~/helpers/event_hub_factory';
|
||||
import Link from '~/content_editor/extensions/link';
|
||||
import { hasSelection } from '~/content_editor/services/utils';
|
||||
import { createTestEditor, mockChainedCommands, emitEditorEvent } from '../test_utils';
|
||||
|
@ -15,6 +16,7 @@ describe('content_editor/components/toolbar_link_button', () => {
|
|||
wrapper = mountExtended(ToolbarLinkButton, {
|
||||
provide: {
|
||||
tiptapEditor: editor,
|
||||
eventHub: eventHubFactory(),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
|
@ -4,6 +4,7 @@ import EditorStateObserver from '~/content_editor/components/editor_state_observ
|
|||
import ToolbarTextStyleDropdown from '~/content_editor/components/toolbar_text_style_dropdown.vue';
|
||||
import { TEXT_STYLE_DROPDOWN_ITEMS } from '~/content_editor/constants';
|
||||
import Heading from '~/content_editor/extensions/heading';
|
||||
import eventHubFactory from '~/helpers/event_hub_factory';
|
||||
import { createTestEditor, mockChainedCommands, emitEditorEvent } from '../test_utils';
|
||||
|
||||
describe('content_editor/components/toolbar_text_style_dropdown', () => {
|
||||
|
@ -27,6 +28,7 @@ describe('content_editor/components/toolbar_text_style_dropdown', () => {
|
|||
},
|
||||
provide: {
|
||||
tiptapEditor,
|
||||
eventHub: eventHubFactory(),
|
||||
},
|
||||
propsData: {
|
||||
...propsData,
|
||||
|
|
|
@ -5,6 +5,7 @@ import Image from '~/content_editor/extensions/image';
|
|||
import Link from '~/content_editor/extensions/link';
|
||||
import Loading from '~/content_editor/extensions/loading';
|
||||
import httpStatus from '~/lib/utils/http_status';
|
||||
import eventHubFactory from '~/helpers/event_hub_factory';
|
||||
import { createTestEditor, createDocBuilder } from '../test_utils';
|
||||
|
||||
const PROJECT_WIKI_ATTACHMENT_IMAGE_HTML = `<p data-sourcepos="1:1-1:27" dir="auto">
|
||||
|
@ -25,6 +26,7 @@ describe('content_editor/extensions/attachment', () => {
|
|||
let link;
|
||||
let renderMarkdown;
|
||||
let mock;
|
||||
let eventHub;
|
||||
|
||||
const uploadsPath = '/uploads/';
|
||||
const imageFile = new File(['foo'], 'test-file.png', { type: 'image/png' });
|
||||
|
@ -50,9 +52,15 @@ describe('content_editor/extensions/attachment', () => {
|
|||
|
||||
beforeEach(() => {
|
||||
renderMarkdown = jest.fn();
|
||||
eventHub = eventHubFactory();
|
||||
|
||||
tiptapEditor = createTestEditor({
|
||||
extensions: [Loading, Link, Image, Attachment.configure({ renderMarkdown, uploadsPath })],
|
||||
extensions: [
|
||||
Loading,
|
||||
Link,
|
||||
Image,
|
||||
Attachment.configure({ renderMarkdown, uploadsPath, eventHub }),
|
||||
],
|
||||
});
|
||||
|
||||
({
|
||||
|
@ -160,7 +168,7 @@ describe('content_editor/extensions/attachment', () => {
|
|||
it('emits an alert event that includes an error message', (done) => {
|
||||
tiptapEditor.commands.uploadAttachment({ file: imageFile });
|
||||
|
||||
tiptapEditor.on('alert', ({ message }) => {
|
||||
eventHub.$on('alert', ({ message }) => {
|
||||
expect(message).toBe('An error occurred while uploading the image. Please try again.');
|
||||
done();
|
||||
});
|
||||
|
@ -236,7 +244,7 @@ describe('content_editor/extensions/attachment', () => {
|
|||
it('emits an alert event that includes an error message', (done) => {
|
||||
tiptapEditor.commands.uploadAttachment({ file: attachmentFile });
|
||||
|
||||
tiptapEditor.on('alert', ({ message }) => {
|
||||
eventHub.$on('alert', ({ message }) => {
|
||||
expect(message).toBe('An error occurred while uploading the file. Please try again.');
|
||||
done();
|
||||
});
|
||||
|
|
|
@ -4,19 +4,21 @@ import {
|
|||
LOADING_ERROR_EVENT,
|
||||
} from '~/content_editor/constants';
|
||||
import { ContentEditor } from '~/content_editor/services/content_editor';
|
||||
|
||||
import eventHubFactory from '~/helpers/event_hub_factory';
|
||||
import { createTestEditor } from '../test_utils';
|
||||
|
||||
describe('content_editor/services/content_editor', () => {
|
||||
let contentEditor;
|
||||
let serializer;
|
||||
let eventHub;
|
||||
|
||||
beforeEach(() => {
|
||||
const tiptapEditor = createTestEditor();
|
||||
jest.spyOn(tiptapEditor, 'destroy');
|
||||
|
||||
serializer = { deserialize: jest.fn() };
|
||||
contentEditor = new ContentEditor({ tiptapEditor, serializer });
|
||||
eventHub = eventHubFactory();
|
||||
contentEditor = new ContentEditor({ tiptapEditor, serializer, eventHub });
|
||||
});
|
||||
|
||||
describe('.dispose', () => {
|
||||
|
@ -34,13 +36,13 @@ describe('content_editor/services/content_editor', () => {
|
|||
serializer.deserialize.mockResolvedValueOnce('');
|
||||
});
|
||||
|
||||
it('emits loadingContent and loadingSuccess event', () => {
|
||||
it('emits loadingContent and loadingSuccess event in the eventHub', () => {
|
||||
let loadingContentEmitted = false;
|
||||
|
||||
contentEditor.on(LOADING_CONTENT_EVENT, () => {
|
||||
eventHub.$on(LOADING_CONTENT_EVENT, () => {
|
||||
loadingContentEmitted = true;
|
||||
});
|
||||
contentEditor.on(LOADING_SUCCESS_EVENT, () => {
|
||||
eventHub.$on(LOADING_SUCCESS_EVENT, () => {
|
||||
expect(loadingContentEmitted).toBe(true);
|
||||
});
|
||||
|
||||
|
@ -56,7 +58,7 @@ describe('content_editor/services/content_editor', () => {
|
|||
});
|
||||
|
||||
it('emits loadingError event', async () => {
|
||||
contentEditor.on(LOADING_ERROR_EVENT, (e) => {
|
||||
eventHub.$on(LOADING_ERROR_EVENT, (e) => {
|
||||
expect(e).toBe('error');
|
||||
});
|
||||
|
||||
|
|
|
@ -8,7 +8,6 @@ describe('Job log controllers', () => {
|
|||
afterEach(() => {
|
||||
if (wrapper?.destroy) {
|
||||
wrapper.destroy();
|
||||
wrapper = null;
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -34,7 +33,6 @@ describe('Job log controllers', () => {
|
|||
const findTruncatedInfo = () => wrapper.find('[data-testid="log-truncated-info"]');
|
||||
const findRawLink = () => wrapper.find('[data-testid="raw-link"]');
|
||||
const findRawLinkController = () => wrapper.find('[data-testid="job-raw-link-controller"]');
|
||||
const findEraseLink = () => wrapper.find('[data-testid="job-log-erase-link"]');
|
||||
const findScrollTop = () => wrapper.find('[data-testid="job-controller-scroll-top"]');
|
||||
const findScrollBottom = () => wrapper.find('[data-testid="job-controller-scroll-bottom"]');
|
||||
|
||||
|
@ -76,28 +74,6 @@ describe('Job log controllers', () => {
|
|||
expect(findRawLinkController().exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when is erasable', () => {
|
||||
beforeEach(() => {
|
||||
createWrapper();
|
||||
});
|
||||
|
||||
it('renders erase job link', () => {
|
||||
expect(findEraseLink().exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when it is not erasable', () => {
|
||||
beforeEach(() => {
|
||||
createWrapper({
|
||||
erasePath: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not render erase button', () => {
|
||||
expect(findEraseLink().exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('scroll buttons', () => {
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import { GlButton, GlLink } from '@gitlab/ui';
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
|
||||
import JobsSidebarRetryButton from '~/jobs/components/job_sidebar_retry_button.vue';
|
||||
import createStore from '~/jobs/store';
|
||||
import job from '../mock_data';
|
||||
|
@ -9,12 +8,12 @@ describe('Job Sidebar Retry Button', () => {
|
|||
let wrapper;
|
||||
|
||||
const forwardDeploymentFailure = 'forward_deployment_failure';
|
||||
const findRetryButton = () => wrapper.find(GlButton);
|
||||
const findRetryLink = () => wrapper.find(GlLink);
|
||||
const findRetryButton = () => wrapper.findByTestId('retry-job-button');
|
||||
const findRetryLink = () => wrapper.findByTestId('retry-job-link');
|
||||
|
||||
const createWrapper = ({ props = {} } = {}) => {
|
||||
store = createStore();
|
||||
wrapper = shallowMount(JobsSidebarRetryButton, {
|
||||
wrapper = shallowMountExtended(JobsSidebarRetryButton, {
|
||||
propsData: {
|
||||
href: job.retry_path,
|
||||
modalId: 'modal-id',
|
||||
|
@ -27,7 +26,6 @@ describe('Job Sidebar Retry Button', () => {
|
|||
afterEach(() => {
|
||||
if (wrapper) {
|
||||
wrapper.destroy();
|
||||
wrapper = null;
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -44,7 +42,6 @@ describe('Job Sidebar Retry Button', () => {
|
|||
|
||||
expect(findRetryButton().exists()).toBe(buttonExists);
|
||||
expect(findRetryLink().exists()).toBe(linkExists);
|
||||
expect(wrapper.text()).toMatch('Retry');
|
||||
},
|
||||
);
|
||||
|
||||
|
@ -55,6 +52,7 @@ describe('Job Sidebar Retry Button', () => {
|
|||
expect(findRetryButton().attributes()).toMatchObject({
|
||||
category: 'primary',
|
||||
variant: 'confirm',
|
||||
icon: 'retry',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -64,6 +62,7 @@ describe('Job Sidebar Retry Button', () => {
|
|||
expect(findRetryLink().attributes()).toMatchObject({
|
||||
'data-method': 'post',
|
||||
href: job.retry_path,
|
||||
icon: 'retry',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -21,25 +21,54 @@ describe('Sidebar details block', () => {
|
|||
const findNewIssueButton = () => wrapper.findByTestId('job-new-issue');
|
||||
const findRetryButton = () => wrapper.find(JobRetryButton);
|
||||
const findTerminalLink = () => wrapper.findByTestId('terminal-link');
|
||||
const findEraseLink = () => wrapper.findByTestId('job-log-erase-link');
|
||||
|
||||
const createWrapper = ({ props = {} } = {}) => {
|
||||
const createWrapper = (props) => {
|
||||
store = createStore();
|
||||
|
||||
store.state.job = job;
|
||||
|
||||
wrapper = extendedWrapper(
|
||||
shallowMount(Sidebar, {
|
||||
...props,
|
||||
propsData: {
|
||||
...props,
|
||||
},
|
||||
|
||||
store,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
if (wrapper) {
|
||||
wrapper.destroy();
|
||||
wrapper = null;
|
||||
}
|
||||
wrapper.destroy();
|
||||
});
|
||||
|
||||
describe('when job log is erasable', () => {
|
||||
const path = '/root/ci-project/-/jobs/1447/erase';
|
||||
|
||||
beforeEach(() => {
|
||||
createWrapper({
|
||||
erasePath: path,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders erase job link', () => {
|
||||
expect(findEraseLink().exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('erase job link has correct path', () => {
|
||||
expect(findEraseLink().attributes('href')).toBe(path);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when job log is not erasable', () => {
|
||||
beforeEach(() => {
|
||||
createWrapper();
|
||||
});
|
||||
|
||||
it('does not render erase button', () => {
|
||||
expect(findEraseLink().exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when there is no retry path retry', () => {
|
||||
|
@ -86,7 +115,7 @@ describe('Sidebar details block', () => {
|
|||
});
|
||||
|
||||
it('should render link to cancel job', () => {
|
||||
expect(findCancelButton().text()).toMatch('Cancel');
|
||||
expect(findCancelButton().props('icon')).toBe('cancel');
|
||||
expect(findCancelButton().attributes('href')).toBe(job.cancel_path);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -0,0 +1,212 @@
|
|||
import { GlFormGroup, GlFormInputGroup } from '@gitlab/ui';
|
||||
import { nextTick } from 'vue';
|
||||
import ListWidget from '~/pipeline_wizard/components/widgets/list.vue';
|
||||
import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
|
||||
|
||||
describe('Pipeline Wizard - List Widget', () => {
|
||||
const defaultProps = {
|
||||
label: 'This label',
|
||||
description: 'some description',
|
||||
placeholder: 'some placeholder',
|
||||
pattern: '^[a-z]+$',
|
||||
invalidFeedback: 'some feedback',
|
||||
};
|
||||
let wrapper;
|
||||
let addStepBtn;
|
||||
|
||||
const findGlFormGroup = () => wrapper.findComponent(GlFormGroup);
|
||||
const findGlFormGroupInvalidFeedback = () => findGlFormGroup().find('.invalid-feedback').text();
|
||||
const findFirstGlFormInputGroup = () => wrapper.findComponent(GlFormInputGroup);
|
||||
const findAllGlFormInputGroups = () => wrapper.findAllComponents(GlFormInputGroup);
|
||||
const findGlFormInputGroupByIndex = (index) => findAllGlFormInputGroups().at(index);
|
||||
const setValueOnInputField = (value, atIndex = 0) => {
|
||||
return findGlFormInputGroupByIndex(atIndex).vm.$emit('input', value);
|
||||
};
|
||||
const findAddStepButton = () => wrapper.findByTestId('add-step-button');
|
||||
const addStep = () => findAddStepButton().vm.$emit('click');
|
||||
|
||||
const createComponent = (props = {}, mountFn = shallowMountExtended) => {
|
||||
wrapper = mountFn(ListWidget, {
|
||||
propsData: {
|
||||
...defaultProps,
|
||||
...props,
|
||||
},
|
||||
});
|
||||
addStepBtn = findAddStepButton();
|
||||
};
|
||||
|
||||
describe('component setup and interface', () => {
|
||||
afterEach(() => {
|
||||
wrapper.destroy();
|
||||
});
|
||||
|
||||
it('prints the label inside the legend', () => {
|
||||
createComponent();
|
||||
|
||||
expect(findGlFormGroup().attributes('label')).toBe(defaultProps.label);
|
||||
});
|
||||
|
||||
it('prints the description inside the legend', () => {
|
||||
createComponent();
|
||||
|
||||
expect(findGlFormGroup().attributes('labeldescription')).toBe(defaultProps.description);
|
||||
});
|
||||
|
||||
it('sets the input field type attribute to "text"', async () => {
|
||||
createComponent();
|
||||
|
||||
expect(findFirstGlFormInputGroup().attributes('type')).toBe('text');
|
||||
});
|
||||
|
||||
it('passes the placeholder to the first input field', () => {
|
||||
createComponent();
|
||||
|
||||
expect(findFirstGlFormInputGroup().attributes('placeholder')).toBe(defaultProps.placeholder);
|
||||
});
|
||||
|
||||
it('shows a delete button on all fields if there are more than one', async () => {
|
||||
createComponent({}, mountExtended);
|
||||
|
||||
await addStep();
|
||||
await addStep();
|
||||
const inputGroups = findAllGlFormInputGroups().wrappers;
|
||||
|
||||
expect(inputGroups.length).toBe(3);
|
||||
inputGroups.forEach((inputGroup) => {
|
||||
const button = inputGroup.find('[data-testid="remove-step-button"]');
|
||||
expect(button.find('[data-testid="remove-icon"]').exists()).toBe(true);
|
||||
expect(button.attributes('aria-label')).toBe('remove step');
|
||||
});
|
||||
});
|
||||
|
||||
it('null values do not cause an input event', async () => {
|
||||
createComponent();
|
||||
|
||||
await addStep();
|
||||
|
||||
expect(wrapper.emitted('input')).toBe(undefined);
|
||||
});
|
||||
|
||||
it('hides the delete button if there is only one', () => {
|
||||
createComponent({}, mountExtended);
|
||||
|
||||
const inputGroups = findAllGlFormInputGroups().wrappers;
|
||||
|
||||
expect(inputGroups.length).toBe(1);
|
||||
expect(wrapper.findByTestId('remove-step-button').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('shows an "add step" button', () => {
|
||||
createComponent();
|
||||
|
||||
expect(addStepBtn.attributes('icon')).toBe('plus');
|
||||
expect(addStepBtn.text()).toBe('add another step');
|
||||
});
|
||||
|
||||
it('the "add step" button increases the number of input fields', async () => {
|
||||
createComponent();
|
||||
|
||||
expect(findAllGlFormInputGroups().wrappers.length).toBe(1);
|
||||
await addStep();
|
||||
expect(findAllGlFormInputGroups().wrappers.length).toBe(2);
|
||||
});
|
||||
|
||||
it('does not pass the placeholder on subsequent input fields', async () => {
|
||||
createComponent();
|
||||
|
||||
await addStep();
|
||||
await addStep();
|
||||
const nullOrUndefined = [null, undefined];
|
||||
expect(nullOrUndefined).toContain(findAllGlFormInputGroups().at(1).attributes('placeholder'));
|
||||
expect(nullOrUndefined).toContain(findAllGlFormInputGroups().at(2).attributes('placeholder'));
|
||||
});
|
||||
|
||||
it('emits an update event on input', async () => {
|
||||
createComponent();
|
||||
|
||||
const localValue = 'somevalue';
|
||||
await setValueOnInputField(localValue);
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.emitted('input')).toEqual([[[localValue]]]);
|
||||
});
|
||||
|
||||
it('only emits non-null values', async () => {
|
||||
createComponent();
|
||||
|
||||
await addStep();
|
||||
await addStep();
|
||||
await setValueOnInputField('abc', 1);
|
||||
await nextTick();
|
||||
|
||||
const events = wrapper.emitted('input');
|
||||
|
||||
expect(events.length).toBe(1);
|
||||
expect(events[0]).toEqual([['abc']]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('form validation', () => {
|
||||
afterEach(() => {
|
||||
wrapper.destroy();
|
||||
});
|
||||
|
||||
it('does not show validation state when untouched', async () => {
|
||||
createComponent({}, mountExtended);
|
||||
expect(findGlFormGroup().classes()).not.toContain('is-valid');
|
||||
expect(findGlFormGroup().classes()).not.toContain('is-invalid');
|
||||
});
|
||||
|
||||
it('shows invalid state on blur', async () => {
|
||||
createComponent({}, mountExtended);
|
||||
expect(findGlFormGroup().classes()).not.toContain('is-invalid');
|
||||
const input = findFirstGlFormInputGroup().find('input');
|
||||
await input.setValue('invalid99');
|
||||
await input.trigger('blur');
|
||||
expect(input.classes()).toContain('is-invalid');
|
||||
expect(findGlFormGroup().classes()).toContain('is-invalid');
|
||||
});
|
||||
|
||||
it('shows invalid state when toggling `validate` prop', async () => {
|
||||
createComponent({ required: true, validate: false }, mountExtended);
|
||||
await setValueOnInputField(null);
|
||||
expect(findGlFormGroup().classes()).not.toContain('is-invalid');
|
||||
await wrapper.setProps({ validate: true });
|
||||
expect(findGlFormGroup().classes()).toContain('is-invalid');
|
||||
});
|
||||
|
||||
it.each`
|
||||
scenario | required | values | inputFieldClasses | inputGroupClass | feedback
|
||||
${'shows invalid if all inputs are empty'} | ${true} | ${[null, null]} | ${['is-invalid', null]} | ${'is-invalid'} | ${'At least one entry is required'}
|
||||
${'is valid if at least one field has a valid entry'} | ${true} | ${[null, 'abc']} | ${[null, 'is-valid']} | ${'is-valid'} | ${expect.anything()}
|
||||
${'is invalid if one field has an invalid entry'} | ${true} | ${['abc', '99']} | ${['is-valid', 'is-invalid']} | ${'is-invalid'} | ${defaultProps.invalidFeedback}
|
||||
${'is not invalid if its not required but all values are null'} | ${false} | ${[null, null]} | ${[null, null]} | ${'is-valid'} | ${expect.anything()}
|
||||
${'is invalid if pattern does not match even if its not required'} | ${false} | ${['99', null]} | ${['is-invalid', null]} | ${'is-invalid'} | ${defaultProps.invalidFeedback}
|
||||
`('$scenario', async ({ required, values, inputFieldClasses, inputGroupClass, feedback }) => {
|
||||
createComponent({ required, validate: true }, mountExtended);
|
||||
|
||||
await Promise.all(
|
||||
values.map(async (value, i) => {
|
||||
if (i > 0) {
|
||||
await addStep();
|
||||
}
|
||||
await setValueOnInputField(value, i);
|
||||
}),
|
||||
);
|
||||
await nextTick();
|
||||
|
||||
inputFieldClasses.forEach((expected, i) => {
|
||||
const inputWrapper = findGlFormInputGroupByIndex(i).find('input');
|
||||
if (expected === null) {
|
||||
expect(inputWrapper.classes()).not.toContain('is-valid');
|
||||
expect(inputWrapper.classes()).not.toContain('is-invalid');
|
||||
} else {
|
||||
expect(inputWrapper.classes()).toContain(expected);
|
||||
}
|
||||
});
|
||||
|
||||
expect(findGlFormGroup().classes()).toContain(inputGroupClass);
|
||||
expect(findGlFormGroupInvalidFeedback()).toEqual(feedback);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -32,12 +32,21 @@ RSpec.describe Gitlab::Email::Receiver do
|
|||
|
||||
metadata = receiver.mail_metadata
|
||||
|
||||
expect(metadata.keys).to match_array(%i(mail_uid from_address to_address mail_key references delivered_to envelope_to x_envelope_to meta))
|
||||
expect(metadata.keys).to match_array(%i(mail_uid from_address to_address mail_key references delivered_to envelope_to x_envelope_to meta received_recipients))
|
||||
expect(metadata[:meta]).to include(client_id: 'email/jake@example.com', project: project.full_path)
|
||||
expect(metadata[meta_key]).to eq(meta_value)
|
||||
end
|
||||
end
|
||||
|
||||
shared_examples 'failed receive' do
|
||||
it 'adds metric event' do
|
||||
expect(::Gitlab::Metrics::BackgroundTransaction).to receive(:current).and_return(metric_transaction)
|
||||
expect(metric_transaction).to receive(:add_event).with('email_receiver_error', { error: expected_error.name })
|
||||
|
||||
expect { receiver.execute }.to raise_error(expected_error)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the email contains a valid email address in a header' do
|
||||
before do
|
||||
stub_incoming_email_setting(enabled: true, address: "incoming+%{key}@appmail.example.com")
|
||||
|
@ -74,14 +83,25 @@ RSpec.describe Gitlab::Email::Receiver do
|
|||
|
||||
it_behaves_like 'successful receive'
|
||||
end
|
||||
end
|
||||
|
||||
shared_examples 'failed receive' do
|
||||
it 'adds metric event' do
|
||||
expect(::Gitlab::Metrics::BackgroundTransaction).to receive(:current).and_return(metric_transaction)
|
||||
expect(metric_transaction).to receive(:add_event).with('email_receiver_error', { error: expected_error.name })
|
||||
context 'when all other headers are missing' do
|
||||
let(:email_raw) { fixture_file('emails/missing_delivered_to_header.eml') }
|
||||
let(:meta_key) { :received_recipients }
|
||||
let(:meta_value) { ['incoming+gitlabhq/gitlabhq+auth_token@appmail.example.com', 'incoming+gitlabhq/gitlabhq@example.com'] }
|
||||
|
||||
expect { receiver.execute }.to raise_error(expected_error)
|
||||
context 'when use_received_header_for_incoming_emails is enabled' do
|
||||
it_behaves_like 'successful receive'
|
||||
end
|
||||
|
||||
context 'when use_received_header_for_incoming_emails is disabled' do
|
||||
let(:expected_error) { Gitlab::Email::UnknownIncomingEmail }
|
||||
|
||||
before do
|
||||
stub_feature_flags(use_received_header_for_incoming_emails: false)
|
||||
end
|
||||
|
||||
it_behaves_like 'failed receive'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -385,23 +385,43 @@ RSpec.describe Group do
|
|||
end
|
||||
end
|
||||
|
||||
before do
|
||||
subject
|
||||
reload_models(old_parent, new_parent, group)
|
||||
end
|
||||
|
||||
context 'within the same hierarchy' do
|
||||
let!(:root) { create(:group).reload }
|
||||
let!(:old_parent) { create(:group, parent: root) }
|
||||
let!(:new_parent) { create(:group, parent: root) }
|
||||
|
||||
it 'updates traversal_ids' do
|
||||
expect(group.traversal_ids).to eq [root.id, new_parent.id, group.id]
|
||||
context 'with FOR UPDATE lock' do
|
||||
before do
|
||||
stub_feature_flags(for_no_key_update_lock: false)
|
||||
subject
|
||||
reload_models(old_parent, new_parent, group)
|
||||
end
|
||||
|
||||
it 'updates traversal_ids' do
|
||||
expect(group.traversal_ids).to eq [root.id, new_parent.id, group.id]
|
||||
end
|
||||
|
||||
it_behaves_like 'hierarchy with traversal_ids'
|
||||
it_behaves_like 'locked row', 'FOR UPDATE' do
|
||||
let(:row) { root }
|
||||
end
|
||||
end
|
||||
|
||||
it_behaves_like 'hierarchy with traversal_ids'
|
||||
it_behaves_like 'locked row' do
|
||||
let(:row) { root }
|
||||
context 'with FOR NO KEY UPDATE lock' do
|
||||
before do
|
||||
stub_feature_flags(for_no_key_update_lock: true)
|
||||
subject
|
||||
reload_models(old_parent, new_parent, group)
|
||||
end
|
||||
|
||||
it 'updates traversal_ids' do
|
||||
expect(group.traversal_ids).to eq [root.id, new_parent.id, group.id]
|
||||
end
|
||||
|
||||
it_behaves_like 'hierarchy with traversal_ids'
|
||||
it_behaves_like 'locked row', 'FOR NO KEY UPDATE' do
|
||||
let(:row) { root }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -410,6 +430,11 @@ RSpec.describe Group do
|
|||
let!(:new_parent) { create(:group) }
|
||||
let!(:group) { create(:group, parent: old_parent) }
|
||||
|
||||
before do
|
||||
subject
|
||||
reload_models(old_parent, new_parent, group)
|
||||
end
|
||||
|
||||
it 'updates traversal_ids' do
|
||||
expect(group.traversal_ids).to eq [new_parent.id, group.id]
|
||||
end
|
||||
|
@ -435,6 +460,11 @@ RSpec.describe Group do
|
|||
let!(:old_parent) { nil }
|
||||
let!(:new_parent) { create(:group) }
|
||||
|
||||
before do
|
||||
subject
|
||||
reload_models(old_parent, new_parent, group)
|
||||
end
|
||||
|
||||
it 'updates traversal_ids' do
|
||||
expect(group.traversal_ids).to eq [new_parent.id, group.id]
|
||||
end
|
||||
|
@ -452,6 +482,11 @@ RSpec.describe Group do
|
|||
let!(:old_parent) { create(:group) }
|
||||
let!(:new_parent) { nil }
|
||||
|
||||
before do
|
||||
subject
|
||||
reload_models(old_parent, new_parent, group)
|
||||
end
|
||||
|
||||
it 'updates traversal_ids' do
|
||||
expect(group.traversal_ids).to eq [group.id]
|
||||
end
|
||||
|
|
|
@ -68,11 +68,24 @@ RSpec.describe Namespace::TraversalHierarchy, type: :model do
|
|||
end
|
||||
end
|
||||
|
||||
it_behaves_like 'locked row' do
|
||||
it_behaves_like 'locked row', 'FOR UPDATE' do
|
||||
let(:recorded_queries) { ActiveRecord::QueryRecorder.new }
|
||||
let(:row) { root }
|
||||
|
||||
before do
|
||||
stub_feature_flags(for_no_key_update_lock: false)
|
||||
|
||||
recorded_queries.record { subject }
|
||||
end
|
||||
end
|
||||
|
||||
it_behaves_like 'locked row', 'FOR NO KEY UPDATE' do
|
||||
let(:recorded_queries) { ActiveRecord::QueryRecorder.new }
|
||||
let(:row) { root }
|
||||
|
||||
before do
|
||||
stub_feature_flags(for_no_key_update_lock: true)
|
||||
|
||||
recorded_queries.record { subject }
|
||||
end
|
||||
end
|
||||
|
|
|
@ -171,6 +171,12 @@ RSpec.describe API::ErrorTracking::Collector do
|
|||
it_behaves_like 'successful request'
|
||||
end
|
||||
|
||||
context 'when JSON key transaction is empty string' do
|
||||
let_it_be(:raw_event) { fixture_file('error_tracking/php_empty_transaction.json') }
|
||||
|
||||
it_behaves_like 'successful request'
|
||||
end
|
||||
|
||||
context 'sentry_key as param and empty headers' do
|
||||
let(:url) { "/error_tracking/collector/api/#{project.id}/store?sentry_key=#{sentry_key}" }
|
||||
let(:headers) { {} }
|
||||
|
|
|
@ -51,25 +51,30 @@ RSpec.describe ErrorTracking::CollectErrorService do
|
|||
end
|
||||
end
|
||||
|
||||
context 'unusual payload' do
|
||||
context 'with unusual payload' do
|
||||
let(:modified_event) { parsed_event }
|
||||
let(:event) { described_class.new(project, nil, event: modified_event).execute }
|
||||
|
||||
context 'missing transaction' do
|
||||
context 'when transaction is missing' do
|
||||
it 'builds actor from stacktrace' do
|
||||
modified_event.delete('transaction')
|
||||
|
||||
event = described_class.new(project, nil, event: modified_event).execute
|
||||
|
||||
expect(event.error.actor).to eq 'find()'
|
||||
end
|
||||
end
|
||||
|
||||
context 'timestamp is numeric' do
|
||||
context 'when transaction is an empty string' do \
|
||||
it 'builds actor from stacktrace' do
|
||||
modified_event['transaction'] = ''
|
||||
|
||||
expect(event.error.actor).to eq 'find()'
|
||||
end
|
||||
end
|
||||
|
||||
context 'when timestamp is numeric' do
|
||||
it 'parses timestamp' do
|
||||
modified_event['timestamp'] = '1631015580.50'
|
||||
|
||||
event = described_class.new(project, nil, event: modified_event).execute
|
||||
|
||||
expect(event.occurred_at).to eq '2021-09-07T11:53:00.5'
|
||||
end
|
||||
end
|
||||
|
|
|
@ -4,10 +4,10 @@
|
|||
# Ensure a transaction also occurred.
|
||||
# Be careful! This form of spec is not foolproof, but better than nothing.
|
||||
|
||||
RSpec.shared_examples 'locked row' do
|
||||
RSpec.shared_examples 'locked row' do |lock_type|
|
||||
it "has locked row" do
|
||||
table_name = row.class.table_name
|
||||
ids_regex = /SELECT.*FROM.*#{table_name}.*"#{table_name}"."id" = #{row.id}.+FOR UPDATE/m
|
||||
ids_regex = /SELECT.*FROM.*#{table_name}.*"#{table_name}"."id" = #{row.id}.+#{lock_type}/m
|
||||
|
||||
expect(recorded_queries.log).to include a_string_matching 'SAVEPOINT'
|
||||
expect(recorded_queries.log).to include a_string_matching ids_regex
|
||||
|
|
Loading…
Reference in New Issue