Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2022-09-08 21:13:09 +00:00
parent 03a8aa2ca6
commit f26a600a69
78 changed files with 955 additions and 730 deletions

View File

@ -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

View File

@ -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'

View File

@ -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'

View File

@ -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'

View File

@ -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'

View File

@ -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'

View File

@ -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'

View File

@ -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>

View File

@ -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>

View File

@ -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];

View File

@ -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>

View File

@ -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);
}
}

View File

@ -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();

View File

@ -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"

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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: [])

View File

@ -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) }

View File

@ -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)

View File

@ -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}"

View File

@ -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)

View File

@ -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) }

View File

@ -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!

View File

@ -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`.

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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(

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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|

View File

@ -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

View File

@ -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)

View File

@ -1,7 +1,7 @@
---
table_name: onboarding_progresses
classes:
- OnboardingProgress
- Onboarding::Progress
feature_categories:
- onboarding
description: TODO

View File

@ -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

View File

@ -0,0 +1 @@
c32756c482bdda948f911d0405d2373673041c57ebc514cfc5f172ba6fda9185

View File

@ -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));

View File

@ -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.

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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 ""

View File

@ -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

View File

@ -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
{

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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);
});
});

View File

@ -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);
});
});

View File

@ -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');

View File

@ -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);
});
});
});

View File

@ -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 hasnt 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,
);
});
});
});

View File

@ -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);
});
});
});

View File

@ -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');

View File

@ -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

View File

@ -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) }

View File

@ -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

View File

@ -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

View File

@ -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 }

View File

@ -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

View File

@ -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) }

View File

@ -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}" }

View File

@ -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) }

View File

@ -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

View File

@ -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'

View File

@ -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

View File

@ -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

View File

@ -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' } }

View File

@ -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)

View File

@ -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