Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2021-05-07 21:10:34 +00:00
parent 1572e2a376
commit 96135034f4
45 changed files with 916 additions and 49 deletions

View File

@ -0,0 +1,154 @@
<script>
import { pickBy } from 'lodash';
import { mapActions } from 'vuex';
import { updateHistory, setUrlParams } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
export default {
i18n: {
search: __('Search'),
label: __('Label'),
author: __('Author'),
},
components: { FilteredSearch },
inject: ['initialFilterParams'],
props: {
tokens: {
type: Array,
required: true,
},
},
data() {
return {
filterParams: this.initialFilterParams,
};
},
computed: {
urlParams() {
const { authorUsername, labelName, search } = this.filterParams;
let notParams = {};
if (Object.prototype.hasOwnProperty.call(this.filterParams, 'not')) {
notParams = pickBy(
{
'not[label_name][]': this.filterParams.not.labelName,
'not[author_username]': this.filterParams.not.authorUsername,
},
undefined,
);
}
return {
...notParams,
author_username: authorUsername,
'label_name[]': labelName,
search,
};
},
},
methods: {
...mapActions(['performSearch']),
handleFilter(filters) {
this.filterParams = this.getFilterParams(filters);
updateHistory({
url: setUrlParams(this.urlParams, window.location.href, true, false, true),
title: document.title,
replace: true,
});
this.performSearch();
},
getFilteredSearchValue() {
const { authorUsername, labelName, search } = this.filterParams;
const filteredSearchValue = [];
if (authorUsername) {
filteredSearchValue.push({
type: 'author_username',
value: { data: authorUsername, operator: '=' },
});
}
if (labelName?.length) {
filteredSearchValue.push(
...labelName.map((label) => ({
type: 'label_name',
value: { data: label, operator: '=' },
})),
);
}
if (this.filterParams['not[authorUsername]']) {
filteredSearchValue.push({
type: 'author_username',
value: { data: this.filterParams['not[authorUsername]'], operator: '!=' },
});
}
if (this.filterParams['not[labelName]']) {
filteredSearchValue.push(
...this.filterParams['not[labelName]'].map((label) => ({
type: 'label_name',
value: { data: label, operator: '!=' },
})),
);
}
if (search) {
filteredSearchValue.push(search);
}
return filteredSearchValue;
},
getFilterParams(filters = []) {
const notFilters = filters.filter((item) => item.value.operator === '!=');
const equalsFilters = filters.filter((item) => item.value.operator === '=');
return { ...this.generateParams(equalsFilters), not: { ...this.generateParams(notFilters) } };
},
generateParams(filters = []) {
const filterParams = {};
const labels = [];
const plainText = [];
filters.forEach((filter) => {
switch (filter.type) {
case 'author_username':
filterParams.authorUsername = filter.value.data;
break;
case 'label_name':
labels.push(filter.value.data);
break;
case 'filtered-search-term':
if (filter.value.data) plainText.push(filter.value.data);
break;
default:
break;
}
});
if (labels.length) {
filterParams.labelName = labels;
}
if (plainText.length) {
filterParams.search = plainText.join(' ');
}
return filterParams;
},
},
};
</script>
<template>
<filtered-search
class="gl-w-full"
namespace=""
:tokens="tokens"
:search-input-placeholder="$options.i18n.search"
:initial-filter-value="getFilteredSearchValue()"
@onFilter="handleFilter"
/>
</template>

View File

@ -0,0 +1,9 @@
mutation dismissUserCallout($input: UserCalloutCreateInput!) {
userCalloutCreate(input: $input) {
errors
userCallout {
dismissedAt
featureName
}
}
}

View File

@ -0,0 +1,26 @@
<script>
import { GlAlert } from '@gitlab/ui';
import { getAlert } from '../lib/alerts';
export default {
components: {
GlAlert,
},
props: {
alertKey: {
type: Symbol,
required: true,
},
},
computed: {
alert() {
return getAlert(this.alertKey);
},
},
};
</script>
<template>
<gl-alert v-bind="alert.props" @dismiss="alert.dismiss($store)">
<component :is="alert.message" />
</gl-alert>
</template>

View File

@ -1,4 +1,5 @@
<script>
import { debounce } from 'lodash';
import { mapState, mapGetters, mapActions } from 'vuex';
import {
EDITOR_TYPE_DIFF,
@ -34,11 +35,13 @@ import mapRulesToMonaco from '../lib/editorconfig/rules_mapper';
import { getFileEditorOrDefault } from '../stores/modules/editor/utils';
import { extractMarkdownImagesFromEntries } from '../stores/utils';
import { getPathParent, readFileAsDataURL, registerSchema, isTextFile } from '../utils';
import FileAlert from './file_alert.vue';
import FileTemplatesBar from './file_templates/bar.vue';
export default {
name: 'RepoEditor',
components: {
FileAlert,
ContentViewer,
DiffViewer,
FileTemplatesBar,
@ -57,6 +60,7 @@ export default {
globalEditor: null,
modelManager: new ModelManager(),
isEditorLoading: true,
unwatchCiYaml: null,
};
},
computed: {
@ -74,6 +78,7 @@ export default {
'currentProjectId',
]),
...mapGetters([
'getAlert',
'currentMergeRequest',
'getStagedFile',
'isEditModeActive',
@ -82,6 +87,9 @@ export default {
'getJsonSchemaForPath',
]),
...mapGetters('fileTemplates', ['showFileTemplatesBar']),
alertKey() {
return this.getAlert(this.file);
},
fileEditor() {
return getFileEditorOrDefault(this.fileEditors, this.file.path);
},
@ -136,6 +144,16 @@ export default {
},
},
watch: {
'file.name': {
handler() {
this.stopWatchingCiYaml();
if (this.file.name === '.gitlab-ci.yml') {
this.startWatchingCiYaml();
}
},
immediate: true,
},
file(newVal, oldVal) {
if (oldVal.pending) {
this.removePendingTab(oldVal);
@ -216,6 +234,7 @@ export default {
'removePendingTab',
'triggerFilesChange',
'addTempImage',
'detectGitlabCiFileAlerts',
]),
...mapActions('editor', ['updateFileEditor']),
initEditor() {
@ -422,6 +441,18 @@ export default {
this.updateFileEditor({ path: this.file.path, data });
},
startWatchingCiYaml() {
this.unwatchCiYaml = this.$watch(
'file.content',
debounce(this.detectGitlabCiFileAlerts, 500),
);
},
stopWatchingCiYaml() {
if (this.unwatchCiYaml) {
this.unwatchCiYaml();
this.unwatchCiYaml = null;
}
},
},
viewerTypes,
FILE_VIEW_MODE_EDITOR,
@ -439,9 +470,8 @@ export default {
role="button"
data-testid="edit-tab"
@click.prevent="updateEditor({ viewMode: $options.FILE_VIEW_MODE_EDITOR })"
>{{ __('Edit') }}</a
>
{{ __('Edit') }}
</a>
</li>
<li v-if="previewMode" :class="previewTabCSS">
<a
@ -454,7 +484,8 @@ export default {
</li>
</ul>
</div>
<file-templates-bar v-if="showFileTemplatesBar(file.name)" />
<file-alert v-if="alertKey" :alert-key="alertKey" />
<file-templates-bar v-else-if="showFileTemplatesBar(file.name)" />
<div
v-show="showEditor"
ref="editor"

View File

@ -56,11 +56,12 @@ export function initIde(el, options = {}) {
webIDEHelpPagePath: el.dataset.webIdeHelpPagePath,
forkInfo: el.dataset.forkInfo ? JSON.parse(el.dataset.forkInfo) : null,
});
this.setInitialData({
this.init({
clientsidePreviewEnabled: parseBoolean(el.dataset.clientsidePreviewEnabled),
renderWhitespaceInCode: parseBoolean(el.dataset.renderWhitespaceInCode),
editorTheme: window.gon?.user_color_scheme || DEFAULT_THEME,
codesandboxBundlerUrl: el.dataset.codesandboxBundlerUrl,
environmentsGuidanceAlertDismissed: !parseBoolean(el.dataset.enableEnvironmentsGuidance),
});
},
beforeDestroy() {
@ -68,7 +69,7 @@ export function initIde(el, options = {}) {
this.$emit('destroy');
},
methods: {
...mapActions(['setEmptyStateSvgs', 'setLinks', 'setInitialData']),
...mapActions(['setEmptyStateSvgs', 'setLinks', 'init']),
},
render(createElement) {
return createElement(rootComponent);

View File

@ -0,0 +1,32 @@
<script>
import { GlSprintf, GlLink } from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
import { __ } from '~/locale';
export default {
components: { GlSprintf, GlLink },
message: __(
"No deployments detected. Use environments to control your software's continuous deployment. %{linkStart}Learn more about deployment jobs.%{linkEnd}",
),
computed: {
helpLink() {
return helpPagePath('ci/environments/index.md');
},
},
};
</script>
<template>
<span>
<gl-sprintf :message="$options.message">
<template #link="{ content }">
<gl-link
:href="helpLink"
target="_blank"
data-track-action="click_link"
data-track-experiment="in_product_guidance_environments_webide"
>{{ content }}</gl-link
>
</template>
</gl-sprintf>
</span>
</template>

View File

@ -0,0 +1,20 @@
import { leftSidebarViews } from '../../constants';
import EnvironmentsMessage from './environments.vue';
const alerts = [
{
key: Symbol('ALERT_ENVIRONMENT'),
show: (state, file) =>
state.currentActivityView === leftSidebarViews.commit.name &&
file.path === '.gitlab-ci.yml' &&
state.environmentsGuidanceAlertDetected &&
!state.environmentsGuidanceAlertDismissed,
props: { variant: 'tip' },
dismiss: ({ dispatch }) => dispatch('dismissEnvironmentsGuidance'),
message: EnvironmentsMessage,
},
];
export const findAlertKeyToShow = (...args) => alerts.find((x) => x.show(...args))?.key;
export const getAlert = (key) => alerts.find((x) => x.key === key);

View File

@ -18,3 +18,4 @@ const getClient = memoize(() =>
);
export const query = (...args) => getClient().query(...args);
export const mutate = (...args) => getClient().mutate(...args);

View File

@ -1,8 +1,10 @@
import getIdeProject from 'ee_else_ce/ide/queries/get_ide_project.query.graphql';
import Api from '~/api';
import dismissUserCallout from '~/graphql_shared/mutations/dismiss_user_callout.mutation.graphql';
import axios from '~/lib/utils/axios_utils';
import { joinPaths, escapeFileUrl } from '~/lib/utils/url_utility';
import { query } from './gql';
import ciConfig from '~/pipeline_editor/graphql/queries/ci_config.graphql';
import { query, mutate } from './gql';
const fetchApiProjectData = (projectPath) => Api.project(projectPath).then(({ data }) => data);
@ -101,4 +103,16 @@ export default {
const url = `${gon.relative_url_root}/${projectPath}/usage_ping/web_ide_pipelines_count`;
return axios.post(url);
},
getCiConfig(projectPath, content) {
return query({
query: ciConfig,
variables: { projectPath, content },
}).then(({ data }) => data.ciConfig);
},
dismissUserCallout(name) {
return mutate({
mutation: dismissUserCallout,
variables: { input: { featureName: name } },
}).then(({ data }) => data);
},
};

View File

@ -17,7 +17,7 @@ import * as types from './mutation_types';
export const redirectToUrl = (self, url) => visitUrl(url);
export const setInitialData = ({ commit }, data) => commit(types.SET_INITIAL_DATA, data);
export const init = ({ commit }, data) => commit(types.SET_INITIAL_DATA, data);
export const discardAllChanges = ({ state, commit, dispatch }) => {
state.changedFiles.forEach((file) => dispatch('restoreOriginalFile', file.path));
@ -316,3 +316,4 @@ export * from './actions/tree';
export * from './actions/file';
export * from './actions/project';
export * from './actions/merge_request';
export * from './actions/alert';

View File

@ -0,0 +1,18 @@
import service from '../../services';
import {
DETECT_ENVIRONMENTS_GUIDANCE_ALERT,
DISMISS_ENVIRONMENTS_GUIDANCE_ALERT,
} from '../mutation_types';
export const detectGitlabCiFileAlerts = ({ dispatch }, content) =>
dispatch('detectEnvironmentsGuidance', content);
export const detectEnvironmentsGuidance = ({ commit, state }, content) =>
service.getCiConfig(state.currentProjectId, content).then((data) => {
commit(DETECT_ENVIRONMENTS_GUIDANCE_ALERT, data?.stages);
});
export const dismissEnvironmentsGuidance = ({ commit }) =>
service.dismissUserCallout('web_ide_ci_environments_guidance').then(() => {
commit(DISMISS_ENVIRONMENTS_GUIDANCE_ALERT);
});

View File

@ -262,3 +262,5 @@ export const getJsonSchemaForPath = (state, getters) => (path) => {
fileMatch: [`*${path}`],
};
};
export * from './getters/alert';

View File

@ -0,0 +1,3 @@
import { findAlertKeyToShow } from '../../lib/alerts';
export const getAlert = (state) => (file) => findAlertKeyToShow(state, file);

View File

@ -70,3 +70,8 @@ export const RENAME_ENTRY = 'RENAME_ENTRY';
export const REVERT_RENAME_ENTRY = 'REVERT_RENAME_ENTRY';
export const RESTORE_TREE = 'RESTORE_TREE';
// Alert mutation types
export const DETECT_ENVIRONMENTS_GUIDANCE_ALERT = 'DETECT_ENVIRONMENTS_GUIDANCE_ALERT';
export const DISMISS_ENVIRONMENTS_GUIDANCE_ALERT = 'DISMISS_ENVIRONMENTS_GUIDANCE_ALERT';

View File

@ -1,5 +1,6 @@
import Vue from 'vue';
import * as types from './mutation_types';
import alertMutations from './mutations/alert';
import branchMutations from './mutations/branch';
import fileMutations from './mutations/file';
import mergeRequestMutation from './mutations/merge_request';
@ -244,4 +245,5 @@ export default {
...fileMutations,
...treeMutations,
...branchMutations,
...alertMutations,
};

View File

@ -0,0 +1,21 @@
import {
DETECT_ENVIRONMENTS_GUIDANCE_ALERT,
DISMISS_ENVIRONMENTS_GUIDANCE_ALERT,
} from '../mutation_types';
export default {
[DETECT_ENVIRONMENTS_GUIDANCE_ALERT](state, stages) {
if (!stages) {
return;
}
const hasEnvironments = stages?.nodes?.some((stage) =>
stage.groups.nodes.some((group) => group.jobs.nodes.some((job) => job.environment)),
);
const hasParsedCi = Array.isArray(stages.nodes);
state.environmentsGuidanceAlertDetected = !hasEnvironments && hasParsedCi;
},
[DISMISS_ENVIRONMENTS_GUIDANCE_ALERT](state) {
state.environmentsGuidanceAlertDismissed = true;
},
};

View File

@ -30,4 +30,6 @@ export default () => ({
renderWhitespaceInCode: false,
editorTheme: DEFAULT_THEME,
codesandboxBundlerUrl: null,
environmentsGuidanceAlertDismissed: false,
environmentsGuidanceAlertDetected: false,
});

View File

@ -0,0 +1,15 @@
# frozen_string_literal: true
class InProductGuidanceEnvironmentsWebideExperiment < ApplicationExperiment # rubocop:disable Gitlab/NamespacedClass
exclude :has_environments?
def control_behavior
false
end
private
def has_environments?
!context.project.environments.empty?
end
end

View File

@ -17,7 +17,8 @@ module IdeHelper
'file-path' => @path,
'merge-request' => @merge_request,
'fork-info' => @fork_info&.to_json,
'project' => convert_to_project_entity_json(@project)
'project' => convert_to_project_entity_json(@project),
'enable-environments-guidance' => enable_environments_guidance?.to_s
}
end
@ -28,6 +29,18 @@ module IdeHelper
API::Entities::Project.represent(project).to_json
end
def enable_environments_guidance?
experiment(:in_product_guidance_environments_webide, project: @project) do |e|
e.try { !has_dismissed_ide_environments_callout? }
e.run
end
end
def has_dismissed_ide_environments_callout?
current_user.dismissed_callout?(feature_name: 'web_ide_ci_environments_guidance')
end
end
::IdeHelper.prepend_if_ee('::EE::IdeHelper')

View File

@ -107,6 +107,8 @@ class Group < Namespace
scope :with_users, -> { includes(:users) }
scope :with_onboarding_progress, -> { joins(:onboarding_progress) }
scope :by_id, ->(groups) { where(id: groups) }
scope :for_authorized_group_members, -> (user_ids) do

View File

@ -31,7 +31,8 @@ class UserCallout < ApplicationRecord
unfinished_tag_cleanup_callout: 27,
eoa_bronze_plan_banner: 28, # EE-only
pipeline_needs_banner: 29,
pipeline_needs_hover_tip: 30
pipeline_needs_hover_tip: 30,
web_ide_ci_environments_guidance: 31
}
validates :user, presence: true

View File

@ -66,7 +66,6 @@ module Namespaces
Experiment.add_group(:in_product_marketing_emails, variant: variant, group: group)
end
# rubocop: disable CodeReuse/ActiveRecord
def groups_for_track
onboarding_progress_scope = OnboardingProgress
.completed_actions_with_latest_in_range(completed_actions, range)
@ -75,9 +74,18 @@ module Namespaces
# Filtering out sub-groups is a temporary fix to prevent calling
# `.root_ancestor` on groups that are not root groups.
# See https://gitlab.com/groups/gitlab-org/-/epics/5594 for more information.
Group.where(parent_id: nil).joins(:onboarding_progress).merge(onboarding_progress_scope)
Group
.top_most
.with_onboarding_progress
.merge(onboarding_progress_scope)
.merge(subscription_scope)
end
def subscription_scope
{}
end
# rubocop: disable CodeReuse/ActiveRecord
def users_for_group(group)
group.users
.where(email_opted_in: true)
@ -136,3 +144,5 @@ module Namespaces
end
end
end
Namespaces::InProductMarketingEmailsService.prepend_ee_mod

View File

@ -15,11 +15,17 @@ module Spam
def execute
spamcheck_result = nil
spamcheck_attribs = {}
external_spam_check_round_trip_time = Benchmark.realtime do
spamcheck_result = spamcheck_verdict
spamcheck_result, spamcheck_attribs = spamcheck_verdict
end
# assign result to a var and log it before reassigning to nil when monitorMode is true
original_spamcheck_result = spamcheck_result
spamcheck_result = nil if spamcheck_attribs&.fetch("monitorMode", "false") == "true"
akismet_result = akismet_verdict
# filter out anything we don't recognise, including nils.
@ -33,7 +39,8 @@ module Spam
logger.info(class: self.class.name,
akismet_verdict: akismet_verdict,
spam_check_verdict: spamcheck_result,
spam_check_verdict: original_spamcheck_result,
extra_attributes: spamcheck_attribs,
spam_check_rtt: external_spam_check_round_trip_time.real,
final_verdict: final_verdict,
username: user.username,
@ -61,21 +68,23 @@ module Spam
return unless Gitlab::CurrentSettings.spam_check_endpoint_enabled
begin
result, _error = spamcheck_client.issue_spam?(spam_issue: target, user: user, context: context)
return unless result
result, attribs, _error = spamcheck_client.issue_spam?(spam_issue: target, user: user, context: context)
return [nil, attribs] unless result
# @TODO log if error is not nil https://gitlab.com/gitlab-org/gitlab/-/issues/329545
return [result, attribs] if result == NOOP || attribs["monitorMode"] == "true"
# Duplicate logic with Akismet logic in #akismet_verdict
if Gitlab::Recaptcha.enabled? && result != ALLOW
CONDITIONAL_ALLOW
[CONDITIONAL_ALLOW, attribs]
else
result
[result, attribs]
end
rescue StandardError => e
Gitlab::ErrorTracking.log_exception(e)
# Default to ALLOW if any errors occur
ALLOW
[ALLOW, attribs]
end
end

View File

@ -83,7 +83,7 @@
.js-toggle-content.toggle-import-form{ class: ('hide' if active_tab != 'import') }
= form_for @project, html: { class: 'new_project' } do |f|
= form_for @project, html: { class: 'new_project gl-show-field-errors' } do |f|
%hr
= render "shared/import_form", f: f
= render 'projects/new_project_fields', f: f, project_name_id: "import-url-name", hide_init_with_readme: true, track_label: track_label

View File

@ -6,8 +6,14 @@
= f.label :import_url, class: 'label-bold' do
%span
= _('Git repository URL')
= f.text_field :import_url, value: import_url.sanitized_url,
autocomplete: 'off', class: 'form-control gl-form-input', placeholder: 'https://gitlab.company.com/group/project.git', required: true
= f.text_field :import_url,
value: import_url.sanitized_url,
autocomplete: 'off',
class: 'form-control gl-form-input',
placeholder: 'https://gitlab.company.com/group/project.git',
required: true,
pattern: '(?:git|https?):\/\/.*/.*\.git$',
title: _('Please provide a valid URL ending with .git')
.row
.form-group.col-md-6

View File

@ -22,7 +22,7 @@
.check-all-holder.d-none.d-sm-block.hidden
= check_box_tag "check-all-issues", nil, false, class: "check-all-issues left"
- if Feature.enabled?(:boards_filtered_search, @group) && is_epic_board
#js-board-filtered-search
#js-board-filtered-search{ data: { full_path: @group&.full_path } }
- else
.issues-other-filters.filtered-search-wrapper.d-flex.flex-column.flex-md-row
.filtered-search-box

View File

@ -0,0 +1,5 @@
---
title: Enforce .git suffix when importing git repo
merge_request: 61115
author:
type: changed

View File

@ -0,0 +1,8 @@
---
name: in_product_guidance_environments_webide
introduced_by_url:
rollout_issue_url:
milestone: '13.12'
type: experiment
group: group::release
default_enabled: false

View File

@ -14418,6 +14418,7 @@ Name of the feature that the callout is for.
| <a id="usercalloutfeaturenameenumunfinished_tag_cleanup_callout"></a>`UNFINISHED_TAG_CLEANUP_CALLOUT` | Callout feature name for unfinished_tag_cleanup_callout. |
| <a id="usercalloutfeaturenameenumwebhooks_moved"></a>`WEBHOOKS_MOVED` | Callout feature name for webhooks_moved. |
| <a id="usercalloutfeaturenameenumweb_ide_alert_dismissed"></a>`WEB_IDE_ALERT_DISMISSED` | Callout feature name for web_ide_alert_dismissed. |
| <a id="usercalloutfeaturenameenumweb_ide_ci_environments_guidance"></a>`WEB_IDE_CI_ENVIRONMENTS_GUIDANCE` | Callout feature name for web_ide_ci_environments_guidance. |
### `UserState`

View File

@ -45,7 +45,7 @@ module Gitlab
metadata: { 'authorization' =>
Gitlab::CurrentSettings.spam_check_api_key })
verdict = convert_verdict_to_gitlab_constant(response.verdict)
[verdict, response.error]
[verdict, response.extra_attributes.to_h, response.error]
end
private

View File

@ -22044,6 +22044,9 @@ msgstr ""
msgid "No data to display"
msgstr ""
msgid "No deployments detected. Use environments to control your software's continuous deployment. %{linkStart}Learn more about deployment jobs.%{linkEnd}"
msgstr ""
msgid "No deployments found"
msgstr ""
@ -24322,6 +24325,9 @@ msgstr ""
msgid "Please provide a valid URL"
msgstr ""
msgid "Please provide a valid URL ending with .git"
msgstr ""
msgid "Please provide a valid YouTube URL or ID"
msgstr ""

View File

@ -0,0 +1,22 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe InProductGuidanceEnvironmentsWebideExperiment, :experiment do
subject { described_class.new(project: project) }
let(:project) { create(:project, :repository) }
before do
stub_experiments(in_product_guidance_environments_webide: :candidate)
end
it 'excludes projects with environments' do
create(:environment, project: project)
expect(subject).to exclude(project: project)
end
it 'does not exlude projects without environments' do
expect(subject).not_to exclude(project: project)
end
end

View File

@ -355,6 +355,16 @@ RSpec.describe 'New project', :js do
expect(git_import_instructions).to have_content 'Git repository URL'
end
it 'reports error if repo URL does not end with .git' do
fill_in 'project_import_url', with: 'http://foo/bar'
fill_in 'project_name', with: 'import-project-without-git-suffix'
fill_in 'project_path', with: 'import-project-without-git-suffix'
click_button 'Create project'
expect(page).to have_text('Please provide a valid URL ending with .git')
end
it 'keeps "Import project" tab open after form validation error' do
collision_project = create(:project, name: 'test-name-collision', namespace: user.namespace)

View File

@ -0,0 +1,146 @@
import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import Vuex from 'vuex';
import BoardFilteredSearch from '~/boards/components/board_filtered_search.vue';
import { createStore } from '~/boards/stores';
import * as urlUtility from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import FilteredSearchBarRoot from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue';
import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue';
Vue.use(Vuex);
describe('BoardFilteredSearch', () => {
let wrapper;
let store;
const tokens = [
{
icon: 'labels',
title: __('Label'),
type: 'label_name',
operators: [
{ value: '=', description: 'is' },
{ value: '!=', description: 'is not' },
],
token: LabelToken,
unique: false,
symbol: '~',
fetchLabels: () => new Promise(() => {}),
},
{
icon: 'pencil',
title: __('Author'),
type: 'author_username',
operators: [
{ value: '=', description: 'is' },
{ value: '!=', description: 'is not' },
],
symbol: '@',
token: AuthorToken,
unique: true,
fetchAuthors: () => new Promise(() => {}),
},
];
const createComponent = ({ initialFilterParams = {} } = {}) => {
wrapper = shallowMount(BoardFilteredSearch, {
provide: { initialFilterParams, fullPath: '' },
store,
propsData: {
tokens,
},
});
};
const findFilteredSearch = () => wrapper.findComponent(FilteredSearchBarRoot);
beforeEach(() => {
// this needed for actions call for performSearch
window.gon = { features: {} };
});
afterEach(() => {
wrapper.destroy();
});
describe('default', () => {
beforeEach(() => {
store = createStore();
jest.spyOn(store, 'dispatch');
createComponent();
});
it('renders FilteredSearch', () => {
expect(findFilteredSearch().exists()).toBe(true);
});
it('passes the correct tokens to FilteredSearch', () => {
expect(findFilteredSearch().props('tokens')).toEqual(tokens);
});
describe('when onFilter is emitted', () => {
it('calls performSearch', () => {
findFilteredSearch().vm.$emit('onFilter', [{ value: { data: '' } }]);
expect(store.dispatch).toHaveBeenCalledWith('performSearch');
});
it('calls historyPushState', () => {
jest.spyOn(urlUtility, 'updateHistory');
findFilteredSearch().vm.$emit('onFilter', [{ value: { data: 'searchQuery' } }]);
expect(urlUtility.updateHistory).toHaveBeenCalledWith({
replace: true,
title: '',
url: 'http://test.host/',
});
});
});
});
describe('when searching', () => {
beforeEach(() => {
store = createStore();
jest.spyOn(store, 'dispatch');
createComponent();
});
it('sets the url params to the correct results', async () => {
const mockFilters = [
{ type: 'author_username', value: { data: 'root', operator: '=' } },
{ type: 'label_name', value: { data: 'label', operator: '=' } },
{ type: 'label_name', value: { data: 'label2', operator: '=' } },
];
jest.spyOn(urlUtility, 'updateHistory');
findFilteredSearch().vm.$emit('onFilter', mockFilters);
expect(urlUtility.updateHistory).toHaveBeenCalledWith({
title: '',
replace: true,
url: 'http://test.host/?author_username=root&label_name[]=label&label_name[]=label2',
});
});
});
describe('when url params are already set', () => {
beforeEach(() => {
store = createStore();
jest.spyOn(store, 'dispatch');
createComponent({ initialFilterParams: { authorUsername: 'root', labelName: ['label'] } });
});
it('passes the correct props to FilterSearchBar', () => {
expect(findFilteredSearch().props('initialFilterValue')).toEqual([
{ type: 'author_username', value: { data: 'root', operator: '=' } },
{ type: 'label_name', value: { data: 'label', operator: '=' } },
]);
});
});
});

View File

@ -510,6 +510,7 @@ describe('RepoEditor', () => {
},
});
await vm.$nextTick();
await vm.$nextTick();
expect(vm.initEditor).toHaveBeenCalled();
});

View File

@ -0,0 +1,21 @@
import { GlLink } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import Environments from '~/ide/lib/alerts/environments.vue';
describe('~/ide/lib/alerts/environment.vue', () => {
let wrapper;
beforeEach(() => {
wrapper = mount(Environments);
});
it('shows a message regarding environments', () => {
expect(wrapper.text()).toBe(
"No deployments detected. Use environments to control your software's continuous deployment. Learn more about deployment jobs.",
);
});
it('links to the help page on environments', () => {
expect(wrapper.findComponent(GlLink).attributes('href')).toBe('/help/ci/environments/index.md');
});
});

View File

@ -2,9 +2,11 @@ import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import getIdeProject from 'ee_else_ce/ide/queries/get_ide_project.query.graphql';
import Api from '~/api';
import dismissUserCallout from '~/graphql_shared/mutations/dismiss_user_callout.mutation.graphql';
import services from '~/ide/services';
import { query } from '~/ide/services/gql';
import { query, mutate } from '~/ide/services/gql';
import { escapeFileUrl } from '~/lib/utils/url_utility';
import ciConfig from '~/pipeline_editor/graphql/queries/ci_config.graphql';
import { projectData } from '../mock_data';
jest.mock('~/api');
@ -299,4 +301,33 @@ describe('IDE services', () => {
});
});
});
describe('getCiConfig', () => {
const TEST_PROJECT_PATH = 'foo/bar';
const TEST_CI_CONFIG = 'test config';
it('queries with the given CI config and project', () => {
const result = { data: { ciConfig: { test: 'data' } } };
query.mockResolvedValue(result);
return services.getCiConfig(TEST_PROJECT_PATH, TEST_CI_CONFIG).then((data) => {
expect(data).toEqual(result.data.ciConfig);
expect(query).toHaveBeenCalledWith({
query: ciConfig,
variables: { projectPath: TEST_PROJECT_PATH, content: TEST_CI_CONFIG },
});
});
});
});
describe('dismissUserCallout', () => {
it('mutates the callout to dismiss', () => {
const result = { data: { callouts: { test: 'data' } } };
mutate.mockResolvedValue(result);
return services.dismissUserCallout('test').then((data) => {
expect(data).toEqual(result.data);
expect(mutate).toHaveBeenCalledWith({
mutation: dismissUserCallout,
variables: { input: { featureName: 'test' } },
});
});
});
});
});

View File

@ -0,0 +1,46 @@
import testAction from 'helpers/vuex_action_helper';
import service from '~/ide/services';
import {
detectEnvironmentsGuidance,
dismissEnvironmentsGuidance,
} from '~/ide/stores/actions/alert';
import * as types from '~/ide/stores/mutation_types';
jest.mock('~/ide/services');
describe('~/ide/stores/actions/alert', () => {
describe('detectEnvironmentsGuidance', () => {
it('should try to fetch CI info', () => {
const stages = ['a', 'b', 'c'];
service.getCiConfig.mockResolvedValue({ stages });
return testAction(
detectEnvironmentsGuidance,
'the content',
{ currentProjectId: 'gitlab/test' },
[{ type: types.DETECT_ENVIRONMENTS_GUIDANCE_ALERT, payload: stages }],
[],
() => expect(service.getCiConfig).toHaveBeenCalledWith('gitlab/test', 'the content'),
);
});
});
describe('dismissCallout', () => {
it('should try to dismiss the given callout', () => {
const callout = { featureName: 'test', dismissedAt: 'now' };
service.dismissUserCallout.mockResolvedValue({ userCalloutCreate: { userCallout: callout } });
return testAction(
dismissEnvironmentsGuidance,
undefined,
{},
[{ type: types.DISMISS_ENVIRONMENTS_GUIDANCE_ALERT }],
[],
() =>
expect(service.dismissUserCallout).toHaveBeenCalledWith(
'web_ide_ci_environments_guidance',
),
);
});
});
});

View File

@ -4,6 +4,7 @@ import eventHub from '~/ide/eventhub';
import { createRouter } from '~/ide/ide_router';
import { createStore } from '~/ide/stores';
import {
init,
stageAllChanges,
unstageAllChanges,
toggleFileFinder,
@ -54,15 +55,15 @@ describe('Multi-file store actions', () => {
});
});
describe('setInitialData', () => {
it('commits initial data', (done) => {
store
.dispatch('setInitialData', { canCommit: true })
.then(() => {
expect(store.state.canCommit).toBeTruthy();
done();
})
.catch(done.fail);
describe('init', () => {
it('commits initial data and requests user callouts', () => {
return testAction(
init,
{ canCommit: true },
store.state,
[{ type: 'SET_INITIAL_DATA', payload: { canCommit: true } }],
[],
);
});
});

View File

@ -0,0 +1,46 @@
import { getAlert } from '~/ide/lib/alerts';
import EnvironmentsMessage from '~/ide/lib/alerts/environments.vue';
import { createStore } from '~/ide/stores';
import * as getters from '~/ide/stores/getters/alert';
import { file } from '../../helpers';
describe('IDE store alert getters', () => {
let localState;
let localStore;
beforeEach(() => {
localStore = createStore();
localState = localStore.state;
});
describe('alerts', () => {
describe('shows an alert about environments', () => {
let alert;
beforeEach(() => {
const f = file('.gitlab-ci.yml');
localState.openFiles.push(f);
localState.currentActivityView = 'repo-commit-section';
localState.environmentsGuidanceAlertDetected = true;
localState.environmentsGuidanceAlertDismissed = false;
const alertKey = getters.getAlert(localState)(f);
alert = getAlert(alertKey);
});
it('has a message suggesting to use environments', () => {
expect(alert.message).toEqual(EnvironmentsMessage);
});
it('dispatches to dismiss the callout on dismiss', () => {
jest.spyOn(localStore, 'dispatch').mockImplementation();
alert.dismiss(localStore);
expect(localStore.dispatch).toHaveBeenCalledWith('dismissEnvironmentsGuidance');
});
it('should be a tip alert', () => {
expect(alert.props).toEqual({ variant: 'tip' });
});
});
});
});

View File

@ -0,0 +1,26 @@
import * as types from '~/ide/stores/mutation_types';
import mutations from '~/ide/stores/mutations/alert';
describe('~/ide/stores/mutations/alert', () => {
const state = {};
describe(types.DETECT_ENVIRONMENTS_GUIDANCE_ALERT, () => {
it('checks the stages for any that configure environments', () => {
mutations[types.DETECT_ENVIRONMENTS_GUIDANCE_ALERT](state, {
nodes: [{ groups: { nodes: [{ jobs: { nodes: [{}] } }] } }],
});
expect(state.environmentsGuidanceAlertDetected).toBe(true);
mutations[types.DETECT_ENVIRONMENTS_GUIDANCE_ALERT](state, {
nodes: [{ groups: { nodes: [{ jobs: { nodes: [{ environment: {} }] } }] } }],
});
expect(state.environmentsGuidanceAlertDetected).toBe(false);
});
});
describe(types.DISMISS_ENVIRONMENTS_GUIDANCE_ALERT, () => {
it('stops environments guidance', () => {
mutations[types.DISMISS_ENVIRONMENTS_GUIDANCE_ALERT](state);
expect(state.environmentsGuidanceAlertDismissed).toBe(true);
});
});
});

View File

@ -45,5 +45,35 @@ RSpec.describe IdeHelper do
)
end
end
context 'environments guidance experiment', :experiment do
before do
stub_experiments(in_product_guidance_environments_webide: :candidate)
self.instance_variable_set(:@project, project)
end
context 'when project has no enviornments' do
it 'enables environment guidance' do
expect(helper.ide_data).to include('enable-environments-guidance' => 'true')
end
context 'and the callout has been dismissed' do
it 'disables environment guidance' do
callout = create(:user_callout, feature_name: :web_ide_ci_environments_guidance, user: project.creator)
callout.update!(dismissed_at: Time.now - 1.week)
allow(helper).to receive(:current_user).and_return(User.find(project.creator.id))
expect(helper.ide_data).to include('enable-environments-guidance' => 'false')
end
end
end
context 'when the project has environments' do
it 'disables environment guidance' do
create(:environment, project: project)
expect(helper.ide_data).to include('enable-environments-guidance' => 'false')
end
end
end
end
end

View File

@ -9,12 +9,20 @@ RSpec.describe Gitlab::Spamcheck::Client do
let_it_be(:user) { create(:user, organization: 'GitLab') }
let(:verdict_value) { nil }
let(:error_value) { "" }
let(:attribs_value) do
extra_attributes = Google::Protobuf::Map.new(:string, :string)
extra_attributes["monitorMode"] = "false"
extra_attributes
end
let_it_be(:issue) { create(:issue, description: 'Test issue description') }
let(:response) do
verdict = ::Spamcheck::SpamVerdict.new
verdict.verdict = verdict_value
verdict.error = error_value
verdict.extra_attributes = attribs_value
verdict
end
@ -45,7 +53,7 @@ RSpec.describe Gitlab::Spamcheck::Client do
let(:verdict_value) { verdict }
it "returns expected spam constant" do
expect(subject).to eq([expected, ""])
expect(subject).to eq([expected, { "monitorMode" => "false" }, ""])
end
end
end

View File

@ -632,6 +632,16 @@ RSpec.describe Group do
it { is_expected.to match_array([private_group, internal_group]) }
end
describe 'with_onboarding_progress' do
subject { described_class.with_onboarding_progress }
it 'joins onboarding_progress' do
create(:onboarding_progress, namespace: group)
expect(subject).to eq([group])
end
end
describe 'for_authorized_group_members' do
let_it_be(:group_member1) { create(:group_member, source: private_group, user_id: user1.id, access_level: Gitlab::Access::OWNER) }

View File

@ -23,12 +23,17 @@ RSpec.describe Spam::SpamVerdictService do
described_class.new(user: user, target: issue, request: request, options: {})
end
let(:attribs) do
extra_attributes = { "monitorMode" => "false" }
extra_attributes
end
describe '#execute' do
subject { service.execute }
before do
allow(service).to receive(:akismet_verdict).and_return(nil)
allow(service).to receive(:spamcheck_verdict).and_return(nil)
allow(service).to receive(:spamcheck_verdict).and_return([nil, attribs])
end
context 'if all services return nil' do
@ -63,7 +68,7 @@ RSpec.describe Spam::SpamVerdictService do
context 'and they are supported' do
before do
allow(service).to receive(:akismet_verdict).and_return(DISALLOW)
allow(service).to receive(:spamcheck_verdict).and_return(BLOCK_USER)
allow(service).to receive(:spamcheck_verdict).and_return([BLOCK_USER, attribs])
end
it 'renders the more restrictive verdict' do
@ -74,7 +79,7 @@ RSpec.describe Spam::SpamVerdictService do
context 'and one is supported' do
before do
allow(service).to receive(:akismet_verdict).and_return('nonsense')
allow(service).to receive(:spamcheck_verdict).and_return(BLOCK_USER)
allow(service).to receive(:spamcheck_verdict).and_return([BLOCK_USER, attribs])
end
it 'renders the more restrictive verdict' do
@ -85,13 +90,29 @@ RSpec.describe Spam::SpamVerdictService do
context 'and none are supported' do
before do
allow(service).to receive(:akismet_verdict).and_return('nonsense')
allow(service).to receive(:spamcheck_verdict).and_return('rubbish')
allow(service).to receive(:spamcheck_verdict).and_return(['rubbish', attribs])
end
it 'renders the more restrictive verdict' do
expect(subject).to eq ALLOW
end
end
context 'and attribs - monitorMode is true' do
let(:attribs) do
extra_attributes = { "monitorMode" => "true" }
extra_attributes
end
before do
allow(service).to receive(:akismet_verdict).and_return(DISALLOW)
allow(service).to receive(:spamcheck_verdict).and_return([BLOCK_USER, attribs])
end
it 'renders the more restrictive verdict' do
expect(subject).to eq(DISALLOW)
end
end
end
end
@ -170,16 +191,42 @@ RSpec.describe Spam::SpamVerdictService do
let(:error) { '' }
let(:verdict) { nil }
let(:attribs) do
extra_attributes = { "monitorMode" => "false" }
extra_attributes
end
before do
allow(service).to receive(:spamcheck_client).and_return(spam_client)
allow(spam_client).to receive(:issue_spam?).and_return([verdict, error])
allow(spam_client).to receive(:issue_spam?).and_return([verdict, attribs, error])
end
context 'if the result is a NOOP verdict' do
let(:verdict) { NOOP }
it 'returns the verdict' do
expect(subject).to eq([NOOP, attribs])
end
end
context 'if attribs - monitorMode is true' do
let(:attribs) do
extra_attributes = { "monitorMode" => "true" }
extra_attributes
end
let(:verdict) { ALLOW }
it 'returns the verdict' do
expect(subject).to eq([ALLOW, attribs])
end
end
context 'the result is a valid verdict' do
let(:verdict) { ALLOW }
it 'returns the verdict' do
expect(subject).to eq ALLOW
expect(subject).to eq([ALLOW, attribs])
end
end
@ -203,7 +250,7 @@ RSpec.describe Spam::SpamVerdictService do
let(:verdict) { verdict_value }
it "returns expected spam constant" do
expect(subject).to eq(expected)
expect(subject).to eq([expected, attribs])
end
end
end
@ -218,7 +265,7 @@ RSpec.describe Spam::SpamVerdictService do
::Spam::SpamConstants::DISALLOW,
::Spam::SpamConstants::BLOCK_USER].each do |verdict_value|
let(:verdict) { verdict_value }
let(:expected) { verdict_value }
let(:expected) { [verdict_value, attribs] }
it "returns expected spam constant" do
expect(subject).to eq(expected)
@ -230,7 +277,7 @@ RSpec.describe Spam::SpamVerdictService do
let(:verdict) { :this_is_fine }
it 'returns the string' do
expect(subject).to eq verdict
expect(subject).to eq([verdict, attribs])
end
end
@ -238,7 +285,7 @@ RSpec.describe Spam::SpamVerdictService do
let(:verdict) { '' }
it 'returns nil' do
expect(subject).to eq verdict
expect(subject).to eq([verdict, attribs])
end
end
@ -246,7 +293,7 @@ RSpec.describe Spam::SpamVerdictService do
let(:verdict) { nil }
it 'returns nil' do
expect(subject).to be_nil
expect(subject).to eq([nil, attribs])
end
end
@ -254,17 +301,19 @@ RSpec.describe Spam::SpamVerdictService do
let(:error) { "Sorry Dave, I can't do that" }
it 'returns nil' do
expect(subject).to be_nil
expect(subject).to eq([nil, attribs])
end
end
context 'the requested is aborted' do
let(:attribs) { nil }
before do
allow(spam_client).to receive(:issue_spam?).and_raise(GRPC::Aborted)
end
it 'returns nil' do
expect(subject).to be(ALLOW)
expect(subject).to eq([ALLOW, attribs])
end
end
@ -273,18 +322,20 @@ RSpec.describe Spam::SpamVerdictService do
let(:error) { 'oh noes!' }
it 'renders the verdict' do
expect(subject).to eq DISALLOW
expect(subject).to eq [DISALLOW, attribs]
end
end
end
context 'if the endpoint times out' do
let(:attribs) { nil }
before do
allow(spam_client).to receive(:issue_spam?).and_raise(GRPC::DeadlineExceeded)
end
it 'returns nil' do
expect(subject).to be(ALLOW)
expect(subject).to eq([ALLOW, attribs])
end
end
end