Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
03a8aa2ca6
commit
f26a600a69
|
|
@ -312,6 +312,8 @@
|
|||
- "{,ee/,jh/}{,spec/}lib/{,ee/,jh/}gitlab/background_migration{,_spec}.rb"
|
||||
- "{,ee/,jh/}spec/support/helpers/database/**/*"
|
||||
- "lib/gitlab/markdown_cache/active_record/**/*"
|
||||
- "lib/api/admin/batched_background_migrations.rb"
|
||||
- "spec/requests/api/admin/batched_background_migrations_spec.rb"
|
||||
- "config/prometheus/common_metrics.yml" # Used by Gitlab::DatabaseImporters::CommonMetrics::Importer
|
||||
- "{,ee/,jh/}app/models/project_statistics.rb" # Used to calculate sizes in migration specs
|
||||
# Gitaly has interactions with background migrations: https://gitlab.com/gitlab-org/gitlab/-/issues/336538
|
||||
|
|
|
|||
|
|
@ -252,7 +252,6 @@ Gitlab/NamespacedClass:
|
|||
- 'app/models/notification_setting.rb'
|
||||
- 'app/models/oauth_access_grant.rb'
|
||||
- 'app/models/oauth_access_token.rb'
|
||||
- 'app/models/onboarding_progress.rb'
|
||||
- 'app/models/out_of_context_discussion.rb'
|
||||
- 'app/models/pages_deployment.rb'
|
||||
- 'app/models/pages_domain.rb'
|
||||
|
|
|
|||
|
|
@ -5394,7 +5394,6 @@ Layout/LineLength:
|
|||
- 'spec/models/namespace_statistics_spec.rb'
|
||||
- 'spec/models/note_spec.rb'
|
||||
- 'spec/models/notification_setting_spec.rb'
|
||||
- 'spec/models/onboarding_progress_spec.rb'
|
||||
- 'spec/models/packages/composer/cache_file_spec.rb'
|
||||
- 'spec/models/packages/composer/metadatum_spec.rb'
|
||||
- 'spec/models/packages/conan/metadatum_spec.rb'
|
||||
|
|
|
|||
|
|
@ -96,7 +96,6 @@ Layout/SpaceInLambdaLiteral:
|
|||
- 'app/models/namespace_statistics.rb'
|
||||
- 'app/models/note.rb'
|
||||
- 'app/models/note_diff_file.rb'
|
||||
- 'app/models/onboarding_progress.rb'
|
||||
- 'app/models/operations/feature_flags/user_list.rb'
|
||||
- 'app/models/packages/build_info.rb'
|
||||
- 'app/models/packages/maven/metadatum.rb'
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ Rails/WhereExists:
|
|||
- 'app/models/lfs_object.rb'
|
||||
- 'app/models/merge_request_diff.rb'
|
||||
- 'app/models/namespace.rb'
|
||||
- 'app/models/onboarding_progress.rb'
|
||||
- 'app/models/project.rb'
|
||||
- 'app/models/protected_branch/push_access_level.rb'
|
||||
- 'app/services/projects/transfer_service.rb'
|
||||
|
|
|
|||
|
|
@ -2466,7 +2466,6 @@ RSpec/ContextWording:
|
|||
- 'spec/models/note_spec.rb'
|
||||
- 'spec/models/notification_recipient_spec.rb'
|
||||
- 'spec/models/notification_setting_spec.rb'
|
||||
- 'spec/models/onboarding_progress_spec.rb'
|
||||
- 'spec/models/operations/feature_flag_spec.rb'
|
||||
- 'spec/models/packages/conan/file_metadatum_spec.rb'
|
||||
- 'spec/models/packages/debian/file_metadatum_spec.rb'
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ Style/Lambda:
|
|||
- 'app/models/note.rb'
|
||||
- 'app/models/note_diff_file.rb'
|
||||
- 'app/models/notification_setting.rb'
|
||||
- 'app/models/onboarding_progress.rb'
|
||||
- 'app/models/onboarding/progress.rb'
|
||||
- 'app/models/operations/feature_flags/user_list.rb'
|
||||
- 'app/models/packages/package.rb'
|
||||
- 'app/models/packages/package_file.rb'
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
<script>
|
||||
import { EditorContent as TiptapEditorContent } from '@tiptap/vue-2';
|
||||
import { __ } from '~/locale';
|
||||
import { VARIANT_DANGER } from '~/flash';
|
||||
import { createContentEditor } from '../services/create_content_editor';
|
||||
import { ALERT_EVENT } from '../constants';
|
||||
import ContentEditorAlert from './content_editor_alert.vue';
|
||||
import ContentEditorProvider from './content_editor_provider.vue';
|
||||
import EditorStateObserver from './editor_state_observer.vue';
|
||||
|
|
@ -43,12 +46,26 @@ export default {
|
|||
required: false,
|
||||
default: () => {},
|
||||
},
|
||||
markdown: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
focused: false,
|
||||
isLoading: false,
|
||||
latestMarkdown: null,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
markdown(markdown) {
|
||||
if (markdown !== this.latestMarkdown) {
|
||||
this.setSerializedContent(markdown);
|
||||
}
|
||||
},
|
||||
},
|
||||
created() {
|
||||
const { renderMarkdown, uploadsPath, extensions, serializerConfig } = this;
|
||||
|
||||
|
|
@ -61,21 +78,61 @@ export default {
|
|||
});
|
||||
},
|
||||
mounted() {
|
||||
this.$emit('initialized', this.contentEditor);
|
||||
this.$emit('initialized');
|
||||
this.setSerializedContent(this.markdown);
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.contentEditor.dispose();
|
||||
},
|
||||
methods: {
|
||||
async setSerializedContent(markdown) {
|
||||
this.notifyLoading();
|
||||
|
||||
try {
|
||||
await this.contentEditor.setSerializedContent(markdown);
|
||||
this.contentEditor.setEditable(true);
|
||||
this.notifyLoadingSuccess();
|
||||
this.latestMarkdown = markdown;
|
||||
} catch {
|
||||
this.contentEditor.eventHub.$emit(ALERT_EVENT, {
|
||||
message: __(
|
||||
'An error occurred while trying to render the content editor. Please try again.',
|
||||
),
|
||||
variant: VARIANT_DANGER,
|
||||
actionLabel: __('Retry'),
|
||||
action: () => {
|
||||
this.setSerializedContent(markdown);
|
||||
},
|
||||
});
|
||||
this.contentEditor.setEditable(false);
|
||||
this.notifyLoadingError();
|
||||
}
|
||||
},
|
||||
focus() {
|
||||
this.focused = true;
|
||||
},
|
||||
blur() {
|
||||
this.focused = false;
|
||||
},
|
||||
notifyLoading() {
|
||||
this.isLoading = true;
|
||||
this.$emit('loading');
|
||||
},
|
||||
notifyLoadingSuccess() {
|
||||
this.isLoading = false;
|
||||
this.$emit('loadingSuccess');
|
||||
},
|
||||
notifyLoadingError(error) {
|
||||
this.isLoading = false;
|
||||
this.$emit('loadingError', error);
|
||||
},
|
||||
notifyChange() {
|
||||
this.latestMarkdown = this.contentEditor.getSerializedContent();
|
||||
|
||||
this.$emit('change', {
|
||||
empty: this.contentEditor.empty,
|
||||
changed: this.contentEditor.changed,
|
||||
markdown: this.latestMarkdown,
|
||||
});
|
||||
},
|
||||
},
|
||||
|
|
@ -84,14 +141,7 @@ export default {
|
|||
<template>
|
||||
<content-editor-provider :content-editor="contentEditor">
|
||||
<div>
|
||||
<editor-state-observer
|
||||
@docUpdate="notifyChange"
|
||||
@focus="focus"
|
||||
@blur="blur"
|
||||
@loading="$emit('loading')"
|
||||
@loadingSuccess="$emit('loadingSuccess')"
|
||||
@loadingError="$emit('loadingError')"
|
||||
/>
|
||||
<editor-state-observer @docUpdate="notifyChange" @focus="focus" @blur="blur" />
|
||||
<content-editor-alert />
|
||||
<div
|
||||
data-testid="content-editor"
|
||||
|
|
@ -110,7 +160,7 @@ export default {
|
|||
data-testid="content_editor_editablebox"
|
||||
:editor="contentEditor.tiptapEditor"
|
||||
/>
|
||||
<loading-indicator />
|
||||
<loading-indicator v-if="isLoading" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -14,19 +14,32 @@ export default {
|
|||
};
|
||||
},
|
||||
methods: {
|
||||
displayAlert({ message, variant }) {
|
||||
displayAlert({ message, variant, action, actionLabel }) {
|
||||
this.message = message;
|
||||
this.variant = variant;
|
||||
this.action = action;
|
||||
this.actionLabel = actionLabel;
|
||||
},
|
||||
dismissAlert() {
|
||||
this.message = null;
|
||||
},
|
||||
primaryAction() {
|
||||
this.dismissAlert();
|
||||
this.action?.();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<editor-state-observer @alert="displayAlert">
|
||||
<gl-alert v-if="message" class="gl-mb-6" :variant="variant" @dismiss="dismissAlert">
|
||||
<gl-alert
|
||||
v-if="message"
|
||||
class="gl-mb-6"
|
||||
:variant="variant"
|
||||
:primary-button-text="actionLabel"
|
||||
@dismiss="dismissAlert"
|
||||
@primaryAction="primaryAction"
|
||||
>
|
||||
{{ message }}
|
||||
</gl-alert>
|
||||
</editor-state-observer>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,6 @@
|
|||
<script>
|
||||
import { debounce } from 'lodash';
|
||||
import {
|
||||
LOADING_CONTENT_EVENT,
|
||||
LOADING_SUCCESS_EVENT,
|
||||
LOADING_ERROR_EVENT,
|
||||
ALERT_EVENT,
|
||||
} from '../constants';
|
||||
import { ALERT_EVENT } from '../constants';
|
||||
|
||||
export const tiptapToComponentMap = {
|
||||
update: 'docUpdate',
|
||||
|
|
@ -15,12 +10,7 @@ export const tiptapToComponentMap = {
|
|||
blur: 'blur',
|
||||
};
|
||||
|
||||
export const eventHubEvents = [
|
||||
ALERT_EVENT,
|
||||
LOADING_CONTENT_EVENT,
|
||||
LOADING_SUCCESS_EVENT,
|
||||
LOADING_ERROR_EVENT,
|
||||
];
|
||||
export const eventHubEvents = [ALERT_EVENT];
|
||||
|
||||
const getComponentEventName = (tiptapEventName) => tiptapToComponentMap[tiptapEventName];
|
||||
|
||||
|
|
|
|||
|
|
@ -1,40 +1,18 @@
|
|||
<script>
|
||||
import { GlLoadingIcon } from '@gitlab/ui';
|
||||
import EditorStateObserver from './editor_state_observer.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
GlLoadingIcon,
|
||||
EditorStateObserver,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isLoading: false,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
displayLoadingIndicator() {
|
||||
this.isLoading = true;
|
||||
},
|
||||
hideLoadingIndicator() {
|
||||
this.isLoading = false;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<editor-state-observer
|
||||
@loading="displayLoadingIndicator"
|
||||
@loadingSuccess="hideLoadingIndicator"
|
||||
@loadingError="hideLoadingIndicator"
|
||||
<div
|
||||
data-testid="content-editor-loading-indicator"
|
||||
class="gl-w-full gl-display-flex gl-justify-content-center gl-align-items-center gl-absolute gl-top-0 gl-bottom-0"
|
||||
>
|
||||
<div
|
||||
v-if="isLoading"
|
||||
data-testid="content-editor-loading-indicator"
|
||||
class="gl-w-full gl-display-flex gl-justify-content-center gl-align-items-center gl-absolute gl-top-0 gl-bottom-0"
|
||||
>
|
||||
<div class="gl-bg-white gl-absolute gl-w-full gl-h-full gl-opacity-3"></div>
|
||||
<gl-loading-icon size="lg" />
|
||||
</div>
|
||||
</editor-state-observer>
|
||||
<div class="gl-bg-white gl-absolute gl-w-full gl-h-full gl-opacity-3"></div>
|
||||
<gl-loading-icon size="lg" />
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
import { LOADING_CONTENT_EVENT, LOADING_SUCCESS_EVENT, LOADING_ERROR_EVENT } from '../constants';
|
||||
|
||||
/* eslint-disable no-underscore-dangle */
|
||||
export class ContentEditor {
|
||||
constructor({ tiptapEditor, serializer, deserializer, assetResolver, eventHub }) {
|
||||
|
|
@ -20,14 +18,19 @@ export class ContentEditor {
|
|||
}
|
||||
|
||||
get changed() {
|
||||
return this._pristineDoc?.eq(this.tiptapEditor.state.doc);
|
||||
if (!this._pristineDoc) {
|
||||
return !this.empty;
|
||||
}
|
||||
|
||||
return !this._pristineDoc.eq(this.tiptapEditor.state.doc);
|
||||
}
|
||||
|
||||
get empty() {
|
||||
const doc = this.tiptapEditor?.state.doc;
|
||||
return this.tiptapEditor.isEmpty;
|
||||
}
|
||||
|
||||
// Makes sure the document has more than one empty paragraph
|
||||
return doc.childCount === 0 || (doc.childCount === 1 && doc.child(0).childCount === 0);
|
||||
get editable() {
|
||||
return this.tiptapEditor.isEditable;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
|
|
@ -55,24 +58,22 @@ export class ContentEditor {
|
|||
return this._assetResolver.renderDiagram(code, language);
|
||||
}
|
||||
|
||||
setEditable(editable = true) {
|
||||
this._tiptapEditor.setOptions({
|
||||
editable,
|
||||
});
|
||||
}
|
||||
|
||||
async setSerializedContent(serializedContent) {
|
||||
const { _tiptapEditor: editor, _eventHub: eventHub } = this;
|
||||
const { _tiptapEditor: editor } = this;
|
||||
const { doc, tr } = editor.state;
|
||||
|
||||
try {
|
||||
eventHub.$emit(LOADING_CONTENT_EVENT);
|
||||
const { document } = await this.deserialize(serializedContent);
|
||||
const { document } = await this.deserialize(serializedContent);
|
||||
|
||||
if (document) {
|
||||
this._pristineDoc = document;
|
||||
tr.replaceWith(0, doc.content.size, document).setMeta('preventUpdate', true);
|
||||
editor.view.dispatch(tr);
|
||||
}
|
||||
|
||||
eventHub.$emit(LOADING_SUCCESS_EVENT);
|
||||
} catch (e) {
|
||||
eventHub.$emit(LOADING_ERROR_EVENT, e);
|
||||
throw e;
|
||||
if (document) {
|
||||
this._pristineDoc = document;
|
||||
tr.replaceWith(0, doc.content.size, document).setMeta('preventUpdate', true);
|
||||
editor.view.dispatch(tr);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,72 @@
|
|||
import createFlash from '~/flash';
|
||||
import axios from '~/lib/utils/axios_utils';
|
||||
import { __ } from '~/locale';
|
||||
|
||||
import { GitLabDropdown } from '~/deprecated_jquery_dropdown/gl_dropdown';
|
||||
|
||||
import initMergeRequest from '~/pages/projects/merge_requests/init_merge_request';
|
||||
import initCheckFormState from './check_form_state';
|
||||
|
||||
function initTargetBranchSelector() {
|
||||
const targetBranch = document.querySelector('.js-target-branch');
|
||||
const { selected, fieldName, refsUrl } = targetBranch?.dataset ?? {};
|
||||
const formField = document.querySelector(`input[name="${fieldName}"]`);
|
||||
|
||||
if (targetBranch && refsUrl && formField) {
|
||||
/* eslint-disable-next-line no-new */
|
||||
new GitLabDropdown(targetBranch, {
|
||||
selectable: true,
|
||||
filterable: true,
|
||||
filterRemote: Boolean(refsUrl),
|
||||
filterInput: 'input[type="search"]',
|
||||
data(term, callback) {
|
||||
const params = {
|
||||
search: term,
|
||||
};
|
||||
|
||||
axios
|
||||
.get(refsUrl, {
|
||||
params,
|
||||
})
|
||||
.then(({ data }) => {
|
||||
callback(data);
|
||||
})
|
||||
.catch(() =>
|
||||
createFlash({
|
||||
message: __('Error fetching branches'),
|
||||
}),
|
||||
);
|
||||
},
|
||||
renderRow(branch) {
|
||||
const item = document.createElement('li');
|
||||
const link = document.createElement('a');
|
||||
|
||||
link.setAttribute('href', '#');
|
||||
link.dataset.branch = branch;
|
||||
link.classList.toggle('is-active', branch === selected);
|
||||
link.textContent = branch;
|
||||
|
||||
item.appendChild(link);
|
||||
|
||||
return item;
|
||||
},
|
||||
id(obj, $el) {
|
||||
return $el.data('id');
|
||||
},
|
||||
toggleLabel(obj, $el) {
|
||||
return $el.text().trim();
|
||||
},
|
||||
clicked({ $el, e }) {
|
||||
e.preventDefault();
|
||||
|
||||
const branchName = $el[0].dataset.branch;
|
||||
|
||||
formField.setAttribute('value', branchName);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
initMergeRequest();
|
||||
initCheckFormState();
|
||||
initTargetBranchSelector();
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import {
|
|||
GlLink,
|
||||
GlButton,
|
||||
GlSprintf,
|
||||
GlAlert,
|
||||
GlFormGroup,
|
||||
GlFormInput,
|
||||
GlFormSelect,
|
||||
|
|
@ -59,14 +58,6 @@ export default {
|
|||
label: s__('WikiPage|Content'),
|
||||
placeholder: s__('WikiPage|Write your content or drag files here…'),
|
||||
},
|
||||
contentEditor: {
|
||||
renderFailed: {
|
||||
message: s__(
|
||||
'WikiPage|An error occurred while trying to render the content editor. Please try again later.',
|
||||
),
|
||||
primaryAction: s__('WikiPage|Retry'),
|
||||
},
|
||||
},
|
||||
linksHelpText: s__(
|
||||
'WikiPage|To link to a (new) page, simply type %{linkExample}. More examples are in the %{linkStart}documentation%{linkEnd}.',
|
||||
),
|
||||
|
|
@ -88,7 +79,6 @@ export default {
|
|||
{ text: s__('Wiki Page|Rich text'), value: 'richText' },
|
||||
],
|
||||
components: {
|
||||
GlAlert,
|
||||
GlIcon,
|
||||
GlForm,
|
||||
GlFormGroup,
|
||||
|
|
@ -115,14 +105,12 @@ export default {
|
|||
content: this.pageInfo.content || '',
|
||||
commitMessage: '',
|
||||
isDirty: false,
|
||||
contentEditorRenderFailed: false,
|
||||
contentEditorEmpty: false,
|
||||
switchEditingControlDisabled: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
noContent() {
|
||||
if (this.isContentEditorActive) return this.contentEditorEmpty;
|
||||
return !this.content.trim();
|
||||
},
|
||||
csrfToken() {
|
||||
|
|
@ -145,11 +133,6 @@ export default {
|
|||
linkExample() {
|
||||
return MARKDOWN_LINK_TEXT[this.format];
|
||||
},
|
||||
toggleEditingModeButtonText() {
|
||||
return this.isContentEditorActive
|
||||
? this.$options.i18n.editSourceButtonText
|
||||
: this.$options.i18n.editRichTextButtonText;
|
||||
},
|
||||
submitButtonText() {
|
||||
return this.pageInfo.persisted
|
||||
? this.$options.i18n.submitButton.existingPage
|
||||
|
|
@ -177,7 +160,7 @@ export default {
|
|||
return !this.isContentEditorActive;
|
||||
},
|
||||
disableSubmitButton() {
|
||||
return this.noContent || !this.title || this.contentEditorRenderFailed;
|
||||
return this.noContent || !this.title;
|
||||
},
|
||||
isContentEditorActive() {
|
||||
return this.isMarkdownFormat && this.useContentEditor;
|
||||
|
|
@ -201,23 +184,14 @@ export default {
|
|||
.then(({ data }) => data.body);
|
||||
},
|
||||
|
||||
toggleEditingMode(editingMode) {
|
||||
setEditingMode(editingMode) {
|
||||
this.editingMode = editingMode;
|
||||
if (!this.useContentEditor && this.contentEditor) {
|
||||
this.content = this.contentEditor.getSerializedContent();
|
||||
}
|
||||
},
|
||||
|
||||
setEditingMode(value) {
|
||||
this.editingMode = value;
|
||||
},
|
||||
|
||||
async handleFormSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
if (this.useContentEditor) {
|
||||
this.content = this.contentEditor.getSerializedContent();
|
||||
|
||||
this.trackFormSubmit();
|
||||
}
|
||||
|
||||
|
|
@ -235,30 +209,10 @@ export default {
|
|||
this.isDirty = true;
|
||||
},
|
||||
|
||||
async loadInitialContent(contentEditor) {
|
||||
this.contentEditor = contentEditor;
|
||||
|
||||
try {
|
||||
await this.contentEditor.setSerializedContent(this.content);
|
||||
this.trackContentEditorLoaded();
|
||||
} catch (e) {
|
||||
this.contentEditorRenderFailed = true;
|
||||
}
|
||||
},
|
||||
|
||||
async retryInitContentEditor() {
|
||||
try {
|
||||
this.contentEditorRenderFailed = false;
|
||||
await this.contentEditor.setSerializedContent(this.content);
|
||||
} catch (e) {
|
||||
this.contentEditorRenderFailed = true;
|
||||
}
|
||||
},
|
||||
|
||||
handleContentEditorChange({ empty }) {
|
||||
handleContentEditorChange({ empty, markdown, changed }) {
|
||||
this.contentEditorEmpty = empty;
|
||||
// TODO: Implement a precise mechanism to detect changes in the Content
|
||||
this.isDirty = true;
|
||||
this.isDirty = changed;
|
||||
this.content = markdown;
|
||||
},
|
||||
|
||||
onPageUnload(event) {
|
||||
|
|
@ -320,17 +274,6 @@ export default {
|
|||
class="wiki-form common-note-form gl-mt-3 js-quick-submit"
|
||||
@submit="handleFormSubmit"
|
||||
>
|
||||
<gl-alert
|
||||
v-if="isContentEditorActive && contentEditorRenderFailed"
|
||||
class="gl-mb-6"
|
||||
:dismissible="false"
|
||||
variant="danger"
|
||||
:primary-button-text="$options.i18n.contentEditor.renderFailed.primaryAction"
|
||||
@primaryAction="retryInitContentEditor"
|
||||
>
|
||||
{{ $options.i18n.contentEditor.renderFailed.message }}
|
||||
</gl-alert>
|
||||
|
||||
<input :value="csrfToken" type="hidden" name="authenticity_token" />
|
||||
<input v-if="pageInfo.persisted" type="hidden" name="_method" value="put" />
|
||||
<input
|
||||
|
|
@ -350,7 +293,6 @@ export default {
|
|||
{{ $options.i18n.title.helpText.learnMore }}
|
||||
</gl-link>
|
||||
</template>
|
||||
|
||||
<gl-form-input
|
||||
id="wiki_title"
|
||||
v-model="title"
|
||||
|
|
@ -395,7 +337,7 @@ export default {
|
|||
:checked="editingMode"
|
||||
:options="$options.switchEditingControlOptions"
|
||||
:disabled="switchEditingControlDisabled"
|
||||
@input="toggleEditingMode"
|
||||
@input="setEditingMode"
|
||||
/>
|
||||
</div>
|
||||
<local-storage-sync
|
||||
|
|
@ -436,7 +378,8 @@ export default {
|
|||
<content-editor
|
||||
:render-markdown="renderMarkdown"
|
||||
:uploads-path="pageInfo.uploadsPath"
|
||||
@initialized="loadInitialContent"
|
||||
:markdown="content"
|
||||
@initialized="trackContentEditorLoaded"
|
||||
@change="handleContentEditorChange"
|
||||
@loading="disableSwitchEditingControl"
|
||||
@loadingSuccess="enableSwitchEditingControl"
|
||||
|
|
|
|||
|
|
@ -11,6 +11,10 @@ module Resolvers
|
|||
required: false,
|
||||
description: 'Search query.'
|
||||
|
||||
argument :sort, ::Types::MemberSortEnum,
|
||||
required: false,
|
||||
description: 'sort query.'
|
||||
|
||||
def resolve_with_lookahead(**args)
|
||||
authorize!(object)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Types
|
||||
class MemberSortEnum < SortEnum
|
||||
graphql_name 'MemberSort'
|
||||
description 'Values for sorting members'
|
||||
|
||||
value 'ACCESS_LEVEL_ASC', 'Access level ascending order.', value: :access_level_asc
|
||||
value 'ACCESS_LEVEL_DESC', 'Access level descending order.', value: :access_level_desc
|
||||
value 'USER_FULL_NAME_ASC', "User's full name ascending order.", value: :name_asc
|
||||
value 'USER_FULL_NAME_DESC', "User's full name descending order.", value: :name_desc
|
||||
end
|
||||
end
|
||||
|
|
@ -21,7 +21,7 @@ module LearnGitlabHelper
|
|||
end
|
||||
|
||||
def learn_gitlab_onboarding_available?(project)
|
||||
OnboardingProgress.onboarding?(project.namespace) &&
|
||||
Onboarding::Progress.onboarding?(project.namespace) &&
|
||||
LearnGitlab::Project.new(current_user).available?
|
||||
end
|
||||
|
||||
|
|
@ -34,7 +34,7 @@ module LearnGitlabHelper
|
|||
[
|
||||
action,
|
||||
url: url,
|
||||
completed: attributes[OnboardingProgress.column_name(action)].present?,
|
||||
completed: attributes[Onboarding::Progress.column_name(action)].present?,
|
||||
svg: image_path("learn_gitlab/#{action}.svg"),
|
||||
enabled: true
|
||||
]
|
||||
|
|
@ -95,7 +95,7 @@ module LearnGitlabHelper
|
|||
end
|
||||
|
||||
def onboarding_progress(project)
|
||||
OnboardingProgress.find_by(namespace: project.namespace) # rubocop: disable CodeReuse/ActiveRecord
|
||||
Onboarding::Progress.find_by(namespace: project.namespace) # rubocop: disable CodeReuse/ActiveRecord
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -611,7 +611,7 @@ module Ci
|
|||
|
||||
if cascade_to_children
|
||||
# cancel any bridges that could spin up new child pipelines
|
||||
cancel_jobs(bridges_in_self_and_descendants.cancelable, retries: retries, auto_canceled_by_pipeline_id: auto_canceled_by_pipeline_id)
|
||||
cancel_jobs(bridges_in_self_and_project_descendants.cancelable, retries: retries, auto_canceled_by_pipeline_id: auto_canceled_by_pipeline_id)
|
||||
cancel_children(auto_canceled_by_pipeline_id: auto_canceled_by_pipeline_id, execute_async: execute_async)
|
||||
end
|
||||
end
|
||||
|
|
@ -943,26 +943,26 @@ module Ci
|
|||
).base_and_descendants.select(:id)
|
||||
end
|
||||
|
||||
def build_with_artifacts_in_self_and_descendants(name)
|
||||
builds_in_self_and_descendants
|
||||
def build_with_artifacts_in_self_and_project_descendants(name)
|
||||
builds_in_self_and_project_descendants
|
||||
.ordered_by_pipeline # find job in hierarchical order
|
||||
.with_downloadable_artifacts
|
||||
.find_by_name(name)
|
||||
end
|
||||
|
||||
def builds_in_self_and_descendants
|
||||
Ci::Build.latest.where(pipeline: self_and_descendants)
|
||||
def builds_in_self_and_project_descendants
|
||||
Ci::Build.latest.where(pipeline: self_and_project_descendants)
|
||||
end
|
||||
|
||||
def bridges_in_self_and_descendants
|
||||
Ci::Bridge.latest.where(pipeline: self_and_descendants)
|
||||
def bridges_in_self_and_project_descendants
|
||||
Ci::Bridge.latest.where(pipeline: self_and_project_descendants)
|
||||
end
|
||||
|
||||
def environments_in_self_and_descendants(deployment_status: nil)
|
||||
def environments_in_self_and_project_descendants(deployment_status: nil)
|
||||
# We limit to 100 unique environments for application safety.
|
||||
# See: https://gitlab.com/gitlab-org/gitlab/-/issues/340781#note_699114700
|
||||
expanded_environment_names =
|
||||
builds_in_self_and_descendants.joins(:metadata)
|
||||
builds_in_self_and_project_descendants.joins(:metadata)
|
||||
.where.not('ci_builds_metadata.expanded_environment_name' => nil)
|
||||
.distinct('ci_builds_metadata.expanded_environment_name')
|
||||
.limit(100)
|
||||
|
|
@ -977,17 +977,17 @@ module Ci
|
|||
end
|
||||
|
||||
# With multi-project and parent-child pipelines
|
||||
def all_pipelines_in_hierarchy
|
||||
def upstream_and_all_downstreams
|
||||
object_hierarchy.all_objects
|
||||
end
|
||||
|
||||
# With only parent-child pipelines
|
||||
def self_and_ancestors
|
||||
def self_and_project_ancestors
|
||||
object_hierarchy(project_condition: :same).base_and_ancestors
|
||||
end
|
||||
|
||||
# With only parent-child pipelines
|
||||
def self_and_descendants
|
||||
def self_and_project_descendants
|
||||
object_hierarchy(project_condition: :same).base_and_descendants
|
||||
end
|
||||
|
||||
|
|
@ -996,8 +996,8 @@ module Ci
|
|||
object_hierarchy(project_condition: :same).descendants
|
||||
end
|
||||
|
||||
def self_and_descendants_complete?
|
||||
self_and_descendants.all?(&:complete?)
|
||||
def self_and_project_descendants_complete?
|
||||
self_and_project_descendants.all?(&:complete?)
|
||||
end
|
||||
|
||||
# Follow the parent-child relationships and return the top-level parent
|
||||
|
|
@ -1061,8 +1061,8 @@ module Ci
|
|||
latest_report_builds(Ci::JobArtifact.of_report_type(:test)).preload(:project, :metadata)
|
||||
end
|
||||
|
||||
def latest_report_builds_in_self_and_descendants(reports_scope = ::Ci::JobArtifact.all_reports)
|
||||
builds_in_self_and_descendants.with_artifacts(reports_scope)
|
||||
def latest_report_builds_in_self_and_project_descendants(reports_scope = ::Ci::JobArtifact.all_reports)
|
||||
builds_in_self_and_project_descendants.with_artifacts(reports_scope)
|
||||
end
|
||||
|
||||
def builds_with_coverage
|
||||
|
|
|
|||
|
|
@ -359,14 +359,6 @@ module Ci
|
|||
runner_projects.limit(2).count(:all) > 1
|
||||
end
|
||||
|
||||
def assigned_to_group?
|
||||
runner_namespaces.any?
|
||||
end
|
||||
|
||||
def assigned_to_project?
|
||||
runner_projects.any?
|
||||
end
|
||||
|
||||
def match_build_if_online?(build)
|
||||
active? && online? && matches_build?(build)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ module Integrations
|
|||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
after_save :update_web_hook!, if: :activated?
|
||||
has_one :service_hook, inverse_of: :integration, foreign_key: :service_id
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -43,6 +43,33 @@ module Sortable
|
|||
}
|
||||
end
|
||||
|
||||
def build_keyset_order_on_joined_column(scope:, attribute_name:, column:, direction:, nullable:)
|
||||
reversed_direction = direction == :asc ? :desc : :asc
|
||||
|
||||
# rubocop: disable GitlabSecurity/PublicSend
|
||||
order = ::Gitlab::Pagination::Keyset::Order.build(
|
||||
[
|
||||
::Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
|
||||
attribute_name: attribute_name,
|
||||
column_expression: column,
|
||||
order_expression: column.send(direction).send(nullable),
|
||||
reversed_order_expression: column.send(reversed_direction).send(nullable),
|
||||
order_direction: direction,
|
||||
distinct: false,
|
||||
add_to_projections: true,
|
||||
nullable: nullable
|
||||
),
|
||||
::Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
|
||||
attribute_name: 'id',
|
||||
order_expression: arel_table['id'].desc
|
||||
)
|
||||
]
|
||||
)
|
||||
# rubocop: enable GitlabSecurity/PublicSend
|
||||
|
||||
order.apply_cursor_conditions(scope).reorder(order)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def highest_label_priority(target_type_column: nil, target_type: nil, target_column:, project_column:, excluded_labels: [])
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ class Environment < ApplicationRecord
|
|||
# Search environments which have names like the given query.
|
||||
# Do not set a large limit unless you've confirmed that it works on gitlab.com scale.
|
||||
scope :for_name_like, -> (query, limit: 5) do
|
||||
where(arel_table[:name].matches("#{sanitize_sql_like query}%")).limit(limit)
|
||||
where('LOWER(environments.name) LIKE LOWER(?) || \'%\'', sanitize_sql_like(query)).limit(limit)
|
||||
end
|
||||
|
||||
scope :for_project, -> (project) { where(project_id: project) }
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ class EnvironmentStatus
|
|||
def self.build_environments_status(mr, user, pipeline)
|
||||
return [] unless pipeline
|
||||
|
||||
pipeline.environments_in_self_and_descendants.includes(:project).available.map do |environment|
|
||||
pipeline.environments_in_self_and_project_descendants.includes(:project).available.map do |environment|
|
||||
next unless Ability.allowed?(user, :read_environment, environment)
|
||||
|
||||
EnvironmentStatus.new(pipeline.project, environment, mr, pipeline.sha)
|
||||
|
|
|
|||
|
|
@ -8,8 +8,6 @@ module Integrations
|
|||
include ReactivelyCached
|
||||
extend Gitlab::Utils::Override
|
||||
|
||||
after_save :ensure_ssl_verification
|
||||
|
||||
ENDPOINT = "https://buildkite.com"
|
||||
|
||||
field :project_url,
|
||||
|
|
@ -50,12 +48,6 @@ module Integrations
|
|||
self.properties = properties.except('enable_ssl_verification') # Remove unused key
|
||||
end
|
||||
|
||||
def ensure_ssl_verification
|
||||
return unless service_hook
|
||||
|
||||
update_web_hook!
|
||||
end
|
||||
|
||||
override :hook_url
|
||||
def hook_url
|
||||
"#{buildkite_endpoint('webhook')}/deliver/#{webhook_token}"
|
||||
|
|
|
|||
|
|
@ -254,32 +254,6 @@ class Issue < ApplicationRecord
|
|||
alias_method :with_state, :with_state_id
|
||||
alias_method :with_states, :with_state_ids
|
||||
|
||||
def build_keyset_order_on_joined_column(scope:, attribute_name:, column:, direction:, nullable:)
|
||||
reversed_direction = direction == :asc ? :desc : :asc
|
||||
|
||||
# rubocop: disable GitlabSecurity/PublicSend
|
||||
order = ::Gitlab::Pagination::Keyset::Order.build(
|
||||
[
|
||||
::Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
|
||||
attribute_name: attribute_name,
|
||||
column_expression: column,
|
||||
order_expression: column.send(direction).send(nullable),
|
||||
reversed_order_expression: column.send(reversed_direction).send(nullable),
|
||||
order_direction: direction,
|
||||
distinct: false,
|
||||
add_to_projections: true,
|
||||
nullable: nullable
|
||||
),
|
||||
::Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
|
||||
attribute_name: 'id',
|
||||
order_expression: arel_table['id'].desc
|
||||
)
|
||||
])
|
||||
# rubocop: enable GitlabSecurity/PublicSend
|
||||
|
||||
order.apply_cursor_conditions(scope).order(order)
|
||||
end
|
||||
|
||||
override :order_upvotes_desc
|
||||
def order_upvotes_desc
|
||||
reorder(upvotes_count: :desc)
|
||||
|
|
|
|||
|
|
@ -184,14 +184,85 @@ class Member < ApplicationRecord
|
|||
unscoped.from(distinct_members, :members)
|
||||
end
|
||||
|
||||
scope :order_name_asc, -> { left_join_users.reorder(User.arel_table[:name].asc.nulls_last) }
|
||||
scope :order_name_desc, -> { left_join_users.reorder(User.arel_table[:name].desc.nulls_last) }
|
||||
scope :order_recent_sign_in, -> { left_join_users.reorder(User.arel_table[:last_sign_in_at].desc.nulls_last) }
|
||||
scope :order_oldest_sign_in, -> { left_join_users.reorder(User.arel_table[:last_sign_in_at].asc.nulls_last) }
|
||||
scope :order_recent_last_activity, -> { left_join_users.reorder(User.arel_table[:last_activity_on].desc.nulls_last) }
|
||||
scope :order_oldest_last_activity, -> { left_join_users.reorder(User.arel_table[:last_activity_on].asc.nulls_first) }
|
||||
scope :order_recent_created_user, -> { left_join_users.reorder(User.arel_table[:created_at].desc.nulls_last) }
|
||||
scope :order_oldest_created_user, -> { left_join_users.reorder(User.arel_table[:created_at].asc.nulls_first) }
|
||||
scope :order_name_asc, -> do
|
||||
build_keyset_order_on_joined_column(
|
||||
scope: left_join_users,
|
||||
attribute_name: 'member_user_full_name',
|
||||
column: User.arel_table[:name],
|
||||
direction: :asc,
|
||||
nullable: :nulls_last
|
||||
)
|
||||
end
|
||||
|
||||
scope :order_name_desc, -> do
|
||||
build_keyset_order_on_joined_column(
|
||||
scope: left_join_users,
|
||||
attribute_name: 'member_user_full_name',
|
||||
column: User.arel_table[:name],
|
||||
direction: :desc,
|
||||
nullable: :nulls_last
|
||||
)
|
||||
end
|
||||
|
||||
scope :order_oldest_sign_in, -> do
|
||||
build_keyset_order_on_joined_column(
|
||||
scope: left_join_users,
|
||||
attribute_name: 'member_user_last_sign_in_at',
|
||||
column: User.arel_table[:last_sign_in_at],
|
||||
direction: :asc,
|
||||
nullable: :nulls_last
|
||||
)
|
||||
end
|
||||
|
||||
scope :order_recent_sign_in, -> do
|
||||
build_keyset_order_on_joined_column(
|
||||
scope: left_join_users,
|
||||
attribute_name: 'member_user_last_sign_in_at',
|
||||
column: User.arel_table[:last_sign_in_at],
|
||||
direction: :desc,
|
||||
nullable: :nulls_last
|
||||
)
|
||||
end
|
||||
|
||||
scope :order_oldest_last_activity, -> do
|
||||
build_keyset_order_on_joined_column(
|
||||
scope: left_join_users,
|
||||
attribute_name: 'member_user_last_activity_on',
|
||||
column: User.arel_table[:last_activity_on],
|
||||
direction: :asc,
|
||||
nullable: :nulls_first
|
||||
)
|
||||
end
|
||||
|
||||
scope :order_recent_last_activity, -> do
|
||||
build_keyset_order_on_joined_column(
|
||||
scope: left_join_users,
|
||||
attribute_name: 'member_user_last_activity_on',
|
||||
column: User.arel_table[:last_activity_on],
|
||||
direction: :desc,
|
||||
nullable: :nulls_last
|
||||
)
|
||||
end
|
||||
|
||||
scope :order_oldest_created_user, -> do
|
||||
build_keyset_order_on_joined_column(
|
||||
scope: left_join_users,
|
||||
attribute_name: 'member_user_created_at',
|
||||
column: User.arel_table[:created_at],
|
||||
direction: :asc,
|
||||
nullable: :nulls_first
|
||||
)
|
||||
end
|
||||
|
||||
scope :order_recent_created_user, -> do
|
||||
build_keyset_order_on_joined_column(
|
||||
scope: left_join_users,
|
||||
attribute_name: 'member_user_created_at',
|
||||
column: User.arel_table[:created_at],
|
||||
direction: :desc,
|
||||
nullable: :nulls_last
|
||||
)
|
||||
end
|
||||
|
||||
scope :on_project_and_ancestors, ->(project) { where(source: [project] + project.ancestors) }
|
||||
|
||||
|
|
|
|||
|
|
@ -1466,7 +1466,7 @@ class MergeRequest < ApplicationRecord
|
|||
end
|
||||
|
||||
def environments_in_head_pipeline(deployment_status: nil)
|
||||
actual_head_pipeline&.environments_in_self_and_descendants(deployment_status: deployment_status) || Environment.none
|
||||
actual_head_pipeline&.environments_in_self_and_project_descendants(deployment_status: deployment_status) || Environment.none
|
||||
end
|
||||
|
||||
def fetch_ref!
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ class Namespace < ApplicationRecord
|
|||
has_many :runner_namespaces, inverse_of: :namespace, class_name: 'Ci::RunnerNamespace'
|
||||
has_many :runners, through: :runner_namespaces, source: :runner, class_name: 'Ci::Runner'
|
||||
has_many :pending_builds, class_name: 'Ci::PendingBuild'
|
||||
has_one :onboarding_progress
|
||||
has_one :onboarding_progress, class_name: 'Onboarding::Progress'
|
||||
|
||||
# This should _not_ be `inverse_of: :namespace`, because that would also set
|
||||
# `user.namespace` when this user creates a group with themselves as `owner`.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,118 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Onboarding
|
||||
class Progress < ApplicationRecord
|
||||
self.table_name = 'onboarding_progresses'
|
||||
|
||||
belongs_to :namespace, optional: false
|
||||
|
||||
validate :namespace_is_root_namespace
|
||||
|
||||
ACTIONS = [
|
||||
:git_pull,
|
||||
:git_write,
|
||||
:merge_request_created,
|
||||
:pipeline_created,
|
||||
:user_added,
|
||||
:trial_started,
|
||||
:subscription_created,
|
||||
:required_mr_approvals_enabled,
|
||||
:code_owners_enabled,
|
||||
:scoped_label_created,
|
||||
:security_scan_enabled,
|
||||
:issue_created,
|
||||
:issue_auto_closed,
|
||||
:repository_imported,
|
||||
:repository_mirrored,
|
||||
:secure_dependency_scanning_run,
|
||||
:secure_container_scanning_run,
|
||||
:secure_dast_run,
|
||||
:secure_secret_detection_run,
|
||||
:secure_coverage_fuzzing_run,
|
||||
:secure_api_fuzzing_run,
|
||||
:secure_cluster_image_scanning_run,
|
||||
:license_scanning_run
|
||||
].freeze
|
||||
|
||||
scope :incomplete_actions, ->(actions) do
|
||||
Array.wrap(actions).inject(self) { |scope, action| scope.where(column_name(action) => nil) }
|
||||
end
|
||||
|
||||
scope :completed_actions, ->(actions) do
|
||||
Array.wrap(actions).inject(self) { |scope, action| scope.where.not(column_name(action) => nil) }
|
||||
end
|
||||
|
||||
scope :completed_actions_with_latest_in_range, ->(actions, range) do
|
||||
actions = Array(actions)
|
||||
if actions.size == 1
|
||||
where(column_name(actions[0]) => range)
|
||||
else
|
||||
action_columns = actions.map { |action| arel_table[column_name(action)] }
|
||||
completed_actions(actions).where(Arel::Nodes::NamedFunction.new('GREATEST', action_columns).between(range))
|
||||
end
|
||||
end
|
||||
|
||||
class << self
|
||||
def onboard(namespace)
|
||||
return unless root_namespace?(namespace)
|
||||
|
||||
create(namespace: namespace)
|
||||
end
|
||||
|
||||
def onboarding?(namespace)
|
||||
where(namespace: namespace).any?
|
||||
end
|
||||
|
||||
def register(namespace, actions)
|
||||
actions = Array(actions)
|
||||
return unless root_namespace?(namespace) && actions.difference(ACTIONS).empty?
|
||||
|
||||
onboarding_progress = find_by(namespace: namespace)
|
||||
return unless onboarding_progress
|
||||
|
||||
now = Time.current
|
||||
nil_actions = actions.select { |action| onboarding_progress[column_name(action)].nil? }
|
||||
return if nil_actions.empty?
|
||||
|
||||
updates = nil_actions.inject({}) { |sum, action| sum.merge!({ column_name(action) => now }) }
|
||||
onboarding_progress.update!(updates)
|
||||
end
|
||||
|
||||
def completed?(namespace, action)
|
||||
return unless root_namespace?(namespace) && ACTIONS.include?(action)
|
||||
|
||||
action_column = column_name(action)
|
||||
where(namespace: namespace).where.not(action_column => nil).exists?
|
||||
end
|
||||
|
||||
def not_completed?(namespace_id, action)
|
||||
return unless ACTIONS.include?(action)
|
||||
|
||||
action_column = column_name(action)
|
||||
exists?(namespace_id: namespace_id, action_column => nil)
|
||||
end
|
||||
|
||||
def column_name(action)
|
||||
:"#{action}_at"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def root_namespace?(namespace)
|
||||
namespace&.root?
|
||||
end
|
||||
end
|
||||
|
||||
def number_of_completed_actions
|
||||
attributes.extract!(*ACTIONS.map { |action| self.class.column_name(action).to_s }).compact!.size
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def namespace_is_root_namespace
|
||||
return unless namespace
|
||||
|
||||
errors.add(:namespace, _('must be a root namespace')) if namespace.has_parent?
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,114 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class OnboardingProgress < ApplicationRecord
|
||||
belongs_to :namespace, optional: false
|
||||
|
||||
validate :namespace_is_root_namespace
|
||||
|
||||
ACTIONS = [
|
||||
:git_pull,
|
||||
:git_write,
|
||||
:merge_request_created,
|
||||
:pipeline_created,
|
||||
:user_added,
|
||||
:trial_started,
|
||||
:subscription_created,
|
||||
:required_mr_approvals_enabled,
|
||||
:code_owners_enabled,
|
||||
:scoped_label_created,
|
||||
:security_scan_enabled,
|
||||
:issue_created,
|
||||
:issue_auto_closed,
|
||||
:repository_imported,
|
||||
:repository_mirrored,
|
||||
:secure_dependency_scanning_run,
|
||||
:secure_container_scanning_run,
|
||||
:secure_dast_run,
|
||||
:secure_secret_detection_run,
|
||||
:secure_coverage_fuzzing_run,
|
||||
:secure_api_fuzzing_run,
|
||||
:secure_cluster_image_scanning_run,
|
||||
:license_scanning_run
|
||||
].freeze
|
||||
|
||||
scope :incomplete_actions, -> (actions) do
|
||||
Array.wrap(actions).inject(self) { |scope, action| scope.where(column_name(action) => nil) }
|
||||
end
|
||||
|
||||
scope :completed_actions, -> (actions) do
|
||||
Array.wrap(actions).inject(self) { |scope, action| scope.where.not(column_name(action) => nil) }
|
||||
end
|
||||
|
||||
scope :completed_actions_with_latest_in_range, -> (actions, range) do
|
||||
actions = Array(actions)
|
||||
if actions.size == 1
|
||||
where(column_name(actions[0]) => range)
|
||||
else
|
||||
action_columns = actions.map { |action| arel_table[column_name(action)] }
|
||||
completed_actions(actions).where(Arel::Nodes::NamedFunction.new('GREATEST', action_columns).between(range))
|
||||
end
|
||||
end
|
||||
|
||||
class << self
|
||||
def onboard(namespace)
|
||||
return unless root_namespace?(namespace)
|
||||
|
||||
create(namespace: namespace)
|
||||
end
|
||||
|
||||
def onboarding?(namespace)
|
||||
where(namespace: namespace).any?
|
||||
end
|
||||
|
||||
def register(namespace, actions)
|
||||
actions = Array(actions)
|
||||
return unless root_namespace?(namespace) && actions.difference(ACTIONS).empty?
|
||||
|
||||
onboarding_progress = find_by(namespace: namespace)
|
||||
return unless onboarding_progress
|
||||
|
||||
now = Time.current
|
||||
nil_actions = actions.select { |action| onboarding_progress[column_name(action)].nil? }
|
||||
return if nil_actions.empty?
|
||||
|
||||
updates = nil_actions.inject({}) { |sum, action| sum.merge!({ column_name(action) => now }) }
|
||||
onboarding_progress.update!(updates)
|
||||
end
|
||||
|
||||
def completed?(namespace, action)
|
||||
return unless root_namespace?(namespace) && ACTIONS.include?(action)
|
||||
|
||||
action_column = column_name(action)
|
||||
where(namespace: namespace).where.not(action_column => nil).exists?
|
||||
end
|
||||
|
||||
def not_completed?(namespace_id, action)
|
||||
return unless ACTIONS.include?(action)
|
||||
|
||||
action_column = column_name(action)
|
||||
where(namespace_id: namespace_id).where(action_column => nil).exists?
|
||||
end
|
||||
|
||||
def column_name(action)
|
||||
:"#{action}_at"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def root_namespace?(namespace)
|
||||
namespace && namespace.root?
|
||||
end
|
||||
end
|
||||
|
||||
def number_of_completed_actions
|
||||
attributes.extract!(*ACTIONS.map { |action| self.class.column_name(action).to_s }).compact!.size
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def namespace_is_root_namespace
|
||||
return unless namespace
|
||||
|
||||
errors.add(:namespace, _('must be a root namespace')) if namespace.has_parent?
|
||||
end
|
||||
end
|
||||
|
|
@ -1170,7 +1170,7 @@ class Project < ApplicationRecord
|
|||
latest_pipeline = ci_pipelines.latest_successful_for_ref(ref)
|
||||
return unless latest_pipeline
|
||||
|
||||
latest_pipeline.build_with_artifacts_in_self_and_descendants(job_name)
|
||||
latest_pipeline.build_with_artifacts_in_self_and_project_descendants(job_name)
|
||||
end
|
||||
|
||||
def latest_successful_build_for_sha(job_name, sha)
|
||||
|
|
@ -1179,7 +1179,7 @@ class Project < ApplicationRecord
|
|||
latest_pipeline = ci_pipelines.latest_successful_for_sha(sha)
|
||||
return unless latest_pipeline
|
||||
|
||||
latest_pipeline.build_with_artifacts_in_self_and_descendants(job_name)
|
||||
latest_pipeline.build_with_artifacts_in_self_and_project_descendants(job_name)
|
||||
end
|
||||
|
||||
def latest_successful_build_for_ref!(job_name, ref = default_branch)
|
||||
|
|
|
|||
|
|
@ -137,7 +137,7 @@ module Ci
|
|||
return false unless @bridge.triggers_child_pipeline?
|
||||
|
||||
# only applies to parent-child pipelines not multi-project
|
||||
ancestors_of_new_child = @bridge.pipeline.self_and_ancestors
|
||||
ancestors_of_new_child = @bridge.pipeline.self_and_project_ancestors
|
||||
ancestors_of_new_child.count > MAX_NESTED_CHILDREN
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ module Ci
|
|||
etag_paths << path
|
||||
end
|
||||
|
||||
pipeline.all_pipelines_in_hierarchy.includes(project: [:route, { namespace: :route }]).each do |relative_pipeline| # rubocop: disable CodeReuse/ActiveRecord
|
||||
pipeline.upstream_and_all_downstreams.includes(project: [:route, { namespace: :route }]).each do |relative_pipeline| # rubocop: disable CodeReuse/ActiveRecord
|
||||
etag_paths << project_pipeline_path(relative_pipeline.project, relative_pipeline)
|
||||
etag_paths << graphql_pipeline_path(relative_pipeline)
|
||||
etag_paths << graphql_pipeline_sha_path(relative_pipeline.sha)
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ module Ci
|
|||
end
|
||||
|
||||
def last_update_timestamp(pipeline_hierarchy)
|
||||
pipeline_hierarchy&.self_and_descendants&.maximum(:updated_at)
|
||||
pipeline_hierarchy&.self_and_project_descendants&.maximum(:updated_at)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ module Ci
|
|||
def log_downstream_pipeline_creation(downstream_pipeline)
|
||||
return unless downstream_pipeline&.persisted?
|
||||
|
||||
hierarchy_size = downstream_pipeline.all_pipelines_in_hierarchy.count
|
||||
hierarchy_size = downstream_pipeline.upstream_and_all_downstreams.count
|
||||
root_pipeline = downstream_pipeline.upstream_root
|
||||
|
||||
::Gitlab::AppLogger.info(
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ module Groups
|
|||
if @group.save
|
||||
@group.add_owner(current_user)
|
||||
Integration.create_from_active_default_integrations(@group, :group_id)
|
||||
OnboardingProgress.onboard(@group)
|
||||
Onboarding::Progress.onboard(@group)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ module Namespaces
|
|||
end
|
||||
|
||||
def groups_for_track
|
||||
onboarding_progress_scope = OnboardingProgress
|
||||
onboarding_progress_scope = Onboarding::Progress
|
||||
.completed_actions_with_latest_in_range(completed_actions, range)
|
||||
.incomplete_actions(incomplete_actions)
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ class OnboardingProgressService
|
|||
end
|
||||
|
||||
def execute(action:)
|
||||
return unless OnboardingProgress.not_completed?(namespace_id, action)
|
||||
return unless Onboarding::Progress.not_completed?(namespace_id, action)
|
||||
|
||||
Namespaces::OnboardingProgressWorker.perform_async(namespace_id, action)
|
||||
end
|
||||
|
|
@ -26,6 +26,6 @@ class OnboardingProgressService
|
|||
def execute(action:)
|
||||
return unless @namespace
|
||||
|
||||
OnboardingProgress.register(@namespace, action)
|
||||
Onboarding::Progress.register(@namespace, action)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -31,10 +31,14 @@
|
|||
- if issuable.merged?
|
||||
%code= target_title
|
||||
- unless issuable.new_record? || issuable.merged?
|
||||
%span.dropdown.gl-ml-2.d-inline-block
|
||||
= form.hidden_field(:target_branch,
|
||||
{ class: 'target_branch js-target-branch-select ref-name mw-xl',
|
||||
data: { placeholder: _('Select branch'), endpoint: refs_project_path(@project, sort: 'updated_desc', find: 'branches') }})
|
||||
.merge-request-select.dropdown.gl-w-auto
|
||||
= form.hidden_field :target_branch
|
||||
= dropdown_toggle form.object.target_branch.presence || _("Select branch"), { toggle: "dropdown", 'field-name': "#{form.object_name}[target_branch]", 'refs-url': refs_project_path(@project, sort: 'updated_desc', find: 'branches'), selected: form.object.target_branch, default_text: _("Select branch") }, { toggle_class: "js-compare-dropdown js-target-branch monospace" }
|
||||
.dropdown-menu.dropdown-menu-selectable.js-target-branch-dropdown.target_branch.ref-name.git-revision-dropdown
|
||||
= dropdown_title(_("Select branch"))
|
||||
= dropdown_filter(_("Search branches"))
|
||||
= dropdown_content
|
||||
= dropdown_loading
|
||||
|
||||
- if source_level < target_level
|
||||
= render Pajamas::AlertComponent.new(variant: :warning, dismissible: false, alert_options: { class: 'gl-mb-4' }) do |c|
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ module Ci
|
|||
return unless pipeline
|
||||
|
||||
pipeline.root_ancestor.try do |root_ancestor_pipeline|
|
||||
next unless root_ancestor_pipeline.self_and_descendants_complete?
|
||||
next unless root_ancestor_pipeline.self_and_project_descendants_complete?
|
||||
|
||||
Ci::PipelineArtifacts::CoverageReportService.new(root_ancestor_pipeline).execute
|
||||
end
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
removal_date: "2023-02-22" # the date of the milestone release when this feature is planned to be removed
|
||||
breaking_change: true
|
||||
body: |
|
||||
The certificate-based integration with Kubernetes will be [deprecated and removed](https://about.gitlab.com/blog/2021/11/15/deprecating-the-cert-based-kubernetes-integration/). As a GitLab SaaS customer, on new namespaces, you will no longer be able to integrate GitLab and your cluster using the certificate-based approach as of GitLab 15.0. The integration for current users will be enabled per namespace. The integrations are expected to be switched off completely on GitLab SaaS around 2022 November 22.
|
||||
The certificate-based integration with Kubernetes will be [deprecated and removed](https://about.gitlab.com/blog/2021/11/15/deprecating-the-cert-based-kubernetes-integration/). As a GitLab SaaS customer, on new namespaces, you will no longer be able to integrate GitLab and your cluster using the certificate-based approach as of GitLab 15.0. The integration for current users will be enabled per namespace.
|
||||
|
||||
For a more robust, secure, forthcoming, and reliable integration with Kubernetes, we recommend you use the
|
||||
[agent for Kubernetes](https://docs.gitlab.com/ee/user/clusters/agent/) to connect Kubernetes clusters with GitLab. [How do I migrate?](https://docs.gitlab.com/ee/user/infrastructure/clusters/migrate_to_gitlab_agent.html)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
table_name: onboarding_progresses
|
||||
classes:
|
||||
- OnboardingProgress
|
||||
- Onboarding::Progress
|
||||
feature_categories:
|
||||
- onboarding
|
||||
description: TODO
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AddEnvironmentsProjectNameLowerPatternOpsIndex < Gitlab::Database::Migration[2.0]
|
||||
disable_ddl_transaction!
|
||||
|
||||
INDEX_NAME = 'index_environments_on_project_name_varchar_pattern_ops'
|
||||
|
||||
def up
|
||||
add_concurrent_index :environments, 'project_id, lower(name) varchar_pattern_ops', name: INDEX_NAME
|
||||
end
|
||||
|
||||
def down
|
||||
remove_concurrent_index_by_name :environments, INDEX_NAME
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1 @@
|
|||
c32756c482bdda948f911d0405d2373673041c57ebc514cfc5f172ba6fda9185
|
||||
|
|
@ -28560,6 +28560,8 @@ CREATE INDEX index_environments_on_project_id_and_tier ON environments USING btr
|
|||
|
||||
CREATE INDEX index_environments_on_project_id_state_environment_type ON environments USING btree (project_id, state, environment_type);
|
||||
|
||||
CREATE INDEX index_environments_on_project_name_varchar_pattern_ops ON environments USING btree (project_id, lower((name)::text) varchar_pattern_ops);
|
||||
|
||||
CREATE INDEX index_environments_on_state_and_auto_delete_at ON environments USING btree (auto_delete_at) WHERE ((auto_delete_at IS NOT NULL) AND ((state)::text = 'stopped'::text));
|
||||
|
||||
CREATE INDEX index_environments_on_state_and_auto_stop_at ON environments USING btree (state, auto_stop_at) WHERE ((auto_stop_at IS NOT NULL) AND ((state)::text = 'available'::text));
|
||||
|
|
|
|||
|
|
@ -12682,6 +12682,7 @@ four standard [pagination arguments](#connection-pagination-arguments):
|
|||
| <a id="groupgroupmembersaccesslevels"></a>`accessLevels` | [`[AccessLevelEnum!]`](#accesslevelenum) | Filter members by the given access levels. |
|
||||
| <a id="groupgroupmembersrelations"></a>`relations` | [`[GroupMemberRelation!]`](#groupmemberrelation) | Filter members by the given member relations. |
|
||||
| <a id="groupgroupmemberssearch"></a>`search` | [`String`](#string) | Search query. |
|
||||
| <a id="groupgroupmemberssort"></a>`sort` | [`MemberSort`](#membersort) | sort query. |
|
||||
|
||||
##### `Group.issues`
|
||||
|
||||
|
|
@ -16597,6 +16598,7 @@ four standard [pagination arguments](#connection-pagination-arguments):
|
|||
| ---- | ---- | ----------- |
|
||||
| <a id="projectprojectmembersrelations"></a>`relations` | [`[ProjectMemberRelation!]`](#projectmemberrelation) | Filter members by the given member relations. |
|
||||
| <a id="projectprojectmemberssearch"></a>`search` | [`String`](#string) | Search query. |
|
||||
| <a id="projectprojectmemberssort"></a>`sort` | [`MemberSort`](#membersort) | sort query. |
|
||||
|
||||
##### `Project.release`
|
||||
|
||||
|
|
@ -20318,6 +20320,25 @@ Possible identifier types for a measurement.
|
|||
| <a id="measurementidentifierprojects"></a>`PROJECTS` | Project count. |
|
||||
| <a id="measurementidentifierusers"></a>`USERS` | User count. |
|
||||
|
||||
### `MemberSort`
|
||||
|
||||
Values for sorting members.
|
||||
|
||||
| Value | Description |
|
||||
| ----- | ----------- |
|
||||
| <a id="membersortaccess_level_asc"></a>`ACCESS_LEVEL_ASC` | Access level ascending order. |
|
||||
| <a id="membersortaccess_level_desc"></a>`ACCESS_LEVEL_DESC` | Access level descending order. |
|
||||
| <a id="membersortcreated_asc"></a>`CREATED_ASC` | Created at ascending order. |
|
||||
| <a id="membersortcreated_desc"></a>`CREATED_DESC` | Created at descending order. |
|
||||
| <a id="membersortupdated_asc"></a>`UPDATED_ASC` | Updated at ascending order. |
|
||||
| <a id="membersortupdated_desc"></a>`UPDATED_DESC` | Updated at descending order. |
|
||||
| <a id="membersortuser_full_name_asc"></a>`USER_FULL_NAME_ASC` | User's full name ascending order. |
|
||||
| <a id="membersortuser_full_name_desc"></a>`USER_FULL_NAME_DESC` | User's full name descending order. |
|
||||
| <a id="membersortcreated_asc"></a>`created_asc` **{warning-solid}** | **Deprecated** in 13.5. This was renamed. Use: `CREATED_ASC`. |
|
||||
| <a id="membersortcreated_desc"></a>`created_desc` **{warning-solid}** | **Deprecated** in 13.5. This was renamed. Use: `CREATED_DESC`. |
|
||||
| <a id="membersortupdated_asc"></a>`updated_asc` **{warning-solid}** | **Deprecated** in 13.5. This was renamed. Use: `UPDATED_ASC`. |
|
||||
| <a id="membersortupdated_desc"></a>`updated_desc` **{warning-solid}** | **Deprecated** in 13.5. This was renamed. Use: `UPDATED_DESC`. |
|
||||
|
||||
### `MergeRequestNewState`
|
||||
|
||||
New state to apply to a merge request.
|
||||
|
|
|
|||
|
|
@ -1783,7 +1783,7 @@ WARNING:
|
|||
This is a [breaking change](https://docs.gitlab.com/ee/development/deprecation_guidelines/).
|
||||
Review the details carefully before upgrading.
|
||||
|
||||
The certificate-based integration with Kubernetes will be [deprecated and removed](https://about.gitlab.com/blog/2021/11/15/deprecating-the-cert-based-kubernetes-integration/). As a GitLab SaaS customer, on new namespaces, you will no longer be able to integrate GitLab and your cluster using the certificate-based approach as of GitLab 15.0. The integration for current users will be enabled per namespace. The integrations are expected to be switched off completely on GitLab SaaS around 2022 November 22.
|
||||
The certificate-based integration with Kubernetes will be [deprecated and removed](https://about.gitlab.com/blog/2021/11/15/deprecating-the-cert-based-kubernetes-integration/). As a GitLab SaaS customer, on new namespaces, you will no longer be able to integrate GitLab and your cluster using the certificate-based approach as of GitLab 15.0. The integration for current users will be enabled per namespace.
|
||||
|
||||
For a more robust, secure, forthcoming, and reliable integration with Kubernetes, we recommend you use the
|
||||
[agent for Kubernetes](https://docs.gitlab.com/ee/user/clusters/agent/) to connect Kubernetes clusters with GitLab. [How do I migrate?](https://docs.gitlab.com/ee/user/infrastructure/clusters/migrate_to_gitlab_agent.html)
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ module Gitlab
|
|||
private
|
||||
|
||||
def report_builds
|
||||
@pipeline.latest_report_builds_in_self_and_descendants(::Ci::JobArtifact.of_report_type(:coverage))
|
||||
@pipeline.latest_report_builds_in_self_and_project_descendants(::Ci::JobArtifact.of_report_type(:coverage))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -39,13 +39,13 @@ module LearnGitlab
|
|||
|
||||
def onboarding_progress
|
||||
strong_memoize(:onboarding_progress) do
|
||||
OnboardingProgress.find_by(namespace: namespace) # rubocop: disable CodeReuse/ActiveRecord
|
||||
::Onboarding::Progress.find_by(namespace: namespace) # rubocop: disable CodeReuse/ActiveRecord
|
||||
end
|
||||
end
|
||||
|
||||
def action_columns
|
||||
strong_memoize(:action_columns) do
|
||||
tracked_actions.map { |action_key| OnboardingProgress.column_name(action_key) }
|
||||
tracked_actions.map { |action_key| ::Onboarding::Progress.column_name(action_key) }
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -4354,6 +4354,9 @@ msgstr ""
|
|||
msgid "An error occurred while trying to generate the report. Please try again later."
|
||||
msgstr ""
|
||||
|
||||
msgid "An error occurred while trying to render the content editor. Please try again."
|
||||
msgstr ""
|
||||
|
||||
msgid "An error occurred while trying to run a new pipeline for this merge request."
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -15152,6 +15155,9 @@ msgstr ""
|
|||
msgid "Error deleting project. Check logs for error details."
|
||||
msgstr ""
|
||||
|
||||
msgid "Error fetching branches"
|
||||
msgstr ""
|
||||
|
||||
msgid "Error fetching burnup chart data"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -44476,9 +44482,6 @@ msgstr ""
|
|||
msgid "WikiPageConflictMessage|Someone edited the page the same time you did. Please check out %{wikiLinkStart}the page%{wikiLinkEnd} and make sure your changes will not unintentionally remove theirs."
|
||||
msgstr ""
|
||||
|
||||
msgid "WikiPage|An error occurred while trying to render the content editor. Please try again later."
|
||||
msgstr ""
|
||||
|
||||
msgid "WikiPage|Cancel"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -44503,9 +44506,6 @@ msgstr ""
|
|||
msgid "WikiPage|Page title"
|
||||
msgstr ""
|
||||
|
||||
msgid "WikiPage|Retry"
|
||||
msgstr ""
|
||||
|
||||
msgid "WikiPage|Save changes"
|
||||
msgstr ""
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,11 @@
|
|||
|
||||
module QA
|
||||
RSpec.describe 'Manage' do
|
||||
describe 'Project owner permissions', :reliable do
|
||||
describe 'Project owner permissions', :reliable, quarantine: {
|
||||
only: { subdomain: %i[staging staging-canary] },
|
||||
type: :investigating,
|
||||
issue: 'https://gitlab.com/gitlab-org/gitlab/-/issues/373038'
|
||||
} do
|
||||
let!(:owner) do
|
||||
Resource::User.fabricate_or_use(Runtime::Env.gitlab_qa_username_1, Runtime::Env.gitlab_qa_password_1)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -5,8 +5,7 @@ require 'spec_helper'
|
|||
RSpec.describe Projects::Settings::IntegrationHookLogsController do
|
||||
let(:project) { create(:project, :repository) }
|
||||
let(:user) { create(:user) }
|
||||
let(:service_hook) { create(:service_hook) }
|
||||
let(:integration) { create(:drone_ci_integration, project: project, service_hook: service_hook) }
|
||||
let(:integration) { create(:drone_ci_integration, project: project) }
|
||||
let(:log) { create(:web_hook_log, web_hook: integration.service_hook) }
|
||||
let(:log_params) do
|
||||
{
|
||||
|
|
|
|||
|
|
@ -55,11 +55,11 @@ RSpec.describe Repositories::GitHttpController do
|
|||
let_it_be(:namespace) { project.namespace }
|
||||
|
||||
before do
|
||||
OnboardingProgress.onboard(namespace)
|
||||
Onboarding::Progress.onboard(namespace)
|
||||
send_request
|
||||
end
|
||||
|
||||
subject { OnboardingProgress.completed?(namespace, :git_pull) }
|
||||
subject { Onboarding::Progress.completed?(namespace, :git_pull) }
|
||||
|
||||
it { is_expected.to be(true) }
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
FactoryBot.define do
|
||||
factory :onboarding_progress do
|
||||
factory :onboarding_progress, class: 'Onboarding::Progress' do
|
||||
namespace
|
||||
end
|
||||
end
|
||||
|
|
@ -3,8 +3,6 @@
|
|||
require 'spec_helper'
|
||||
|
||||
RSpec.describe 'User edits a merge request', :js do
|
||||
include Select2Helper
|
||||
|
||||
let(:project) { create(:project, :repository) }
|
||||
let(:merge_request) { create(:merge_request, source_project: project, target_project: project) }
|
||||
let(:user) { create(:user) }
|
||||
|
|
@ -89,7 +87,12 @@ RSpec.describe 'User edits a merge request', :js do
|
|||
it 'allows user to change target branch' do
|
||||
expect(page).to have_content('From master into feature')
|
||||
|
||||
select2('merge-test', from: '#merge_request_target_branch')
|
||||
first('.js-target-branch').click
|
||||
|
||||
wait_for_requests
|
||||
|
||||
first('.js-target-branch-dropdown a', text: 'merge-test').click
|
||||
|
||||
click_button('Save changes')
|
||||
|
||||
expect(page).to have_content("requested to merge #{merge_request.source_branch} into merge-test")
|
||||
|
|
@ -101,7 +104,7 @@ RSpec.describe 'User edits a merge request', :js do
|
|||
|
||||
it 'does not allow user to change target branch' do
|
||||
expect(page).to have_content('From master into feature')
|
||||
expect(page).not_to have_selector('.select2-container')
|
||||
expect(page).not_to have_selector('.js-target-branch.js-compare-dropdown')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -51,6 +51,16 @@ describe('content_editor/components/content_editor_alert', () => {
|
|||
},
|
||||
);
|
||||
|
||||
it('does not show primary action by default', async () => {
|
||||
const message = 'error message';
|
||||
|
||||
createWrapper();
|
||||
eventHub.$emit(ALERT_EVENT, { message });
|
||||
await nextTick();
|
||||
|
||||
expect(findErrorAlert().attributes().primaryButtonText).toBeUndefined();
|
||||
});
|
||||
|
||||
it('allows dismissing the error', async () => {
|
||||
const message = 'error message';
|
||||
|
||||
|
|
@ -62,4 +72,19 @@ describe('content_editor/components/content_editor_alert', () => {
|
|||
|
||||
expect(findErrorAlert().exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('allows dismissing the error with a primary action button', async () => {
|
||||
const message = 'error message';
|
||||
const actionLabel = 'Retry';
|
||||
const action = jest.fn();
|
||||
|
||||
createWrapper();
|
||||
eventHub.$emit(ALERT_EVENT, { message, action, actionLabel });
|
||||
await nextTick();
|
||||
findErrorAlert().vm.$emit('primaryAction');
|
||||
await nextTick();
|
||||
|
||||
expect(action).toHaveBeenCalled();
|
||||
expect(findErrorAlert().exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { EditorContent } from '@tiptap/vue-2';
|
||||
import { GlAlert } from '@gitlab/ui';
|
||||
import { EditorContent, Editor } from '@tiptap/vue-2';
|
||||
import { nextTick } from 'vue';
|
||||
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
|
||||
import ContentEditor from '~/content_editor/components/content_editor.vue';
|
||||
import ContentEditorAlert from '~/content_editor/components/content_editor_alert.vue';
|
||||
|
|
@ -10,112 +12,205 @@ import LinkBubbleMenu from '~/content_editor/components/bubble_menus/link_bubble
|
|||
import MediaBubbleMenu from '~/content_editor/components/bubble_menus/media_bubble_menu.vue';
|
||||
import TopToolbar from '~/content_editor/components/top_toolbar.vue';
|
||||
import LoadingIndicator from '~/content_editor/components/loading_indicator.vue';
|
||||
import { emitEditorEvent } from '../test_utils';
|
||||
import waitForPromises from 'helpers/wait_for_promises';
|
||||
|
||||
jest.mock('~/emoji');
|
||||
|
||||
describe('ContentEditor', () => {
|
||||
let wrapper;
|
||||
let contentEditor;
|
||||
let renderMarkdown;
|
||||
const uploadsPath = '/uploads';
|
||||
|
||||
const findEditorElement = () => wrapper.findByTestId('content-editor');
|
||||
const findEditorContent = () => wrapper.findComponent(EditorContent);
|
||||
const findEditorStateObserver = () => wrapper.findComponent(EditorStateObserver);
|
||||
const createWrapper = (propsData = {}) => {
|
||||
renderMarkdown = jest.fn();
|
||||
|
||||
const findLoadingIndicator = () => wrapper.findComponent(LoadingIndicator);
|
||||
const findContentEditorAlert = () => wrapper.findComponent(ContentEditorAlert);
|
||||
const createWrapper = ({ markdown } = {}) => {
|
||||
wrapper = shallowMountExtended(ContentEditor, {
|
||||
propsData: {
|
||||
renderMarkdown,
|
||||
uploadsPath,
|
||||
...propsData,
|
||||
markdown,
|
||||
},
|
||||
stubs: {
|
||||
EditorStateObserver,
|
||||
ContentEditorProvider,
|
||||
},
|
||||
listeners: {
|
||||
initialized(editor) {
|
||||
contentEditor = editor;
|
||||
},
|
||||
ContentEditorAlert,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
renderMarkdown = jest.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.destroy();
|
||||
});
|
||||
|
||||
it('triggers initialized event and provides contentEditor instance as event data', () => {
|
||||
it('triggers initialized event', () => {
|
||||
createWrapper();
|
||||
|
||||
expect(contentEditor).not.toBe(false);
|
||||
expect(wrapper.emitted('initialized')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('renders EditorContent component and provides tiptapEditor instance', () => {
|
||||
createWrapper();
|
||||
it('renders EditorContent component and provides tiptapEditor instance', async () => {
|
||||
const markdown = 'hello world';
|
||||
|
||||
createWrapper({ markdown });
|
||||
|
||||
renderMarkdown.mockResolvedValueOnce(markdown);
|
||||
|
||||
await nextTick();
|
||||
|
||||
const editorContent = findEditorContent();
|
||||
|
||||
expect(editorContent.props().editor).toBe(contentEditor.tiptapEditor);
|
||||
expect(editorContent.props().editor).toBeInstanceOf(Editor);
|
||||
expect(editorContent.classes()).toContain('md');
|
||||
});
|
||||
|
||||
it('renders ContentEditorProvider component', () => {
|
||||
createWrapper();
|
||||
it('renders ContentEditorProvider component', async () => {
|
||||
await createWrapper();
|
||||
|
||||
expect(wrapper.findComponent(ContentEditorProvider).exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('renders top toolbar component', () => {
|
||||
createWrapper();
|
||||
it('renders top toolbar component', async () => {
|
||||
await createWrapper();
|
||||
|
||||
expect(wrapper.findComponent(TopToolbar).exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('adds is-focused class when focus event is emitted', async () => {
|
||||
createWrapper();
|
||||
describe('when setting initial content', () => {
|
||||
it('displays loading indicator', async () => {
|
||||
createWrapper();
|
||||
|
||||
await emitEditorEvent({ tiptapEditor: contentEditor.tiptapEditor, event: 'focus' });
|
||||
await nextTick();
|
||||
|
||||
expect(findEditorElement().classes()).toContain('is-focused');
|
||||
expect(findLoadingIndicator().exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('emits loading event', async () => {
|
||||
createWrapper();
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.emitted('loading')).toHaveLength(1);
|
||||
});
|
||||
|
||||
describe('succeeds', () => {
|
||||
beforeEach(async () => {
|
||||
renderMarkdown.mockResolvedValueOnce('hello world');
|
||||
|
||||
createWrapper({ markddown: 'hello world' });
|
||||
await nextTick();
|
||||
});
|
||||
|
||||
it('hides loading indicator', async () => {
|
||||
await nextTick();
|
||||
expect(findLoadingIndicator().exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('emits loadingSuccess event', () => {
|
||||
expect(wrapper.emitted('loadingSuccess')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fails', () => {
|
||||
beforeEach(async () => {
|
||||
renderMarkdown.mockRejectedValueOnce(new Error());
|
||||
|
||||
createWrapper({ markddown: 'hello world' });
|
||||
await nextTick();
|
||||
});
|
||||
|
||||
it('sets the content editor as read only when loading content fails', async () => {
|
||||
await nextTick();
|
||||
|
||||
expect(findEditorContent().props().editor.isEditable).toBe(false);
|
||||
});
|
||||
|
||||
it('hides loading indicator', async () => {
|
||||
await nextTick();
|
||||
|
||||
expect(findLoadingIndicator().exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('emits loadingError event', () => {
|
||||
expect(wrapper.emitted('loadingError')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('displays error alert indicating that the content editor failed to load', () => {
|
||||
expect(findContentEditorAlert().text()).toContain(
|
||||
'An error occurred while trying to render the content editor. Please try again.',
|
||||
);
|
||||
});
|
||||
|
||||
describe('when clicking the retry button in the loading error alert and loading succeeds', () => {
|
||||
beforeEach(async () => {
|
||||
renderMarkdown.mockResolvedValueOnce('hello markdown');
|
||||
await wrapper.findComponent(GlAlert).vm.$emit('primaryAction');
|
||||
});
|
||||
|
||||
it('hides the loading error alert', () => {
|
||||
expect(findContentEditorAlert().text()).toBe('');
|
||||
});
|
||||
|
||||
it('sets the content editor as writable', async () => {
|
||||
await nextTick();
|
||||
|
||||
expect(findEditorContent().props().editor.isEditable).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('removes is-focused class when blur event is emitted', async () => {
|
||||
createWrapper();
|
||||
describe('when focused event is emitted', () => {
|
||||
beforeEach(async () => {
|
||||
createWrapper();
|
||||
|
||||
await emitEditorEvent({ tiptapEditor: contentEditor.tiptapEditor, event: 'focus' });
|
||||
await emitEditorEvent({ tiptapEditor: contentEditor.tiptapEditor, event: 'blur' });
|
||||
findEditorStateObserver().vm.$emit('focus');
|
||||
|
||||
expect(findEditorElement().classes()).not.toContain('is-focused');
|
||||
await nextTick();
|
||||
});
|
||||
|
||||
it('adds is-focused class when focus event is emitted', () => {
|
||||
expect(findEditorElement().classes()).toContain('is-focused');
|
||||
});
|
||||
|
||||
it('removes is-focused class when blur event is emitted', async () => {
|
||||
findEditorStateObserver().vm.$emit('blur');
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(findEditorElement().classes()).not.toContain('is-focused');
|
||||
});
|
||||
});
|
||||
|
||||
it('emits change event when document is updated', async () => {
|
||||
createWrapper();
|
||||
describe('when editorStateObserver emits docUpdate event', () => {
|
||||
it('emits change event with the latest markdown', async () => {
|
||||
const markdown = 'Loaded content';
|
||||
|
||||
await emitEditorEvent({ tiptapEditor: contentEditor.tiptapEditor, event: 'update' });
|
||||
renderMarkdown.mockResolvedValueOnce(markdown);
|
||||
|
||||
expect(wrapper.emitted('change')).toEqual([
|
||||
[
|
||||
{
|
||||
empty: contentEditor.empty,
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
createWrapper({ markdown: 'initial content' });
|
||||
|
||||
it('renders content_editor_alert component', () => {
|
||||
createWrapper();
|
||||
await nextTick();
|
||||
await waitForPromises();
|
||||
|
||||
expect(wrapper.findComponent(ContentEditorAlert).exists()).toBe(true);
|
||||
});
|
||||
findEditorStateObserver().vm.$emit('docUpdate');
|
||||
|
||||
it('renders loading indicator component', () => {
|
||||
createWrapper();
|
||||
|
||||
expect(wrapper.findComponent(LoadingIndicator).exists()).toBe(true);
|
||||
expect(wrapper.emitted('change')).toEqual([
|
||||
[
|
||||
{
|
||||
markdown,
|
||||
changed: false,
|
||||
empty: false,
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it.each`
|
||||
|
|
@ -129,17 +224,4 @@ describe('ContentEditor', () => {
|
|||
|
||||
expect(wrapper.findComponent(component).exists()).toBe(true);
|
||||
});
|
||||
|
||||
it.each`
|
||||
event
|
||||
${'loading'}
|
||||
${'loadingSuccess'}
|
||||
${'loadingError'}
|
||||
`('broadcasts $event event triggered by editor-state-observer component', ({ event }) => {
|
||||
createWrapper();
|
||||
|
||||
findEditorStateObserver().vm.$emit(event);
|
||||
|
||||
expect(wrapper.emitted(event)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,12 +4,7 @@ import EditorStateObserver, {
|
|||
tiptapToComponentMap,
|
||||
} from '~/content_editor/components/editor_state_observer.vue';
|
||||
import eventHubFactory from '~/helpers/event_hub_factory';
|
||||
import {
|
||||
LOADING_CONTENT_EVENT,
|
||||
LOADING_SUCCESS_EVENT,
|
||||
LOADING_ERROR_EVENT,
|
||||
ALERT_EVENT,
|
||||
} from '~/content_editor/constants';
|
||||
import { ALERT_EVENT } from '~/content_editor/constants';
|
||||
import { createTestEditor } from '../test_utils';
|
||||
|
||||
describe('content_editor/components/editor_state_observer', () => {
|
||||
|
|
@ -18,9 +13,6 @@ describe('content_editor/components/editor_state_observer', () => {
|
|||
let onDocUpdateListener;
|
||||
let onSelectionUpdateListener;
|
||||
let onTransactionListener;
|
||||
let onLoadingContentListener;
|
||||
let onLoadingSuccessListener;
|
||||
let onLoadingErrorListener;
|
||||
let onAlertListener;
|
||||
let eventHub;
|
||||
|
||||
|
|
@ -38,9 +30,6 @@ describe('content_editor/components/editor_state_observer', () => {
|
|||
selectionUpdate: onSelectionUpdateListener,
|
||||
transaction: onTransactionListener,
|
||||
[ALERT_EVENT]: onAlertListener,
|
||||
[LOADING_CONTENT_EVENT]: onLoadingContentListener,
|
||||
[LOADING_SUCCESS_EVENT]: onLoadingSuccessListener,
|
||||
[LOADING_ERROR_EVENT]: onLoadingErrorListener,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -50,9 +39,6 @@ describe('content_editor/components/editor_state_observer', () => {
|
|||
onSelectionUpdateListener = jest.fn();
|
||||
onTransactionListener = jest.fn();
|
||||
onAlertListener = jest.fn();
|
||||
onLoadingSuccessListener = jest.fn();
|
||||
onLoadingContentListener = jest.fn();
|
||||
onLoadingErrorListener = jest.fn();
|
||||
buildEditor();
|
||||
});
|
||||
|
||||
|
|
@ -81,11 +67,8 @@ 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}
|
||||
event | listener
|
||||
${ALERT_EVENT} | ${() => onAlertListener}
|
||||
`('listens to $event event in the eventBus object', ({ event, listener }) => {
|
||||
const args = {};
|
||||
|
||||
|
|
@ -114,9 +97,6 @@ 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');
|
||||
|
|
|
|||
|
|
@ -1,18 +1,10 @@
|
|||
import { GlLoadingIcon } from '@gitlab/ui';
|
||||
import { nextTick } from 'vue';
|
||||
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
|
||||
import LoadingIndicator from '~/content_editor/components/loading_indicator.vue';
|
||||
import EditorStateObserver from '~/content_editor/components/editor_state_observer.vue';
|
||||
import {
|
||||
LOADING_CONTENT_EVENT,
|
||||
LOADING_SUCCESS_EVENT,
|
||||
LOADING_ERROR_EVENT,
|
||||
} from '~/content_editor/constants';
|
||||
|
||||
describe('content_editor/components/loading_indicator', () => {
|
||||
let wrapper;
|
||||
|
||||
const findEditorStateObserver = () => wrapper.findComponent(EditorStateObserver);
|
||||
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
|
||||
|
||||
const createWrapper = () => {
|
||||
|
|
@ -24,48 +16,12 @@ describe('content_editor/components/loading_indicator', () => {
|
|||
});
|
||||
|
||||
describe('when loading content', () => {
|
||||
beforeEach(async () => {
|
||||
beforeEach(() => {
|
||||
createWrapper();
|
||||
|
||||
findEditorStateObserver().vm.$emit(LOADING_CONTENT_EVENT);
|
||||
|
||||
await nextTick();
|
||||
});
|
||||
|
||||
it('displays loading indicator', () => {
|
||||
expect(findLoadingIcon().exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when loading content succeeds', () => {
|
||||
beforeEach(async () => {
|
||||
createWrapper();
|
||||
|
||||
findEditorStateObserver().vm.$emit(LOADING_CONTENT_EVENT);
|
||||
await nextTick();
|
||||
findEditorStateObserver().vm.$emit(LOADING_SUCCESS_EVENT);
|
||||
await nextTick();
|
||||
});
|
||||
|
||||
it('hides loading indicator', () => {
|
||||
expect(findLoadingIcon().exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when loading content fails', () => {
|
||||
const error = 'error';
|
||||
|
||||
beforeEach(async () => {
|
||||
createWrapper();
|
||||
|
||||
findEditorStateObserver().vm.$emit(LOADING_CONTENT_EVENT);
|
||||
await nextTick();
|
||||
findEditorStateObserver().vm.$emit(LOADING_ERROR_EVENT, error);
|
||||
await nextTick();
|
||||
});
|
||||
|
||||
it('hides loading indicator', () => {
|
||||
expect(findLoadingIcon().exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,8 +1,3 @@
|
|||
import {
|
||||
LOADING_CONTENT_EVENT,
|
||||
LOADING_SUCCESS_EVENT,
|
||||
LOADING_ERROR_EVENT,
|
||||
} from '~/content_editor/constants';
|
||||
import { ContentEditor } from '~/content_editor/services/content_editor';
|
||||
import eventHubFactory from '~/helpers/event_hub_factory';
|
||||
import { createTestEditor, createDocBuilder } from '../test_utils';
|
||||
|
|
@ -14,6 +9,7 @@ describe('content_editor/services/content_editor', () => {
|
|||
let eventHub;
|
||||
let doc;
|
||||
let p;
|
||||
const testMarkdown = '**bold text**';
|
||||
|
||||
beforeEach(() => {
|
||||
const tiptapEditor = createTestEditor();
|
||||
|
|
@ -36,6 +32,9 @@ describe('content_editor/services/content_editor', () => {
|
|||
});
|
||||
});
|
||||
|
||||
const testDoc = () => doc(p('document'));
|
||||
const testEmptyDoc = () => doc();
|
||||
|
||||
describe('.dispose', () => {
|
||||
it('destroys the tiptapEditor', () => {
|
||||
expect(contentEditor.tiptapEditor.destroy).not.toHaveBeenCalled();
|
||||
|
|
@ -46,51 +45,77 @@ describe('content_editor/services/content_editor', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('empty', () => {
|
||||
it('returns true when tiptapEditor is empty', async () => {
|
||||
deserializer.deserialize.mockResolvedValueOnce({ document: testEmptyDoc() });
|
||||
|
||||
await contentEditor.setSerializedContent(testMarkdown);
|
||||
|
||||
expect(contentEditor.empty).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when tiptapEditor is not empty', async () => {
|
||||
deserializer.deserialize.mockResolvedValueOnce({ document: testDoc() });
|
||||
|
||||
await contentEditor.setSerializedContent(testMarkdown);
|
||||
|
||||
expect(contentEditor.empty).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('editable', () => {
|
||||
it('returns true when tiptapEditor is editable', async () => {
|
||||
contentEditor.setEditable(true);
|
||||
|
||||
expect(contentEditor.editable).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when tiptapEditor is readonly', async () => {
|
||||
contentEditor.setEditable(false);
|
||||
|
||||
expect(contentEditor.editable).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('changed', () => {
|
||||
it('returns true when the initial document changes', async () => {
|
||||
deserializer.deserialize.mockResolvedValueOnce({ document: testDoc() });
|
||||
|
||||
await contentEditor.setSerializedContent(testMarkdown);
|
||||
|
||||
contentEditor.tiptapEditor.commands.insertContent(' new content');
|
||||
|
||||
expect(contentEditor.changed).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when the initial document hasn’t changed', async () => {
|
||||
deserializer.deserialize.mockResolvedValueOnce({ document: testDoc() });
|
||||
|
||||
await contentEditor.setSerializedContent(testMarkdown);
|
||||
|
||||
expect(contentEditor.changed).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when an initial document is not set and the document is empty', () => {
|
||||
expect(contentEditor.changed).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when an initial document is not set and the document is not empty', () => {
|
||||
contentEditor.tiptapEditor.commands.insertContent('new content');
|
||||
|
||||
expect(contentEditor.changed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when setSerializedContent succeeds', () => {
|
||||
let document;
|
||||
const languages = ['javascript'];
|
||||
const testMarkdown = '**bold text**';
|
||||
|
||||
beforeEach(() => {
|
||||
document = doc(p('document'));
|
||||
deserializer.deserialize.mockResolvedValueOnce({ document, languages });
|
||||
});
|
||||
|
||||
it('emits loadingContent and loadingSuccess event in the eventHub', () => {
|
||||
let loadingContentEmitted = false;
|
||||
|
||||
eventHub.$on(LOADING_CONTENT_EVENT, () => {
|
||||
loadingContentEmitted = true;
|
||||
});
|
||||
eventHub.$on(LOADING_SUCCESS_EVENT, () => {
|
||||
expect(loadingContentEmitted).toBe(true);
|
||||
});
|
||||
|
||||
contentEditor.setSerializedContent(testMarkdown);
|
||||
});
|
||||
|
||||
it('sets the deserialized document in the tiptap editor object', async () => {
|
||||
const document = testDoc();
|
||||
|
||||
deserializer.deserialize.mockResolvedValueOnce({ document });
|
||||
|
||||
await contentEditor.setSerializedContent(testMarkdown);
|
||||
|
||||
expect(contentEditor.tiptapEditor.state.doc.toJSON()).toEqual(document.toJSON());
|
||||
});
|
||||
});
|
||||
|
||||
describe('when setSerializedContent fails', () => {
|
||||
const error = 'error';
|
||||
|
||||
beforeEach(() => {
|
||||
deserializer.deserialize.mockRejectedValueOnce(error);
|
||||
});
|
||||
|
||||
it('emits loadingError event', async () => {
|
||||
eventHub.$on(LOADING_ERROR_EVENT, (e) => {
|
||||
expect(e).toBe('error');
|
||||
});
|
||||
|
||||
await expect(() => contentEditor.setSerializedContent('**bold text**')).rejects.toEqual(
|
||||
error,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -302,19 +302,15 @@ describe('WikiForm', () => {
|
|||
});
|
||||
|
||||
it.each`
|
||||
format | enabled | action
|
||||
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, enabled }) => {
|
||||
`('$action toggle editing mode button when format is $format', async ({ format, exists }) => {
|
||||
await setFormat(format);
|
||||
|
||||
expect(findToggleEditingModeButton().exists()).toBe(enabled);
|
||||
});
|
||||
|
||||
it('displays toggle editing mode button', () => {
|
||||
expect(findToggleEditingModeButton().exists()).toBe(true);
|
||||
expect(findToggleEditingModeButton().exists()).toBe(exists);
|
||||
});
|
||||
|
||||
describe('when content editor is not active', () => {
|
||||
|
|
@ -351,15 +347,8 @@ describe('WikiForm', () => {
|
|||
});
|
||||
|
||||
describe('when content editor is active', () => {
|
||||
let mockContentEditor;
|
||||
|
||||
beforeEach(() => {
|
||||
createWrapper();
|
||||
mockContentEditor = {
|
||||
getSerializedContent: jest.fn(),
|
||||
setSerializedContent: jest.fn(),
|
||||
};
|
||||
|
||||
findToggleEditingModeButton().vm.$emit('input', 'richText');
|
||||
});
|
||||
|
||||
|
|
@ -368,14 +357,7 @@ describe('WikiForm', () => {
|
|||
});
|
||||
|
||||
describe('when clicking the toggle editing mode button', () => {
|
||||
const contentEditorFakeSerializedContent = 'fake content';
|
||||
|
||||
beforeEach(async () => {
|
||||
mockContentEditor.getSerializedContent.mockReturnValueOnce(
|
||||
contentEditorFakeSerializedContent,
|
||||
);
|
||||
|
||||
findContentEditor().vm.$emit('initialized', mockContentEditor);
|
||||
await findToggleEditingModeButton().vm.$emit('input', 'source');
|
||||
await nextTick();
|
||||
});
|
||||
|
|
@ -387,10 +369,6 @@ describe('WikiForm', () => {
|
|||
it('displays the classic editor', () => {
|
||||
expect(findClassicEditor().exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('updates the classic editor content field', () => {
|
||||
expect(findContent().element.value).toBe(contentEditorFakeSerializedContent);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when content editor is loading', () => {
|
||||
|
|
@ -480,8 +458,14 @@ describe('WikiForm', () => {
|
|||
});
|
||||
|
||||
describe('when wiki content is updated', () => {
|
||||
const updatedMarkdown = 'hello **world**';
|
||||
|
||||
beforeEach(() => {
|
||||
findContentEditor().vm.$emit('change', { empty: false });
|
||||
findContentEditor().vm.$emit('change', {
|
||||
empty: false,
|
||||
changed: true,
|
||||
markdown: updatedMarkdown,
|
||||
});
|
||||
});
|
||||
|
||||
it('sets before unload warning', () => {
|
||||
|
|
@ -512,16 +496,8 @@ describe('WikiForm', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('updates content from content editor on form submit', async () => {
|
||||
// old value
|
||||
expect(findContent().element.value).toBe(' My page content ');
|
||||
|
||||
// wait for content editor to load
|
||||
await waitForPromises();
|
||||
|
||||
await triggerFormSubmit();
|
||||
|
||||
expect(findContent().element.value).toBe('hello **world**');
|
||||
it('sets content field to the content editor updated markdown', async () => {
|
||||
expect(findContent().element.value).toBe(updatedMarkdown);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { nextTick } from 'vue';
|
||||
import { mountExtended } from 'helpers/vue_test_utils_helper';
|
||||
import { ContentEditor } from '~/content_editor';
|
||||
import waitForPromises from 'helpers/wait_for_promises';
|
||||
|
||||
/**
|
||||
* This spec exercises some workflows in the Content Editor without mocking
|
||||
|
|
@ -10,32 +11,34 @@ import { ContentEditor } from '~/content_editor';
|
|||
describe('content_editor', () => {
|
||||
let wrapper;
|
||||
let renderMarkdown;
|
||||
let contentEditorService;
|
||||
|
||||
const buildWrapper = () => {
|
||||
renderMarkdown = jest.fn();
|
||||
const buildWrapper = ({ markdown = '' } = {}) => {
|
||||
wrapper = mountExtended(ContentEditor, {
|
||||
propsData: {
|
||||
renderMarkdown,
|
||||
uploadsPath: '/',
|
||||
},
|
||||
listeners: {
|
||||
initialized(contentEditor) {
|
||||
contentEditorService = contentEditor;
|
||||
},
|
||||
markdown,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const waitUntilContentIsLoaded = async () => {
|
||||
await waitForPromises();
|
||||
await nextTick();
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
renderMarkdown = jest.fn();
|
||||
});
|
||||
|
||||
describe('when loading initial content', () => {
|
||||
describe('when the initial content is empty', () => {
|
||||
it('still hides the loading indicator', async () => {
|
||||
buildWrapper();
|
||||
|
||||
renderMarkdown.mockResolvedValue('');
|
||||
|
||||
await contentEditorService.setSerializedContent('');
|
||||
await nextTick();
|
||||
buildWrapper();
|
||||
|
||||
await waitUntilContentIsLoaded();
|
||||
|
||||
expect(wrapper.findByTestId('content-editor-loading-indicator').exists()).toBe(false);
|
||||
});
|
||||
|
|
@ -44,14 +47,13 @@ describe('content_editor', () => {
|
|||
describe('when the initial content is not empty', () => {
|
||||
const initialContent = '<p><strong>bold text</strong></p>';
|
||||
beforeEach(async () => {
|
||||
buildWrapper();
|
||||
|
||||
renderMarkdown.mockResolvedValue(initialContent);
|
||||
|
||||
await contentEditorService.setSerializedContent('**bold text**');
|
||||
await nextTick();
|
||||
buildWrapper();
|
||||
|
||||
await waitUntilContentIsLoaded();
|
||||
});
|
||||
it('hides the loading indicator', async () => {
|
||||
it('hides the loading indicator', () => {
|
||||
expect(wrapper.findByTestId('content-editor-loading-indicator').exists()).toBe(false);
|
||||
});
|
||||
|
||||
|
|
@ -70,27 +72,29 @@ describe('content_editor', () => {
|
|||
});
|
||||
|
||||
it('processes and renders footnote ids alongside the footnote definition', async () => {
|
||||
buildWrapper();
|
||||
|
||||
await contentEditorService.setSerializedContent(`
|
||||
buildWrapper({
|
||||
markdown: `
|
||||
This reference tag is a mix of letters and numbers [^footnote].
|
||||
|
||||
[^footnote]: This is another footnote.
|
||||
`);
|
||||
await nextTick();
|
||||
`,
|
||||
});
|
||||
|
||||
await waitUntilContentIsLoaded();
|
||||
|
||||
expect(wrapper.text()).toContain('footnote: This is another footnote');
|
||||
});
|
||||
|
||||
it('processes and displays reference definitions', async () => {
|
||||
buildWrapper();
|
||||
|
||||
await contentEditorService.setSerializedContent(`
|
||||
buildWrapper({
|
||||
markdown: `
|
||||
[GitLab][gitlab]
|
||||
|
||||
[gitlab]: https://gitlab.com
|
||||
`);
|
||||
await nextTick();
|
||||
`,
|
||||
});
|
||||
|
||||
await waitUntilContentIsLoaded();
|
||||
|
||||
expect(wrapper.find('pre').text()).toContain('[gitlab]: https://gitlab.com');
|
||||
});
|
||||
|
|
@ -99,9 +103,7 @@ This reference tag is a mix of letters and numbers [^footnote].
|
|||
it('renders table of contents', async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
buildWrapper();
|
||||
|
||||
renderMarkdown.mockResolvedValue(`
|
||||
renderMarkdown.mockResolvedValueOnce(`
|
||||
<ul class="section-nav">
|
||||
</ul>
|
||||
<h1 dir="auto" data-sourcepos="3:1-3:11">
|
||||
|
|
@ -112,16 +114,17 @@ This reference tag is a mix of letters and numbers [^footnote].
|
|||
</h2>
|
||||
`);
|
||||
|
||||
await contentEditorService.setSerializedContent(`
|
||||
buildWrapper({
|
||||
markdown: `
|
||||
[TOC]
|
||||
|
||||
# Heading 1
|
||||
|
||||
## Heading 2
|
||||
`);
|
||||
`,
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
jest.runAllTimers();
|
||||
await waitUntilContentIsLoaded();
|
||||
|
||||
expect(wrapper.findByTestId('table-of-contents').text()).toContain('Heading 1');
|
||||
expect(wrapper.findByTestId('table-of-contents').text()).toContain('Heading 2');
|
||||
|
|
|
|||
|
|
@ -2,9 +2,11 @@
|
|||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe Resolvers::GroupMembersResolver do
|
||||
RSpec.describe 'Resolvers::GroupMembersResolver' do
|
||||
include GraphqlHelpers
|
||||
|
||||
let(:described_class) { Resolvers::GroupMembersResolver }
|
||||
|
||||
specify do
|
||||
expect(described_class).to have_nullable_graphql_type(Types::GroupMemberType.connection_type)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -2,9 +2,11 @@
|
|||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe Resolvers::ProjectMembersResolver do
|
||||
RSpec.describe 'Resolvers::ProjectMembersResolver' do
|
||||
include GraphqlHelpers
|
||||
|
||||
let(:described_class) { Resolvers::ProjectMembersResolver }
|
||||
|
||||
it_behaves_like 'querying members with a group' do
|
||||
let_it_be(:project) { create(:project, group: group_1) }
|
||||
let_it_be(:resource_member) { create(:project_member, user: user_1, project: project) }
|
||||
|
|
|
|||
|
|
@ -15,8 +15,8 @@ RSpec.describe LearnGitlabHelper do
|
|||
allow(learn_gitlab).to receive(:project).and_return(project)
|
||||
end
|
||||
|
||||
OnboardingProgress.onboard(namespace)
|
||||
OnboardingProgress.register(namespace, :git_write)
|
||||
Onboarding::Progress.onboard(namespace)
|
||||
Onboarding::Progress.register(namespace, :git_write)
|
||||
end
|
||||
|
||||
describe '#learn_gitlab_enabled?' do
|
||||
|
|
@ -37,7 +37,7 @@ RSpec.describe LearnGitlabHelper do
|
|||
|
||||
with_them do
|
||||
before do
|
||||
allow(OnboardingProgress).to receive(:onboarding?).with(project.namespace).and_return(onboarding)
|
||||
allow(Onboarding::Progress).to receive(:onboarding?).with(project.namespace).and_return(onboarding)
|
||||
allow_next(LearnGitlab::Project, user).to receive(:available?).and_return(learn_gitlab_available)
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ RSpec.describe Gitlab::WebHooks::RateLimiter, :clean_gitlab_redis_rate_limiting
|
|||
let_it_be(:plan) { create(:default_plan) }
|
||||
let_it_be_with_reload(:project_hook) { create(:project_hook) }
|
||||
let_it_be_with_reload(:system_hook) { create(:system_hook) }
|
||||
let_it_be_with_reload(:integration_hook) { create(:service_hook) }
|
||||
let_it_be_with_reload(:integration_hook) { create(:jenkins_integration).service_hook }
|
||||
let_it_be(:limit) { 1 }
|
||||
|
||||
using RSpec::Parameterized::TableSyntax
|
||||
|
|
|
|||
|
|
@ -13,11 +13,11 @@ RSpec.describe LearnGitlab::Onboarding do
|
|||
*described_class::ACTION_ISSUE_IDS.keys,
|
||||
*described_class::ACTION_PATHS,
|
||||
:security_scan_enabled
|
||||
].map { |key| OnboardingProgress.column_name(key) }
|
||||
].map { |key| ::Onboarding::Progress.column_name(key) }
|
||||
end
|
||||
|
||||
before do
|
||||
expect(OnboardingProgress).to receive(:find_by).with(namespace: namespace).and_return(onboarding_progress)
|
||||
expect(::Onboarding::Progress).to receive(:find_by).with(namespace: namespace).and_return(onboarding_progress)
|
||||
end
|
||||
|
||||
subject { described_class.new(namespace).completed_percentage }
|
||||
|
|
|
|||
|
|
@ -3629,8 +3629,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
|
|||
end
|
||||
end
|
||||
|
||||
describe '#environments_in_self_and_descendants' do
|
||||
subject { pipeline.environments_in_self_and_descendants }
|
||||
describe '#environments_in_self_and_project_descendants' do
|
||||
subject { pipeline.environments_in_self_and_project_descendants }
|
||||
|
||||
context 'when pipeline is not child nor parent' do
|
||||
let_it_be(:pipeline) { create(:ci_pipeline, :created) }
|
||||
|
|
@ -4036,13 +4036,13 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
|
|||
end
|
||||
end
|
||||
|
||||
describe '#self_and_descendants_complete?' do
|
||||
describe '#self_and_project_descendants_complete?' do
|
||||
let_it_be(:pipeline) { create(:ci_pipeline, :success) }
|
||||
let_it_be(:child_pipeline) { create(:ci_pipeline, :success, child_of: pipeline) }
|
||||
let_it_be_with_reload(:grandchild_pipeline) { create(:ci_pipeline, :success, child_of: child_pipeline) }
|
||||
|
||||
context 'when all pipelines in the hierarchy is complete' do
|
||||
it { expect(pipeline.self_and_descendants_complete?).to be(true) }
|
||||
it { expect(pipeline.self_and_project_descendants_complete?).to be(true) }
|
||||
end
|
||||
|
||||
context 'when a pipeline in the hierarchy is not complete' do
|
||||
|
|
@ -4050,12 +4050,12 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
|
|||
grandchild_pipeline.update!(status: :running)
|
||||
end
|
||||
|
||||
it { expect(pipeline.self_and_descendants_complete?).to be(false) }
|
||||
it { expect(pipeline.self_and_project_descendants_complete?).to be(false) }
|
||||
end
|
||||
end
|
||||
|
||||
describe '#builds_in_self_and_descendants' do
|
||||
subject(:builds) { pipeline.builds_in_self_and_descendants }
|
||||
describe '#builds_in_self_and_project_descendants' do
|
||||
subject(:builds) { pipeline.builds_in_self_and_project_descendants }
|
||||
|
||||
let(:pipeline) { create(:ci_pipeline) }
|
||||
let!(:build) { create(:ci_build, pipeline: pipeline) }
|
||||
|
|
@ -4087,7 +4087,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
|
|||
end
|
||||
end
|
||||
|
||||
describe '#build_with_artifacts_in_self_and_descendants' do
|
||||
describe '#build_with_artifacts_in_self_and_project_descendants' do
|
||||
let_it_be(:pipeline) { create(:ci_pipeline) }
|
||||
|
||||
let!(:build) { create(:ci_build, name: 'test', pipeline: pipeline) }
|
||||
|
|
@ -4095,14 +4095,14 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
|
|||
let!(:child_build) { create(:ci_build, :artifacts, name: 'test', pipeline: child_pipeline) }
|
||||
|
||||
it 'returns the build with a given name, having artifacts' do
|
||||
expect(pipeline.build_with_artifacts_in_self_and_descendants('test')).to eq(child_build)
|
||||
expect(pipeline.build_with_artifacts_in_self_and_project_descendants('test')).to eq(child_build)
|
||||
end
|
||||
|
||||
context 'when same job name is present in both parent and child pipeline' do
|
||||
let!(:build) { create(:ci_build, :artifacts, name: 'test', pipeline: pipeline) }
|
||||
|
||||
it 'returns the job in the parent pipeline' do
|
||||
expect(pipeline.build_with_artifacts_in_self_and_descendants('test')).to eq(build)
|
||||
expect(pipeline.build_with_artifacts_in_self_and_project_descendants('test')).to eq(build)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -4183,7 +4183,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
|
|||
end
|
||||
end
|
||||
|
||||
describe '#latest_report_builds_in_self_and_descendants' do
|
||||
describe '#latest_report_builds_in_self_and_project_descendants' do
|
||||
let_it_be(:pipeline) { create(:ci_pipeline, project: project) }
|
||||
let_it_be(:child_pipeline) { create(:ci_pipeline, child_of: pipeline) }
|
||||
let_it_be(:grandchild_pipeline) { create(:ci_pipeline, child_of: child_pipeline) }
|
||||
|
|
@ -4193,21 +4193,21 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
|
|||
child_build = create(:ci_build, :coverage_reports, pipeline: child_pipeline)
|
||||
grandchild_build = create(:ci_build, :codequality_reports, pipeline: grandchild_pipeline)
|
||||
|
||||
expect(pipeline.latest_report_builds_in_self_and_descendants).to contain_exactly(parent_build, child_build, grandchild_build)
|
||||
expect(pipeline.latest_report_builds_in_self_and_project_descendants).to contain_exactly(parent_build, child_build, grandchild_build)
|
||||
end
|
||||
|
||||
it 'filters builds by scope' do
|
||||
create(:ci_build, :test_reports, pipeline: pipeline)
|
||||
grandchild_build = create(:ci_build, :codequality_reports, pipeline: grandchild_pipeline)
|
||||
|
||||
expect(pipeline.latest_report_builds_in_self_and_descendants(Ci::JobArtifact.of_report_type(:codequality))).to contain_exactly(grandchild_build)
|
||||
expect(pipeline.latest_report_builds_in_self_and_project_descendants(Ci::JobArtifact.of_report_type(:codequality))).to contain_exactly(grandchild_build)
|
||||
end
|
||||
|
||||
it 'only returns builds that are not retried' do
|
||||
create(:ci_build, :codequality_reports, :retried, pipeline: grandchild_pipeline)
|
||||
grandchild_build = create(:ci_build, :codequality_reports, pipeline: grandchild_pipeline)
|
||||
|
||||
expect(pipeline.latest_report_builds_in_self_and_descendants).to contain_exactly(grandchild_build)
|
||||
expect(pipeline.latest_report_builds_in_self_and_project_descendants).to contain_exactly(grandchild_build)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -4967,8 +4967,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
|
|||
end
|
||||
end
|
||||
|
||||
describe '#self_and_ancestors' do
|
||||
subject(:self_and_ancestors) { pipeline.self_and_ancestors }
|
||||
describe '#self_and_project_ancestors' do
|
||||
subject(:self_and_project_ancestors) { pipeline.self_and_project_ancestors }
|
||||
|
||||
context 'when pipeline is child' do
|
||||
let(:pipeline) { create(:ci_pipeline, :created) }
|
||||
|
|
@ -4981,7 +4981,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
|
|||
end
|
||||
|
||||
it 'returns parent and self' do
|
||||
expect(self_and_ancestors).to contain_exactly(parent, pipeline)
|
||||
expect(self_and_project_ancestors).to contain_exactly(parent, pipeline)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -4995,7 +4995,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
|
|||
end
|
||||
|
||||
it 'returns only self' do
|
||||
expect(self_and_ancestors).to contain_exactly(pipeline)
|
||||
expect(self_and_project_ancestors).to contain_exactly(pipeline)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1326,54 +1326,6 @@ RSpec.describe Ci::Runner do
|
|||
end
|
||||
end
|
||||
|
||||
describe '#assigned_to_group?' do
|
||||
subject { runner.assigned_to_group? }
|
||||
|
||||
context 'when project runner' do
|
||||
let(:runner) { create(:ci_runner, :project, description: 'Project runner', projects: [project]) }
|
||||
let(:project) { create(:project) }
|
||||
|
||||
it { is_expected.to be_falsey }
|
||||
end
|
||||
|
||||
context 'when shared runner' do
|
||||
let(:runner) { create(:ci_runner, :instance, description: 'Shared runner') }
|
||||
|
||||
it { is_expected.to be_falsey }
|
||||
end
|
||||
|
||||
context 'when group runner' do
|
||||
let(:group) { create(:group) }
|
||||
let(:runner) { create(:ci_runner, :group, description: 'Group runner', groups: [group]) }
|
||||
|
||||
it { is_expected.to be_truthy }
|
||||
end
|
||||
end
|
||||
|
||||
describe '#assigned_to_project?' do
|
||||
subject { runner.assigned_to_project? }
|
||||
|
||||
context 'when group runner' do
|
||||
let(:runner) { create(:ci_runner, :group, description: 'Group runner', groups: [group]) }
|
||||
let(:group) { create(:group) }
|
||||
|
||||
it { is_expected.to be_falsey }
|
||||
end
|
||||
|
||||
context 'when shared runner' do
|
||||
let(:runner) { create(:ci_runner, :instance, description: 'Shared runner') }
|
||||
|
||||
it { is_expected.to be_falsey }
|
||||
end
|
||||
|
||||
context 'when project runner' do
|
||||
let(:runner) { create(:ci_runner, :project, description: 'Project runner', projects: [project]) }
|
||||
let(:project) { create(:project) }
|
||||
|
||||
it { is_expected.to be_truthy }
|
||||
end
|
||||
end
|
||||
|
||||
describe '#pick_build!' do
|
||||
let(:build) { create(:ci_build) }
|
||||
let(:runner) { create(:ci_runner) }
|
||||
|
|
|
|||
|
|
@ -115,7 +115,6 @@ RSpec.describe Integrations::DroneCi, :use_clean_rails_memory_store_caching do
|
|||
it_behaves_like Integrations::HasWebHook do
|
||||
include_context :drone_ci_integration
|
||||
|
||||
let(:drone_url) { 'https://cloud.drone.io' }
|
||||
let(:integration) { drone }
|
||||
let(:hook_url) { "#{drone_url}/hook?owner=#{project.namespace.full_path}&name=#{project.path}&access_token=#{token}" }
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe OnboardingProgress do
|
||||
RSpec.describe Onboarding::Progress do
|
||||
let(:namespace) { create(:namespace) }
|
||||
let(:action) { :subscription_created }
|
||||
|
||||
|
|
@ -34,7 +34,9 @@ RSpec.describe OnboardingProgress do
|
|||
subject { described_class.incomplete_actions(actions) }
|
||||
|
||||
let!(:no_actions_completed) { create(:onboarding_progress) }
|
||||
let!(:one_action_completed_one_action_incompleted) { create(:onboarding_progress, "#{action}_at" => Time.current) }
|
||||
let!(:one_action_completed_one_action_incompleted) do
|
||||
create(:onboarding_progress, "#{action}_at" => Time.current)
|
||||
end
|
||||
|
||||
context 'when given one action' do
|
||||
let(:actions) { action }
|
||||
|
|
@ -52,8 +54,13 @@ RSpec.describe OnboardingProgress do
|
|||
describe '.completed_actions' do
|
||||
subject { described_class.completed_actions(actions) }
|
||||
|
||||
let!(:one_action_completed_one_action_incompleted) { create(:onboarding_progress, "#{action}_at" => Time.current) }
|
||||
let!(:both_actions_completed) { create(:onboarding_progress, "#{action}_at" => Time.current, git_write_at: Time.current) }
|
||||
let!(:one_action_completed_one_action_incompleted) do
|
||||
create(:onboarding_progress, "#{action}_at" => Time.current)
|
||||
end
|
||||
|
||||
let!(:both_actions_completed) do
|
||||
create(:onboarding_progress, "#{action}_at" => Time.current, git_write_at: Time.current)
|
||||
end
|
||||
|
||||
context 'when given one action' do
|
||||
let(:actions) { action }
|
||||
|
|
@ -69,12 +76,23 @@ RSpec.describe OnboardingProgress do
|
|||
end
|
||||
|
||||
describe '.completed_actions_with_latest_in_range' do
|
||||
subject { described_class.completed_actions_with_latest_in_range(actions, 1.day.ago.beginning_of_day..1.day.ago.end_of_day) }
|
||||
subject do
|
||||
described_class.completed_actions_with_latest_in_range(actions,
|
||||
1.day.ago.beginning_of_day..1.day.ago.end_of_day)
|
||||
end
|
||||
|
||||
let!(:one_action_completed_in_range_one_action_incompleted) do
|
||||
create(:onboarding_progress, "#{action}_at" => 1.day.ago.middle_of_day)
|
||||
end
|
||||
|
||||
let!(:one_action_completed_in_range_one_action_incompleted) { create(:onboarding_progress, "#{action}_at" => 1.day.ago.middle_of_day) }
|
||||
let!(:git_write_action_completed_in_range) { create(:onboarding_progress, git_write_at: 1.day.ago.middle_of_day) }
|
||||
let!(:both_actions_completed_latest_action_out_of_range) { create(:onboarding_progress, "#{action}_at" => 1.day.ago.middle_of_day, git_write_at: Time.current) }
|
||||
let!(:both_actions_completed_latest_action_in_range) { create(:onboarding_progress, "#{action}_at" => 1.day.ago.middle_of_day, git_write_at: 2.days.ago.middle_of_day) }
|
||||
let!(:both_actions_completed_latest_action_out_of_range) do
|
||||
create(:onboarding_progress, "#{action}_at" => 1.day.ago.middle_of_day, git_write_at: Time.current)
|
||||
end
|
||||
|
||||
let!(:both_actions_completed_latest_action_in_range) do
|
||||
create(:onboarding_progress, "#{action}_at" => 1.day.ago.middle_of_day, git_write_at: 2.days.ago.middle_of_day)
|
||||
end
|
||||
|
||||
context 'when given one action' do
|
||||
let(:actions) { :git_write }
|
||||
|
|
@ -147,7 +165,9 @@ RSpec.describe OnboardingProgress do
|
|||
expect(described_class.find_by_namespace_id(namespace.id).subscription_created_at).to be_nil
|
||||
register_action
|
||||
expect(described_class.find_by_namespace_id(namespace.id).subscription_created_at).not_to be_nil
|
||||
expect { described_class.register(namespace, action) }.not_to change { described_class.find_by_namespace_id(namespace.id).subscription_created_at }
|
||||
expect do
|
||||
described_class.register(namespace, action)
|
||||
end.not_to change { described_class.find_by_namespace_id(namespace.id).subscription_created_at }
|
||||
end
|
||||
|
||||
context 'when the action does not exist' do
|
||||
|
|
@ -209,7 +229,9 @@ RSpec.describe OnboardingProgress do
|
|||
context 'when the namespace was not onboarded' do
|
||||
it 'does not register the action for the namespace' do
|
||||
expect { register_action }.not_to change { described_class.completed?(namespace, action1) }.from(false)
|
||||
expect { described_class.register(namespace, action) }.not_to change { described_class.completed?(namespace, action2) }.from(false)
|
||||
expect do
|
||||
described_class.register(namespace, action)
|
||||
end.not_to change { described_class.completed?(namespace, action2) }.from(false)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -270,21 +292,23 @@ RSpec.describe OnboardingProgress do
|
|||
end
|
||||
|
||||
describe '#number_of_completed_actions' do
|
||||
subject { build(:onboarding_progress, actions.map { |x| { x => Time.current } }.inject(:merge)).number_of_completed_actions }
|
||||
subject do
|
||||
build(:onboarding_progress, actions.map { |x| { x => Time.current } }.inject(:merge)).number_of_completed_actions
|
||||
end
|
||||
|
||||
context '0 completed actions' do
|
||||
context 'with 0 completed actions' do
|
||||
let(:actions) { [:created_at, :updated_at] }
|
||||
|
||||
it { is_expected.to eq(0) }
|
||||
end
|
||||
|
||||
context '1 completed action' do
|
||||
context 'with 1 completed action' do
|
||||
let(:actions) { [:created_at, :subscription_created_at] }
|
||||
|
||||
it { is_expected.to eq(1) }
|
||||
end
|
||||
|
||||
context '2 completed actions' do
|
||||
context 'with 2 completed actions' do
|
||||
let(:actions) { [:subscription_created_at, :git_write_at] }
|
||||
|
||||
it { is_expected.to eq(2) }
|
||||
|
|
@ -193,7 +193,7 @@ RSpec.describe Environments::StopService do
|
|||
end
|
||||
|
||||
it 'has active environment at first' do
|
||||
expect(pipeline.environments_in_self_and_descendants.first).to be_available
|
||||
expect(pipeline.environments_in_self_and_project_descendants.first).to be_available
|
||||
end
|
||||
|
||||
context 'when user is a developer' do
|
||||
|
|
@ -203,7 +203,7 @@ RSpec.describe Environments::StopService do
|
|||
|
||||
it 'stops the active environment' do
|
||||
subject
|
||||
expect(pipeline.environments_in_self_and_descendants.first).to be_stopping
|
||||
expect(pipeline.environments_in_self_and_project_descendants.first).to be_stopping
|
||||
end
|
||||
|
||||
context 'when pipeline is a branch pipeline for merge request' do
|
||||
|
|
@ -218,7 +218,7 @@ RSpec.describe Environments::StopService do
|
|||
it 'does not stop the active environment' do
|
||||
subject
|
||||
|
||||
expect(pipeline.environments_in_self_and_descendants.first).to be_available
|
||||
expect(pipeline.environments_in_self_and_project_descendants.first).to be_available
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -244,7 +244,7 @@ RSpec.describe Environments::StopService do
|
|||
it 'does not stop the active environment' do
|
||||
subject
|
||||
|
||||
expect(pipeline.environments_in_self_and_descendants.first).to be_available
|
||||
expect(pipeline.environments_in_self_and_project_descendants.first).to be_available
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -268,7 +268,7 @@ RSpec.describe Environments::StopService do
|
|||
it 'does not stop the active environment' do
|
||||
subject
|
||||
|
||||
expect(pipeline.environments_in_self_and_descendants.first).to be_available
|
||||
expect(pipeline.environments_in_self_and_project_descendants.first).to be_available
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ RSpec.describe Groups::CreateService, '#execute' do
|
|||
it { is_expected.to be_persisted }
|
||||
|
||||
it 'adds an onboarding progress record' do
|
||||
expect { subject }.to change(OnboardingProgress, :count).from(0).to(1)
|
||||
expect { subject }.to change(Onboarding::Progress, :count).from(0).to(1)
|
||||
end
|
||||
|
||||
context 'with before_commit callback' do
|
||||
|
|
@ -108,7 +108,7 @@ RSpec.describe Groups::CreateService, '#execute' do
|
|||
it { is_expected.to be_persisted }
|
||||
|
||||
it 'does not add an onboarding progress record' do
|
||||
expect { subject }.not_to change(OnboardingProgress, :count).from(0)
|
||||
expect { subject }.not_to change(Onboarding::Progress, :count).from(0)
|
||||
end
|
||||
|
||||
it_behaves_like 'has sync-ed traversal_ids'
|
||||
|
|
|
|||
|
|
@ -20,10 +20,10 @@ RSpec.describe Members::CreateService, :aggregate_failures, :clean_gitlab_redis_
|
|||
case source
|
||||
when Project
|
||||
source.add_maintainer(user)
|
||||
OnboardingProgress.onboard(source.namespace)
|
||||
Onboarding::Progress.onboard(source.namespace)
|
||||
when Group
|
||||
source.add_owner(user)
|
||||
OnboardingProgress.onboard(source)
|
||||
Onboarding::Progress.onboard(source)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -59,7 +59,7 @@ RSpec.describe Members::CreateService, :aggregate_failures, :clean_gitlab_redis_
|
|||
it 'adds a user to members' do
|
||||
expect(execute_service[:status]).to eq(:success)
|
||||
expect(source.users).to include member
|
||||
expect(OnboardingProgress.completed?(source.namespace, :user_added)).to be(true)
|
||||
expect(Onboarding::Progress.completed?(source.namespace, :user_added)).to be(true)
|
||||
end
|
||||
|
||||
context 'when user_id is passed as an integer' do
|
||||
|
|
@ -68,7 +68,7 @@ RSpec.describe Members::CreateService, :aggregate_failures, :clean_gitlab_redis_
|
|||
it 'successfully creates member' do
|
||||
expect(execute_service[:status]).to eq(:success)
|
||||
expect(source.users).to include member
|
||||
expect(OnboardingProgress.completed?(source.namespace, :user_added)).to be(true)
|
||||
expect(Onboarding::Progress.completed?(source.namespace, :user_added)).to be(true)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -78,7 +78,7 @@ RSpec.describe Members::CreateService, :aggregate_failures, :clean_gitlab_redis_
|
|||
it 'successfully creates members' do
|
||||
expect(execute_service[:status]).to eq(:success)
|
||||
expect(source.users).to include(member, user_invited_by_id)
|
||||
expect(OnboardingProgress.completed?(source.namespace, :user_added)).to be(true)
|
||||
expect(Onboarding::Progress.completed?(source.namespace, :user_added)).to be(true)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -88,7 +88,7 @@ RSpec.describe Members::CreateService, :aggregate_failures, :clean_gitlab_redis_
|
|||
it 'successfully creates members' do
|
||||
expect(execute_service[:status]).to eq(:success)
|
||||
expect(source.users).to include(member, user_invited_by_id)
|
||||
expect(OnboardingProgress.completed?(source.namespace, :user_added)).to be(true)
|
||||
expect(Onboarding::Progress.completed?(source.namespace, :user_added)).to be(true)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -98,7 +98,7 @@ RSpec.describe Members::CreateService, :aggregate_failures, :clean_gitlab_redis_
|
|||
it 'adds a user to members' do
|
||||
expect(execute_service[:status]).to eq(:success)
|
||||
expect(source.users).to include member
|
||||
expect(OnboardingProgress.completed?(source, :user_added)).to be(true)
|
||||
expect(Onboarding::Progress.completed?(source, :user_added)).to be(true)
|
||||
end
|
||||
|
||||
it 'triggers a members added event' do
|
||||
|
|
@ -119,7 +119,7 @@ RSpec.describe Members::CreateService, :aggregate_failures, :clean_gitlab_redis_
|
|||
expect(execute_service[:status]).to eq(:error)
|
||||
expect(execute_service[:message]).to be_present
|
||||
expect(source.users).not_to include member
|
||||
expect(OnboardingProgress.completed?(source.namespace, :user_added)).to be(false)
|
||||
expect(Onboarding::Progress.completed?(source.namespace, :user_added)).to be(false)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -130,7 +130,7 @@ RSpec.describe Members::CreateService, :aggregate_failures, :clean_gitlab_redis_
|
|||
expect(execute_service[:status]).to eq(:error)
|
||||
expect(execute_service[:message]).to be_present
|
||||
expect(source.users).not_to include member
|
||||
expect(OnboardingProgress.completed?(source.namespace, :user_added)).to be(false)
|
||||
expect(Onboarding::Progress.completed?(source.namespace, :user_added)).to be(false)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -141,7 +141,7 @@ RSpec.describe Members::CreateService, :aggregate_failures, :clean_gitlab_redis_
|
|||
expect(execute_service[:status]).to eq(:error)
|
||||
expect(execute_service[:message]).to include("#{member.username}: Access level is not included in the list")
|
||||
expect(source.users).not_to include member
|
||||
expect(OnboardingProgress.completed?(source.namespace, :user_added)).to be(false)
|
||||
expect(Onboarding::Progress.completed?(source.namespace, :user_added)).to be(false)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -153,7 +153,7 @@ RSpec.describe Members::CreateService, :aggregate_failures, :clean_gitlab_redis_
|
|||
it 'allows already invited members to be re-invited by email and updates the member access' do
|
||||
expect(execute_service[:status]).to eq(:success)
|
||||
expect(invited_member.reset.access_level).to eq ProjectMember::MAINTAINER
|
||||
expect(OnboardingProgress.completed?(source.namespace, :user_added)).to be(true)
|
||||
expect(Onboarding::Progress.completed?(source.namespace, :user_added)).to be(true)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -170,7 +170,7 @@ RSpec.describe Members::CreateService, :aggregate_failures, :clean_gitlab_redis_
|
|||
it 'does not update the member' do
|
||||
expect(execute_service[:status]).to eq(:error)
|
||||
expect(execute_service[:message]).to eq("#{project_bot.username}: not authorized to update member")
|
||||
expect(OnboardingProgress.completed?(source.namespace, :user_added)).to be(false)
|
||||
expect(Onboarding::Progress.completed?(source.namespace, :user_added)).to be(false)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -178,7 +178,7 @@ RSpec.describe Members::CreateService, :aggregate_failures, :clean_gitlab_redis_
|
|||
it 'adds the member' do
|
||||
expect(execute_service[:status]).to eq(:success)
|
||||
expect(source.users).to include project_bot
|
||||
expect(OnboardingProgress.completed?(source.namespace, :user_added)).to be(true)
|
||||
expect(Onboarding::Progress.completed?(source.namespace, :user_added)).to be(true)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -19,12 +19,12 @@ RSpec.describe OnboardingProgressService do
|
|||
|
||||
context 'when onboarded' do
|
||||
before do
|
||||
OnboardingProgress.onboard(namespace)
|
||||
Onboarding::Progress.onboard(namespace)
|
||||
end
|
||||
|
||||
context 'when action is already completed' do
|
||||
before do
|
||||
OnboardingProgress.register(namespace, action)
|
||||
Onboarding::Progress.register(namespace, action)
|
||||
end
|
||||
|
||||
it 'does not schedule a worker' do
|
||||
|
|
@ -52,13 +52,13 @@ RSpec.describe OnboardingProgressService do
|
|||
|
||||
context 'when the namespace is a root' do
|
||||
before do
|
||||
OnboardingProgress.onboard(namespace)
|
||||
Onboarding::Progress.onboard(namespace)
|
||||
end
|
||||
|
||||
it 'registers a namespace onboarding progress action for the given namespace' do
|
||||
execute_service
|
||||
|
||||
expect(OnboardingProgress.completed?(namespace, :subscription_created)).to eq(true)
|
||||
expect(Onboarding::Progress.completed?(namespace, :subscription_created)).to eq(true)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -66,13 +66,13 @@ RSpec.describe OnboardingProgressService do
|
|||
let(:group) { create(:group, :nested) }
|
||||
|
||||
before do
|
||||
OnboardingProgress.onboard(group)
|
||||
Onboarding::Progress.onboard(group)
|
||||
end
|
||||
|
||||
it 'does not register a namespace onboarding progress action' do
|
||||
execute_service
|
||||
|
||||
expect(OnboardingProgress.completed?(group, :subscription_created)).to be(nil)
|
||||
expect(Onboarding::Progress.completed?(group, :subscription_created)).to be(nil)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -82,7 +82,7 @@ RSpec.describe OnboardingProgressService do
|
|||
it 'does not register a namespace onboarding progress action' do
|
||||
execute_service
|
||||
|
||||
expect(OnboardingProgress.completed?(namespace, :subscription_created)).to be(nil)
|
||||
expect(Onboarding::Progress.completed?(namespace, :subscription_created)).to be(nil)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -52,6 +52,15 @@ RSpec.shared_examples 'querying members with a group' do
|
|||
expect(subject).to contain_exactly(resource_member, group_1_member, root_group_member)
|
||||
end
|
||||
|
||||
context 'with sort options' do
|
||||
let(:args) { { sort: 'name_asc' } }
|
||||
|
||||
it 'searches users by user name' do
|
||||
# the order is important here
|
||||
expect(subject.items).to eq([root_group_member, resource_member, group_1_member])
|
||||
end
|
||||
end
|
||||
|
||||
context 'with search' do
|
||||
context 'when the search term matches a user' do
|
||||
let(:args) { { search: 'test' } }
|
||||
|
|
|
|||
|
|
@ -7,6 +7,30 @@ RSpec.shared_examples Integrations::HasWebHook do
|
|||
it { is_expected.to have_one(:service_hook).inverse_of(:integration).with_foreign_key(:service_id) }
|
||||
end
|
||||
|
||||
describe 'callbacks' do
|
||||
it 'calls #update_web_hook! when enabled' do
|
||||
expect(integration).to receive(:update_web_hook!)
|
||||
|
||||
integration.active = true
|
||||
integration.save!
|
||||
end
|
||||
|
||||
it 'does not call #update_web_hook! when disabled' do
|
||||
expect(integration).not_to receive(:update_web_hook!)
|
||||
|
||||
integration.active = false
|
||||
integration.save!
|
||||
end
|
||||
|
||||
it 'does not call #update_web_hook! when validation fails' do
|
||||
expect(integration).not_to receive(:update_web_hook!)
|
||||
|
||||
integration.active = true
|
||||
integration.project = nil
|
||||
expect(integration.save).to be(false)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#hook_url' do
|
||||
it 'returns a string' do
|
||||
expect(integration.hook_url).to be_a(String)
|
||||
|
|
|
|||
|
|
@ -19,11 +19,11 @@ RSpec.describe Namespaces::OnboardingIssueCreatedWorker, '#perform' do
|
|||
let(:job_args) { [namespace.id] }
|
||||
|
||||
it 'sets the onboarding progress action' do
|
||||
OnboardingProgress.onboard(namespace)
|
||||
Onboarding::Progress.onboard(namespace)
|
||||
|
||||
subject
|
||||
|
||||
expect(OnboardingProgress.completed?(namespace, :issue_created)).to eq(true)
|
||||
expect(Onboarding::Progress.completed?(namespace, :issue_created)).to eq(true)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Reference in New Issue