Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
fd767b7d65
commit
1ccebc7b3f
|
@ -1 +1 @@
|
||||||
e4d8f69ffa2efd3f2cb0adff5fa66f367f66f6fb
|
c13d9d902ef8175a0b1165ef0bc8643fb37b7897
|
||||||
|
|
|
@ -8,21 +8,18 @@ import {
|
||||||
GlFormGroup,
|
GlFormGroup,
|
||||||
GlFormInput,
|
GlFormInput,
|
||||||
GlFormSelect,
|
GlFormSelect,
|
||||||
GlSegmentedControl,
|
|
||||||
} from '@gitlab/ui';
|
} from '@gitlab/ui';
|
||||||
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
|
|
||||||
import axios from '~/lib/utils/axios_utils';
|
|
||||||
import csrf from '~/lib/utils/csrf';
|
import csrf from '~/lib/utils/csrf';
|
||||||
import { setUrlFragment } from '~/lib/utils/url_utility';
|
import { setUrlFragment } from '~/lib/utils/url_utility';
|
||||||
import { s__, sprintf } from '~/locale';
|
import { s__, sprintf } from '~/locale';
|
||||||
import Tracking from '~/tracking';
|
import Tracking from '~/tracking';
|
||||||
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
|
import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue';
|
||||||
import {
|
import {
|
||||||
CONTENT_EDITOR_LOADED_ACTION,
|
|
||||||
SAVED_USING_CONTENT_EDITOR_ACTION,
|
SAVED_USING_CONTENT_EDITOR_ACTION,
|
||||||
WIKI_CONTENT_EDITOR_TRACKING_LABEL,
|
WIKI_CONTENT_EDITOR_TRACKING_LABEL,
|
||||||
WIKI_FORMAT_LABEL,
|
WIKI_FORMAT_LABEL,
|
||||||
WIKI_FORMAT_UPDATED_ACTION,
|
WIKI_FORMAT_UPDATED_ACTION,
|
||||||
|
CONTENT_EDITOR_LOADED_ACTION,
|
||||||
} from '../constants';
|
} from '../constants';
|
||||||
|
|
||||||
const trackingMixin = Tracking.mixin({
|
const trackingMixin = Tracking.mixin({
|
||||||
|
@ -74,10 +71,6 @@ export default {
|
||||||
},
|
},
|
||||||
cancel: s__('WikiPage|Cancel'),
|
cancel: s__('WikiPage|Cancel'),
|
||||||
},
|
},
|
||||||
switchEditingControlOptions: [
|
|
||||||
{ text: s__('Wiki Page|Source'), value: 'source' },
|
|
||||||
{ text: s__('Wiki Page|Rich text'), value: 'richText' },
|
|
||||||
],
|
|
||||||
components: {
|
components: {
|
||||||
GlIcon,
|
GlIcon,
|
||||||
GlForm,
|
GlForm,
|
||||||
|
@ -87,13 +80,7 @@ export default {
|
||||||
GlSprintf,
|
GlSprintf,
|
||||||
GlLink,
|
GlLink,
|
||||||
GlButton,
|
GlButton,
|
||||||
GlSegmentedControl,
|
MarkdownEditor,
|
||||||
MarkdownField,
|
|
||||||
LocalStorageSync,
|
|
||||||
ContentEditor: () =>
|
|
||||||
import(
|
|
||||||
/* webpackChunkName: 'content_editor' */ '~/content_editor/components/content_editor.vue'
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
mixins: [trackingMixin],
|
mixins: [trackingMixin],
|
||||||
inject: ['formatOptions', 'pageInfo'],
|
inject: ['formatOptions', 'pageInfo'],
|
||||||
|
@ -106,7 +93,7 @@ export default {
|
||||||
commitMessage: '',
|
commitMessage: '',
|
||||||
isDirty: false,
|
isDirty: false,
|
||||||
contentEditorEmpty: false,
|
contentEditorEmpty: false,
|
||||||
switchEditingControlDisabled: false,
|
isContentEditorActive: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
@ -162,12 +149,6 @@ export default {
|
||||||
disableSubmitButton() {
|
disableSubmitButton() {
|
||||||
return this.noContent || !this.title;
|
return this.noContent || !this.title;
|
||||||
},
|
},
|
||||||
isContentEditorActive() {
|
|
||||||
return this.isMarkdownFormat && this.useContentEditor;
|
|
||||||
},
|
|
||||||
useContentEditor() {
|
|
||||||
return this.editingMode === 'richText';
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.updateCommitMessage();
|
this.updateCommitMessage();
|
||||||
|
@ -178,23 +159,10 @@ export default {
|
||||||
window.removeEventListener('beforeunload', this.onPageUnload);
|
window.removeEventListener('beforeunload', this.onPageUnload);
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
renderMarkdown(content) {
|
|
||||||
return axios
|
|
||||||
.post(this.pageInfo.markdownPreviewPath, { text: content })
|
|
||||||
.then(({ data }) => data.body);
|
|
||||||
},
|
|
||||||
|
|
||||||
setEditingMode(editingMode) {
|
|
||||||
this.editingMode = editingMode;
|
|
||||||
},
|
|
||||||
|
|
||||||
async handleFormSubmit(e) {
|
async handleFormSubmit(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (this.useContentEditor) {
|
this.trackFormSubmit();
|
||||||
this.trackFormSubmit();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.trackWikiFormat();
|
this.trackWikiFormat();
|
||||||
|
|
||||||
// Wait until form field values are refreshed
|
// Wait until form field values are refreshed
|
||||||
|
@ -205,16 +173,6 @@ export default {
|
||||||
this.isDirty = false;
|
this.isDirty = false;
|
||||||
},
|
},
|
||||||
|
|
||||||
handleContentChange() {
|
|
||||||
this.isDirty = true;
|
|
||||||
},
|
|
||||||
|
|
||||||
handleContentEditorChange({ empty, markdown, changed }) {
|
|
||||||
this.contentEditorEmpty = empty;
|
|
||||||
this.isDirty = changed;
|
|
||||||
this.content = markdown;
|
|
||||||
},
|
|
||||||
|
|
||||||
onPageUnload(event) {
|
onPageUnload(event) {
|
||||||
if (!this.isDirty) return undefined;
|
if (!this.isDirty) return undefined;
|
||||||
|
|
||||||
|
@ -235,8 +193,13 @@ export default {
|
||||||
this.commitMessage = newCommitMessage;
|
this.commitMessage = newCommitMessage;
|
||||||
},
|
},
|
||||||
|
|
||||||
trackContentEditorLoaded() {
|
notifyContentEditorActive() {
|
||||||
this.track(CONTENT_EDITOR_LOADED_ACTION);
|
this.isContentEditorActive = true;
|
||||||
|
this.trackContentEditorLoaded();
|
||||||
|
},
|
||||||
|
|
||||||
|
notifyContentEditorInactive() {
|
||||||
|
this.isContentEditorActive = false;
|
||||||
},
|
},
|
||||||
|
|
||||||
trackFormSubmit() {
|
trackFormSubmit() {
|
||||||
|
@ -256,12 +219,12 @@ export default {
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
enableSwitchEditingControl() {
|
trackContentEditorLoaded() {
|
||||||
this.switchEditingControlDisabled = false;
|
this.track(CONTENT_EDITOR_LOADED_ACTION);
|
||||||
},
|
},
|
||||||
|
|
||||||
disableSwitchEditingControl() {
|
checkDirty(markdown) {
|
||||||
this.switchEditingControlDisabled = true;
|
this.isDirty = this.pageInfo.content !== markdown;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@ -329,74 +292,22 @@ export default {
|
||||||
<div class="row" data-testid="wiki-form-content-fieldset">
|
<div class="row" data-testid="wiki-form-content-fieldset">
|
||||||
<div class="col-sm-12 row-sm-5">
|
<div class="col-sm-12 row-sm-5">
|
||||||
<gl-form-group>
|
<gl-form-group>
|
||||||
<div v-if="isMarkdownFormat" class="gl-display-flex gl-justify-content-start gl-mb-3">
|
<markdown-editor
|
||||||
<gl-segmented-control
|
v-model="content"
|
||||||
data-testid="toggle-editing-mode-button"
|
:render-markdown-path="pageInfo.markdownPreviewPath"
|
||||||
data-qa-selector="editing_mode_button"
|
|
||||||
class="gl-display-flex"
|
|
||||||
:checked="editingMode"
|
|
||||||
:options="$options.switchEditingControlOptions"
|
|
||||||
:disabled="switchEditingControlDisabled"
|
|
||||||
@input="setEditingMode"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<local-storage-sync
|
|
||||||
storage-key="gl-wiki-content-editor-enabled"
|
|
||||||
:value="editingMode"
|
|
||||||
@input="setEditingMode"
|
|
||||||
/>
|
|
||||||
<markdown-field
|
|
||||||
v-if="!isContentEditorActive"
|
|
||||||
:markdown-preview-path="pageInfo.markdownPreviewPath"
|
|
||||||
:can-attach-file="true"
|
|
||||||
:enable-autocomplete="true"
|
|
||||||
:textarea-value="content"
|
|
||||||
:markdown-docs-path="pageInfo.markdownHelpPath"
|
:markdown-docs-path="pageInfo.markdownHelpPath"
|
||||||
:uploads-path="pageInfo.uploadsPath"
|
:uploads-path="pageInfo.uploadsPath"
|
||||||
|
:enable-content-editor="isMarkdownFormat"
|
||||||
:enable-preview="isMarkdownFormat"
|
:enable-preview="isMarkdownFormat"
|
||||||
class="bordered-box"
|
:autofocus="pageInfo.persisted"
|
||||||
>
|
:form-field-placeholder="$options.i18n.content.placeholder"
|
||||||
<template #textarea>
|
:form-field-aria-label="$options.i18n.content.label"
|
||||||
<textarea
|
form-field-id="wiki_content"
|
||||||
id="wiki_content"
|
form-field-name="wiki[content]"
|
||||||
ref="textarea"
|
@contentEditor="notifyContentEditorActive"
|
||||||
v-model="content"
|
@markdownField="notifyContentEditorInactive"
|
||||||
name="wiki[content]"
|
@input="checkDirty"
|
||||||
class="note-textarea js-gfm-input js-autosize markdown-area"
|
/>
|
||||||
dir="auto"
|
|
||||||
data-supports-quick-actions="false"
|
|
||||||
data-qa-selector="wiki_content_textarea"
|
|
||||||
:autofocus="pageInfo.persisted"
|
|
||||||
:aria-label="$options.i18n.content.label"
|
|
||||||
:placeholder="$options.i18n.content.placeholder"
|
|
||||||
@input="handleContentChange"
|
|
||||||
>
|
|
||||||
</textarea>
|
|
||||||
</template>
|
|
||||||
</markdown-field>
|
|
||||||
<div v-if="isContentEditorActive">
|
|
||||||
<content-editor
|
|
||||||
:render-markdown="renderMarkdown"
|
|
||||||
:uploads-path="pageInfo.uploadsPath"
|
|
||||||
:markdown="content"
|
|
||||||
@initialized="trackContentEditorLoaded"
|
|
||||||
@change="handleContentEditorChange"
|
|
||||||
@loading="disableSwitchEditingControl"
|
|
||||||
@loadingSuccess="enableSwitchEditingControl"
|
|
||||||
@loadingError="enableSwitchEditingControl"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
id="wiki_content"
|
|
||||||
v-model.trim="content"
|
|
||||||
type="hidden"
|
|
||||||
name="wiki[content]"
|
|
||||||
data-qa-selector="wiki_hidden_content"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="clearfix"></div>
|
|
||||||
<div class="error-alert"></div>
|
|
||||||
|
|
||||||
<div class="form-text gl-text-gray-600">
|
<div class="form-text gl-text-gray-600">
|
||||||
<gl-sprintf
|
<gl-sprintf
|
||||||
v-if="displayWikiSpecificMarkdownHelp"
|
v-if="displayWikiSpecificMarkdownHelp"
|
||||||
|
|
|
@ -161,11 +161,7 @@ export default {
|
||||||
@click="handleEmojiClick"
|
@click="handleEmojiClick"
|
||||||
>
|
>
|
||||||
<template #button-content>
|
<template #button-content>
|
||||||
<span
|
<span v-if="noEmoji" class="gl-relative" data-testid="no-emoji-placeholder">
|
||||||
v-if="noEmoji"
|
|
||||||
class="no-emoji-placeholder position-relative"
|
|
||||||
data-testid="no-emoji-placeholder"
|
|
||||||
>
|
|
||||||
<gl-icon name="slight-smile" class="award-control-icon-neutral" />
|
<gl-icon name="slight-smile" class="award-control-icon-neutral" />
|
||||||
<gl-icon name="smiley" class="award-control-icon-positive" />
|
<gl-icon name="smiley" class="award-control-icon-positive" />
|
||||||
<gl-icon name="smile" class="award-control-icon-super-positive" />
|
<gl-icon name="smile" class="award-control-icon-super-positive" />
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<script>
|
<script>
|
||||||
import { GlIcon, GlLoadingIcon, GlToggle, GlTooltipDirective } from '@gitlab/ui';
|
import { GlDropdownForm, GlIcon, GlLoadingIcon, GlToggle, GlTooltipDirective } from '@gitlab/ui';
|
||||||
import createFlash from '~/flash';
|
import createFlash from '~/flash';
|
||||||
import { IssuableType } from '~/issues/constants';
|
import { IssuableType } from '~/issues/constants';
|
||||||
import { isLoggedIn } from '~/lib/utils/common_utils';
|
import { isLoggedIn } from '~/lib/utils/common_utils';
|
||||||
|
@ -22,6 +22,7 @@ export default {
|
||||||
GlTooltip: GlTooltipDirective,
|
GlTooltip: GlTooltipDirective,
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
|
GlDropdownForm,
|
||||||
GlIcon,
|
GlIcon,
|
||||||
GlLoadingIcon,
|
GlLoadingIcon,
|
||||||
GlToggle,
|
GlToggle,
|
||||||
|
@ -181,7 +182,7 @@ export default {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="isMergeRequest" class="gl-new-dropdown-item">
|
<gl-dropdown-form v-if="isMergeRequest" class="gl-new-dropdown-item">
|
||||||
<div class="gl-px-5 gl-pb-2 gl-pt-1">
|
<div class="gl-px-5 gl-pb-2 gl-pt-1">
|
||||||
<gl-toggle
|
<gl-toggle
|
||||||
:value="subscribed"
|
:value="subscribed"
|
||||||
|
@ -192,7 +193,7 @@ export default {
|
||||||
@change="toggleSubscribed"
|
@change="toggleSubscribed"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</gl-dropdown-form>
|
||||||
<sidebar-editable-item
|
<sidebar-editable-item
|
||||||
v-else
|
v-else
|
||||||
ref="editable"
|
ref="editable"
|
||||||
|
|
|
@ -0,0 +1,178 @@
|
||||||
|
<script>
|
||||||
|
import { GlSegmentedControl } from '@gitlab/ui';
|
||||||
|
import { __ } from '~/locale';
|
||||||
|
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
|
||||||
|
import axios from '~/lib/utils/axios_utils';
|
||||||
|
import { EDITING_MODE_MARKDOWN_FIELD, EDITING_MODE_CONTENT_EDITOR } from '../../constants';
|
||||||
|
import MarkdownField from './field.vue';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
MarkdownField,
|
||||||
|
LocalStorageSync,
|
||||||
|
GlSegmentedControl,
|
||||||
|
ContentEditor: () =>
|
||||||
|
import(
|
||||||
|
/* webpackChunkName: 'content_editor' */ '~/content_editor/components/content_editor.vue'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
value: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
renderMarkdownPath: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
markdownDocsPath: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
uploadsPath: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
enableContentEditor: {
|
||||||
|
type: Boolean,
|
||||||
|
required: false,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
formFieldId: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
formFieldName: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
enablePreview: {
|
||||||
|
type: Boolean,
|
||||||
|
required: false,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
enableAutocomplete: {
|
||||||
|
type: Boolean,
|
||||||
|
required: false,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
autofocus: {
|
||||||
|
type: Boolean,
|
||||||
|
required: false,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
formFieldPlaceholder: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
formFieldAriaLabel: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
editingMode: EDITING_MODE_MARKDOWN_FIELD,
|
||||||
|
switchEditingControlEnabled: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
isContentEditorActive() {
|
||||||
|
return this.enableContentEditor && this.editingMode === EDITING_MODE_CONTENT_EDITOR;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
updateMarkdownFromContentEditor({ markdown }) {
|
||||||
|
this.$emit('input', markdown);
|
||||||
|
},
|
||||||
|
updateMarkdownFromMarkdownField({ target }) {
|
||||||
|
this.$emit('input', target.value);
|
||||||
|
},
|
||||||
|
enableSwitchEditingControl() {
|
||||||
|
this.switchEditingControlEnabled = true;
|
||||||
|
},
|
||||||
|
disableSwitchEditingControl() {
|
||||||
|
this.switchEditingControlEnabled = false;
|
||||||
|
},
|
||||||
|
renderMarkdown(markdown) {
|
||||||
|
return axios.post(this.renderMarkdownPath, { text: markdown }).then(({ data }) => data.body);
|
||||||
|
},
|
||||||
|
notifyEditingModeChange(editingMode) {
|
||||||
|
this.$emit(editingMode);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
switchEditingControlOptions: [
|
||||||
|
{ text: __('Source'), value: EDITING_MODE_MARKDOWN_FIELD },
|
||||||
|
{ text: __('Rich text'), value: EDITING_MODE_CONTENT_EDITOR },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="gl-display-flex gl-justify-content-start gl-mb-3">
|
||||||
|
<gl-segmented-control
|
||||||
|
v-model="editingMode"
|
||||||
|
data-testid="toggle-editing-mode-button"
|
||||||
|
data-qa-selector="editing_mode_button"
|
||||||
|
class="gl-display-flex"
|
||||||
|
:options="$options.switchEditingControlOptions"
|
||||||
|
:disabled="!enableContentEditor || !switchEditingControlEnabled"
|
||||||
|
@change="notifyEditingModeChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<local-storage-sync
|
||||||
|
v-model="editingMode"
|
||||||
|
storage-key="gl-wiki-content-editor-enabled"
|
||||||
|
@input="notifyEditingModeChange"
|
||||||
|
/>
|
||||||
|
<markdown-field
|
||||||
|
v-if="!isContentEditorActive"
|
||||||
|
:markdown-preview-path="renderMarkdownPath"
|
||||||
|
can-attach-file
|
||||||
|
:enable-autocomplete="enableAutocomplete"
|
||||||
|
:textarea-value="value"
|
||||||
|
:markdown-docs-path="markdownDocsPath"
|
||||||
|
:uploads-path="uploadsPath"
|
||||||
|
:enable-preview="enablePreview"
|
||||||
|
class="bordered-box"
|
||||||
|
>
|
||||||
|
<template #textarea>
|
||||||
|
<textarea
|
||||||
|
:id="formFieldId"
|
||||||
|
ref="textarea"
|
||||||
|
:value="value"
|
||||||
|
:name="formFieldName"
|
||||||
|
class="note-textarea js-gfm-input js-autosize markdown-area"
|
||||||
|
dir="auto"
|
||||||
|
data-supports-quick-actions="false"
|
||||||
|
data-qa-selector="markdown_editor_form_field"
|
||||||
|
:autofocus="autofocus"
|
||||||
|
:aria-label="formFieldAriaLabel"
|
||||||
|
:placeholder="formFieldPlaceholder"
|
||||||
|
@input="updateMarkdownFromMarkdownField"
|
||||||
|
>
|
||||||
|
</textarea>
|
||||||
|
</template>
|
||||||
|
</markdown-field>
|
||||||
|
<div v-else>
|
||||||
|
<content-editor
|
||||||
|
:render-markdown="renderMarkdown"
|
||||||
|
:uploads-path="uploadsPath"
|
||||||
|
:markdown="value"
|
||||||
|
@change="updateMarkdownFromContentEditor"
|
||||||
|
@loading="disableSwitchEditingControl"
|
||||||
|
@loadingSuccess="enableSwitchEditingControl"
|
||||||
|
@loadingError="enableSwitchEditingControl"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
:id="formFieldId"
|
||||||
|
:value="value"
|
||||||
|
:name="formFieldName"
|
||||||
|
data-qa-selector="markdown_editor_form_field"
|
||||||
|
type="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
|
@ -93,3 +93,6 @@ export const confidentialityInfoText = (workspaceType, issuableType) =>
|
||||||
: __('at least the Reporter role'),
|
: __('at least the Reporter role'),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const EDITING_MODE_MARKDOWN_FIELD = 'markdownField';
|
||||||
|
export const EDITING_MODE_CONTENT_EDITOR = 'contentEditor';
|
||||||
|
|
|
@ -832,6 +832,8 @@ $tabs-holder-z-index: 250;
|
||||||
.detail-page-header-actions {
|
.detail-page-header-actions {
|
||||||
.gl-toggle {
|
.gl-toggle {
|
||||||
@include gl-ml-auto;
|
@include gl-ml-auto;
|
||||||
|
@include gl-rounded-pill;
|
||||||
|
@include gl-w-9;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -844,3 +846,7 @@ $tabs-holder-z-index: 250;
|
||||||
@include gl-font-weight-normal;
|
@include gl-font-weight-normal;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dropdown-menu li button.gl-toggle:not(.is-checked) {
|
||||||
|
background: $gray-400;
|
||||||
|
}
|
||||||
|
|
|
@ -1,4 +1,46 @@
|
||||||
@import 'mixins_and_variables_and_functions';
|
@import 'mixins_and_variables_and_functions';
|
||||||
|
@import 'framework/buttons';
|
||||||
|
|
||||||
|
.avatar-image {
|
||||||
|
margin-bottom: $grid-size;
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
float: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include media-breakpoint-up(sm) {
|
||||||
|
float: left;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-user {
|
||||||
|
.emoji-menu-toggle-button {
|
||||||
|
@include emoji-menu-toggle-button;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include media-breakpoint-down(sm) {
|
||||||
|
.input-md,
|
||||||
|
.input-lg {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-profile-crop {
|
||||||
|
.modal-dialog {
|
||||||
|
width: 380px;
|
||||||
|
|
||||||
|
@include media-breakpoint-down(xs) {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-crop-image-container {
|
||||||
|
height: 300px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.calendar-block {
|
.calendar-block {
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
|
|
|
@ -1,22 +1,3 @@
|
||||||
.avatar-image {
|
|
||||||
margin-bottom: $grid-size;
|
|
||||||
|
|
||||||
.avatar {
|
|
||||||
float: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@include media-breakpoint-up(sm) {
|
|
||||||
float: left;
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar-file-name {
|
|
||||||
position: relative;
|
|
||||||
top: 2px;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.account-well {
|
.account-well {
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
background-color: $gray-light;
|
background-color: $gray-light;
|
||||||
|
@ -29,13 +10,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-avatar-button {
|
|
||||||
.file-name {
|
|
||||||
display: inline-block;
|
|
||||||
padding-left: 10px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.subkeys-list {
|
.subkeys-list {
|
||||||
@include basic-list;
|
@include basic-list;
|
||||||
|
|
||||||
|
@ -113,26 +87,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-profile-crop {
|
|
||||||
.modal-dialog {
|
|
||||||
width: 380px;
|
|
||||||
|
|
||||||
@include media-breakpoint-down(xs) {
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-crop-image-container {
|
|
||||||
height: 300px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.crop-controls {
|
|
||||||
padding: 10px 0 0;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.created-personal-access-token-container {
|
.created-personal-access-token-container {
|
||||||
.btn-clipboard {
|
.btn-clipboard {
|
||||||
border: 1px solid $border-color;
|
border: 1px solid $border-color;
|
||||||
|
@ -247,36 +201,6 @@ table.u2f-registrations {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-user {
|
|
||||||
svg {
|
|
||||||
fill: $gl-text-color-secondary;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group > label {
|
|
||||||
font-weight: $gl-font-weight-bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group > .form-text {
|
|
||||||
font-size: $gl-font-size;
|
|
||||||
}
|
|
||||||
|
|
||||||
.emoji-menu-toggle-button {
|
|
||||||
@include emoji-menu-toggle-button;
|
|
||||||
padding: 6px 10px;
|
|
||||||
|
|
||||||
.no-emoji-placeholder {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@include media-breakpoint-down(sm) {
|
|
||||||
.input-md,
|
|
||||||
.input-lg {
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.help-block {
|
.help-block {
|
||||||
color: $gl-text-color-secondary;
|
color: $gl-text-color-secondary;
|
||||||
}
|
}
|
||||||
|
|
|
@ -217,6 +217,10 @@ class NotifyPreview < ActionMailer::Preview
|
||||||
Notify.project_was_exported_email(user, project).message
|
Notify.project_was_exported_email(user, project).message
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def request_review_merge_request_email
|
||||||
|
Notify.request_review_merge_request_email(user.id, merge_request.id, user.id).message
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def project
|
def project
|
||||||
|
|
|
@ -760,8 +760,14 @@ module Ci
|
||||||
# There is no ActiveRecord relation between Ci::Pipeline and notes
|
# There is no ActiveRecord relation between Ci::Pipeline and notes
|
||||||
# as they are related to a commit sha. This method helps importing
|
# as they are related to a commit sha. This method helps importing
|
||||||
# them using the +Gitlab::ImportExport::Project::RelationFactory+ class.
|
# them using the +Gitlab::ImportExport::Project::RelationFactory+ class.
|
||||||
def notes=(notes)
|
def notes=(notes_to_save)
|
||||||
notes.each do |note|
|
notes_to_save.reject! do |note_to_save|
|
||||||
|
notes.any? do |note|
|
||||||
|
[note_to_save.note, note_to_save.created_at.to_i] == [note.note, note.created_at.to_i]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
notes_to_save.each do |note|
|
||||||
note[:id] = nil
|
note[:id] = nil
|
||||||
note[:commit_id] = sha
|
note[:commit_id] = sha
|
||||||
note[:noteable_id] = self['id']
|
note[:noteable_id] = self['id']
|
||||||
|
|
|
@ -1,2 +1,2 @@
|
||||||
%p
|
%p
|
||||||
#{sanitize_name(@updated_by.name)} requested a new review on #{merge_request_reference_link(@merge_request)}.
|
= html_escape(s_('Notify|%{name} requested a new review on %{mr_link}.')) % {name: sanitize_name(@updated_by.name), mr_link: merge_request_reference_link(@merge_request).html_safe}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
- breadcrumb_title s_("Profiles|Edit Profile")
|
- breadcrumb_title s_("Profiles|Edit Profile")
|
||||||
- page_title s_("Profiles|Edit Profile")
|
- page_title s_("Profiles|Edit Profile")
|
||||||
|
- add_page_specific_style 'page_bundles/profile'
|
||||||
- @content_class = "limit-container-width" unless fluid_layout
|
- @content_class = "limit-container-width" unless fluid_layout
|
||||||
- gravatar_link = link_to Gitlab.config.gravatar.host, 'https://' + Gitlab.config.gravatar.host
|
- gravatar_link = link_to Gitlab.config.gravatar.host, 'https://' + Gitlab.config.gravatar.host
|
||||||
|
|
||||||
|
@ -27,9 +28,9 @@
|
||||||
= link_to avatar_icon_for_user(@user, 400), target: '_blank', rel: 'noopener noreferrer' do
|
= link_to avatar_icon_for_user(@user, 400), target: '_blank', rel: 'noopener noreferrer' do
|
||||||
= image_tag avatar_icon_for_user(@user, 96), alt: '', class: 'avatar s96'
|
= image_tag avatar_icon_for_user(@user, 96), alt: '', class: 'avatar s96'
|
||||||
%h5.gl-mt-0= s_("Profiles|Upload new avatar")
|
%h5.gl-mt-0= s_("Profiles|Upload new avatar")
|
||||||
.gl-my-3
|
.gl-display-flex.gl-align-items-center.gl-my-3
|
||||||
%button.gl-button.btn.btn-default.js-choose-user-avatar-button{ type: 'button' }= s_("Profiles|Choose file...")
|
%button.gl-button.btn.btn-default.js-choose-user-avatar-button{ type: 'button' }= s_("Profiles|Choose file...")
|
||||||
%span.avatar-file-name.gl-ml-3.js-avatar-filename= s_("Profiles|No file chosen.")
|
%span.gl-ml-3.js-avatar-filename= s_("Profiles|No file chosen.")
|
||||||
= f.file_field :avatar, class: 'js-user-avatar-input hidden', accept: 'image/*'
|
= f.file_field :avatar, class: 'js-user-avatar-input hidden', accept: 'image/*'
|
||||||
.gl-text-gray-500= s_("Profiles|The maximum file size allowed is 200KB.")
|
.gl-text-gray-500= s_("Profiles|The maximum file size allowed is 200KB.")
|
||||||
- if @user.avatar?
|
- if @user.avatar?
|
||||||
|
@ -152,7 +153,7 @@
|
||||||
.modal-body
|
.modal-body
|
||||||
.profile-crop-image-container
|
.profile-crop-image-container
|
||||||
%img.modal-profile-crop-image{ alt: s_("Profiles|Avatar cropper") }
|
%img.modal-profile-crop-image{ alt: s_("Profiles|Avatar cropper") }
|
||||||
.crop-controls
|
.gl-text-center.gl-mt-4
|
||||||
.btn-group
|
.btn-group
|
||||||
%button.btn.gl-button.btn-default{ data: { method: 'zoom', option: '-0.1' } }
|
%button.btn.gl-button.btn-default{ data: { method: 'zoom', option: '-0.1' } }
|
||||||
%span
|
%span
|
||||||
|
|
|
@ -1,8 +0,0 @@
|
||||||
---
|
|
||||||
name: ci_rules_changes_compare
|
|
||||||
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/90968
|
|
||||||
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/366412
|
|
||||||
milestone: '15.3'
|
|
||||||
type: development
|
|
||||||
group: group::pipeline authoring
|
|
||||||
default_enabled: true
|
|
|
@ -0,0 +1,26 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class BackfillInternalOnNotes < Gitlab::Database::Migration[2.0]
|
||||||
|
MIGRATION = 'BackfillInternalOnNotes'
|
||||||
|
DELAY_INTERVAL = 2.minutes
|
||||||
|
TABLE = :notes
|
||||||
|
BATCH_SIZE = 2000
|
||||||
|
SUB_BATCH_SIZE = 10
|
||||||
|
|
||||||
|
restrict_gitlab_migration gitlab_schema: :gitlab_main
|
||||||
|
|
||||||
|
def up
|
||||||
|
queue_batched_background_migration(
|
||||||
|
MIGRATION,
|
||||||
|
TABLE,
|
||||||
|
:id,
|
||||||
|
job_interval: DELAY_INTERVAL,
|
||||||
|
batch_size: BATCH_SIZE,
|
||||||
|
sub_batch_size: SUB_BATCH_SIZE
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def down
|
||||||
|
delete_batched_background_migration(MIGRATION, TABLE, :id, [])
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1 @@
|
||||||
|
4a975867dc0539049902229521b4d94f940817ffd9196810856c8eb962c57e62
|
|
@ -351,6 +351,22 @@ scope block takes an argument). Preloading instance dependent scopes is not
|
||||||
supported.
|
supported.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Primary key
|
||||||
|
|
||||||
|
Primary key must include the partitioning key column to partition the table.
|
||||||
|
|
||||||
|
We first create a unique index including the `(id, partition_id)`.
|
||||||
|
Then, we drop the primary key constraint and use the new index created to set
|
||||||
|
the new primary key constraint.
|
||||||
|
|
||||||
|
We must set the primary key explicitly as `ActiveRecord` does not support composite primary keys.
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
class Model
|
||||||
|
self.primary_key = 'id'
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
### Foreign keys
|
### Foreign keys
|
||||||
|
|
||||||
Foreign keys must reference columns that either are a primary key or form a
|
Foreign keys must reference columns that either are a primary key or form a
|
||||||
|
|
|
@ -3343,7 +3343,8 @@ In this example, both jobs have the same behavior.
|
||||||
|
|
||||||
##### `rules:changes:compare_to`
|
##### `rules:changes:compare_to`
|
||||||
|
|
||||||
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/293645) in GitLab 15.3 [with a flag](../../administration/feature_flags.md) named `ci_rules_changes_compare`. Enabled by default.
|
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/293645) in GitLab 15.3 [with a flag](../../administration/feature_flags.md) named `ci_rules_changes_compare`. Enabled by default.
|
||||||
|
> - [Generally available](https://gitlab.com/gitlab-org/gitlab/-/issues/366412) in GitLab 15.5. Feature flag `ci_rules_changes_compare` removed.
|
||||||
|
|
||||||
Use `rules:changes:compare_to` to specify which ref to compare against for changes to the files
|
Use `rules:changes:compare_to` to specify which ref to compare against for changes to the files
|
||||||
listed under [`rules:changes:paths`](#ruleschangespaths).
|
listed under [`rules:changes:paths`](#ruleschangespaths).
|
||||||
|
|
|
@ -0,0 +1,16 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Gitlab
|
||||||
|
module BackgroundMigration
|
||||||
|
# This syncs the data to `internal` from `confidential` as we rename the column.
|
||||||
|
class BackfillInternalOnNotes < BatchedMigrationJob
|
||||||
|
scope_to -> (relation) { relation.where(confidential: true) }
|
||||||
|
|
||||||
|
def perform
|
||||||
|
each_sub_batch(operation_name: :update_all) do |sub_batch|
|
||||||
|
sub_batch.update_all(internal: true)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -41,7 +41,6 @@ module Gitlab
|
||||||
|
|
||||||
def find_modified_paths(pipeline)
|
def find_modified_paths(pipeline)
|
||||||
return unless pipeline
|
return unless pipeline
|
||||||
return pipeline.modified_paths unless ::Feature.enabled?(:ci_rules_changes_compare, pipeline.project)
|
|
||||||
|
|
||||||
compare_to_sha = find_compare_to_sha(pipeline)
|
compare_to_sha = find_compare_to_sha(pipeline)
|
||||||
|
|
||||||
|
|
|
@ -11,6 +11,7 @@ pages:
|
||||||
- apk update && apk add doxygen
|
- apk update && apk add doxygen
|
||||||
- doxygen doxygen/Doxyfile
|
- doxygen doxygen/Doxyfile
|
||||||
- mv doxygen/documentation/html/ public/
|
- mv doxygen/documentation/html/ public/
|
||||||
|
environment: production
|
||||||
artifacts:
|
artifacts:
|
||||||
paths:
|
paths:
|
||||||
- public
|
- public
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
# Full project: https://gitlab.com/pages/plain-html
|
# Full project: https://gitlab.com/pages/plain-html
|
||||||
pages:
|
pages:
|
||||||
stage: deploy
|
stage: deploy
|
||||||
|
environment: production
|
||||||
script:
|
script:
|
||||||
- mkdir .public
|
- mkdir .public
|
||||||
- cp -r ./* .public
|
- cp -r ./* .public
|
||||||
|
|
|
@ -11,6 +11,7 @@ pages:
|
||||||
- npm install hexo-cli -g
|
- npm install hexo-cli -g
|
||||||
- test -e package.json && npm install
|
- test -e package.json && npm install
|
||||||
- hexo generate
|
- hexo generate
|
||||||
|
environment: production
|
||||||
artifacts:
|
artifacts:
|
||||||
paths:
|
paths:
|
||||||
- public
|
- public
|
||||||
|
|
|
@ -21,6 +21,7 @@ test:
|
||||||
|
|
||||||
pages:
|
pages:
|
||||||
stage: deploy
|
stage: deploy
|
||||||
|
environment: production
|
||||||
script:
|
script:
|
||||||
- pip install hyde
|
- pip install hyde
|
||||||
- hyde gen -d public
|
- hyde gen -d public
|
||||||
|
|
|
@ -29,6 +29,7 @@ before_script:
|
||||||
|
|
||||||
# This build job produced the output directory of your site
|
# This build job produced the output directory of your site
|
||||||
pages:
|
pages:
|
||||||
|
environment: production
|
||||||
script:
|
script:
|
||||||
- jbake . public
|
- jbake . public
|
||||||
artifacts:
|
artifacts:
|
||||||
|
|
|
@ -46,10 +46,7 @@ module Gitlab
|
||||||
end
|
end
|
||||||
|
|
||||||
def instrumentation_object
|
def instrumentation_object
|
||||||
@instrumentation_object ||= instrumentation_class.constantize.new(
|
@instrumentation_object ||= instrumentation_class.constantize.new(definition.attributes)
|
||||||
time_frame: definition.time_frame,
|
|
||||||
options: definition.attributes[:options]
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -23,9 +23,9 @@ module Gitlab
|
||||||
attr_reader :metric_available
|
attr_reader :metric_available
|
||||||
end
|
end
|
||||||
|
|
||||||
def initialize(time_frame:, options: {})
|
def initialize(metric_definition)
|
||||||
@time_frame = time_frame
|
@time_frame = metric_definition.fetch(:time_frame)
|
||||||
@options = options
|
@options = metric_definition.fetch(:options, {})
|
||||||
end
|
end
|
||||||
|
|
||||||
def instrumentation
|
def instrumentation
|
||||||
|
|
|
@ -7,7 +7,7 @@ module Gitlab
|
||||||
class CountBulkImportsEntitiesMetric < DatabaseMetric
|
class CountBulkImportsEntitiesMetric < DatabaseMetric
|
||||||
operation :count
|
operation :count
|
||||||
|
|
||||||
def initialize(time_frame:, options: {})
|
def initialize(metric_definition)
|
||||||
super
|
super
|
||||||
|
|
||||||
if source_type.present? && !source_type.in?(allowed_source_types)
|
if source_type.present? && !source_type.in?(allowed_source_types)
|
||||||
|
|
|
@ -7,7 +7,7 @@ module Gitlab
|
||||||
class CountImportedProjectsMetric < DatabaseMetric
|
class CountImportedProjectsMetric < DatabaseMetric
|
||||||
operation :count
|
operation :count
|
||||||
|
|
||||||
def initialize(time_frame:, options: {})
|
def initialize(metric_definition)
|
||||||
super
|
super
|
||||||
|
|
||||||
raise ArgumentError, "import_type options attribute is required" unless import_type.present?
|
raise ArgumentError, "import_type options attribute is required" unless import_type.present?
|
||||||
|
|
|
@ -28,9 +28,8 @@ module Gitlab
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def initialize(time_frame: 'none', options: {})
|
def initialize(metric_definition)
|
||||||
@time_frame = time_frame
|
super(metric_definition.reverse_merge(time_frame: 'none'))
|
||||||
@options = options
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def value
|
def value
|
||||||
|
|
|
@ -12,7 +12,7 @@ module Gitlab
|
||||||
# events:
|
# events:
|
||||||
# - g_analytics_valuestream
|
# - g_analytics_valuestream
|
||||||
# end
|
# end
|
||||||
def initialize(time_frame:, options: {})
|
def initialize(metric_definition)
|
||||||
super
|
super
|
||||||
|
|
||||||
raise ArgumentError, "options events are required" unless metric_events.present?
|
raise ArgumentError, "options events are required" unless metric_events.present?
|
||||||
|
|
|
@ -19,7 +19,7 @@ module Gitlab
|
||||||
USAGE_PREFIX = "USAGE_"
|
USAGE_PREFIX = "USAGE_"
|
||||||
OPTIONS_PREFIX_KEY = :prefix
|
OPTIONS_PREFIX_KEY = :prefix
|
||||||
|
|
||||||
def initialize(time_frame:, options: {})
|
def initialize(metric_definition)
|
||||||
super
|
super
|
||||||
|
|
||||||
raise ArgumentError, "'event' option is required" unless metric_event.present?
|
raise ArgumentError, "'event' option is required" unless metric_event.present?
|
||||||
|
|
|
@ -27162,6 +27162,9 @@ msgstr ""
|
||||||
msgid "Notify|%{mr_highlight}Merge request%{highlight_end} %{mr_link} %{reviewer_highlight}was unapproved by%{highlight_end} %{reviewer_avatar} %{reviewer_link}"
|
msgid "Notify|%{mr_highlight}Merge request%{highlight_end} %{mr_link} %{reviewer_highlight}was unapproved by%{highlight_end} %{reviewer_avatar} %{reviewer_link}"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Notify|%{name} requested a new review on %{mr_link}."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "Notify|%{paragraph_start}Hi %{name}!%{paragraph_end} %{paragraph_start}A new public key was added to your account:%{paragraph_end} %{paragraph_start}title: %{key_title}%{paragraph_end} %{paragraph_start}If this key was added in error, you can remove it under %{removal_link}%{paragraph_end}"
|
msgid "Notify|%{paragraph_start}Hi %{name}!%{paragraph_end} %{paragraph_start}A new public key was added to your account:%{paragraph_end} %{paragraph_start}title: %{key_title}%{paragraph_end} %{paragraph_start}If this key was added in error, you can remove it under %{removal_link}%{paragraph_end}"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
@ -34176,6 +34179,9 @@ msgstr ""
|
||||||
msgid "Revoked personal access token %{personal_access_token_name}!"
|
msgid "Revoked personal access token %{personal_access_token_name}!"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Rich text"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "RightSidebar|Copy email address"
|
msgid "RightSidebar|Copy email address"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
@ -44969,12 +44975,6 @@ msgstr ""
|
||||||
msgid "Wiki"
|
msgid "Wiki"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
msgid "Wiki Page|Rich text"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
msgid "Wiki Page|Source"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
msgid "Wiki page"
|
msgid "Wiki page"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
|
|
@ -22,8 +22,8 @@ module QA
|
||||||
element :file_upload_field
|
element :file_upload_field
|
||||||
end
|
end
|
||||||
|
|
||||||
base.view 'app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue' do
|
base.view 'app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue' do
|
||||||
element :wiki_hidden_content
|
element :markdown_editor_form_field
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -47,7 +47,7 @@ module QA
|
||||||
end
|
end
|
||||||
|
|
||||||
QA::Support::Retrier.retry_on_exception do
|
QA::Support::Retrier.retry_on_exception do
|
||||||
source = find_element(:wiki_hidden_content, visible: false)
|
source = find_element(:markdown_editor_form_field, visible: false)
|
||||||
source.value =~ %r{uploads/.*#{::File.basename(image_path)}}
|
source.value =~ %r{uploads/.*#{::File.basename(image_path)}}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -11,9 +11,12 @@ module QA
|
||||||
|
|
||||||
base.view 'app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue' do
|
base.view 'app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue' do
|
||||||
element :wiki_title_textbox
|
element :wiki_title_textbox
|
||||||
element :wiki_content_textarea
|
|
||||||
element :wiki_message_textbox
|
element :wiki_message_textbox
|
||||||
element :wiki_submit_button
|
element :wiki_submit_button
|
||||||
|
end
|
||||||
|
|
||||||
|
base.view 'app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue' do
|
||||||
|
element :markdown_editor_form_field
|
||||||
element :editing_mode_button
|
element :editing_mode_button
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -27,7 +30,7 @@ module QA
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_content(content)
|
def set_content(content)
|
||||||
fill_element(:wiki_content_textarea, content)
|
fill_element(:markdown_editor_form_field, content)
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_message(message)
|
def set_message(message)
|
||||||
|
|
|
@ -50,15 +50,11 @@ RSpec.describe 'User manages subscription', :js do
|
||||||
|
|
||||||
wait_for_requests
|
wait_for_requests
|
||||||
|
|
||||||
click_button 'Toggle dropdown'
|
|
||||||
|
|
||||||
expect(page).to have_selector('.gl-toggle.is-checked')
|
expect(page).to have_selector('.gl-toggle.is-checked')
|
||||||
find('[data-testid="notifications-toggle"] .gl-toggle').click
|
find('[data-testid="notifications-toggle"] .gl-toggle').click
|
||||||
|
|
||||||
wait_for_requests
|
wait_for_requests
|
||||||
|
|
||||||
click_button 'Toggle dropdown'
|
|
||||||
|
|
||||||
expect(page).to have_selector('.gl-toggle:not(.is-checked)')
|
expect(page).to have_selector('.gl-toggle:not(.is-checked)')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,15 +1,12 @@
|
||||||
import { nextTick } from 'vue';
|
import { nextTick } from 'vue';
|
||||||
import { GlAlert, GlButton, GlFormInput, GlFormGroup, GlSegmentedControl } from '@gitlab/ui';
|
import { GlAlert, GlButton, GlFormInput, GlFormGroup } from '@gitlab/ui';
|
||||||
import { mount, shallowMount } from '@vue/test-utils';
|
import { mount, shallowMount } from '@vue/test-utils';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import MockAdapter from 'axios-mock-adapter';
|
import MockAdapter from 'axios-mock-adapter';
|
||||||
import { mockTracking } from 'helpers/tracking_helper';
|
import { mockTracking } from 'helpers/tracking_helper';
|
||||||
import { stubComponent } from 'helpers/stub_component';
|
|
||||||
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
|
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
|
||||||
import waitForPromises from 'helpers/wait_for_promises';
|
|
||||||
import ContentEditor from '~/content_editor/components/content_editor.vue';
|
|
||||||
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
|
|
||||||
import WikiForm from '~/pages/shared/wikis/components/wiki_form.vue';
|
import WikiForm from '~/pages/shared/wikis/components/wiki_form.vue';
|
||||||
|
import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue';
|
||||||
import {
|
import {
|
||||||
CONTENT_EDITOR_LOADED_ACTION,
|
CONTENT_EDITOR_LOADED_ACTION,
|
||||||
SAVED_USING_CONTENT_EDITOR_ACTION,
|
SAVED_USING_CONTENT_EDITOR_ACTION,
|
||||||
|
@ -18,8 +15,6 @@ import {
|
||||||
WIKI_FORMAT_UPDATED_ACTION,
|
WIKI_FORMAT_UPDATED_ACTION,
|
||||||
} from '~/pages/shared/wikis/constants';
|
} from '~/pages/shared/wikis/constants';
|
||||||
|
|
||||||
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
|
|
||||||
|
|
||||||
jest.mock('~/emoji');
|
jest.mock('~/emoji');
|
||||||
|
|
||||||
describe('WikiForm', () => {
|
describe('WikiForm', () => {
|
||||||
|
@ -30,16 +25,12 @@ describe('WikiForm', () => {
|
||||||
const findForm = () => wrapper.find('form');
|
const findForm = () => wrapper.find('form');
|
||||||
const findTitle = () => wrapper.find('#wiki_title');
|
const findTitle = () => wrapper.find('#wiki_title');
|
||||||
const findFormat = () => wrapper.find('#wiki_format');
|
const findFormat = () => wrapper.find('#wiki_format');
|
||||||
const findContent = () => wrapper.find('#wiki_content');
|
|
||||||
const findMessage = () => wrapper.find('#wiki_message');
|
const findMessage = () => wrapper.find('#wiki_message');
|
||||||
|
const findMarkdownEditor = () => wrapper.findComponent(MarkdownEditor);
|
||||||
const findSubmitButton = () => wrapper.findByTestId('wiki-submit-button');
|
const findSubmitButton = () => wrapper.findByTestId('wiki-submit-button');
|
||||||
const findCancelButton = () => wrapper.findByTestId('wiki-cancel-button');
|
const findCancelButton = () => wrapper.findByTestId('wiki-cancel-button');
|
||||||
const findToggleEditingModeButton = () => wrapper.findByTestId('toggle-editing-mode-button');
|
|
||||||
const findTitleHelpLink = () => wrapper.findByText('Learn more.');
|
const findTitleHelpLink = () => wrapper.findByText('Learn more.');
|
||||||
const findMarkdownHelpLink = () => wrapper.findByTestId('wiki-markdown-help-link');
|
const findMarkdownHelpLink = () => wrapper.findByTestId('wiki-markdown-help-link');
|
||||||
const findContentEditor = () => wrapper.findComponent(ContentEditor);
|
|
||||||
const findClassicEditor = () => wrapper.findComponent(MarkdownField);
|
|
||||||
const findLocalStorageSync = () => wrapper.findComponent(LocalStorageSync);
|
|
||||||
|
|
||||||
const setFormat = (value) => {
|
const setFormat = (value) => {
|
||||||
const format = findFormat();
|
const format = findFormat();
|
||||||
|
@ -103,11 +94,8 @@ describe('WikiForm', () => {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
stubs: {
|
stubs: {
|
||||||
MarkdownField,
|
|
||||||
GlAlert,
|
GlAlert,
|
||||||
GlButton,
|
GlButton,
|
||||||
GlSegmentedControl,
|
|
||||||
LocalStorageSync: stubComponent(LocalStorageSync),
|
|
||||||
GlFormInput,
|
GlFormInput,
|
||||||
GlFormGroup,
|
GlFormGroup,
|
||||||
},
|
},
|
||||||
|
@ -126,6 +114,22 @@ describe('WikiForm', () => {
|
||||||
wrapper = null;
|
wrapper = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('displays markdown editor', () => {
|
||||||
|
createWrapper({ persisted: true });
|
||||||
|
|
||||||
|
expect(findMarkdownEditor().props()).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
value: pageInfoPersisted.content,
|
||||||
|
renderMarkdownPath: pageInfoPersisted.markdownPreviewPath,
|
||||||
|
markdownDocsPath: pageInfoPersisted.markdownHelpPath,
|
||||||
|
uploadsPath: pageInfoPersisted.uploadsPath,
|
||||||
|
autofocus: pageInfoPersisted.persisted,
|
||||||
|
formFieldId: 'wiki_content',
|
||||||
|
formFieldName: 'wiki[content]',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it.each`
|
it.each`
|
||||||
title | persisted | message
|
title | persisted | message
|
||||||
${'my page'} | ${false} | ${'Create my page'}
|
${'my page'} | ${false} | ${'Create my page'}
|
||||||
|
@ -154,7 +158,7 @@ describe('WikiForm', () => {
|
||||||
it('does not trim page content by default', () => {
|
it('does not trim page content by default', () => {
|
||||||
createWrapper({ persisted: true });
|
createWrapper({ persisted: true });
|
||||||
|
|
||||||
expect(findContent().element.value).toBe(' My page content ');
|
expect(findMarkdownEditor().props().value).toBe(' My page content ');
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each`
|
it.each`
|
||||||
|
@ -168,7 +172,9 @@ describe('WikiForm', () => {
|
||||||
|
|
||||||
await setFormat(format);
|
await setFormat(format);
|
||||||
|
|
||||||
expect(findClassicEditor().props('enablePreview')).toBe(enabled);
|
nextTick();
|
||||||
|
|
||||||
|
expect(findMarkdownEditor().props('enablePreview')).toBe(enabled);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each`
|
it.each`
|
||||||
|
@ -219,9 +225,7 @@ describe('WikiForm', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
createWrapper({ mountFn: mount, persisted: true });
|
createWrapper({ mountFn: mount, persisted: true });
|
||||||
|
|
||||||
const input = findContent();
|
await findMarkdownEditor().vm.$emit('input', ' Lorem ipsum dolar sit! ');
|
||||||
|
|
||||||
await input.setValue(' Lorem ipsum dolar sit! ');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sets before unload warning', () => {
|
it('sets before unload warning', () => {
|
||||||
|
@ -245,7 +249,7 @@ describe('WikiForm', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not trim page content', () => {
|
it('does not trim page content', () => {
|
||||||
expect(findContent().element.value).toBe(' Lorem ipsum dolar sit! ');
|
expect(findMarkdownEditor().props().value).toBe(' Lorem ipsum dolar sit! ');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -264,7 +268,7 @@ describe('WikiForm', () => {
|
||||||
createWrapper({ mountFn: mount });
|
createWrapper({ mountFn: mount });
|
||||||
|
|
||||||
await findTitle().setValue(title);
|
await findTitle().setValue(title);
|
||||||
await findContent().setValue(content);
|
await findMarkdownEditor().vm.$emit('input', content);
|
||||||
|
|
||||||
expect(findSubmitButton().props().disabled).toBe(disabledAttr);
|
expect(findSubmitButton().props().disabled).toBe(disabledAttr);
|
||||||
},
|
},
|
||||||
|
@ -296,208 +300,64 @@ describe('WikiForm', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('toggle editing mode control', () => {
|
it.each`
|
||||||
beforeEach(() => {
|
format | enabled | action
|
||||||
createWrapper({ mountFn: mount });
|
${'markdown'} | ${true} | ${'enables'}
|
||||||
});
|
${'rdoc'} | ${false} | ${'disables'}
|
||||||
|
${'asciidoc'} | ${false} | ${'disables'}
|
||||||
|
${'org'} | ${false} | ${'disables'}
|
||||||
|
`('$action content editor when format is $format', async ({ format, enabled }) => {
|
||||||
|
createWrapper({ mountFn: mount });
|
||||||
|
|
||||||
it.each`
|
setFormat(format);
|
||||||
format | exists | action
|
|
||||||
${'markdown'} | ${true} | ${'displays'}
|
|
||||||
${'rdoc'} | ${false} | ${'hides'}
|
|
||||||
${'asciidoc'} | ${false} | ${'hides'}
|
|
||||||
${'org'} | ${false} | ${'hides'}
|
|
||||||
`('$action toggle editing mode button when format is $format', async ({ format, exists }) => {
|
|
||||||
await setFormat(format);
|
|
||||||
|
|
||||||
expect(findToggleEditingModeButton().exists()).toBe(exists);
|
await nextTick();
|
||||||
});
|
|
||||||
|
|
||||||
describe('when content editor is not active', () => {
|
expect(findMarkdownEditor().props().enableContentEditor).toBe(enabled);
|
||||||
it('displays "Source" label in the toggle editing mode button', () => {
|
|
||||||
expect(findToggleEditingModeButton().props().checked).toBe('source');
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('when clicking the toggle editing mode button', () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
await findToggleEditingModeButton().vm.$emit('input', 'richText');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('hides the classic editor', () => {
|
|
||||||
expect(findClassicEditor().exists()).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows the content editor', () => {
|
|
||||||
expect(findContentEditor().exists()).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('markdown editor type persistance', () => {
|
|
||||||
it('loads content editor by default if it is persisted in local storage', async () => {
|
|
||||||
expect(findClassicEditor().exists()).toBe(true);
|
|
||||||
expect(findContentEditor().exists()).toBe(false);
|
|
||||||
|
|
||||||
// enable content editor
|
|
||||||
await findLocalStorageSync().vm.$emit('input', 'richText');
|
|
||||||
|
|
||||||
expect(findContentEditor().exists()).toBe(true);
|
|
||||||
expect(findClassicEditor().exists()).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('when content editor is active', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
createWrapper();
|
|
||||||
findToggleEditingModeButton().vm.$emit('input', 'richText');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('displays "Edit Rich" label in the toggle editing mode button', () => {
|
|
||||||
expect(findToggleEditingModeButton().props().checked).toBe('richText');
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('when clicking the toggle editing mode button', () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
await findToggleEditingModeButton().vm.$emit('input', 'source');
|
|
||||||
await nextTick();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('hides the content editor', () => {
|
|
||||||
expect(findContentEditor().exists()).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('displays the classic editor', () => {
|
|
||||||
expect(findClassicEditor().exists()).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('when content editor is loading', () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
findContentEditor().vm.$emit('loading');
|
|
||||||
|
|
||||||
await nextTick();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('disables toggle editing mode button', () => {
|
|
||||||
expect(findToggleEditingModeButton().attributes().disabled).toBe('true');
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('when content editor loads successfully', () => {
|
|
||||||
it('enables toggle editing mode button', async () => {
|
|
||||||
findContentEditor().vm.$emit('loadingSuccess');
|
|
||||||
|
|
||||||
await nextTick();
|
|
||||||
|
|
||||||
expect(findToggleEditingModeButton().attributes().disabled).not.toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('when content editor fails to load', () => {
|
|
||||||
it('enables toggle editing mode button', async () => {
|
|
||||||
findContentEditor().vm.$emit('loadingError');
|
|
||||||
|
|
||||||
await nextTick();
|
|
||||||
|
|
||||||
expect(findToggleEditingModeButton().attributes().disabled).not.toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('wiki content editor', () => {
|
describe('when markdown editor activates the content editor', () => {
|
||||||
describe('clicking "Edit rich text": editor fails to load', () => {
|
beforeEach(async () => {
|
||||||
beforeEach(async () => {
|
createWrapper({ mountFn: mount, persisted: true });
|
||||||
createWrapper({ mountFn: mount });
|
|
||||||
mock.onPost(/preview-markdown/).reply(400);
|
|
||||||
|
|
||||||
await findToggleEditingModeButton().vm.$emit('input', 'richText');
|
await findMarkdownEditor().vm.$emit('contentEditor');
|
||||||
|
});
|
||||||
|
|
||||||
// try waiting for content editor to load (but it will never actually load)
|
it('disables the format dropdown', () => {
|
||||||
await waitForPromises();
|
expect(findFormat().element.getAttribute('disabled')).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('disables the submit button', () => {
|
it('sends tracking event when editor loads', async () => {
|
||||||
expect(findSubmitButton().props('disabled')).toBe(true);
|
expect(trackingSpy).toHaveBeenCalledWith(undefined, CONTENT_EDITOR_LOADED_ACTION, {
|
||||||
});
|
label: WIKI_CONTENT_EDITOR_TRACKING_LABEL,
|
||||||
|
|
||||||
describe('toggling editing modes to the classic editor', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
return findToggleEditingModeButton().vm.$emit('input', 'source');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('switches to classic editor', () => {
|
|
||||||
expect(findContentEditor().exists()).toBe(false);
|
|
||||||
expect(findClassicEditor().exists()).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('clicking "Edit rich text": editor loads successfully', () => {
|
describe('when triggering form submit', () => {
|
||||||
|
const updatedMarkdown = 'hello **world**';
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
createWrapper({ persisted: true, mountFn: mount });
|
findMarkdownEditor().vm.$emit('input', updatedMarkdown);
|
||||||
|
await triggerFormSubmit();
|
||||||
mock.onPost(/preview-markdown/).reply(200, { body: '<p>hello <strong>world</strong></p>' });
|
|
||||||
|
|
||||||
await findToggleEditingModeButton().vm.$emit('input', 'richText');
|
|
||||||
await waitForPromises();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows the rich text editor when loading finishes', async () => {
|
it('unsets before unload warning on form submit', async () => {
|
||||||
expect(findContentEditor().exists()).toBe(true);
|
const e = dispatchBeforeUnload();
|
||||||
|
expect(e.preventDefault).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sends tracking event when editor loads', async () => {
|
it('triggers tracking events on form submit', async () => {
|
||||||
expect(trackingSpy).toHaveBeenCalledWith(undefined, CONTENT_EDITOR_LOADED_ACTION, {
|
expect(trackingSpy).toHaveBeenCalledWith(undefined, SAVED_USING_CONTENT_EDITOR_ACTION, {
|
||||||
label: WIKI_CONTENT_EDITOR_TRACKING_LABEL,
|
label: WIKI_CONTENT_EDITOR_TRACKING_LABEL,
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
it('disables the format dropdown', () => {
|
expect(trackingSpy).toHaveBeenCalledWith(undefined, WIKI_FORMAT_UPDATED_ACTION, {
|
||||||
expect(findFormat().element.getAttribute('disabled')).toBeDefined();
|
label: WIKI_FORMAT_LABEL,
|
||||||
});
|
extra: {
|
||||||
|
value: findFormat().element.value,
|
||||||
describe('when wiki content is updated', () => {
|
old_format: pageInfoPersisted.format,
|
||||||
const updatedMarkdown = 'hello **world**';
|
project_path: pageInfoPersisted.path,
|
||||||
|
},
|
||||||
beforeEach(() => {
|
|
||||||
findContentEditor().vm.$emit('change', {
|
|
||||||
empty: false,
|
|
||||||
changed: true,
|
|
||||||
markdown: updatedMarkdown,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sets before unload warning', () => {
|
|
||||||
const e = dispatchBeforeUnload();
|
|
||||||
expect(e.preventDefault).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('unsets before unload warning on form submit', async () => {
|
|
||||||
await triggerFormSubmit();
|
|
||||||
|
|
||||||
const e = dispatchBeforeUnload();
|
|
||||||
expect(e.preventDefault).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('triggers tracking events on form submit', async () => {
|
|
||||||
await triggerFormSubmit();
|
|
||||||
expect(trackingSpy).toHaveBeenCalledWith(undefined, SAVED_USING_CONTENT_EDITOR_ACTION, {
|
|
||||||
label: WIKI_CONTENT_EDITOR_TRACKING_LABEL,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(trackingSpy).toHaveBeenCalledWith(undefined, WIKI_FORMAT_UPDATED_ACTION, {
|
|
||||||
label: WIKI_FORMAT_LABEL,
|
|
||||||
extra: {
|
|
||||||
value: findFormat().element.value,
|
|
||||||
old_format: pageInfoPersisted.format,
|
|
||||||
project_path: pageInfoPersisted.path,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sets content field to the content editor updated markdown', async () => {
|
|
||||||
expect(findContent().element.value).toBe(updatedMarkdown);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -0,0 +1,226 @@
|
||||||
|
import { GlSegmentedControl } from '@gitlab/ui';
|
||||||
|
import axios from 'axios';
|
||||||
|
import MockAdapter from 'axios-mock-adapter';
|
||||||
|
import { mountExtended } from 'helpers/vue_test_utils_helper';
|
||||||
|
import { EDITING_MODE_MARKDOWN_FIELD, EDITING_MODE_CONTENT_EDITOR } from '~/vue_shared/constants';
|
||||||
|
import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue';
|
||||||
|
import ContentEditor from '~/content_editor/components/content_editor.vue';
|
||||||
|
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
|
||||||
|
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
|
||||||
|
|
||||||
|
jest.mock('~/emoji');
|
||||||
|
|
||||||
|
describe('vue_shared/component/markdown/markdown_editor', () => {
|
||||||
|
let wrapper;
|
||||||
|
const value = 'test markdown';
|
||||||
|
const renderMarkdownPath = '/api/markdown';
|
||||||
|
const markdownDocsPath = '/help/markdown';
|
||||||
|
const uploadsPath = '/uploads';
|
||||||
|
const enableAutocomplete = true;
|
||||||
|
const enablePreview = false;
|
||||||
|
const formFieldId = 'markdown_field';
|
||||||
|
const formFieldName = 'form[markdown_field]';
|
||||||
|
const formFieldPlaceholder = 'Write some markdown';
|
||||||
|
const formFieldAriaLabel = 'Edit your content';
|
||||||
|
let mock;
|
||||||
|
|
||||||
|
const buildWrapper = (propsData = {}) => {
|
||||||
|
wrapper = mountExtended(MarkdownEditor, {
|
||||||
|
propsData: {
|
||||||
|
value,
|
||||||
|
renderMarkdownPath,
|
||||||
|
markdownDocsPath,
|
||||||
|
uploadsPath,
|
||||||
|
enableAutocomplete,
|
||||||
|
enablePreview,
|
||||||
|
formFieldId,
|
||||||
|
formFieldName,
|
||||||
|
formFieldPlaceholder,
|
||||||
|
formFieldAriaLabel,
|
||||||
|
...propsData,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const findSegmentedControl = () => wrapper.findComponent(GlSegmentedControl);
|
||||||
|
const findMarkdownField = () => wrapper.findComponent(MarkdownField);
|
||||||
|
const findTextarea = () => wrapper.find('textarea');
|
||||||
|
const findLocalStorageSync = () => wrapper.findComponent(LocalStorageSync);
|
||||||
|
const findContentEditor = () => wrapper.findComponent(ContentEditor);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mock = new MockAdapter(axios);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
wrapper.destroy();
|
||||||
|
mock.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays markdown field by default', () => {
|
||||||
|
buildWrapper();
|
||||||
|
|
||||||
|
expect(findMarkdownField().props()).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
markdownPreviewPath: renderMarkdownPath,
|
||||||
|
canAttachFile: true,
|
||||||
|
enableAutocomplete,
|
||||||
|
textareaValue: value,
|
||||||
|
markdownDocsPath,
|
||||||
|
uploadsPath,
|
||||||
|
enablePreview,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders markdown field textarea', () => {
|
||||||
|
buildWrapper();
|
||||||
|
|
||||||
|
expect(findTextarea().attributes()).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: formFieldId,
|
||||||
|
name: formFieldName,
|
||||||
|
placeholder: formFieldPlaceholder,
|
||||||
|
'aria-label': formFieldAriaLabel,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(findTextarea().element.value).toBe(value);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders switch segmented control', () => {
|
||||||
|
buildWrapper();
|
||||||
|
|
||||||
|
expect(findSegmentedControl().props()).toEqual({
|
||||||
|
checked: EDITING_MODE_MARKDOWN_FIELD,
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
text: expect.any(String),
|
||||||
|
value: EDITING_MODE_MARKDOWN_FIELD,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: expect.any(String),
|
||||||
|
value: EDITING_MODE_CONTENT_EDITOR,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.each`
|
||||||
|
editingMode
|
||||||
|
${EDITING_MODE_CONTENT_EDITOR}
|
||||||
|
${EDITING_MODE_MARKDOWN_FIELD}
|
||||||
|
`('when segmented control emits change event with $editingMode value', ({ editingMode }) => {
|
||||||
|
it(`emits ${editingMode} event`, () => {
|
||||||
|
buildWrapper();
|
||||||
|
|
||||||
|
findSegmentedControl().vm.$emit('change', editingMode);
|
||||||
|
|
||||||
|
expect(wrapper.emitted(editingMode)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe(`when editingMode is ${EDITING_MODE_MARKDOWN_FIELD}`, () => {
|
||||||
|
it('emits input event when markdown field textarea changes', async () => {
|
||||||
|
buildWrapper();
|
||||||
|
const newValue = 'new value';
|
||||||
|
|
||||||
|
await findTextarea().setValue(newValue);
|
||||||
|
|
||||||
|
expect(wrapper.emitted('input')).toEqual([[newValue]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe(`when segmented control triggers input event with ${EDITING_MODE_CONTENT_EDITOR} value`, () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
buildWrapper();
|
||||||
|
findSegmentedControl().vm.$emit('input', EDITING_MODE_CONTENT_EDITOR);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays the content editor', () => {
|
||||||
|
expect(findContentEditor().props()).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
renderMarkdown: expect.any(Function),
|
||||||
|
uploadsPath,
|
||||||
|
markdown: value,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds hidden field with current markdown', () => {
|
||||||
|
const hiddenField = wrapper.find(`#${formFieldId}`);
|
||||||
|
|
||||||
|
expect(hiddenField.attributes()).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: formFieldId,
|
||||||
|
name: formFieldName,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(hiddenField.element.value).toBe(value);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides the markdown field', () => {
|
||||||
|
expect(findMarkdownField().exists()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates localStorage value', () => {
|
||||||
|
expect(findLocalStorageSync().props().value).toBe(EDITING_MODE_CONTENT_EDITOR);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe(`when editingMode is ${EDITING_MODE_CONTENT_EDITOR}`, () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
buildWrapper();
|
||||||
|
findSegmentedControl().vm.$emit('input', EDITING_MODE_CONTENT_EDITOR);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits input event when content editor emits change event', async () => {
|
||||||
|
const newValue = 'new value';
|
||||||
|
|
||||||
|
await findContentEditor().vm.$emit('change', { markdown: newValue });
|
||||||
|
|
||||||
|
expect(wrapper.emitted('input')).toEqual([[newValue]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe(`when segmented control triggers input event with ${EDITING_MODE_MARKDOWN_FIELD} value`, () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
findSegmentedControl().vm.$emit('input', EDITING_MODE_MARKDOWN_FIELD);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides the content editor', () => {
|
||||||
|
expect(findContentEditor().exists()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the markdown field', () => {
|
||||||
|
expect(findMarkdownField().exists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates localStorage value', () => {
|
||||||
|
expect(findLocalStorageSync().props().value).toBe(EDITING_MODE_MARKDOWN_FIELD);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when content editor emits loading event', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
findContentEditor().vm.$emit('loading');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables switch editing mode control', () => {
|
||||||
|
// This is the only way that I found to check the segmented control is disabled
|
||||||
|
expect(findSegmentedControl().find('input[disabled]').exists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.each`
|
||||||
|
event
|
||||||
|
${'loadingSuccess'}
|
||||||
|
${'loadingError'}
|
||||||
|
`('when content editor emits $event event', ({ event }) => {
|
||||||
|
beforeEach(() => {
|
||||||
|
findContentEditor().vm.$emit(event);
|
||||||
|
});
|
||||||
|
it('enables the switch editing mode control', () => {
|
||||||
|
expect(findSegmentedControl().find('input[disabled]').exists()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,30 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'spec_helper'
|
||||||
|
|
||||||
|
RSpec.describe Gitlab::BackgroundMigration::BackfillInternalOnNotes, :migration, schema: 20220920124709 do
|
||||||
|
let(:notes_table) { table(:notes) }
|
||||||
|
|
||||||
|
let!(:confidential_note) { notes_table.create!(id: 1, confidential: true, internal: false) }
|
||||||
|
let!(:non_confidential_note) { notes_table.create!(id: 2, confidential: false, internal: false) }
|
||||||
|
|
||||||
|
describe '#perform' do
|
||||||
|
subject(:perform) do
|
||||||
|
described_class.new(
|
||||||
|
start_id: 1,
|
||||||
|
end_id: 2,
|
||||||
|
batch_table: :notes,
|
||||||
|
batch_column: :id,
|
||||||
|
sub_batch_size: 1,
|
||||||
|
pause_ms: 0,
|
||||||
|
connection: ApplicationRecord.connection
|
||||||
|
).perform
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'backfills internal column on notes when confidential' do
|
||||||
|
expect { perform }
|
||||||
|
.to change { confidential_note.reload.internal }.from(false).to(true)
|
||||||
|
.and not_change { non_confidential_note.reload.internal }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -122,19 +122,17 @@ RSpec.describe Gitlab::Ci::Build::Rules::Rule::Clause::Changes do
|
||||||
context 'when compare_to is branch or tag' do
|
context 'when compare_to is branch or tag' do
|
||||||
using RSpec::Parameterized::TableSyntax
|
using RSpec::Parameterized::TableSyntax
|
||||||
|
|
||||||
where(:pipeline_ref, :compare_to, :paths, :ff, :result) do
|
where(:pipeline_ref, :compare_to, :paths, :result) do
|
||||||
'feature_1' | 'master' | ['file1.txt'] | true | true
|
'feature_1' | 'master' | ['file1.txt'] | true
|
||||||
'feature_1' | 'master' | ['README.md'] | true | false
|
'feature_1' | 'master' | ['README.md'] | false
|
||||||
'feature_1' | 'master' | ['xyz.md'] | true | false
|
'feature_1' | 'master' | ['xyz.md'] | false
|
||||||
'feature_2' | 'master' | ['file1.txt'] | true | true
|
'feature_2' | 'master' | ['file1.txt'] | true
|
||||||
'feature_2' | 'master' | ['file2.txt'] | true | true
|
'feature_2' | 'master' | ['file2.txt'] | true
|
||||||
'feature_2' | 'feature_1' | ['file1.txt'] | true | false
|
'feature_2' | 'feature_1' | ['file1.txt'] | false
|
||||||
'feature_2' | 'feature_1' | ['file1.txt'] | false | true
|
'feature_2' | 'feature_1' | ['file2.txt'] | true
|
||||||
'feature_2' | 'feature_1' | ['file2.txt'] | true | true
|
'feature_1' | 'tag_1' | ['file1.txt'] | false
|
||||||
'feature_1' | 'tag_1' | ['file1.txt'] | true | false
|
'feature_1' | 'tag_1' | ['file2.txt'] | true
|
||||||
'feature_1' | 'tag_1' | ['file1.txt'] | false | true
|
'feature_2' | 'tag_1' | ['file2.txt'] | true
|
||||||
'feature_1' | 'tag_1' | ['file2.txt'] | true | true
|
|
||||||
'feature_2' | 'tag_1' | ['file2.txt'] | true | true
|
|
||||||
end
|
end
|
||||||
|
|
||||||
with_them do
|
with_them do
|
||||||
|
@ -144,10 +142,6 @@ RSpec.describe Gitlab::Ci::Build::Rules::Rule::Clause::Changes do
|
||||||
build(:ci_pipeline, project: project, ref: pipeline_ref, sha: project.commit(pipeline_ref).sha)
|
build(:ci_pipeline, project: project, ref: pipeline_ref, sha: project.commit(pipeline_ref).sha)
|
||||||
end
|
end
|
||||||
|
|
||||||
before do
|
|
||||||
stub_feature_flags(ci_rules_changes_compare: ff)
|
|
||||||
end
|
|
||||||
|
|
||||||
it { is_expected.to eq(result) }
|
it { is_expected.to eq(result) }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -174,14 +168,6 @@ RSpec.describe Gitlab::Ci::Build::Rules::Rule::Clause::Changes do
|
||||||
::Gitlab::Ci::Build::Rules::Rule::Clause::ParseError, 'rules:changes:compare_to is not a valid ref'
|
::Gitlab::Ci::Build::Rules::Rule::Clause::ParseError, 'rules:changes:compare_to is not a valid ref'
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when the FF ci_rules_changes_compare is disabled' do
|
|
||||||
before do
|
|
||||||
stub_feature_flags(ci_rules_changes_compare: false)
|
|
||||||
end
|
|
||||||
|
|
||||||
it { is_expected.to be_truthy }
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -11,14 +11,21 @@ RSpec.describe Gitlab::Usage::Metrics::Instrumentations::RedisMetric, :clean_git
|
||||||
|
|
||||||
let(:expected_value) { 4 }
|
let(:expected_value) { 4 }
|
||||||
|
|
||||||
it_behaves_like 'a correct instrumented metric value', { options: { event: 'pushes', prefix: 'source_code' } }
|
it_behaves_like 'a correct instrumented metric value', {
|
||||||
|
options: { event: 'pushes', prefix: 'source_code' },
|
||||||
|
time_frame: 'all'
|
||||||
|
}
|
||||||
|
|
||||||
it 'raises an exception if event option is not present' do
|
it 'raises an exception if event option is not present' do
|
||||||
expect { described_class.new(prefix: 'source_code') }.to raise_error(ArgumentError)
|
expect do
|
||||||
|
described_class.new(options: { prefix: 'source_code' }, time_frame: 'all')
|
||||||
|
end.to raise_error(ArgumentError, /'event' option is required/)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'raises an exception if prefix option is not present' do
|
it 'raises an exception if prefix option is not present' do
|
||||||
expect { described_class.new(event: 'pushes') }.to raise_error(ArgumentError)
|
expect do
|
||||||
|
described_class.new(options: { event: 'pushes' }, time_frame: 'all')
|
||||||
|
end.to raise_error(ArgumentError, /'prefix' option is required/)
|
||||||
end
|
end
|
||||||
|
|
||||||
describe 'children classes' do
|
describe 'children classes' do
|
||||||
|
@ -55,7 +62,8 @@ RSpec.describe Gitlab::Usage::Metrics::Instrumentations::RedisMetric, :clean_git
|
||||||
end
|
end
|
||||||
|
|
||||||
it_behaves_like 'a correct instrumented metric value', {
|
it_behaves_like 'a correct instrumented metric value', {
|
||||||
options: { event: 'merge_requests_count', prefix: 'web_ide', include_usage_prefix: false }
|
options: { event: 'merge_requests_count', prefix: 'web_ide', include_usage_prefix: false },
|
||||||
|
time_frame: 'all'
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,31 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'spec_helper'
|
||||||
|
require_migration!
|
||||||
|
|
||||||
|
RSpec.describe BackfillInternalOnNotes, :migration do
|
||||||
|
let(:migration) { described_class::MIGRATION }
|
||||||
|
|
||||||
|
describe '#up' do
|
||||||
|
it 'schedules background jobs for each batch of issues' do
|
||||||
|
migrate!
|
||||||
|
|
||||||
|
expect(migration).to have_scheduled_batched_migration(
|
||||||
|
table_name: :notes,
|
||||||
|
column_name: :id,
|
||||||
|
interval: described_class::DELAY_INTERVAL,
|
||||||
|
batch_size: described_class::BATCH_SIZE,
|
||||||
|
sub_batch_size: described_class::SUB_BATCH_SIZE
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#down' do
|
||||||
|
it 'deletes all batched migration records' do
|
||||||
|
migrate!
|
||||||
|
schema_migrate_down!
|
||||||
|
|
||||||
|
expect(migration).not_to have_scheduled_batched_migration
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -5516,4 +5516,34 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe '#notes=' do
|
||||||
|
context 'when notes already exist' do
|
||||||
|
it 'does not create duplicate notes', :aggregate_failures do
|
||||||
|
time = Time.zone.now
|
||||||
|
pipeline = create(:ci_pipeline, user: user, project: project)
|
||||||
|
note = Note.new(
|
||||||
|
note: 'note',
|
||||||
|
noteable_type: 'Commit',
|
||||||
|
noteable_id: pipeline.id,
|
||||||
|
commit_id: pipeline.id,
|
||||||
|
author_id: user.id,
|
||||||
|
project_id: pipeline.project_id,
|
||||||
|
created_at: time
|
||||||
|
)
|
||||||
|
another_note = note.dup.tap { |note| note.note = 'another note' }
|
||||||
|
|
||||||
|
expect(project.notes.for_commit_id(pipeline.sha).count).to eq(0)
|
||||||
|
|
||||||
|
pipeline.notes = [note]
|
||||||
|
|
||||||
|
expect(project.notes.for_commit_id(pipeline.sha).count).to eq(1)
|
||||||
|
|
||||||
|
pipeline.notes = [note, note, another_note]
|
||||||
|
|
||||||
|
expect(project.notes.for_commit_id(pipeline.sha).count).to eq(2)
|
||||||
|
expect(project.notes.for_commit_id(pipeline.sha).pluck(:note)).to contain_exactly(note.note, another_note.note)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -544,16 +544,6 @@ RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectnes
|
||||||
'Failed to parse rule for job1: rules:changes:compare_to is not a valid ref'
|
'Failed to parse rule for job1: rules:changes:compare_to is not a valid ref'
|
||||||
])
|
])
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when the FF ci_rules_changes_compare is not enabled' do
|
|
||||||
before do
|
|
||||||
stub_feature_flags(ci_rules_changes_compare: false)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'ignores compare_to and changes is always true' do
|
|
||||||
expect(build_names).to contain_exactly('job1', 'job2')
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when the compare_to ref exists' do
|
context 'when the compare_to ref exists' do
|
||||||
|
@ -563,16 +553,6 @@ RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectnes
|
||||||
it 'creates job1 and job2' do
|
it 'creates job1 and job2' do
|
||||||
expect(build_names).to contain_exactly('job1', 'job2')
|
expect(build_names).to contain_exactly('job1', 'job2')
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when the FF ci_rules_changes_compare is not enabled' do
|
|
||||||
before do
|
|
||||||
stub_feature_flags(ci_rules_changes_compare: false)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'ignores compare_to and changes is always true' do
|
|
||||||
expect(build_names).to contain_exactly('job1', 'job2')
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when the rule does not match' do
|
context 'when the rule does not match' do
|
||||||
|
@ -581,16 +561,6 @@ RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectnes
|
||||||
it 'does not create job1' do
|
it 'does not create job1' do
|
||||||
expect(build_names).to contain_exactly('job2')
|
expect(build_names).to contain_exactly('job2')
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when the FF ci_rules_changes_compare is not enabled' do
|
|
||||||
before do
|
|
||||||
stub_feature_flags(ci_rules_changes_compare: false)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'ignores compare_to and changes is always true' do
|
|
||||||
expect(build_names).to contain_exactly('job1', 'job2')
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -616,17 +586,6 @@ RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectnes
|
||||||
expect(pipeline).to be_created_successfully
|
expect(pipeline).to be_created_successfully
|
||||||
expect(build_names).to contain_exactly('job1')
|
expect(build_names).to contain_exactly('job1')
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when the FF ci_rules_changes_compare is not enabled' do
|
|
||||||
before do
|
|
||||||
stub_feature_flags(ci_rules_changes_compare: false)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'ignores compare_to and changes is always true' do
|
|
||||||
expect(pipeline).to be_created_successfully
|
|
||||||
expect(build_names).to contain_exactly('job1')
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when the rule does not match' do
|
context 'when the rule does not match' do
|
||||||
|
|
|
@ -19,7 +19,6 @@ import (
|
||||||
"gitlab.com/gitlab-org/gitlab/workhorse/internal/config"
|
"gitlab.com/gitlab-org/gitlab/workhorse/internal/config"
|
||||||
"gitlab.com/gitlab-org/gitlab/workhorse/internal/helper"
|
"gitlab.com/gitlab-org/gitlab/workhorse/internal/helper"
|
||||||
"gitlab.com/gitlab-org/gitlab/workhorse/internal/log"
|
"gitlab.com/gitlab-org/gitlab/workhorse/internal/log"
|
||||||
|
|
||||||
"gitlab.com/gitlab-org/gitlab/workhorse/internal/secret"
|
"gitlab.com/gitlab-org/gitlab/workhorse/internal/secret"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -9,12 +9,12 @@ import (
|
||||||
"gitlab.com/gitlab-org/labkit/log"
|
"gitlab.com/gitlab-org/labkit/log"
|
||||||
|
|
||||||
"gitlab.com/gitlab-org/gitlab/workhorse/internal/helper"
|
"gitlab.com/gitlab-org/gitlab/workhorse/internal/helper"
|
||||||
"gitlab.com/gitlab-org/gitlab/workhorse/internal/helper/httptransport"
|
|
||||||
"gitlab.com/gitlab-org/gitlab/workhorse/internal/senddata"
|
"gitlab.com/gitlab-org/gitlab/workhorse/internal/senddata"
|
||||||
|
"gitlab.com/gitlab-org/gitlab/workhorse/internal/transport"
|
||||||
)
|
)
|
||||||
|
|
||||||
var httpClient = &http.Client{
|
var httpClient = &http.Client{
|
||||||
Transport: httptransport.New(),
|
Transport: transport.NewRestrictedTransport(),
|
||||||
}
|
}
|
||||||
|
|
||||||
type Injector struct {
|
type Injector struct {
|
||||||
|
|
|
@ -1,37 +0,0 @@
|
||||||
package httptransport
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gitlab.com/gitlab-org/labkit/correlation"
|
|
||||||
"gitlab.com/gitlab-org/labkit/tracing"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Option func(*http.Transport)
|
|
||||||
|
|
||||||
// Defines a http.Transport with values
|
|
||||||
// that are more restrictive than for http.DefaultTransport,
|
|
||||||
// they define shorter TLS Handshake, and more aggressive connection closing
|
|
||||||
// to prevent the connection hanging and reduce FD usage
|
|
||||||
func New(options ...Option) http.RoundTripper {
|
|
||||||
t := http.DefaultTransport.(*http.Transport).Clone()
|
|
||||||
|
|
||||||
// To avoid keep around TCP connections to http servers we're done with
|
|
||||||
t.MaxIdleConns = 2
|
|
||||||
|
|
||||||
// A stricter timeout for fetching from external sources that can be slow
|
|
||||||
t.ResponseHeaderTimeout = 30 * time.Second
|
|
||||||
|
|
||||||
for _, option := range options {
|
|
||||||
option(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
return tracing.NewRoundTripper(correlation.NewInstrumentedRoundTripper(t))
|
|
||||||
}
|
|
||||||
|
|
||||||
func WithDisabledCompression() Option {
|
|
||||||
return func(t *http.Transport) {
|
|
||||||
t.DisableCompression = true
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -21,9 +21,9 @@ import (
|
||||||
|
|
||||||
"gitlab.com/gitlab-org/gitlab/workhorse/internal/config"
|
"gitlab.com/gitlab-org/gitlab/workhorse/internal/config"
|
||||||
"gitlab.com/gitlab-org/gitlab/workhorse/internal/helper"
|
"gitlab.com/gitlab-org/gitlab/workhorse/internal/helper"
|
||||||
"gitlab.com/gitlab-org/gitlab/workhorse/internal/helper/httptransport"
|
|
||||||
"gitlab.com/gitlab-org/gitlab/workhorse/internal/log"
|
"gitlab.com/gitlab-org/gitlab/workhorse/internal/log"
|
||||||
"gitlab.com/gitlab-org/gitlab/workhorse/internal/senddata"
|
"gitlab.com/gitlab-org/gitlab/workhorse/internal/senddata"
|
||||||
|
"gitlab.com/gitlab-org/gitlab/workhorse/internal/transport"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Resizer struct {
|
type Resizer struct {
|
||||||
|
@ -69,7 +69,7 @@ const (
|
||||||
var envInjector = tracing.NewEnvInjector()
|
var envInjector = tracing.NewEnvInjector()
|
||||||
|
|
||||||
var httpClient = &http.Client{
|
var httpClient = &http.Client{
|
||||||
Transport: httptransport.New(),
|
Transport: transport.NewRestrictedTransport(),
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
|
@ -11,9 +11,9 @@ import (
|
||||||
"gitlab.com/gitlab-org/labkit/mask"
|
"gitlab.com/gitlab-org/labkit/mask"
|
||||||
|
|
||||||
"gitlab.com/gitlab-org/gitlab/workhorse/internal/helper"
|
"gitlab.com/gitlab-org/gitlab/workhorse/internal/helper"
|
||||||
"gitlab.com/gitlab-org/gitlab/workhorse/internal/helper/httptransport"
|
|
||||||
"gitlab.com/gitlab-org/gitlab/workhorse/internal/log"
|
"gitlab.com/gitlab-org/gitlab/workhorse/internal/log"
|
||||||
"gitlab.com/gitlab-org/gitlab/workhorse/internal/senddata"
|
"gitlab.com/gitlab-org/gitlab/workhorse/internal/senddata"
|
||||||
|
"gitlab.com/gitlab-org/gitlab/workhorse/internal/transport"
|
||||||
)
|
)
|
||||||
|
|
||||||
type entry struct{ senddata.Prefix }
|
type entry struct{ senddata.Prefix }
|
||||||
|
@ -44,7 +44,7 @@ var preserveHeaderKeys = map[string]bool{
|
||||||
"Pragma": true, // Support for HTTP 1.0 proxies
|
"Pragma": true, // Support for HTTP 1.0 proxies
|
||||||
}
|
}
|
||||||
|
|
||||||
var httpTransport = httptransport.New()
|
var httpTransport = transport.NewRestrictedTransport()
|
||||||
|
|
||||||
var httpClient = &http.Client{
|
var httpClient = &http.Client{
|
||||||
Transport: httpTransport,
|
Transport: httpTransport,
|
||||||
|
|
|
@ -0,0 +1,58 @@
|
||||||
|
package transport
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitlab.com/gitlab-org/labkit/correlation"
|
||||||
|
"gitlab.com/gitlab-org/labkit/tracing"
|
||||||
|
|
||||||
|
"gitlab.com/gitlab-org/gitlab/workhorse/internal/version"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Creates a new default transport that has Workhorse's User-Agent header set.
|
||||||
|
func NewDefaultTransport() http.RoundTripper {
|
||||||
|
return &DefaultTransport{Next: http.DefaultTransport}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defines a http.Transport with values that are more restrictive than for
|
||||||
|
// http.DefaultTransport, they define shorter TLS Handshake, and more
|
||||||
|
// aggressive connection closing to prevent the connection hanging and reduce
|
||||||
|
// FD usage
|
||||||
|
func NewRestrictedTransport(options ...Option) http.RoundTripper {
|
||||||
|
return &DefaultTransport{Next: newRestrictedTransport(options...)}
|
||||||
|
}
|
||||||
|
|
||||||
|
type DefaultTransport struct {
|
||||||
|
Next http.RoundTripper
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t DefaultTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
req.Header.Set("User-Agent", version.GetUserAgent())
|
||||||
|
|
||||||
|
return t.Next.RoundTrip(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Option func(*http.Transport)
|
||||||
|
|
||||||
|
func WithDisabledCompression() Option {
|
||||||
|
return func(t *http.Transport) {
|
||||||
|
t.DisableCompression = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRestrictedTransport(options ...Option) http.RoundTripper {
|
||||||
|
t := http.DefaultTransport.(*http.Transport).Clone()
|
||||||
|
|
||||||
|
// To avoid keep around TCP connections to http servers we're done with
|
||||||
|
t.MaxIdleConns = 2
|
||||||
|
|
||||||
|
// A stricter timeout for fetching from external sources that can be slow
|
||||||
|
t.ResponseHeaderTimeout = 30 * time.Second
|
||||||
|
|
||||||
|
for _, option := range options {
|
||||||
|
option(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tracing.NewRoundTripper(correlation.NewInstrumentedRoundTripper(t))
|
||||||
|
}
|
|
@ -8,11 +8,11 @@ import (
|
||||||
|
|
||||||
"gitlab.com/gitlab-org/labkit/mask"
|
"gitlab.com/gitlab-org/labkit/mask"
|
||||||
|
|
||||||
"gitlab.com/gitlab-org/gitlab/workhorse/internal/helper/httptransport"
|
"gitlab.com/gitlab-org/gitlab/workhorse/internal/transport"
|
||||||
)
|
)
|
||||||
|
|
||||||
var httpClient = &http.Client{
|
var httpClient = &http.Client{
|
||||||
Transport: httptransport.New(),
|
Transport: transport.NewRestrictedTransport(),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Object represents an object on a S3 compatible Object Store service.
|
// Object represents an object on a S3 compatible Object Store service.
|
||||||
|
|
|
@ -0,0 +1,20 @@
|
||||||
|
package version
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
var version = "unknown"
|
||||||
|
var build = "unknown"
|
||||||
|
var schema = "gitlab-workhorse (%s)-(%s)"
|
||||||
|
|
||||||
|
func SetVersion(v, b string) {
|
||||||
|
version = v
|
||||||
|
build = b
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetUserAgent() string {
|
||||||
|
return GetApplicationVersion()
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetApplicationVersion() string {
|
||||||
|
return fmt.Sprintf(schema, version, build)
|
||||||
|
}
|
|
@ -0,0 +1,19 @@
|
||||||
|
package version
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestVersion(t *testing.T) {
|
||||||
|
require.Equal(t, GetApplicationVersion(), "gitlab-workhorse (unknown)-(unknown)")
|
||||||
|
|
||||||
|
SetVersion("15.3", "123.123")
|
||||||
|
|
||||||
|
require.Equal(t, GetApplicationVersion(), "gitlab-workhorse (15.3)-(123.123)")
|
||||||
|
|
||||||
|
SetVersion("", "123.123")
|
||||||
|
|
||||||
|
require.Equal(t, GetApplicationVersion(), "gitlab-workhorse ()-(123.123)")
|
||||||
|
}
|
|
@ -8,16 +8,16 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitlab.com/gitlab-org/gitlab/workhorse/internal/helper/httptransport"
|
|
||||||
"gitlab.com/gitlab-org/gitlab/workhorse/internal/httprs"
|
"gitlab.com/gitlab-org/gitlab/workhorse/internal/httprs"
|
||||||
|
"gitlab.com/gitlab-org/gitlab/workhorse/internal/transport"
|
||||||
|
|
||||||
zip "gitlab.com/gitlab-org/golang-archive-zip"
|
zip "gitlab.com/gitlab-org/golang-archive-zip"
|
||||||
"gitlab.com/gitlab-org/labkit/mask"
|
"gitlab.com/gitlab-org/labkit/mask"
|
||||||
)
|
)
|
||||||
|
|
||||||
var httpClient = &http.Client{
|
var httpClient = &http.Client{
|
||||||
Transport: httptransport.New(
|
Transport: transport.NewRestrictedTransport(
|
||||||
httptransport.WithDisabledCompression(), // To avoid bugs when serving compressed files from object storage
|
transport.WithDisabledCompression(), // To avoid bugs when serving compressed files from object storage
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -23,6 +23,7 @@ import (
|
||||||
"gitlab.com/gitlab-org/gitlab/workhorse/internal/redis"
|
"gitlab.com/gitlab-org/gitlab/workhorse/internal/redis"
|
||||||
"gitlab.com/gitlab-org/gitlab/workhorse/internal/secret"
|
"gitlab.com/gitlab-org/gitlab/workhorse/internal/secret"
|
||||||
"gitlab.com/gitlab-org/gitlab/workhorse/internal/upstream"
|
"gitlab.com/gitlab-org/gitlab/workhorse/internal/upstream"
|
||||||
|
"gitlab.com/gitlab-org/gitlab/workhorse/internal/version"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Version is the current version of GitLab Workhorse
|
// Version is the current version of GitLab Workhorse
|
||||||
|
@ -55,8 +56,10 @@ func main() {
|
||||||
os.Exit(2)
|
os.Exit(2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
version.SetVersion(Version, BuildTime)
|
||||||
|
|
||||||
if boot.printVersion {
|
if boot.printVersion {
|
||||||
fmt.Printf("gitlab-workhorse %s-%s\n", Version, BuildTime)
|
fmt.Println(version.GetApplicationVersion())
|
||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue