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/raketasks/x509_signatures.md @aqualls
|
||||||
/doc/security/ @eread
|
/doc/security/ @eread
|
||||||
/doc/ssh/index.md @eread
|
/doc/ssh/index.md @eread
|
||||||
/doc/subscriptions/ @sselhorn
|
/doc/subscriptions/ @fneill
|
||||||
/doc/system_hooks/system_hooks.md @kpaizee
|
/doc/system_hooks/system_hooks.md @kpaizee
|
||||||
/doc/topics/authentication/index.md @eread
|
/doc/topics/authentication/index.md @eread
|
||||||
/doc/topics/autodevops/customize.md @marcia
|
/doc/topics/autodevops/customize.md @marcia
|
||||||
|
@ -791,5 +791,5 @@ lib/gitlab/checks/** @proglottis @toon @zj-gitlab
|
||||||
/doc/user/snippets.md @aqualls
|
/doc/user/snippets.md @aqualls
|
||||||
/doc/user/tasks.md @msedlakjakubowski
|
/doc/user/tasks.md @msedlakjakubowski
|
||||||
/doc/user/todos.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
|
/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>
|
<script>
|
||||||
import { GlLoadingIcon } from '@gitlab/ui';
|
import { GlLoadingIcon } from '@gitlab/ui';
|
||||||
import { EditorContent as TiptapEditorContent } from '@tiptap/vue-2';
|
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 { createContentEditor } from '../services/create_content_editor';
|
||||||
import ContentEditorAlert from './content_editor_alert.vue';
|
import ContentEditorAlert from './content_editor_alert.vue';
|
||||||
import ContentEditorProvider from './content_editor_provider.vue';
|
import ContentEditorProvider from './content_editor_provider.vue';
|
||||||
|
@ -55,17 +54,12 @@ export default {
|
||||||
extensions,
|
extensions,
|
||||||
serializerConfig,
|
serializerConfig,
|
||||||
});
|
});
|
||||||
|
},
|
||||||
this.contentEditor.on(LOADING_CONTENT_EVENT, this.displayLoadingIndicator);
|
mounted() {
|
||||||
this.contentEditor.on(LOADING_SUCCESS_EVENT, this.hideLoadingIndicator);
|
|
||||||
this.contentEditor.on(LOADING_ERROR_EVENT, this.hideLoadingIndicator);
|
|
||||||
this.$emit('initialized', this.contentEditor);
|
this.$emit('initialized', this.contentEditor);
|
||||||
},
|
},
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
this.contentEditor.dispose();
|
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: {
|
methods: {
|
||||||
displayLoadingIndicator() {
|
displayLoadingIndicator() {
|
||||||
|
@ -91,7 +85,14 @@ export default {
|
||||||
<template>
|
<template>
|
||||||
<content-editor-provider :content-editor="contentEditor">
|
<content-editor-provider :content-editor="contentEditor">
|
||||||
<div>
|
<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 />
|
<content-editor-alert />
|
||||||
<div
|
<div
|
||||||
data-testid="content-editor"
|
data-testid="content-editor"
|
||||||
|
|
|
@ -8,6 +8,7 @@ export default {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
contentEditor,
|
contentEditor,
|
||||||
|
eventHub: contentEditor.eventHub,
|
||||||
tiptapEditor: contentEditor.tiptapEditor,
|
tiptapEditor: contentEditor.tiptapEditor,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,5 +1,11 @@
|
||||||
<script>
|
<script>
|
||||||
import { debounce } from 'lodash';
|
import { debounce } from 'lodash';
|
||||||
|
import {
|
||||||
|
LOADING_CONTENT_EVENT,
|
||||||
|
LOADING_SUCCESS_EVENT,
|
||||||
|
LOADING_ERROR_EVENT,
|
||||||
|
ALERT_EVENT,
|
||||||
|
} from '../constants';
|
||||||
|
|
||||||
export const tiptapToComponentMap = {
|
export const tiptapToComponentMap = {
|
||||||
update: 'docUpdate',
|
update: 'docUpdate',
|
||||||
|
@ -7,30 +13,48 @@ export const tiptapToComponentMap = {
|
||||||
transaction: 'transaction',
|
transaction: 'transaction',
|
||||||
focus: 'focus',
|
focus: 'focus',
|
||||||
blur: 'blur',
|
blur: 'blur',
|
||||||
alert: 'alert',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const eventHubEvents = [
|
||||||
|
ALERT_EVENT,
|
||||||
|
LOADING_CONTENT_EVENT,
|
||||||
|
LOADING_SUCCESS_EVENT,
|
||||||
|
LOADING_ERROR_EVENT,
|
||||||
|
];
|
||||||
|
|
||||||
const getComponentEventName = (tiptapEventName) => tiptapToComponentMap[tiptapEventName];
|
const getComponentEventName = (tiptapEventName) => tiptapToComponentMap[tiptapEventName];
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
inject: ['tiptapEditor'],
|
inject: ['tiptapEditor', 'eventHub'],
|
||||||
created() {
|
created() {
|
||||||
this.disposables = [];
|
this.disposables = [];
|
||||||
|
|
||||||
Object.keys(tiptapToComponentMap).forEach((tiptapEvent) => {
|
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.tiptapEditor?.on(tiptapEvent, eventHandler);
|
||||||
|
|
||||||
this.disposables.push(() => this.tiptapEditor?.off(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() {
|
beforeDestroy() {
|
||||||
this.disposables.forEach((dispose) => dispose());
|
this.disposables.forEach((dispose) => dispose());
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
handleTipTapEvent(tiptapEvent, params) {
|
bubbleEvent(eventHubEvent, params) {
|
||||||
this.$emit(getComponentEventName(tiptapEvent), params);
|
this.$emit(eventHubEvent, params);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
render() {
|
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_SUCCESS_EVENT = 'loadingSuccess';
|
||||||
export const LOADING_ERROR_EVENT = 'loadingError';
|
export const LOADING_ERROR_EVENT = 'loadingError';
|
||||||
|
export const ALERT_EVENT = 'alert';
|
||||||
|
|
||||||
export const PARSE_HTML_PRIORITY_LOWEST = 1;
|
export const PARSE_HTML_PRIORITY_LOWEST = 1;
|
||||||
export const PARSE_HTML_PRIORITY_DEFAULT = 50;
|
export const PARSE_HTML_PRIORITY_DEFAULT = 50;
|
||||||
|
@ -56,3 +57,4 @@ export const EXTENSION_PRIORITY_LOWER = 75;
|
||||||
* https://tiptap.dev/guide/custom-extensions/#priority
|
* https://tiptap.dev/guide/custom-extensions/#priority
|
||||||
*/
|
*/
|
||||||
export const EXTENSION_PRIORITY_DEFAULT = 100;
|
export const EXTENSION_PRIORITY_DEFAULT = 100;
|
||||||
|
export const EXTENSION_PRIORITY_HIGHEST = 200;
|
||||||
|
|
|
@ -9,15 +9,22 @@ export default Extension.create({
|
||||||
return {
|
return {
|
||||||
uploadsPath: null,
|
uploadsPath: null,
|
||||||
renderMarkdown: null,
|
renderMarkdown: null,
|
||||||
|
eventHub: null,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
addCommands() {
|
addCommands() {
|
||||||
return {
|
return {
|
||||||
uploadAttachment: ({ file }) => () => {
|
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'),
|
key: new PluginKey('attachment'),
|
||||||
props: {
|
props: {
|
||||||
handlePaste: (_, event) => {
|
handlePaste: (_, event) => {
|
||||||
const { uploadsPath, renderMarkdown } = this.options;
|
const { uploadsPath, renderMarkdown, eventHub } = this.options;
|
||||||
|
|
||||||
return handleFileEvent({
|
return handleFileEvent({
|
||||||
editor,
|
editor,
|
||||||
file: event.clipboardData.files[0],
|
file: event.clipboardData.files[0],
|
||||||
uploadsPath,
|
uploadsPath,
|
||||||
renderMarkdown,
|
renderMarkdown,
|
||||||
|
eventHub,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
handleDrop: (_, event) => {
|
handleDrop: (_, event) => {
|
||||||
const { uploadsPath, renderMarkdown } = this.options;
|
const { uploadsPath, renderMarkdown, eventHub } = this.options;
|
||||||
|
|
||||||
return handleFileEvent({
|
return handleFileEvent({
|
||||||
editor,
|
editor,
|
||||||
file: event.dataTransfer.files[0],
|
file: event.dataTransfer.files[0],
|
||||||
uploadsPath,
|
uploadsPath,
|
||||||
renderMarkdown,
|
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';
|
import { LOADING_CONTENT_EVENT, LOADING_SUCCESS_EVENT, LOADING_ERROR_EVENT } from '../constants';
|
||||||
/* eslint-disable no-underscore-dangle */
|
/* eslint-disable no-underscore-dangle */
|
||||||
export class ContentEditor {
|
export class ContentEditor {
|
||||||
constructor({ tiptapEditor, serializer }) {
|
constructor({ tiptapEditor, serializer, eventHub }) {
|
||||||
this._tiptapEditor = tiptapEditor;
|
this._tiptapEditor = tiptapEditor;
|
||||||
this._serializer = serializer;
|
this._serializer = serializer;
|
||||||
this._eventHub = eventHubFactory();
|
this._eventHub = eventHub;
|
||||||
}
|
}
|
||||||
|
|
||||||
get tiptapEditor() {
|
get tiptapEditor() {
|
||||||
return this._tiptapEditor;
|
return this._tiptapEditor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get eventHub() {
|
||||||
|
return this._eventHub;
|
||||||
|
}
|
||||||
|
|
||||||
get empty() {
|
get empty() {
|
||||||
const doc = this.tiptapEditor?.state.doc;
|
const doc = this.tiptapEditor?.state.doc;
|
||||||
|
|
||||||
|
@ -23,39 +26,23 @@ export class ContentEditor {
|
||||||
this.tiptapEditor.destroy();
|
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() {
|
disposeAllEvents() {
|
||||||
this._eventHub.dispose();
|
this._eventHub.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
async setSerializedContent(serializedContent) {
|
async setSerializedContent(serializedContent) {
|
||||||
const { _tiptapEditor: editor, _serializer: serializer } = this;
|
const { _tiptapEditor: editor, _serializer: serializer, _eventHub: eventHub } = this;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this._eventHub.$emit(LOADING_CONTENT_EVENT);
|
eventHub.$emit(LOADING_CONTENT_EVENT);
|
||||||
const document = await serializer.deserialize({
|
const document = await serializer.deserialize({
|
||||||
schema: editor.schema,
|
schema: editor.schema,
|
||||||
content: serializedContent,
|
content: serializedContent,
|
||||||
});
|
});
|
||||||
editor.commands.setContent(document);
|
editor.commands.setContent(document);
|
||||||
this._eventHub.$emit(LOADING_SUCCESS_EVENT);
|
eventHub.$emit(LOADING_SUCCESS_EVENT);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this._eventHub.$emit(LOADING_ERROR_EVENT, e);
|
eventHub.$emit(LOADING_ERROR_EVENT, e);
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { Editor } from '@tiptap/vue-2';
|
import { Editor } from '@tiptap/vue-2';
|
||||||
import { isFunction } from 'lodash';
|
import { isFunction } from 'lodash';
|
||||||
|
import eventHubFactory from '~/helpers/event_hub_factory';
|
||||||
import { PROVIDE_SERIALIZER_OR_RENDERER_ERROR } from '../constants';
|
import { PROVIDE_SERIALIZER_OR_RENDERER_ERROR } from '../constants';
|
||||||
import Attachment from '../extensions/attachment';
|
import Attachment from '../extensions/attachment';
|
||||||
import Audio from '../extensions/audio';
|
import Audio from '../extensions/audio';
|
||||||
|
@ -78,8 +79,10 @@ export const createContentEditor = ({
|
||||||
throw new Error(PROVIDE_SERIALIZER_OR_RENDERER_ERROR);
|
throw new Error(PROVIDE_SERIALIZER_OR_RENDERER_ERROR);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const eventHub = eventHubFactory();
|
||||||
|
|
||||||
const builtInContentEditorExtensions = [
|
const builtInContentEditorExtensions = [
|
||||||
Attachment.configure({ uploadsPath, renderMarkdown }),
|
Attachment.configure({ uploadsPath, renderMarkdown, eventHub }),
|
||||||
Audio,
|
Audio,
|
||||||
Blockquote,
|
Blockquote,
|
||||||
Bold,
|
Bold,
|
||||||
|
@ -137,5 +140,5 @@ export const createContentEditor = ({
|
||||||
const tiptapEditor = createTiptapEditor({ extensions: trackedExtensions, ...tiptapOptions });
|
const tiptapEditor = createTiptapEditor({ extensions: trackedExtensions, ...tiptapOptions });
|
||||||
const serializer = createMarkdownSerializer({ render: renderMarkdown, serializerConfig });
|
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);
|
return extractAttachmentLinkUrl(rendered);
|
||||||
};
|
};
|
||||||
|
|
||||||
const uploadImage = async ({ editor, file, uploadsPath, renderMarkdown }) => {
|
const uploadImage = async ({ editor, file, uploadsPath, renderMarkdown, eventHub }) => {
|
||||||
const encodedSrc = await readFileAsDataURL(file);
|
const encodedSrc = await readFileAsDataURL(file);
|
||||||
const { view } = editor;
|
const { view } = editor;
|
||||||
|
|
||||||
|
@ -72,14 +72,14 @@ const uploadImage = async ({ editor, file, uploadsPath, renderMarkdown }) => {
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
editor.commands.deleteRange({ from: position, to: position + 1 });
|
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.'),
|
message: __('An error occurred while uploading the image. Please try again.'),
|
||||||
variant: 'danger',
|
variant: 'danger',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const uploadAttachment = async ({ editor, file, uploadsPath, renderMarkdown }) => {
|
const uploadAttachment = async ({ editor, file, uploadsPath, renderMarkdown, eventHub }) => {
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
|
|
||||||
const { view } = editor;
|
const { view } = editor;
|
||||||
|
@ -103,23 +103,23 @@ const uploadAttachment = async ({ editor, file, uploadsPath, renderMarkdown }) =
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
editor.commands.deleteRange({ from, to: from + 1 });
|
editor.commands.deleteRange({ from, to: from + 1 });
|
||||||
editor.emit('alert', {
|
eventHub.$emit('alert', {
|
||||||
message: __('An error occurred while uploading the file. Please try again.'),
|
message: __('An error occurred while uploading the file. Please try again.'),
|
||||||
variant: 'danger',
|
variant: 'danger',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleFileEvent = ({ editor, file, uploadsPath, renderMarkdown }) => {
|
export const handleFileEvent = ({ editor, file, uploadsPath, renderMarkdown, eventHub }) => {
|
||||||
if (!file) return false;
|
if (!file) return false;
|
||||||
|
|
||||||
if (acceptedMimes.image.includes(file?.type)) {
|
if (acceptedMimes.image.includes(file?.type)) {
|
||||||
uploadImage({ editor, file, uploadsPath, renderMarkdown });
|
uploadImage({ editor, file, uploadsPath, renderMarkdown, eventHub });
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
uploadAttachment({ editor, file, uploadsPath, renderMarkdown });
|
uploadAttachment({ editor, file, uploadsPath, renderMarkdown, eventHub });
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
|
@ -288,7 +288,6 @@ export default {
|
||||||
'sidebar-collapsed': !isSidebarOpen,
|
'sidebar-collapsed': !isSidebarOpen,
|
||||||
'has-archived-block': job.archived,
|
'has-archived-block': job.archived,
|
||||||
}"
|
}"
|
||||||
:erase-path="job.erase_path"
|
|
||||||
:size="jobLogSize"
|
:size="jobLogSize"
|
||||||
:raw-path="job.raw_path"
|
:raw-path="job.raw_path"
|
||||||
:is-scroll-bottom-disabled="isScrollBottomDisabled"
|
:is-scroll-bottom-disabled="isScrollBottomDisabled"
|
||||||
|
@ -325,6 +324,7 @@ export default {
|
||||||
'right-sidebar-expanded': isSidebarOpen,
|
'right-sidebar-expanded': isSidebarOpen,
|
||||||
'right-sidebar-collapsed': !isSidebarOpen,
|
'right-sidebar-collapsed': !isSidebarOpen,
|
||||||
}"
|
}"
|
||||||
|
:erase-path="job.erase_path"
|
||||||
:artifact-help-url="artifactHelpUrl"
|
:artifact-help-url="artifactHelpUrl"
|
||||||
data-testid="job-sidebar"
|
data-testid="job-sidebar"
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -5,7 +5,6 @@ import { __, s__, sprintf } from '~/locale';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
i18n: {
|
i18n: {
|
||||||
eraseLogButtonLabel: s__('Job|Erase job log and artifacts'),
|
|
||||||
scrollToBottomButtonLabel: s__('Job|Scroll to bottom'),
|
scrollToBottomButtonLabel: s__('Job|Scroll to bottom'),
|
||||||
scrollToTopButtonLabel: s__('Job|Scroll to top'),
|
scrollToTopButtonLabel: s__('Job|Scroll to top'),
|
||||||
showRawButtonLabel: s__('Job|Show complete raw'),
|
showRawButtonLabel: s__('Job|Show complete raw'),
|
||||||
|
@ -18,11 +17,6 @@ export default {
|
||||||
GlTooltip: GlTooltipDirective,
|
GlTooltip: GlTooltipDirective,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
erasePath: {
|
|
||||||
type: String,
|
|
||||||
required: false,
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
size: {
|
size: {
|
||||||
type: Number,
|
type: Number,
|
||||||
required: true,
|
required: true,
|
||||||
|
@ -97,20 +91,6 @@ export default {
|
||||||
data-testid="job-raw-link-controller"
|
data-testid="job-raw-link-controller"
|
||||||
icon="doc-text"
|
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 -->
|
<!-- eo links -->
|
||||||
|
|
||||||
<!-- scroll buttons -->
|
<!-- scroll buttons -->
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<script>
|
<script>
|
||||||
import { GlButton, GlLink, GlModalDirective } from '@gitlab/ui';
|
import { GlButton, GlModalDirective } from '@gitlab/ui';
|
||||||
import { mapGetters } from 'vuex';
|
import { mapGetters } from 'vuex';
|
||||||
import { JOB_SIDEBAR } from '../constants';
|
import { JOB_SIDEBAR } from '../constants';
|
||||||
|
|
||||||
|
@ -10,7 +10,6 @@ export default {
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
GlButton,
|
GlButton,
|
||||||
GlLink,
|
|
||||||
},
|
},
|
||||||
directives: {
|
directives: {
|
||||||
GlModal: GlModalDirective,
|
GlModal: GlModalDirective,
|
||||||
|
@ -37,9 +36,18 @@ export default {
|
||||||
:aria-label="$options.i18n.retryLabel"
|
:aria-label="$options.i18n.retryLabel"
|
||||||
category="primary"
|
category="primary"
|
||||||
variant="confirm"
|
variant="confirm"
|
||||||
>{{ $options.i18n.retryLabel }}</gl-button
|
icon="retry"
|
||||||
>
|
data-testid="retry-job-button"
|
||||||
<gl-link v-else :href="href" class="btn gl-button btn-confirm" data-method="post" rel="nofollow"
|
/>
|
||||||
>{{ $options.i18n.retryLabel }}
|
|
||||||
</gl-link>
|
<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>
|
</template>
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
<script>
|
<script>
|
||||||
import { GlButton, GlIcon } from '@gitlab/ui';
|
import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui';
|
||||||
import { isEmpty } from 'lodash';
|
import { isEmpty } from 'lodash';
|
||||||
import { mapActions, mapGetters, mapState } from 'vuex';
|
import { mapActions, mapGetters, mapState } from 'vuex';
|
||||||
|
import { s__ } from '~/locale';
|
||||||
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
|
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
|
||||||
import { JOB_SIDEBAR } from '../constants';
|
import { JOB_SIDEBAR } from '../constants';
|
||||||
import ArtifactsBlock from './artifacts_block.vue';
|
import ArtifactsBlock from './artifacts_block.vue';
|
||||||
|
@ -18,10 +19,17 @@ export const forwardDeploymentFailureModalId = 'forward-deployment-failure';
|
||||||
export default {
|
export default {
|
||||||
name: 'JobSidebar',
|
name: 'JobSidebar',
|
||||||
i18n: {
|
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,
|
...JOB_SIDEBAR,
|
||||||
},
|
},
|
||||||
borderTopClass: ['gl-border-t-solid', 'gl-border-t-1', 'gl-border-t-gray-100'],
|
borderTopClass: ['gl-border-t-solid', 'gl-border-t-1', 'gl-border-t-gray-100'],
|
||||||
forwardDeploymentFailureModalId,
|
forwardDeploymentFailureModalId,
|
||||||
|
directives: {
|
||||||
|
GlTooltip: GlTooltipDirective,
|
||||||
|
},
|
||||||
components: {
|
components: {
|
||||||
ArtifactsBlock,
|
ArtifactsBlock,
|
||||||
CommitBlock,
|
CommitBlock,
|
||||||
|
@ -41,6 +49,11 @@ export default {
|
||||||
required: false,
|
required: false,
|
||||||
default: '',
|
default: '',
|
||||||
},
|
},
|
||||||
|
erasePath: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
...mapGetters(['hasForwardDeploymentFailure']),
|
...mapGetters(['hasForwardDeploymentFailure']),
|
||||||
|
@ -81,8 +94,24 @@ export default {
|
||||||
</h4>
|
</h4>
|
||||||
</tooltip-on-truncate>
|
</tooltip-on-truncate>
|
||||||
<div class="gl-flex-grow-1 gl-flex-shrink-0 gl-text-right">
|
<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
|
<job-sidebar-retry-button
|
||||||
v-if="job.retry_path"
|
v-if="job.retry_path"
|
||||||
|
v-gl-tooltip.left
|
||||||
|
:title="$options.i18n.retryJobButtonLabel"
|
||||||
|
:aria-label="$options.i18n.retryJobButtonLabel"
|
||||||
:category="retryButtonCategory"
|
:category="retryButtonCategory"
|
||||||
:href="job.retry_path"
|
:href="job.retry_path"
|
||||||
:modal-id="$options.forwardDeploymentFailureModalId"
|
:modal-id="$options.forwardDeploymentFailureModalId"
|
||||||
|
@ -92,12 +121,15 @@ export default {
|
||||||
/>
|
/>
|
||||||
<gl-button
|
<gl-button
|
||||||
v-if="job.cancel_path"
|
v-if="job.cancel_path"
|
||||||
|
v-gl-tooltip.left
|
||||||
|
:title="$options.i18n.cancelJobButtonLabel"
|
||||||
|
:aria-label="$options.i18n.cancelJobButtonLabel"
|
||||||
:href="job.cancel_path"
|
:href="job.cancel_path"
|
||||||
|
icon="cancel"
|
||||||
data-method="post"
|
data-method="post"
|
||||||
data-testid="cancel-button"
|
data-testid="cancel-button"
|
||||||
rel="nofollow"
|
rel="nofollow"
|
||||||
>{{ $options.i18n.cancel }}
|
/>
|
||||||
</gl-button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<gl-button
|
<gl-button
|
||||||
|
|
|
@ -1,12 +1,9 @@
|
||||||
import initDeprecatedRemoveRowBehavior from '~/behaviors/deprecated_remove_row_behavior';
|
import initDeprecatedRemoveRowBehavior from '~/behaviors/deprecated_remove_row_behavior';
|
||||||
import AjaxLoadingSpinner from '~/branches/ajax_loading_spinner';
|
|
||||||
import BranchSortDropdown from '~/branches/branch_sort_dropdown';
|
import BranchSortDropdown from '~/branches/branch_sort_dropdown';
|
||||||
import initDiverganceGraph from '~/branches/divergence_graph';
|
import initDiverganceGraph from '~/branches/divergence_graph';
|
||||||
import initDeleteBranchButton from '~/branches/init_delete_branch_button';
|
import initDeleteBranchButton from '~/branches/init_delete_branch_button';
|
||||||
import initDeleteBranchModal from '~/branches/init_delete_branch_modal';
|
import initDeleteBranchModal from '~/branches/init_delete_branch_modal';
|
||||||
|
|
||||||
AjaxLoadingSpinner.init();
|
|
||||||
|
|
||||||
const { divergingCountsEndpoint, defaultBranch } = document.querySelector(
|
const { divergingCountsEndpoint, defaultBranch } = document.querySelector(
|
||||||
'.js-branch-list',
|
'.js-branch-list',
|
||||||
).dataset;
|
).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
|
# ActiveRecord. https://github.com/rails/rails/issues/13496
|
||||||
# Ideally it would be:
|
# Ideally it would be:
|
||||||
# `incorrect_traversal_ids.update_all('traversal_ids = cte.traversal_ids')`
|
# `incorrect_traversal_ids.update_all('traversal_ids = cte.traversal_ids')`
|
||||||
sql = """
|
sql = <<-SQL
|
||||||
UPDATE namespaces
|
UPDATE namespaces
|
||||||
SET traversal_ids = cte.traversal_ids
|
SET traversal_ids = cte.traversal_ids
|
||||||
FROM (#{recursive_traversal_ids}) as cte
|
FROM (#{recursive_traversal_ids}) as cte
|
||||||
WHERE namespaces.id = cte.id
|
WHERE namespaces.id = cte.id
|
||||||
AND namespaces.traversal_ids::bigint[] <> cte.traversal_ids
|
AND namespaces.traversal_ids::bigint[] <> cte.traversal_ids
|
||||||
"""
|
SQL
|
||||||
|
|
||||||
Namespace.transaction do
|
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)
|
Namespace.connection.exec_query(sql)
|
||||||
end
|
end
|
||||||
rescue ActiveRecord::Deadlocked
|
rescue ActiveRecord::Deadlocked
|
||||||
|
|
|
@ -60,7 +60,7 @@ module ErrorTracking
|
||||||
end
|
end
|
||||||
|
|
||||||
def actor
|
def actor
|
||||||
return event['transaction'] if event['transaction']
|
return event['transaction'] if event['transaction'].present?
|
||||||
|
|
||||||
# Some SDKs do not have a transaction attribute.
|
# Some SDKs do not have a transaction attribute.
|
||||||
# So we build it by combining function name and module name from
|
# 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
|
$ vault policy write myproject-staging - <<EOF
|
||||||
# Policy name: myproject-staging
|
# Policy name: myproject-staging
|
||||||
#
|
#
|
||||||
# Read-only permission on 'secret/data/myproject/staging/*' path
|
# Read-only permission on 'secret/myproject/staging/*' path
|
||||||
path "secret/data/myproject/staging/*" {
|
path "secret/myproject/staging/*" {
|
||||||
capabilities = [ "read" ]
|
capabilities = [ "read" ]
|
||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
|
@ -131,8 +131,8 @@ Success! Uploaded policy: myproject-staging
|
||||||
$ vault policy write myproject-production - <<EOF
|
$ vault policy write myproject-production - <<EOF
|
||||||
# Policy name: myproject-production
|
# Policy name: myproject-production
|
||||||
#
|
#
|
||||||
# Read-only permission on 'secret/data/myproject/production/*' path
|
# Read-only permission on 'secret/myproject/production/*' path
|
||||||
path "secret/data/myproject/production/*" {
|
path "secret/myproject/production/*" {
|
||||||
capabilities = [ "read" ]
|
capabilities = [ "read" ]
|
||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
|
|
|
@ -140,7 +140,7 @@ to its **Pipelines** tab.
|
||||||
|
|
||||||
![Pipelines index page](img/pipelines_index_v13_0.png)
|
![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,
|
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).
|
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),
|
[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.
|
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.
|
to execute that particular job.
|
||||||
|
|
||||||
For example, your pipeline can start automatically, but require a manual action to
|
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.
|
> [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.
|
Multiple manual actions in a single stage can be started at the same time using the "Play all manual"
|
||||||
After you click this button, each individual manual action is triggered and refreshed
|
After you select this action, each individual manual action is triggered and refreshed
|
||||||
to an updated status.
|
to an updated status.
|
||||||
|
|
||||||
This functionality is only available:
|
This functionality is only available:
|
||||||
|
@ -283,9 +283,9 @@ pipelines.
|
||||||
|
|
||||||
Users with the Owner role for a project can delete a pipeline
|
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**
|
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:
|
WARNING:
|
||||||
Deleting a pipeline expires all pipeline caches, and deletes all related objects,
|
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
|
**Runners** marked as **protected** can run jobs only on protected
|
||||||
branches, preventing untrusted code from executing on the protected runner and
|
branches, preventing untrusted code from executing on the protected runner and
|
||||||
preserving deployment keys and other credentials from being unintentionally
|
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.
|
runners do not use regular runners, they must be tagged accordingly.
|
||||||
|
|
||||||
### How pipeline duration is calculated
|
### How pipeline duration is calculated
|
||||||
|
@ -434,7 +434,7 @@ fix it.
|
||||||
|
|
||||||
Pipeline mini graphs only display jobs by stage.
|
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 |
|
| 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:
|
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:
|
- Create explicit downtime by stopping all Gitaly services before backing up the repositories:
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
|
@ -1354,15 +1354,13 @@ To prepare the new server:
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
sudo rm -f /var/opt/gitlab/redis/dump.rdb
|
sudo rm -f /var/opt/gitlab/redis/dump.rdb
|
||||||
sudo chown <your-linux-username> /var/opt/gitlab/redis
|
sudo chown <your-linux-username> /var/opt/gitlab/redis /var/opt/gitlab/backups
|
||||||
sudo mkdir /var/opt/gitlab/backups
|
|
||||||
sudo chown <your-linux-username> /var/opt/gitlab/backups
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Prepare and transfer content from the old server
|
### 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. 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.
|
if supported by your GitLab edition.
|
||||||
1. Block new CI/CD jobs from starting:
|
1. Block new CI/CD jobs from starting:
|
||||||
1. Edit `/etc/gitlab/gitlab.rb`, and set the following:
|
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**
|
1. While still under the Sidekiq dashboard, select **Cron** and then **Enable All**
|
||||||
to re-enable periodic background jobs.
|
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. 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. 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. 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. Update your DNS or load balancer to point at the new server.
|
||||||
|
|
|
@ -1,34 +1,9 @@
|
||||||
---
|
---
|
||||||
stage: Enablement
|
redirect_to: 'index.md'
|
||||||
group: Distribution
|
remove_date: '2022-05-24'
|
||||||
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
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Namespaces **(FREE SELF)**
|
This document was moved to [another location](index.md).
|
||||||
|
|
||||||
This Rake task enables [namespaces](../user/group/index.md#namespaces) for projects.
|
<!-- 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 -->
|
||||||
## 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
|
|
||||||
```
|
|
||||||
|
|
|
@ -12,12 +12,49 @@ instance entirely offline.
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
NOTE:
|
NOTE:
|
||||||
This guide assumes the server is Ubuntu 18.04. Instructions for other servers may vary.
|
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`, which you should replace with your
|
This guide also assumes the server host resolves as `my-host.internal`, which you should replace with your
|
||||||
server's name.
|
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
|
<i class="fa fa-youtube-play youtube" aria-hidden="true"></i>
|
||||||
guide](https://about.gitlab.com/install/#ubuntu), but make sure to specify an `http`
|
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
|
URL for the `EXTERNAL_URL` installation step. Once installed, we can manually
|
||||||
configure the SSL ourselves.
|
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
|
to the server's IP address. This better ensures a stable target for our certs' CN
|
||||||
and makes long-term resolution simpler.
|
and makes long-term resolution simpler.
|
||||||
|
|
||||||
|
The following example for Ubuntu specifies the `EXTERNAL_URL` using HTTP and installs the GitLab package:
|
||||||
|
|
||||||
```shell
|
```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
|
## Enabling SSL
|
||||||
|
@ -38,7 +77,7 @@ Follow these steps to enable SSL for your fresh instance. Note that these steps
|
||||||
|
|
||||||
```ruby
|
```ruby
|
||||||
# Update external_url from "http" to "https"
|
# 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
|
# Set Let's Encrypt to false
|
||||||
letsencrypt['enable'] = 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
|
When you [add a linked issue](#add-a-linked-issue), you can show that it **blocks** or
|
||||||
**is blocked by** another issue.
|
**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
|
class Receiver
|
||||||
include Gitlab::Utils::StrongMemoize
|
include Gitlab::Utils::StrongMemoize
|
||||||
|
|
||||||
|
RECEIVED_HEADER_REGEX = /for\s+\<(.+)\>/.freeze
|
||||||
|
|
||||||
def initialize(raw)
|
def initialize(raw)
|
||||||
@raw = raw
|
@raw = raw
|
||||||
end
|
end
|
||||||
|
@ -37,6 +39,8 @@ module Gitlab
|
||||||
delivered_to: delivered_to.map(&:value),
|
delivered_to: delivered_to.map(&:value),
|
||||||
envelope_to: envelope_to.map(&:value),
|
envelope_to: envelope_to.map(&:value),
|
||||||
x_envelope_to: x_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: {
|
meta: {
|
||||||
client_id: "email/#{mail.from.first}",
|
client_id: "email/#{mail.from.first}",
|
||||||
project: handler&.project&.full_path
|
project: handler&.project&.full_path
|
||||||
|
@ -82,7 +86,8 @@ module Gitlab
|
||||||
find_key_from_references ||
|
find_key_from_references ||
|
||||||
find_key_from_delivered_to_header ||
|
find_key_from_delivered_to_header ||
|
||||||
find_key_from_envelope_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
|
end
|
||||||
|
|
||||||
def ensure_references_array(references)
|
def ensure_references_array(references)
|
||||||
|
@ -117,6 +122,10 @@ module Gitlab
|
||||||
Array(mail[:x_envelope_to])
|
Array(mail[:x_envelope_to])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def received
|
||||||
|
Array(mail[:received])
|
||||||
|
end
|
||||||
|
|
||||||
def find_key_from_delivered_to_header
|
def find_key_from_delivered_to_header
|
||||||
delivered_to.find do |header|
|
delivered_to.find do |header|
|
||||||
key = email_class.key_from_address(header.value)
|
key = email_class.key_from_address(header.value)
|
||||||
|
@ -138,6 +147,21 @@ module Gitlab
|
||||||
end
|
end
|
||||||
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!
|
def ignore_auto_reply!
|
||||||
if auto_submitted? || auto_replied?
|
if auto_submitted? || auto_replied?
|
||||||
raise AutoGeneratedEmailError
|
raise AutoGeneratedEmailError
|
||||||
|
|
|
@ -4738,9 +4738,6 @@ msgstr ""
|
||||||
msgid "Are you sure you want to discard your changes?"
|
msgid "Are you sure you want to discard your changes?"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
msgid "Are you sure you want to erase this build?"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
msgid "Are you sure you want to import %d repository?"
|
msgid "Are you sure you want to import %d repository?"
|
||||||
msgid_plural "Are you sure you want to import %d repositories?"
|
msgid_plural "Are you sure you want to import %d repositories?"
|
||||||
msgstr[0] ""
|
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."
|
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 ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Job|Are you sure you want to erase this job log and artifacts?"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "Job|Browse"
|
msgid "Job|Browse"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Job|Cancel"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "Job|Complete Raw"
|
msgid "Job|Complete Raw"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
@ -21061,6 +21064,9 @@ msgstr ""
|
||||||
msgid "Job|Pipeline"
|
msgid "Job|Pipeline"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Job|Retry"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "Job|Scroll to bottom"
|
msgid "Job|Scroll to bottom"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
@ -26813,12 +26819,21 @@ msgstr ""
|
||||||
msgid "PipelineWizardDefaultCommitMessage|Update %{filename}"
|
msgid "PipelineWizardDefaultCommitMessage|Update %{filename}"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "PipelineWizardInputValidation|At least one entry is required"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "PipelineWizardInputValidation|This field is required"
|
msgid "PipelineWizardInputValidation|This field is required"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
msgid "PipelineWizardInputValidation|This value is not valid"
|
msgid "PipelineWizardInputValidation|This value is not valid"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "PipelineWizardListWidget|add another step"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "PipelineWizardListWidget|remove step"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "PipelineWizard|Commit"
|
msgid "PipelineWizard|Commit"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
|
|
@ -313,7 +313,7 @@ RSpec.describe 'Jobs', :clean_gitlab_redis_shared_state do
|
||||||
|
|
||||||
context 'job is cancelable' do
|
context 'job is cancelable' do
|
||||||
it 'shows cancel button' do
|
it 'shows cancel button' do
|
||||||
click_link 'Cancel'
|
find('[data-testid="cancel-button"]').click
|
||||||
|
|
||||||
expect(page.current_path).to eq(job_url)
|
expect(page.current_path).to eq(job_url)
|
||||||
end
|
end
|
||||||
|
@ -1031,7 +1031,7 @@ RSpec.describe 'Jobs', :clean_gitlab_redis_shared_state do
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'loads the page and shows all needed controls' do
|
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
|
end
|
||||||
end
|
end
|
||||||
|
@ -1049,7 +1049,7 @@ RSpec.describe 'Jobs', :clean_gitlab_redis_shared_state do
|
||||||
|
|
||||||
it 'shows the right status and buttons' do
|
it 'shows the right status and buttons' do
|
||||||
page.within('aside.right-sidebar') 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
|
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>
|
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 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 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
|
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
|
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 { shallowMountExtended } from 'helpers/vue_test_utils_helper';
|
||||||
import ContentEditorAlert from '~/content_editor/components/content_editor_alert.vue';
|
import ContentEditorAlert from '~/content_editor/components/content_editor_alert.vue';
|
||||||
import EditorStateObserver from '~/content_editor/components/editor_state_observer.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', () => {
|
describe('content_editor/components/content_editor_alert', () => {
|
||||||
let wrapper;
|
let wrapper;
|
||||||
let tiptapEditor;
|
let tiptapEditor;
|
||||||
|
let eventHub;
|
||||||
|
|
||||||
const findErrorAlert = () => wrapper.findComponent(GlAlert);
|
const findErrorAlert = () => wrapper.findComponent(GlAlert);
|
||||||
|
|
||||||
const createWrapper = async () => {
|
const createWrapper = async () => {
|
||||||
tiptapEditor = createTestEditor();
|
tiptapEditor = createTestEditor();
|
||||||
|
eventHub = eventHubFactory();
|
||||||
|
|
||||||
wrapper = shallowMountExtended(ContentEditorAlert, {
|
wrapper = shallowMountExtended(ContentEditorAlert, {
|
||||||
provide: {
|
provide: {
|
||||||
tiptapEditor,
|
tiptapEditor,
|
||||||
|
eventHub,
|
||||||
},
|
},
|
||||||
stubs: {
|
stubs: {
|
||||||
EditorStateObserver,
|
EditorStateObserver,
|
||||||
|
@ -37,7 +42,9 @@ describe('content_editor/components/content_editor_alert', () => {
|
||||||
async ({ message, variant }) => {
|
async ({ message, variant }) => {
|
||||||
createWrapper();
|
createWrapper();
|
||||||
|
|
||||||
await emitEditorEvent({ tiptapEditor, event: 'alert', params: { message, variant } });
|
eventHub.$emit(ALERT_EVENT, { message, variant });
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
expect(findErrorAlert().text()).toBe(message);
|
expect(findErrorAlert().text()).toBe(message);
|
||||||
expect(findErrorAlert().attributes().variant).toBe(variant);
|
expect(findErrorAlert().attributes().variant).toBe(variant);
|
||||||
|
@ -48,11 +55,9 @@ describe('content_editor/components/content_editor_alert', () => {
|
||||||
const message = 'error message';
|
const message = 'error message';
|
||||||
|
|
||||||
createWrapper();
|
createWrapper();
|
||||||
|
eventHub.$emit(ALERT_EVENT, { message });
|
||||||
await emitEditorEvent({ tiptapEditor, event: 'alert', params: { message } });
|
await nextTick();
|
||||||
|
|
||||||
findErrorAlert().vm.$emit('dismiss');
|
findErrorAlert().vm.$emit('dismiss');
|
||||||
|
|
||||||
await nextTick();
|
await nextTick();
|
||||||
|
|
||||||
expect(findErrorAlert().exists()).toBe(false);
|
expect(findErrorAlert().exists()).toBe(false);
|
||||||
|
|
|
@ -121,7 +121,7 @@ describe('ContentEditor', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
createWrapper();
|
createWrapper();
|
||||||
|
|
||||||
contentEditor.emit(LOADING_CONTENT_EVENT);
|
contentEditor.eventHub.$emit(LOADING_CONTENT_EVENT);
|
||||||
|
|
||||||
await nextTick();
|
await nextTick();
|
||||||
});
|
});
|
||||||
|
@ -143,9 +143,9 @@ describe('ContentEditor', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
createWrapper();
|
createWrapper();
|
||||||
|
|
||||||
contentEditor.emit(LOADING_CONTENT_EVENT);
|
contentEditor.eventHub.$emit(LOADING_CONTENT_EVENT);
|
||||||
await nextTick();
|
await nextTick();
|
||||||
contentEditor.emit(LOADING_SUCCESS_EVENT);
|
contentEditor.eventHub.$emit(LOADING_SUCCESS_EVENT);
|
||||||
await nextTick();
|
await nextTick();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -164,9 +164,9 @@ describe('ContentEditor', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
createWrapper();
|
createWrapper();
|
||||||
|
|
||||||
contentEditor.emit(LOADING_CONTENT_EVENT);
|
contentEditor.eventHub.$emit(LOADING_CONTENT_EVENT);
|
||||||
await nextTick();
|
await nextTick();
|
||||||
contentEditor.emit(LOADING_ERROR_EVENT, error);
|
contentEditor.eventHub.$emit(LOADING_ERROR_EVENT, error);
|
||||||
await nextTick();
|
await nextTick();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -3,6 +3,13 @@ import { each } from 'lodash';
|
||||||
import EditorStateObserver, {
|
import EditorStateObserver, {
|
||||||
tiptapToComponentMap,
|
tiptapToComponentMap,
|
||||||
} from '~/content_editor/components/editor_state_observer.vue';
|
} 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';
|
import { createTestEditor } from '../test_utils';
|
||||||
|
|
||||||
describe('content_editor/components/editor_state_observer', () => {
|
describe('content_editor/components/editor_state_observer', () => {
|
||||||
|
@ -11,19 +18,29 @@ describe('content_editor/components/editor_state_observer', () => {
|
||||||
let onDocUpdateListener;
|
let onDocUpdateListener;
|
||||||
let onSelectionUpdateListener;
|
let onSelectionUpdateListener;
|
||||||
let onTransactionListener;
|
let onTransactionListener;
|
||||||
|
let onLoadingContentListener;
|
||||||
|
let onLoadingSuccessListener;
|
||||||
|
let onLoadingErrorListener;
|
||||||
|
let onAlertListener;
|
||||||
|
let eventHub;
|
||||||
|
|
||||||
const buildEditor = () => {
|
const buildEditor = () => {
|
||||||
tiptapEditor = createTestEditor();
|
tiptapEditor = createTestEditor();
|
||||||
|
eventHub = eventHubFactory();
|
||||||
jest.spyOn(tiptapEditor, 'on');
|
jest.spyOn(tiptapEditor, 'on');
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildWrapper = () => {
|
const buildWrapper = () => {
|
||||||
wrapper = shallowMount(EditorStateObserver, {
|
wrapper = shallowMount(EditorStateObserver, {
|
||||||
provide: { tiptapEditor },
|
provide: { tiptapEditor, eventHub },
|
||||||
listeners: {
|
listeners: {
|
||||||
docUpdate: onDocUpdateListener,
|
docUpdate: onDocUpdateListener,
|
||||||
selectionUpdate: onSelectionUpdateListener,
|
selectionUpdate: onSelectionUpdateListener,
|
||||||
transaction: onTransactionListener,
|
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();
|
onDocUpdateListener = jest.fn();
|
||||||
onSelectionUpdateListener = jest.fn();
|
onSelectionUpdateListener = jest.fn();
|
||||||
onTransactionListener = jest.fn();
|
onTransactionListener = jest.fn();
|
||||||
|
onAlertListener = jest.fn();
|
||||||
|
onLoadingSuccessListener = jest.fn();
|
||||||
|
onLoadingContentListener = jest.fn();
|
||||||
|
onLoadingErrorListener = jest.fn();
|
||||||
buildEditor();
|
buildEditor();
|
||||||
buildWrapper();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
@ -44,6 +64,8 @@ describe('content_editor/components/editor_state_observer', () => {
|
||||||
it('emits update, selectionUpdate, and transaction events', () => {
|
it('emits update, selectionUpdate, and transaction events', () => {
|
||||||
const content = '<p>My paragraph</p>';
|
const content = '<p>My paragraph</p>';
|
||||||
|
|
||||||
|
buildWrapper();
|
||||||
|
|
||||||
tiptapEditor.commands.insertContent(content);
|
tiptapEditor.commands.insertContent(content);
|
||||||
|
|
||||||
expect(onDocUpdateListener).toHaveBeenCalledWith(
|
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', () => {
|
describe('when component is destroyed', () => {
|
||||||
it('removes onTiptapDocUpdate and onTiptapSelectionUpdate hooks', () => {
|
it('removes onTiptapDocUpdate and onTiptapSelectionUpdate hooks', () => {
|
||||||
jest.spyOn(tiptapEditor, 'off');
|
jest.spyOn(tiptapEditor, 'off');
|
||||||
|
|
||||||
|
buildWrapper();
|
||||||
|
|
||||||
wrapper.destroy();
|
wrapper.destroy();
|
||||||
|
|
||||||
each(tiptapToComponentMap, (_, tiptapEvent) => {
|
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 { shallowMount } from '@vue/test-utils';
|
||||||
import EditorStateObserver from '~/content_editor/components/editor_state_observer.vue';
|
import EditorStateObserver from '~/content_editor/components/editor_state_observer.vue';
|
||||||
import ToolbarButton from '~/content_editor/components/toolbar_button.vue';
|
import ToolbarButton from '~/content_editor/components/toolbar_button.vue';
|
||||||
|
import eventHubFactory from '~/helpers/event_hub_factory';
|
||||||
import { createTestEditor, mockChainedCommands, emitEditorEvent } from '../test_utils';
|
import { createTestEditor, mockChainedCommands, emitEditorEvent } from '../test_utils';
|
||||||
|
|
||||||
describe('content_editor/components/toolbar_button', () => {
|
describe('content_editor/components/toolbar_button', () => {
|
||||||
|
@ -25,6 +26,7 @@ describe('content_editor/components/toolbar_button', () => {
|
||||||
},
|
},
|
||||||
provide: {
|
provide: {
|
||||||
tiptapEditor,
|
tiptapEditor,
|
||||||
|
eventHub: eventHubFactory(),
|
||||||
},
|
},
|
||||||
propsData: {
|
propsData: {
|
||||||
contentType: CONTENT_TYPE,
|
contentType: CONTENT_TYPE,
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { GlDropdown, GlButton, GlFormInputGroup } from '@gitlab/ui';
|
import { GlDropdown, GlButton, GlFormInputGroup } from '@gitlab/ui';
|
||||||
import { mountExtended } from 'helpers/vue_test_utils_helper';
|
import { mountExtended } from 'helpers/vue_test_utils_helper';
|
||||||
import ToolbarLinkButton from '~/content_editor/components/toolbar_link_button.vue';
|
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 Link from '~/content_editor/extensions/link';
|
||||||
import { hasSelection } from '~/content_editor/services/utils';
|
import { hasSelection } from '~/content_editor/services/utils';
|
||||||
import { createTestEditor, mockChainedCommands, emitEditorEvent } from '../test_utils';
|
import { createTestEditor, mockChainedCommands, emitEditorEvent } from '../test_utils';
|
||||||
|
@ -15,6 +16,7 @@ describe('content_editor/components/toolbar_link_button', () => {
|
||||||
wrapper = mountExtended(ToolbarLinkButton, {
|
wrapper = mountExtended(ToolbarLinkButton, {
|
||||||
provide: {
|
provide: {
|
||||||
tiptapEditor: editor,
|
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 ToolbarTextStyleDropdown from '~/content_editor/components/toolbar_text_style_dropdown.vue';
|
||||||
import { TEXT_STYLE_DROPDOWN_ITEMS } from '~/content_editor/constants';
|
import { TEXT_STYLE_DROPDOWN_ITEMS } from '~/content_editor/constants';
|
||||||
import Heading from '~/content_editor/extensions/heading';
|
import Heading from '~/content_editor/extensions/heading';
|
||||||
|
import eventHubFactory from '~/helpers/event_hub_factory';
|
||||||
import { createTestEditor, mockChainedCommands, emitEditorEvent } from '../test_utils';
|
import { createTestEditor, mockChainedCommands, emitEditorEvent } from '../test_utils';
|
||||||
|
|
||||||
describe('content_editor/components/toolbar_text_style_dropdown', () => {
|
describe('content_editor/components/toolbar_text_style_dropdown', () => {
|
||||||
|
@ -27,6 +28,7 @@ describe('content_editor/components/toolbar_text_style_dropdown', () => {
|
||||||
},
|
},
|
||||||
provide: {
|
provide: {
|
||||||
tiptapEditor,
|
tiptapEditor,
|
||||||
|
eventHub: eventHubFactory(),
|
||||||
},
|
},
|
||||||
propsData: {
|
propsData: {
|
||||||
...propsData,
|
...propsData,
|
||||||
|
|
|
@ -5,6 +5,7 @@ import Image from '~/content_editor/extensions/image';
|
||||||
import Link from '~/content_editor/extensions/link';
|
import Link from '~/content_editor/extensions/link';
|
||||||
import Loading from '~/content_editor/extensions/loading';
|
import Loading from '~/content_editor/extensions/loading';
|
||||||
import httpStatus from '~/lib/utils/http_status';
|
import httpStatus from '~/lib/utils/http_status';
|
||||||
|
import eventHubFactory from '~/helpers/event_hub_factory';
|
||||||
import { createTestEditor, createDocBuilder } from '../test_utils';
|
import { createTestEditor, createDocBuilder } from '../test_utils';
|
||||||
|
|
||||||
const PROJECT_WIKI_ATTACHMENT_IMAGE_HTML = `<p data-sourcepos="1:1-1:27" dir="auto">
|
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 link;
|
||||||
let renderMarkdown;
|
let renderMarkdown;
|
||||||
let mock;
|
let mock;
|
||||||
|
let eventHub;
|
||||||
|
|
||||||
const uploadsPath = '/uploads/';
|
const uploadsPath = '/uploads/';
|
||||||
const imageFile = new File(['foo'], 'test-file.png', { type: 'image/png' });
|
const imageFile = new File(['foo'], 'test-file.png', { type: 'image/png' });
|
||||||
|
@ -50,9 +52,15 @@ describe('content_editor/extensions/attachment', () => {
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
renderMarkdown = jest.fn();
|
renderMarkdown = jest.fn();
|
||||||
|
eventHub = eventHubFactory();
|
||||||
|
|
||||||
tiptapEditor = createTestEditor({
|
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) => {
|
it('emits an alert event that includes an error message', (done) => {
|
||||||
tiptapEditor.commands.uploadAttachment({ file: imageFile });
|
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.');
|
expect(message).toBe('An error occurred while uploading the image. Please try again.');
|
||||||
done();
|
done();
|
||||||
});
|
});
|
||||||
|
@ -236,7 +244,7 @@ describe('content_editor/extensions/attachment', () => {
|
||||||
it('emits an alert event that includes an error message', (done) => {
|
it('emits an alert event that includes an error message', (done) => {
|
||||||
tiptapEditor.commands.uploadAttachment({ file: attachmentFile });
|
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.');
|
expect(message).toBe('An error occurred while uploading the file. Please try again.');
|
||||||
done();
|
done();
|
||||||
});
|
});
|
||||||
|
|
|
@ -4,19 +4,21 @@ import {
|
||||||
LOADING_ERROR_EVENT,
|
LOADING_ERROR_EVENT,
|
||||||
} from '~/content_editor/constants';
|
} from '~/content_editor/constants';
|
||||||
import { ContentEditor } from '~/content_editor/services/content_editor';
|
import { ContentEditor } from '~/content_editor/services/content_editor';
|
||||||
|
import eventHubFactory from '~/helpers/event_hub_factory';
|
||||||
import { createTestEditor } from '../test_utils';
|
import { createTestEditor } from '../test_utils';
|
||||||
|
|
||||||
describe('content_editor/services/content_editor', () => {
|
describe('content_editor/services/content_editor', () => {
|
||||||
let contentEditor;
|
let contentEditor;
|
||||||
let serializer;
|
let serializer;
|
||||||
|
let eventHub;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
const tiptapEditor = createTestEditor();
|
const tiptapEditor = createTestEditor();
|
||||||
jest.spyOn(tiptapEditor, 'destroy');
|
jest.spyOn(tiptapEditor, 'destroy');
|
||||||
|
|
||||||
serializer = { deserialize: jest.fn() };
|
serializer = { deserialize: jest.fn() };
|
||||||
contentEditor = new ContentEditor({ tiptapEditor, serializer });
|
eventHub = eventHubFactory();
|
||||||
|
contentEditor = new ContentEditor({ tiptapEditor, serializer, eventHub });
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('.dispose', () => {
|
describe('.dispose', () => {
|
||||||
|
@ -34,13 +36,13 @@ describe('content_editor/services/content_editor', () => {
|
||||||
serializer.deserialize.mockResolvedValueOnce('');
|
serializer.deserialize.mockResolvedValueOnce('');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('emits loadingContent and loadingSuccess event', () => {
|
it('emits loadingContent and loadingSuccess event in the eventHub', () => {
|
||||||
let loadingContentEmitted = false;
|
let loadingContentEmitted = false;
|
||||||
|
|
||||||
contentEditor.on(LOADING_CONTENT_EVENT, () => {
|
eventHub.$on(LOADING_CONTENT_EVENT, () => {
|
||||||
loadingContentEmitted = true;
|
loadingContentEmitted = true;
|
||||||
});
|
});
|
||||||
contentEditor.on(LOADING_SUCCESS_EVENT, () => {
|
eventHub.$on(LOADING_SUCCESS_EVENT, () => {
|
||||||
expect(loadingContentEmitted).toBe(true);
|
expect(loadingContentEmitted).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -56,7 +58,7 @@ describe('content_editor/services/content_editor', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('emits loadingError event', async () => {
|
it('emits loadingError event', async () => {
|
||||||
contentEditor.on(LOADING_ERROR_EVENT, (e) => {
|
eventHub.$on(LOADING_ERROR_EVENT, (e) => {
|
||||||
expect(e).toBe('error');
|
expect(e).toBe('error');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -8,7 +8,6 @@ describe('Job log controllers', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
if (wrapper?.destroy) {
|
if (wrapper?.destroy) {
|
||||||
wrapper.destroy();
|
wrapper.destroy();
|
||||||
wrapper = null;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -34,7 +33,6 @@ describe('Job log controllers', () => {
|
||||||
const findTruncatedInfo = () => wrapper.find('[data-testid="log-truncated-info"]');
|
const findTruncatedInfo = () => wrapper.find('[data-testid="log-truncated-info"]');
|
||||||
const findRawLink = () => wrapper.find('[data-testid="raw-link"]');
|
const findRawLink = () => wrapper.find('[data-testid="raw-link"]');
|
||||||
const findRawLinkController = () => wrapper.find('[data-testid="job-raw-link-controller"]');
|
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 findScrollTop = () => wrapper.find('[data-testid="job-controller-scroll-top"]');
|
||||||
const findScrollBottom = () => wrapper.find('[data-testid="job-controller-scroll-bottom"]');
|
const findScrollBottom = () => wrapper.find('[data-testid="job-controller-scroll-bottom"]');
|
||||||
|
|
||||||
|
@ -76,28 +74,6 @@ describe('Job log controllers', () => {
|
||||||
expect(findRawLinkController().exists()).toBe(false);
|
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', () => {
|
describe('scroll buttons', () => {
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import { GlButton, GlLink } from '@gitlab/ui';
|
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
|
||||||
import { shallowMount } from '@vue/test-utils';
|
|
||||||
import JobsSidebarRetryButton from '~/jobs/components/job_sidebar_retry_button.vue';
|
import JobsSidebarRetryButton from '~/jobs/components/job_sidebar_retry_button.vue';
|
||||||
import createStore from '~/jobs/store';
|
import createStore from '~/jobs/store';
|
||||||
import job from '../mock_data';
|
import job from '../mock_data';
|
||||||
|
@ -9,12 +8,12 @@ describe('Job Sidebar Retry Button', () => {
|
||||||
let wrapper;
|
let wrapper;
|
||||||
|
|
||||||
const forwardDeploymentFailure = 'forward_deployment_failure';
|
const forwardDeploymentFailure = 'forward_deployment_failure';
|
||||||
const findRetryButton = () => wrapper.find(GlButton);
|
const findRetryButton = () => wrapper.findByTestId('retry-job-button');
|
||||||
const findRetryLink = () => wrapper.find(GlLink);
|
const findRetryLink = () => wrapper.findByTestId('retry-job-link');
|
||||||
|
|
||||||
const createWrapper = ({ props = {} } = {}) => {
|
const createWrapper = ({ props = {} } = {}) => {
|
||||||
store = createStore();
|
store = createStore();
|
||||||
wrapper = shallowMount(JobsSidebarRetryButton, {
|
wrapper = shallowMountExtended(JobsSidebarRetryButton, {
|
||||||
propsData: {
|
propsData: {
|
||||||
href: job.retry_path,
|
href: job.retry_path,
|
||||||
modalId: 'modal-id',
|
modalId: 'modal-id',
|
||||||
|
@ -27,7 +26,6 @@ describe('Job Sidebar Retry Button', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
if (wrapper) {
|
if (wrapper) {
|
||||||
wrapper.destroy();
|
wrapper.destroy();
|
||||||
wrapper = null;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -44,7 +42,6 @@ describe('Job Sidebar Retry Button', () => {
|
||||||
|
|
||||||
expect(findRetryButton().exists()).toBe(buttonExists);
|
expect(findRetryButton().exists()).toBe(buttonExists);
|
||||||
expect(findRetryLink().exists()).toBe(linkExists);
|
expect(findRetryLink().exists()).toBe(linkExists);
|
||||||
expect(wrapper.text()).toMatch('Retry');
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -55,6 +52,7 @@ describe('Job Sidebar Retry Button', () => {
|
||||||
expect(findRetryButton().attributes()).toMatchObject({
|
expect(findRetryButton().attributes()).toMatchObject({
|
||||||
category: 'primary',
|
category: 'primary',
|
||||||
variant: 'confirm',
|
variant: 'confirm',
|
||||||
|
icon: 'retry',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -64,6 +62,7 @@ describe('Job Sidebar Retry Button', () => {
|
||||||
expect(findRetryLink().attributes()).toMatchObject({
|
expect(findRetryLink().attributes()).toMatchObject({
|
||||||
'data-method': 'post',
|
'data-method': 'post',
|
||||||
href: job.retry_path,
|
href: job.retry_path,
|
||||||
|
icon: 'retry',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -21,25 +21,54 @@ describe('Sidebar details block', () => {
|
||||||
const findNewIssueButton = () => wrapper.findByTestId('job-new-issue');
|
const findNewIssueButton = () => wrapper.findByTestId('job-new-issue');
|
||||||
const findRetryButton = () => wrapper.find(JobRetryButton);
|
const findRetryButton = () => wrapper.find(JobRetryButton);
|
||||||
const findTerminalLink = () => wrapper.findByTestId('terminal-link');
|
const findTerminalLink = () => wrapper.findByTestId('terminal-link');
|
||||||
|
const findEraseLink = () => wrapper.findByTestId('job-log-erase-link');
|
||||||
|
|
||||||
const createWrapper = ({ props = {} } = {}) => {
|
const createWrapper = (props) => {
|
||||||
store = createStore();
|
store = createStore();
|
||||||
|
|
||||||
store.state.job = job;
|
store.state.job = job;
|
||||||
|
|
||||||
wrapper = extendedWrapper(
|
wrapper = extendedWrapper(
|
||||||
shallowMount(Sidebar, {
|
shallowMount(Sidebar, {
|
||||||
...props,
|
propsData: {
|
||||||
|
...props,
|
||||||
|
},
|
||||||
|
|
||||||
store,
|
store,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
if (wrapper) {
|
wrapper.destroy();
|
||||||
wrapper.destroy();
|
});
|
||||||
wrapper = null;
|
|
||||||
}
|
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', () => {
|
describe('when there is no retry path retry', () => {
|
||||||
|
@ -86,7 +115,7 @@ describe('Sidebar details block', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render link to cancel job', () => {
|
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);
|
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
|
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]).to include(client_id: 'email/jake@example.com', project: project.full_path)
|
||||||
expect(metadata[meta_key]).to eq(meta_value)
|
expect(metadata[meta_key]).to eq(meta_value)
|
||||||
end
|
end
|
||||||
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
|
context 'when the email contains a valid email address in a header' do
|
||||||
before do
|
before do
|
||||||
stub_incoming_email_setting(enabled: true, address: "incoming+%{key}@appmail.example.com")
|
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'
|
it_behaves_like 'successful receive'
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|
||||||
shared_examples 'failed receive' do
|
context 'when all other headers are missing' do
|
||||||
it 'adds metric event' do
|
let(:email_raw) { fixture_file('emails/missing_delivered_to_header.eml') }
|
||||||
expect(::Gitlab::Metrics::BackgroundTransaction).to receive(:current).and_return(metric_transaction)
|
let(:meta_key) { :received_recipients }
|
||||||
expect(metric_transaction).to receive(:add_event).with('email_receiver_error', { error: expected_error.name })
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -385,23 +385,43 @@ RSpec.describe Group do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
before do
|
|
||||||
subject
|
|
||||||
reload_models(old_parent, new_parent, group)
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'within the same hierarchy' do
|
context 'within the same hierarchy' do
|
||||||
let!(:root) { create(:group).reload }
|
let!(:root) { create(:group).reload }
|
||||||
let!(:old_parent) { create(:group, parent: root) }
|
let!(:old_parent) { create(:group, parent: root) }
|
||||||
let!(:new_parent) { create(:group, parent: root) }
|
let!(:new_parent) { create(:group, parent: root) }
|
||||||
|
|
||||||
it 'updates traversal_ids' do
|
context 'with FOR UPDATE lock' do
|
||||||
expect(group.traversal_ids).to eq [root.id, new_parent.id, group.id]
|
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
|
end
|
||||||
|
|
||||||
it_behaves_like 'hierarchy with traversal_ids'
|
context 'with FOR NO KEY UPDATE lock' do
|
||||||
it_behaves_like 'locked row' do
|
before do
|
||||||
let(:row) { root }
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -410,6 +430,11 @@ RSpec.describe Group do
|
||||||
let!(:new_parent) { create(:group) }
|
let!(:new_parent) { create(:group) }
|
||||||
let!(:group) { create(:group, parent: old_parent) }
|
let!(:group) { create(:group, parent: old_parent) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
subject
|
||||||
|
reload_models(old_parent, new_parent, group)
|
||||||
|
end
|
||||||
|
|
||||||
it 'updates traversal_ids' do
|
it 'updates traversal_ids' do
|
||||||
expect(group.traversal_ids).to eq [new_parent.id, group.id]
|
expect(group.traversal_ids).to eq [new_parent.id, group.id]
|
||||||
end
|
end
|
||||||
|
@ -435,6 +460,11 @@ RSpec.describe Group do
|
||||||
let!(:old_parent) { nil }
|
let!(:old_parent) { nil }
|
||||||
let!(:new_parent) { create(:group) }
|
let!(:new_parent) { create(:group) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
subject
|
||||||
|
reload_models(old_parent, new_parent, group)
|
||||||
|
end
|
||||||
|
|
||||||
it 'updates traversal_ids' do
|
it 'updates traversal_ids' do
|
||||||
expect(group.traversal_ids).to eq [new_parent.id, group.id]
|
expect(group.traversal_ids).to eq [new_parent.id, group.id]
|
||||||
end
|
end
|
||||||
|
@ -452,6 +482,11 @@ RSpec.describe Group do
|
||||||
let!(:old_parent) { create(:group) }
|
let!(:old_parent) { create(:group) }
|
||||||
let!(:new_parent) { nil }
|
let!(:new_parent) { nil }
|
||||||
|
|
||||||
|
before do
|
||||||
|
subject
|
||||||
|
reload_models(old_parent, new_parent, group)
|
||||||
|
end
|
||||||
|
|
||||||
it 'updates traversal_ids' do
|
it 'updates traversal_ids' do
|
||||||
expect(group.traversal_ids).to eq [group.id]
|
expect(group.traversal_ids).to eq [group.id]
|
||||||
end
|
end
|
||||||
|
|
|
@ -68,11 +68,24 @@ RSpec.describe Namespace::TraversalHierarchy, type: :model do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
it_behaves_like 'locked row' do
|
it_behaves_like 'locked row', 'FOR UPDATE' do
|
||||||
let(:recorded_queries) { ActiveRecord::QueryRecorder.new }
|
let(:recorded_queries) { ActiveRecord::QueryRecorder.new }
|
||||||
let(:row) { root }
|
let(:row) { root }
|
||||||
|
|
||||||
before do
|
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 }
|
recorded_queries.record { subject }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -171,6 +171,12 @@ RSpec.describe API::ErrorTracking::Collector do
|
||||||
it_behaves_like 'successful request'
|
it_behaves_like 'successful request'
|
||||||
end
|
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
|
context 'sentry_key as param and empty headers' do
|
||||||
let(:url) { "/error_tracking/collector/api/#{project.id}/store?sentry_key=#{sentry_key}" }
|
let(:url) { "/error_tracking/collector/api/#{project.id}/store?sentry_key=#{sentry_key}" }
|
||||||
let(:headers) { {} }
|
let(:headers) { {} }
|
||||||
|
|
|
@ -51,25 +51,30 @@ RSpec.describe ErrorTracking::CollectErrorService do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'unusual payload' do
|
context 'with unusual payload' do
|
||||||
let(:modified_event) { parsed_event }
|
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
|
it 'builds actor from stacktrace' do
|
||||||
modified_event.delete('transaction')
|
modified_event.delete('transaction')
|
||||||
|
|
||||||
event = described_class.new(project, nil, event: modified_event).execute
|
|
||||||
|
|
||||||
expect(event.error.actor).to eq 'find()'
|
expect(event.error.actor).to eq 'find()'
|
||||||
end
|
end
|
||||||
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
|
it 'parses timestamp' do
|
||||||
modified_event['timestamp'] = '1631015580.50'
|
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'
|
expect(event.occurred_at).to eq '2021-09-07T11:53:00.5'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -4,10 +4,10 @@
|
||||||
# Ensure a transaction also occurred.
|
# Ensure a transaction also occurred.
|
||||||
# Be careful! This form of spec is not foolproof, but better than nothing.
|
# 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
|
it "has locked row" do
|
||||||
table_name = row.class.table_name
|
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 'SAVEPOINT'
|
||||||
expect(recorded_queries.log).to include a_string_matching ids_regex
|
expect(recorded_queries.log).to include a_string_matching ids_regex
|
||||||
|
|
Loading…
Reference in New Issue