Commit dd250391 authored by GitLab Bot's avatar GitLab Bot

Automatic merge of gitlab-org/gitlab master

parents 1fe5d1d7 d69dede9
......@@ -7,7 +7,6 @@
before_script:
- '[ "$FOSS_ONLY" = "1" ] && rm -rf ee/ qa/spec/ee/ qa/qa/specs/features/ee/ qa/qa/ee/ qa/qa/ee.rb'
- cd qa/
- gem install bundler -v 1.17.3
- bundle install --clean --jobs=$(nproc) --path=vendor --retry=3 --without=development --quiet
- bundle check
......
......@@ -183,7 +183,6 @@ update-coverage-cache:
- .shared:rules:update-cache
stage: prepare
script:
- run_timed_command "gem install bundler -v 1.17.3"
- run_timed_command "bundle install --jobs=$(nproc) --path=vendor --retry=3 --quiet --without default development test production puma unicorn kerberos metrics omnibus ed25519"
cache:
policy: push # We want to rebuild the cache from scratch to ensure stale dependencies are cleaned up.
......@@ -289,6 +288,7 @@ db:migrate-from-v12.10.0:
- git checkout -f FETCH_HEAD
- sed -i -e "s/gem 'grpc', '~> 1.24.0'/gem 'grpc', '~> 1.30.2'/" Gemfile # Update gRPC for Ruby 2.7
- sed -i -e "s/gem 'google-protobuf', '~> 3.8.0'/gem 'google-protobuf', '~> 3.12.0'/" Gemfile
- gem install bundler:1.17.3
- bundle update google-protobuf grpc bootsnap
- bundle install $BUNDLE_INSTALL_FLAGS
- date
......@@ -363,7 +363,6 @@ rspec:coverage:
- memory-static
- memory-on-boot
script:
- run_timed_command "gem install bundler -v 1.17.3"
- run_timed_command "bundle install --jobs=$(nproc) --path=vendor --retry=3 --quiet --without default development test production puma unicorn kerberos metrics omnibus ed25519"
- run_timed_command "bundle exec scripts/merge-simplecov"
- run_timed_command "bundle exec scripts/gather-test-memory-data"
......@@ -401,7 +400,6 @@ rspec:feature-flags:
- memory-static
- memory-on-boot
script:
- run_timed_command "gem install bundler -v 1.17.3"
- run_timed_command "bundle install --jobs=$(nproc) --path=vendor --retry=3 --quiet --without default development test production puma unicorn kerberos metrics omnibus ed25519"
- run_timed_command "bundle exec scripts/used-feature-flags"
# EE/FOSS: default refs (MRs, master, schedules) jobs #
......
......@@ -39,7 +39,7 @@ update-tests-metadata:
- rspec-ee integration pg11 geo
- rspec-ee system pg11 geo
script:
- run_timed_command "retry gem install bundler:1.17.3 fog-aws mime-types activesupport rspec_profiling postgres-copy --no-document"
- run_timed_command "retry gem install fog-aws mime-types activesupport rspec_profiling postgres-copy --no-document"
- source ./scripts/rspec_helpers.sh
- update_tests_metadata
- update_tests_mapping
......@@ -1524,4 +1524,4 @@ DEPENDENCIES
yajl-ruby (~> 1.4.1)
BUNDLED WITH
1.17.3
2.1.4
<script>
import { GlIcon } from '@gitlab/ui';
import tooltip from '~/vue_shared/directives/tooltip';
import { sprintf, n__, __ } from '~/locale';
export default {
components: {
GlIcon,
},
directives: {
tooltip,
},
props: {
files: {
type: Array,
required: true,
},
iconName: {
type: String,
required: true,
},
title: {
type: String,
required: true,
},
},
computed: {
addedFilesLength() {
return this.files.filter(f => f.tempFile).length;
},
modifiedFilesLength() {
return this.files.filter(f => !f.tempFile).length;
},
addedFilesIconClass() {
return this.addedFilesLength ? 'multi-file-addition' : '';
},
modifiedFilesClass() {
return this.modifiedFilesLength ? 'multi-file-modified' : '';
},
additionsTooltip() {
return sprintf(
n__('1 %{type} addition', '%{count} %{type} additions', this.addedFilesLength),
{
type: this.title.toLowerCase(),
count: this.addedFilesLength,
},
);
},
modifiedTooltip() {
return sprintf(
n__('1 %{type} modification', '%{count} %{type} modifications', this.modifiedFilesLength),
{
type: this.title.toLowerCase(),
count: this.modifiedFilesLength,
},
);
},
titleTooltip() {
return sprintf(__('%{title} changes'), { title: this.title });
},
additionIconName() {
return this.title.toLowerCase() === 'staged' ? 'file-addition-solid' : 'file-addition';
},
modifiedIconName() {
return this.title.toLowerCase() === 'staged' ? 'file-modified-solid' : 'file-modified';
},
},
};
</script>
<template>
<div class="multi-file-commit-list-collapsed text-center">
<div
v-tooltip
:title="titleTooltip"
data-container="body"
data-placement="left"
class="gl-mb-5"
>
<gl-icon v-once :name="iconName" :size="18" />
</div>
<div
v-tooltip
:title="additionsTooltip"
data-container="body"
data-placement="left"
class="gl-mb-3"
>
<gl-icon :name="additionIconName" :size="18" :class="addedFilesIconClass" />
</div>
{{ addedFilesLength }}
<div
v-tooltip
:title="modifiedTooltip"
data-container="body"
data-placement="left"
class="gl-mt-3 gl-mb-3"
>
<gl-icon :name="modifiedIconName" :size="18" :class="modifiedFilesClass" />
</div>
{{ modifiedFilesLength }}
</div>
</template>
......@@ -14,6 +14,7 @@ export default {
},
computed: {
...mapGetters(['activeFile']),
...mapGetters('editor', ['activeFileEditor']),
activeFileEOL() {
return getFileEOL(this.activeFile.content);
},
......@@ -33,8 +34,10 @@ export default {
</gl-link>
</div>
<div>{{ activeFileEOL }}</div>
<div v-if="activeFileIsText">{{ activeFile.editorRow }}:{{ activeFile.editorColumn }}</div>
<div>{{ activeFile.fileLanguage }}</div>
<div v-if="activeFileIsText">
{{ activeFileEditor.editorRow }}:{{ activeFileEditor.editorColumn }}
</div>
<div>{{ activeFileEditor.fileLanguage }}</div>
</template>
<terminal-sync-status-safe />
</div>
......
......@@ -22,6 +22,7 @@ import Editor from '../lib/editor';
import FileTemplatesBar from './file_templates/bar.vue';
import { __ } from '~/locale';
import { extractMarkdownImagesFromEntries } from '../stores/utils';
import { getFileEditorOrDefault } from '../stores/modules/editor/utils';
import { getPathParent, readFileAsDataURL, registerSchema, isTextFile } from '../utils';
import { getRulesWithTraversal } from '../lib/editorconfig/parser';
import mapRulesToMonaco from '../lib/editorconfig/rules_mapper';
......@@ -49,6 +50,7 @@ export default {
...mapState('rightPane', {
rightPaneIsOpen: 'isOpen',
}),
...mapState('editor', ['fileEditors']),
...mapState([
'viewer',
'panelResizing',
......@@ -67,6 +69,9 @@ export default {
'getJsonSchemaForPath',
]),
...mapGetters('fileTemplates', ['showFileTemplatesBar']),
fileEditor() {
return getFileEditorOrDefault(this.fileEditors, this.file.path);
},
shouldHideEditor() {
return this.file && !this.file.loading && !isTextFile(this.file);
},
......@@ -80,10 +85,10 @@ export default {
return this.shouldHideEditor && this.file.mrChange && this.viewer === viewerTypes.mr;
},
isEditorViewMode() {
return this.file.viewMode === FILE_VIEW_MODE_EDITOR;
return this.fileEditor.viewMode === FILE_VIEW_MODE_EDITOR;
},
isPreviewViewMode() {
return this.file.viewMode === FILE_VIEW_MODE_PREVIEW;
return this.fileEditor.viewMode === FILE_VIEW_MODE_PREVIEW;
},
editTabCSS() {
return {
......@@ -125,8 +130,7 @@ export default {
this.initEditor();
if (this.currentActivityView !== leftSidebarViews.edit.name) {
this.setFileViewMode({
file: this.file,
this.updateEditor({
viewMode: FILE_VIEW_MODE_EDITOR,
});
}
......@@ -134,8 +138,7 @@ export default {
},
currentActivityView() {
if (this.currentActivityView !== leftSidebarViews.edit.name) {
this.setFileViewMode({
file: this.file,
this.updateEditor({
viewMode: FILE_VIEW_MODE_EDITOR,
});
}
......@@ -195,13 +198,11 @@ export default {
'getFileData',
'getRawFileData',
'changeFileContent',
'setFileLanguage',
'setEditorPosition',
'setFileViewMode',
'removePendingTab',
'triggerFilesChange',
'addTempImage',
]),
...mapActions('editor', ['updateFileEditor']),
initEditor() {
if (this.shouldHideEditor && (this.file.content || this.file.raw)) {
return;
......@@ -284,19 +285,19 @@ export default {
// Handle Cursor Position
this.editor.onPositionChange((instance, e) => {
this.setEditorPosition({
this.updateEditor({
editorRow: e.position.lineNumber,
editorColumn: e.position.column,
});
});
this.editor.setPosition({
lineNumber: this.file.editorRow,
column: this.file.editorColumn,
lineNumber: this.fileEditor.editorRow,
column: this.fileEditor.editorColumn,
});
// Handle File Language
this.setFileLanguage({
this.updateEditor({
fileLanguage: this.model.language,
});
......@@ -354,6 +355,16 @@ export default {
const schema = this.getJsonSchemaForPath(this.file.path);
registerSchema(schema);
},
updateEditor(data) {
// Looks like our model wrapper `.dispose` causes the monaco editor to emit some position changes after
// when disposing. We want to ignore these by only capturing editor changes that happen to the currently active
// file.
if (!this.file.active) {
return;
}
this.updateFileEditor({ path: this.file.path, data });
},
},
viewerTypes,
FILE_VIEW_MODE_EDITOR,
......@@ -369,7 +380,7 @@ export default {
<a
href="javascript:void(0);"
role="button"
@click.prevent="setFileViewMode({ file, viewMode: $options.FILE_VIEW_MODE_EDITOR })"
@click.prevent="updateEditor({ viewMode: $options.FILE_VIEW_MODE_EDITOR })"
>
{{ __('Edit') }}
</a>
......@@ -378,7 +389,7 @@ export default {
<a
href="javascript:void(0);"
role="button"
@click.prevent="setFileViewMode({ file, viewMode: $options.FILE_VIEW_MODE_PREVIEW })"
@click.prevent="updateEditor({ viewMode: $options.FILE_VIEW_MODE_PREVIEW })"
>{{ previewMode.previewTitle }}</a
>
</li>
......
......@@ -5,7 +5,7 @@ import { visitUrl } from '~/lib/utils/url_utility';
import { deprecatedCreateFlash as flash } from '~/flash';
import * as types from './mutation_types';
import { decorateFiles } from '../lib/files';
import { stageKeys } from '../constants';
import { stageKeys, commitActionTypes } from '../constants';
import service from '../services';
import eventHub from '../eventhub';
......@@ -242,7 +242,7 @@ export const renameEntry = ({ dispatch, commit, state, getters }, { path, name,
}
}
dispatch('triggerFilesChange');
dispatch('triggerFilesChange', { type: commitActionTypes.move, path, newPath });
};
export const getBranchData = ({ commit, state }, { projectId, branchId, force = false } = {}) =>
......
......@@ -164,26 +164,6 @@ export const changeFileContent = ({ commit, state, getters }, { path, content })
}
};
export const setFileLanguage = ({ getters, commit }, { fileLanguage }) => {
if (getters.activeFile) {
commit(types.SET_FILE_LANGUAGE, { file: getters.activeFile, fileLanguage });
}
};
export const setEditorPosition = ({ getters, commit }, { editorRow, editorColumn }) => {
if (getters.activeFile) {
commit(types.SET_FILE_POSITION, {
file: getters.activeFile,
editorRow,
editorColumn,
});
}
};
export const setFileViewMode = ({ commit }, { file, viewMode }) => {
commit(types.SET_FILE_VIEWMODE, { file, viewMode });
};
export const restoreOriginalFile = ({ dispatch, state, commit }, path) => {
const file = state.entries[path];
const isDestructiveDiscard = file.tempFile || file.prevPath;
......@@ -289,7 +269,7 @@ export const removePendingTab = ({ commit }, file) => {
eventHub.$emit(`editor.update.model.dispose.${file.key}`);
};
export const triggerFilesChange = () => {
export const triggerFilesChange = (ctx, payload = {}) => {
// Used in EE for file mirroring
eventHub.$emit('ide.files.change');
eventHub.$emit('ide.files.change', payload);
};
......@@ -12,6 +12,8 @@ import fileTemplates from './modules/file_templates';
import paneModule from './modules/pane';
import clientsideModule from './modules/clientside';
import routerModule from './modules/router';
import editorModule from './modules/editor';
import { setupFileEditorsSync } from './modules/editor/setup';
Vue.use(Vuex);
......@@ -29,7 +31,14 @@ export const createStoreOptions = () => ({
rightPane: paneModule(),
clientside: clientsideModule(),
router: routerModule,
editor: editorModule,
},
});
export const createStore = () => new Vuex.Store(createStoreOptions());
export const createStore = () => {
const store = new Vuex.Store(createStoreOptions());
setupFileEditorsSync(store);
return store;
};
import * as types from './mutation_types';
/**
* Action to update the current file editor info at the given `path` with the given `data`
*
* @param {} vuex
* @param {{ path: String, data: any }} payload
*/
export const updateFileEditor = ({ commit }, payload) => {
commit(types.UPDATE_FILE_EDITOR, payload);
};
export const removeFileEditor = ({ commit }, path) => {
commit(types.REMOVE_FILE_EDITOR, path);
};
export const renameFileEditor = ({ commit }, payload) => {
commit(types.RENAME_FILE_EDITOR, payload);
};
import { getFileEditorOrDefault } from './utils';
export const activeFileEditor = (state, getters, rootState, rootGetters) => {
const { activeFile } = rootGetters;
if (!activeFile) {
return null;
}
const { path } = rootGetters.activeFile;
return getFileEditorOrDefault(state.fileEditors, path);
};
import * as actions from './actions';
import * as getters from './getters';
import state from './state';
import mutations from './mutations';
export default {
namespaced: true,
actions,
state,
mutations,
getters,
};
export const UPDATE_FILE_EDITOR = 'UPDATE_FILE_EDITOR';
export const REMOVE_FILE_EDITOR = 'REMOVE_FILE_EDITOR';
export const RENAME_FILE_EDITOR = 'RENAME_FILE_EDITOR';
import Vue from 'vue';
import * as types from './mutation_types';
import { getFileEditorOrDefault } from './utils';
export default {
[types.UPDATE_FILE_EDITOR](state, { path, data }) {
const editor = getFileEditorOrDefault(state.fileEditors, path);
Vue.set(state.fileEditors, path, Object.assign(editor, data));
},
[types.REMOVE_FILE_EDITOR](state, path) {
Vue.delete(state.fileEditors, path);
},
[types.RENAME_FILE_EDITOR](state, { path, newPath }) {
const existing = state.fileEditors[path];
// Gracefully do nothing if fileEditor isn't found.
if (!existing) {
return;
}
Vue.delete(state.fileEditors, path);
Vue.set(state.fileEditors, newPath, existing);
},
};
import eventHub from '~/ide/eventhub';
import { commitActionTypes } from '~/ide/constants';
const removeUnusedFileEditors = store => {
Object.keys(store.state.editor.fileEditors)
.filter(path => !store.state.entries[path])
.forEach(path => store.dispatch('editor/removeFileEditor', path));
};
export const setupFileEditorsSync = store => {
eventHub.$on('ide.files.change', ({ type, ...payload } = {}) => {
if (type === commitActionTypes.move) {
store.dispatch('editor/renameFileEditor', payload);
} else {
// The files have changed, but the specific change is not known.
removeUnusedFileEditors(store);
}
});
};
export default () => ({
// Object which represents a dictionary of filePath to editor specific properties, including:
// - fileLanguage
// - editorRow
// - editorCol
// - viewMode
fileEditors: {},
});
import { FILE_VIEW_MODE_EDITOR } from '../../../constants';
export const createDefaultFileEditor = () => ({
editorRow: 1,
editorColumn: 1,
fileLanguage: '',
viewMode: FILE_VIEW_MODE_EDITOR,
});
export const getFileEditorOrDefault = (fileEditors, path) =>
fileEditors[path] || createDefaultFileEditor();
......@@ -36,9 +36,6 @@ export const SET_FILE_ACTIVE = 'SET_FILE_ACTIVE';
export const SET_FILE_RAW_DATA = 'SET_FILE_RAW_DATA';
export const SET_FILE_BASE_RAW_DATA = 'SET_FILE_BASE_RAW_DATA';
export const UPDATE_FILE_CONTENT = 'UPDATE_FILE_CONTENT';
export const SET_FILE_LANGUAGE = 'SET_FILE_LANGUAGE';
export const SET_FILE_POSITION = 'SET_FILE_POSITION';
export const SET_FILE_VIEWMODE = 'SET_FILE_VIEWMODE';
export const DISCARD_FILE_CHANGES = 'DISCARD_FILE_CHANGES';
export const ADD_FILE_TO_CHANGED = 'ADD_FILE_TO_CHANGED';
export const REMOVE_FILE_FROM_CHANGED = 'REMOVE_FILE_FROM_CHANGED';
......
......@@ -95,17 +95,6 @@ export default {
changed,
});
},
[types.SET_FILE_LANGUAGE](state, { file, fileLanguage }) {
Object.assign(state.entries[file.path], {
fileLanguage,
});
},
[types.SET_FILE_POSITION](state, { file, editorRow, editorColumn }) {
Object.assign(state.entries[file.path], {
editorRow,
editorColumn,
});
},
[types.SET_FILE_MERGE_REQUEST_CHANGE](state, { file, mrChange }) {
let diffMode = diffModes.replaced;
if (mrChange.new_file) {
......@@ -122,11 +111,6 @@ export default {
},
});
},
[types.SET_FILE_VIEWMODE](state, { file, viewMode }) {
Object.assign(state.entries[file.path], {
viewMode,
});
},
[types.DISCARD_FILE_CHANGES](state, path) {
const stagedFile = state.stagedFiles.find(f => f.path === path);
const entry = state.entries[path];
......
import { commitActionTypes, FILE_VIEW_MODE_EDITOR } from '../constants';
import { commitActionTypes } from '../constants';
import {
relativePathToAbsolute,
isAbsolute,
......@@ -25,10 +25,6 @@ export const dataStructure = () => ({
rawPath: '',
raw: '',
content: '',
editorRow: 1,
editorColumn: 1,
fileLanguage: '',
viewMode: FILE_VIEW_MODE_EDITOR,
size: 0,
parentPath: null,
lastOpenedAt: 0,
......
......@@ -60,8 +60,7 @@ class RegistrationsController < Devise::RegistrationsController
def update_registration
return redirect_to new_user_registration_path unless current_user
user_params = params.require(:user).permit(:role, :setup_for_company)
result = ::Users::SignupService.new(current_user, user_params).execute
result = ::Users::SignupService.new(current_user, update_registration_params).execute
if result[:status] == :success
if ::Gitlab.com? && show_onboarding_issues_experiment?
......@@ -164,6 +163,10 @@ class RegistrationsController < Devise::RegistrationsController
params.require(:user).permit(:username, :email, :name, :first_name, :last_name, :password)
end
def update_registration_params
params.require(:user).permit(:role, :setup_for_company)
end
def resource_name
:user
end
......
......@@ -153,10 +153,8 @@ class IssuableFinder
end
def row_count
fast_fail = Feature.enabled?(:soft_fail_count_by_state, params.group || params.project)
Gitlab::IssuablesCountForState
.new(self, nil, fast_fail: fast_fail)
.new(self, nil, fast_fail: true)
.for_state_or_opened(params[:state])
end
......
......@@ -28,7 +28,6 @@
= f.label :password, class: 'label-bold'
= f.password_field :password, class: "form-control bottom", data: { qa_selector: 'new_user_password_field' }, required: true, pattern: ".{#{@minimum_password_length},}", title: _("Minimum length is %{minimum_password_length} characters.") % { minimum_password_length: @minimum_password_length }
%p.gl-field-hint.text-secondary= _('Minimum length is %{minimum_password_length} characters') % { minimum_password_length: @minimum_password_length }
= render_if_exists 'devise/shared/email_opted_in', f: f
%div
- if show_recaptcha_sign_up?
= recaptcha_tags
......
......@@ -28,7 +28,6 @@
= f.label :password, class: 'label-bold'
= f.password_field :password, class: "form-control bottom", data: { qa_selector: 'new_user_password_field' }, required: true, pattern: ".{#{@minimum_password_length},}", title: _("Minimum length is %{minimum_password_length} characters.") % { minimum_password_length: @minimum_password_length }
%p.gl-field-hint.text-secondary= _('Minimum length is %{minimum_password_length} characters') % { minimum_password_length: @minimum_password_length }
= render_if_exists 'devise/shared/email_opted_in', f: f
%div
- if show_recaptcha_sign_up?
= recaptcha_tags
......
---
title: Gracefully degrade when counting takes too long for a filtered search
merge_request: 46350
author:
type: performance
---
name: soft_fail_count_by_state
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/44184
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/263222
type: development
group: group::source code
default_enabled: false
......@@ -124,8 +124,6 @@ The following metrics can be controlled by feature flags:
|:---------------------------------------------------------------|:-------------------------------------------------------------------|
| `gitlab_method_call_duration_seconds` | `prometheus_metrics_method_instrumentation` |
| `gitlab_view_rendering_duration_seconds` | `prometheus_metrics_view_instrumentation` |
| `gitlab_issuable_fast_count_by_state_total` | `soft_fail_count_by_state` |
| `gitlab_issuable_fast_count_by_state_failures_total` | `soft_fail_count_by_state` |
## Sidekiq metrics
......
......@@ -307,8 +307,12 @@ by testing the following commands:
```shell
sudo mkdir /gitlab-nfs/test-dir
sudo chown git /gitlab-nfs/test-dir
sudo chgrp gitlab-www /gitlab-nfs/test-dir
sudo chgrp root /gitlab-nfs/test-dir
sudo chmod 0700 /gitlab-nfs/test-dir
sudo chgrp gitlab-www /gitlab-nfs/test-dir
sudo chmod 0751 /gitlab-nfs/test-dir
sudo chgrp git /gitlab-nfs/test-dir
sudo chmod 2770 /gitlab-nfs/test-dir
sudo chmod 2755 /gitlab-nfs/test-dir
sudo -u git mkdir /gitlab-nfs/test-dir/test2
sudo -u git chmod 2755 /gitlab-nfs/test-dir/test2
......
......@@ -1335,6 +1335,13 @@ To configure the Sentinel Queues server:
## Configure Gitaly
NOTE: **Note:**
[Gitaly Cluster](../gitaly/praefect.md) support
for the Reference Architectures is being
worked on as a [collaborative effort](https://gitlab.com/gitlab-org/quality/reference-architectures/-/issues/1) between the Quality Engineering and Gitaly teams. When this component has been verified
some Architecture specs will likely change as a result to support the new
and improved designed.
[Gitaly](../gitaly/index.md) server node requirements are dependent on data,
specifically the number of projects and those projects' sizes. It's recommended
that a Gitaly server node stores no more than 5 TB of data. Although this
......
......@@ -1335,6 +1335,13 @@ To configure the Sentinel Queues server:
## Configure Gitaly
NOTE: **Note:**
[Gitaly Cluster](../gitaly/praefect.md) support
for the Reference Architectures is being
worked on as a [collaborative effort](https://gitlab.com/gitlab-org/quality/reference-architectures/-/issues/1) between the Quality Engineering and Gitaly teams. When this component has been verified
some Architecture specs will likely change as a result to support the new
and improved designed.
[Gitaly](../gitaly/index.md) server node requirements are dependent on data,
specifically the number of projects and those projects' sizes. It's recommended
that a Gitaly server node stores no more than 5 TB of data. Although this
......
......@@ -356,6 +356,13 @@ are supported and can be added if needed.
## Configure Gitaly
NOTE: **Note:**
[Gitaly Cluster](../gitaly/praefect.md) support
for the Reference Architectures is being
worked on as a [collaborative effort](https://gitlab.com/gitlab-org/quality/reference-architectures/-/issues/1) between the Quality Engineering and Gitaly teams. When this component has been verified
some Architecture specs will likely change as a result to support the new
and improved designed.
[Gitaly](../gitaly/index.md) server node requirements are dependent on data,
specifically the number of projects and those projects' sizes. It's recommended
that a Gitaly server node stores no more than 5TB of data. Although this
......
......@@ -1058,6 +1058,13 @@ Refer to your preferred Load Balancer's documentation for further guidance.
## Configure Gitaly
NOTE: **Note:**
[Gitaly Cluster](../gitaly/praefect.md) support
for the Reference Architectures is being
worked on as a [collaborative effort](https://gitlab.com/gitlab-org/quality/reference-architectures/-/issues/1) between the Quality Engineering and Gitaly teams. When this component has been verified
some Architecture specs will likely change as a result to support the new
and improved designed.
[Gitaly](../gitaly/index.md) server node requirements are dependent on data,
specifically the number of projects and those projects' sizes. It's recommended
that a Gitaly server node stores no more than 5 TB of data. Although this
......
......@@ -1335,6 +1335,13 @@ To configure the Sentinel Queues server:
## Configure Gitaly
NOTE: **Note:**
[Gitaly Cluster](../gitaly/praefect.md) support
for the Reference Architectures is being
worked on as a [collaborative effort](https://gitlab.com/gitlab-org/quality/reference-architectures/-/issues/1) between the Quality Engineering and Gitaly teams. When this component has been verified
some Architecture specs will likely change as a result to support the new
and improved designed.
[Gitaly](../gitaly/index.md) server node requirements are dependent on data,
specifically the number of projects and those projects' sizes. It's recommended
that a Gitaly server node stores no more than 5 TB of data. Although this
......
......@@ -1057,6 +1057,13 @@ Refer to your preferred Load Balancer's documentation for further guidance.
## Configure Gitaly
NOTE: **Note:**
[Gitaly Cluster](../gitaly/praefect.md) support
for the Reference Architectures is being
worked on as a [collaborative effort](https://gitlab.com/gitlab-org/quality/reference-architectures/-/issues/1) between the Quality Engineering and Gitaly teams. When this component has been verified
some Architecture specs will likely change as a result to support the new
and improved designed.
[Gitaly](../gitaly/index.md) server node requirements are dependent on data,
specifically the number of projects and those projects' sizes. It's recommended
that a Gitaly server node stores no more than 5 TB of data. Although this
......
......@@ -38,7 +38,7 @@ addressed.
## How to create an A/B test
### Implementation
### Implement the experiment
1. Add the experiment to the `Gitlab::Experimentation::EXPERIMENTS` hash in [`experimentation.rb`](https://gitlab.com/gitlab-org/gitlab/blob/master/lib%2Fgitlab%2Fexperimentation.rb):
......@@ -50,7 +50,7 @@ addressed.
# Add your experiment here:
signup_flow: {
environment: ::Gitlab.dev_env_or_com?, # Target environment, defaults to enabled for development and GitLab.com
tracking_category: 'Growth::Acquisition::Experiment::SignUpFlow' # Used for providing the category when setting up tracking data
tracking_category: 'Growth::Activation::Experiment::SignUpFlow' # Used for providing the category when setting up tracking data
}
}.freeze
```
......@@ -111,8 +111,131 @@ addressed.
end
```
1. Track necessary events. See the [product analytics guide](../product_analytics/index.md) for details.
1. After the merge request is merged, use [`chatops`](../../ci/chatops/README.md) in the
### Implement the tracking events
To determine whether the experiment is a success or not, we must implement tracking events
to acquire data for analyzing. We can send events to Snowplow via either the backend or frontend.
Read the [product analytics guide](../product_analytics/index.md) for more details.
#### Track backend events
The framework provides the following helper method that is available in controllers:
```ruby
before_action do
track_experiment_event(:signup_flow, 'action', 'value')
end
```
Which can be tested as follows:
```ruby
context 'when the experiment is active and the user is in the experimental group' do
before do
stub_experiment(signup_flow: true)
stub_experiment_for_user(signup_flow: true)
end
it 'tracks an event', :snowplow do
subject
expect_snowplow_event(
category: 'Growth::Activation::Experiment::SignUpFlow',
action: 'action',
label: 'value',
label: 'experimentation_subject_id',
property: 'experimental_group'
)
end
end
```
#### Track frontend events
The framework provides the following helper method that is available in controllers:
```ruby
before_action do
push_frontend_experiment(:signup_flow)
frontend_experimentation_tracking_data(:signup_flow, 'action', 'value')
end
```
This pushes tracking data to `gon.experiments` and `gon.tracking_data`.
```ruby
expect(Gon.experiments['signupFlow']).to eq(true)
expect(Gon.tracking_data).to eq(
{
category: 'Growth::Activation::Experiment::SignUpFlow',
action: 'action',
value: 'value',
label: 'experimentation_subject_id',
property: 'experimental_group'
}
)
```
Which can then be used for tracking as follows:
```javascript
import { isExperimentEnabled } from '~/lib/utils/experimentation';
import Tracking from '~/tracking';
document.addEventListener('DOMContentLoaded', () => {
const signupFlowExperimentEnabled = isExperimentEnabled('signupFlow');
if (signupFlowExperimentEnabled && gon.tracking_data) {
const { category, action, ...data } = gon.tracking_data;
Tracking.event(category, action, data);
}
}
```
Which can be tested in Jest as follows:
```javascript
import { withGonExperiment } from 'helpers/experimentation_helper';
import Tracking from '~/tracking';
describe('event tracking', () => {
describe('with tracking data', () => {
withGonExperiment('signupFlow');
beforeEach(() => {
jest.spyOn(Tracking, 'event').mockImplementation(() => {});
gon.tracking_data = {
category: 'Growth::Activation::Experiment::SignUpFlow',
action: 'action',
value: 'value',
label: 'experimentation_subject_id',
property: 'experimental_group'
};
});
it('should track data', () => {
performAction()
expect(Tracking.event).toHaveBeenCalledWith(
'Growth::Activation::Experiment::SignUpFlow',
'action',
{
value: 'value',
label: 'experimentation_subject_id',
property: 'experimental_group'
},
);
});
});
});
```
### Enable the experiment
After all merge requests have been merged, use [`chatops`](../../ci/chatops/README.md) in the
[appropriate channel](../feature_flags/controls.md#communicate-the-change) to start the experiment for 10% of the users.
The feature flag should have the name of the experiment with the `_experiment_percentage` suffix appended.
For visibility, please also share any commands run against production in the `#s_growth` channel:
......@@ -127,9 +250,39 @@ For visibility, please also share any commands run against production in the `#s
/chatops run feature delete signup_flow_experiment_percentage
```
### Tests and test helpers
### Testing and test helpers
#### RSpec
Use the folowing in RSpec to mock the experiment:
```ruby
context 'when the experiment is active' do
before do
stub_experiment(signup_flow: true)
end
context 'when the user is in the experimental group' do
before do
stub_experiment_for_user(signup_flow: true)
end
it { is_expected.to do_experimental_thing }
end
context 'when the user is in the control group' do
before do
stub_experiment_for_user(signup_flow: false)
end
it { is_expected.to do_control_thing }
end
end
```
#### Jest
Use the following in Jest to test the experiment is enabled.
Use the following in Jest to mock the experiment:
```javascript
import { withGonExperiment } from 'helpers/experimentation_helper';
......
......@@ -23,7 +23,10 @@ you can run fuzz tests as part your CI/CD workflow.
- SOAP
- GraphQL
- Form bodies, JSON, or XML
- An OpenAPI definition, or HTTP Archive (HAR) of requests to test
- One of the following assets to provide APIs to test:
- OpenAPI v2 API definition
- HTTP Archive (HAR) of API requests to test
- Postman Collection v2.0 or v2.1
## When fuzzing scans run
......@@ -48,15 +51,17 @@ changes, other pipelines, or other scanners) during a scan could cause inaccurat
## Configuration
There are two ways to perform scans. See the configuration section for the one you wish to use:
There are three ways to perform scans. See the configuration section for the one you wish to use:
- [OpenAPI v2 specification](#openapi-specification)
- [HTTP Archive (HAR)](#http-archive-har)
- [Postman Collection v2.0 or v2.1](#postman-collection)
Examples of both configurations can be found here:
- [Example OpenAPI v2 specification project](https://gitlab.com/gitlab-org/security-products/demos/api-fuzzing-example/-/tree/openapi)
- [Example HTTP Archive (HAR) project](https://gitlab.com/gitlab-org/security-products/demos/api-fuzzing-example/-/tree/har)
- [Example Postman Collection project](https://gitlab.com/gitlab-org/security-products/demos/api-fuzzing/postman-collection/)
### OpenAPI Specification
......@@ -229,6 +234,97 @@ DANGER: **Warning:**
the API can, it may also trigger bugs in the API. This includes actions like modifying and deleting
data. Only run fuzzing against a test server.
### Postman Collection
The [Postman API Client](https://www.postman.com/product/api-client/) is a popular tool that
developers and testers use to call various types of APIs. The API definitions
[can be exported as a Postman Collection file](https://learning.postman.com/docs/getting-started/importing-and-exporting-data/#exporting-postman-data)
for use with API Fuzzing. When exporting, make sure to select a supported version of Postman
Collection: v2.0 or v2.1.
When used with GitLab's API fuzzer, Postman Collections must contain definitions of the web API to
test with valid data. The API fuzzer extracts all the API definitions and uses them to perform
testing.
DANGER: **Warning:**
Postman Collection files may contain sensitive information such as authentication tokens, API keys,
and session cookies. We recommend that you review the Postman Collection file contents before adding
them to a repository.
Follow these steps to configure API fuzzing to use a Postman Collection file that provides
information about the target API to test:
1. To use API fuzzing, you must [include](../../../ci/yaml/README.md#includetemplate)
the [`API-Fuzzing.gitlab-ci.yml` template](https://gitlab.com/gitlab-org/gitlab/blob/master/lib/gitlab/ci/templates/Security/API-Fuzzing.gitlab-ci.yml)
that's provided as part of your GitLab installation. To do so, add the following to your
`.gitlab-ci.yml` file:
```yaml
include:
- template: API-Fuzzing.gitlab-ci.yml
```
1. Add the configuration file [`gitlab-api-fuzzing-config.yml`](https://gitlab.com/gitlab-org/security-products/analyzers/api-fuzzing/-/blob/master/gitlab-api-fuzzing-config.yml)
to your repository's root as `.gitlab-api-fuzzing.yml`.
1. The [configuration file](#configuration-files) has several testing profiles defined with varying
amounts of fuzzing. We recommend that you start with the `Quick-10` profile. Testing with this
profile completes quickly, allowing for easier configuration validation.
Provide the profile by adding the `FUZZAPI_PROFILE` variable to your `.gitlab-ci.yml` file,
substituting `Quick-10` for the profile you choose:
```yaml
include:
- template: API-Fuzzing.gitlab-ci.yml
variables:
FUZZAPI_PROFILE: Quick-10
```
1. Add the `FUZZAPI_POSTMAN_COLLECTION` variable and set it to the Postman Collection's location:
```yaml
include:
- template: API-Fuzzing.gitlab-ci.yml
variables:
FUZZAPI_PROFILE: Quick-10
FUZZAPI_POSTMAN_COLLECTION: postman-collection_serviceA.json
```
1. The target API instance's base URL is also required. Provide it by using the `FUZZAPI_TARGET_URL`
variable or an `environment_url.txt` file.
Adding the URL in an `environment_url.txt` file at your project's root is great for testing in
dynamic environments. To run API fuzzing against an app dynamically created during a GitLab CI/CD
pipeline, have the app persist its domain in an `environment_url.txt` file. API fuzzing
automatically parses that file to find its scan target. You can see an
[example of this in our Auto DevOps CI YAML](https://gitlab.com/gitlab-org/gitlab/blob/master/lib/gitlab/ci/templates/Jobs/Deploy.gitlab-ci.yml).
Here's an example of using `FUZZAPI_TARGET_URL`:
```yaml
include:
- template: API-Fuzzing.gitlab-ci.yml
variables:
FUZZAPI_PROFILE: Quick-10
FUZZAPI_POSTMAN_COLLECTION: postman-collection_serviceA.json
FUZZAPI_TARGET_URL: http://test-deployment/
```
This is a minimal configuration for API Fuzzing. From here you can:
- [Run your first scan](#running-your-first-scan).
- [Add authentication](#authentication).
- Learn how to [handle false positives](#handling-false-positives).
DANGER: **Warning:**
**NEVER** run fuzz testing against a production server. Not only can it perform *any* function that
the API can, it may also trigger bugs in the API. This includes actions like modifying and deleting
data. Only run fuzzing against a test server.
### Authentication
Authentication is handled by providing the authentication token as a header or cookie. You can
......@@ -398,6 +494,7 @@ increases as the numbers go up. To use a configuration file, add it to your repo
| `FUZZAPI_REPORT` |Scan report filename. Defaults to `gl-api_fuzzing-report.xml`. |
|[`FUZZAPI_OPENAPI`](#openapi-specification)|OpenAPI specification file or URL. |
|[`FUZZAPI_HAR`](#http-archive-har)|HTTP Archive (HAR) file. |
|[`FUZZAPI_POSTMAN_COLLECTION`](#postman-collection)|Postman Collection file. |
|[`FUZZAPI_OVERRIDES_FILE`](#overrides) |Path to a JSON file containing overrides. |
|[`FUZZAPI_OVERRIDES_ENV`](#overrides) |JSON string containing headers to override. |
|[`FUZZAPI_OVERRIDES_CMD`](#overrides) |Overrides command. |
......
......@@ -7,7 +7,7 @@ export default {
geoReplicationPath: '/api/:version/geo_replication/:replicable',
ldapGroupsPath: '/api/:version/ldap/:provider/groups.json',
subscriptionPath: '/api/:version/namespaces/:id/gitlab_subscription',
childEpicPath: '/api/:version/groups/:id/epics/:epic_iid/epics',
childEpicPath: '/api/:version/groups/:id/epics',
groupEpicsPath: '/api/:version/groups/:id/epics',
epicIssuePath: '/api/:version/groups/:id/epics/:epic_iid/issues/:issue_id',
cycleAnalyticsTasksByTypePath: '/groups/:id/-/analytics/type_of_work/tasks_by_type',
......@@ -41,6 +41,7 @@ export default {
vulnerabilityActionPath: '/api/:version/vulnerabilities/:id/:action',
vulnerabilityIssueLinksPath: '/api/:version/vulnerabilities/:id/issue_links',
applicationSettingsPath: '/api/:version/application/settings',
descendantGroupsPath: '/api/:version/groups/:group_id/descendant_groups',
userSubscription(namespaceId) {
const url = Api.buildUrl(this.subscriptionPath).replace(':id', encodeURIComponent(namespaceId));
......@@ -65,17 +66,26 @@ export default {
});
},
createChildEpic({ confidential, groupId, parentEpicIid, title }) {
const url = Api.buildUrl(this.childEpicPath)
.replace(':id', encodeURIComponent(groupId))
.replace(':epic_iid', parentEpicIid);
createChildEpic({ confidential, groupId, parentEpicId, title }) {
const url = Api.buildUrl(this.childEpicPath).replace(':id', encodeURIComponent(groupId));
return axios.post(url, {
parent_id: parentEpicId,
confidential,
title,
});
},
descendantGroups({ groupId, search }) {
const url = Api.buildUrl(this.descendantGroupsPath).replace(':group_id', groupId);
return axios.get(url, {
params: {
search,
},
});
},
groupEpics({
groupId,
includeAncestorGroups = false,
......
......@@ -2,4 +2,16 @@ import mountProgressBar from 'ee/registrations/welcome';
document.addEventListener('DOMContentLoaded', () => {
mountProgressBar();
const emailUpdatesForm = document.querySelector('.js-email-opt-in');
const setupForCompany = document.querySelector('.js-setup-for-company');
const setupForMe = document.querySelector('.js-setup-for-me');
setupForCompany.addEventListener('change', () => {
emailUpdatesForm.classList.add('hidden');
});
setupForMe.addEventListener('change', () => {
emailUpdatesForm.classList.remove('hidden');
});
});
<script>
import { mapState } from 'vuex';
import { mapState, mapActions } from 'vuex';
import { GlButton } from '@gitlab/ui';
import {
GlAvatar,
GlButton,
GlFormInput,
GlDropdown,
GlSearchBoxByType,
GlDropdownItem,
GlLoadingIcon,
} from '@gitlab/ui';
import { SEARCH_DEBOUNCE } from '../constants';
import { __ } from '~/locale';
export default {
components: {
GlButton,
GlFormInput,
GlDropdown,
GlSearchBoxByType,
GlDropdownItem,
GlAvatar,
GlLoadingIcon,
},
props: {
isSubmitting: {
......@@ -19,16 +34,45 @@ export default {
data() {
return {
inputValue: '',
searchTerm: '',
selectedGroup: null,
};
},
computed: {
...mapState(['parentItem']),
...mapState([
'descendantGroupsFetchInProgress',
'itemCreateInProgress',
'descendantGroups',
'parentItem',
]),
isSubmitButtonDisabled() {
return this.inputValue.length === 0 || this.isSubmitting;
},
buttonLabel() {
return this.isSubmitting ? __('Creating epic') : __('Create epic');
},
dropdownPlaceholderText() {
return this.selectedGroup?.name || __('Search a group');
},
canRenderNoResults() {
return !this.descendantGroupsFetchInProgress && !this.descendantGroups?.length;
},
canRenderSearchResults() {
return !this.descendantGroupsFetchInProgress;
},
},
watch: {
searchTerm() {
this.handleDropdownShow();
},
descendantGroupsFetchInProgress(value) {
if (!value) {
this.$nextTick(() => {
this.$refs.searchInputField.focusInput();
});
}
},
},
mounted() {
this.$nextTick()
......@@ -37,29 +81,95 @@ export default {
})
.catch(() => {});
},
methods: {
...mapActions(['fetchDescendantGroups']),
onFormSubmit() {
this.$emit('createEpicFormSubmit', this.inputValue.trim());
const groupFullPath = this.selectedGroup?.full_path;
this.$emit('createEpicFormSubmit', this.inputValue.trim(), groupFullPath);
},
onFormCancel() {
this.$emit('createEpicFormCancel');
},
handleDropdownShow() {
const {
parentItem: { groupId },
searchTerm,
} = this;
this.fetchDescendantGroups({ groupId, search: searchTerm });
},
},
debounce: SEARCH_DEBOUNCE,
};
</script>
<template>
<form @submit.prevent="onFormSubmit">
<input
ref="input"
v-model="inputValue"
:placeholder="
parentItem.confidential ? __('New confidential epic title ') : __('New epic title')
"
type="text"
class="form-control"
@keyup.escape.exact="onFormCancel"
/>
<div class="row mb-3">
<div class="col-sm">
<label class="label-bold">{{ s__('Issue|Title') }}</label>
<gl-form-input
ref="input"
v-model="inputValue"
:placeholder="
parentItem.confidential ? __('New confidential epic title ') : __('New epic title')
"
type="text"
class="form-control"
@keyup.escape.exact="onFormCancel"
/>
</div>
<div class="col-sm">
<label class="label-bold">{{ __('Group') }}</label>
<gl-dropdown
block
:text="dropdownPlaceholderText"
class="dropdown-descendant-groups"
menu-class="w-100 gl-pt-0"
@show="handleDropdownShow"
>
<gl-search-box-by-type
ref="searchInputField"
v-model.trim="searchTerm"
:disabled="descendantGroupsFetchInProgress"
:debounce="$options.debounce"
/>
<gl-loading-icon
v-show="descendantGroupsFetchInProgress"
class="projects-fetch-loading align-items-center p-2"
size="md"
/>
<template v-if="canRenderSearchResults">
<gl-dropdown-item
v-for="group in descendantGroups"
:key="group.id"
class="w-100"
@click="selectedGroup = group"
>
<gl-avatar
:src="group.avatar_url"
:entity-name="group.name"
shape="rect"
:size="32"
class="d-inline-flex"
/>
<div class="d-inline-flex flex-column">
{{ group.name }}
<div class="text-secondary">{{ group.path }}</div>
</div>
</gl-dropdown-item>
</template>
<gl-dropdown-item v-if="canRenderNoResults">{{
__('No matching results')
}}</gl-dropdown-item>
</gl-dropdown>
</div>
</div>
<div class="add-issuable-form-actions clearfix">
<gl-button
:disabled="isSubmitButtonDisabled"
......
......@@ -137,9 +137,10 @@ export default {
this.addItem();
}
},
handleCreateEpicFormSubmit(newValue) {
handleCreateEpicFormSubmit(newValue, groupFullPath) {
this.createItem({
itemTitle: newValue,
groupFullPath,
});
},
handleAddItemFormCancel() {
......
......@@ -21,7 +21,9 @@ export default () => {
const {
id,
iid,
numericalId,
fullPath,
groupId,
autoCompleteEpics,
autoCompleteIssues,
userSignedIn,
......@@ -40,8 +42,10 @@ export default () => {
created() {
this.setInitialParentItem({
fullPath,
numericalId: parseInt(numericalId, 10),
groupId: parseInt(groupId, 10),
id,
iid: Number(iid),
iid: parseInt(iid, 10),
title: initialData.initialTitleText,
confidential: initialData.confidential,
reference: `${initialData.fullPath}${initialData.issuableRef}`,
......
......@@ -385,13 +385,13 @@ export const receiveCreateItemFailure = ({ commit }) => {
commit(types.RECEIVE_CREATE_ITEM_FAILURE);
flash(s__('Epics|Something went wrong while creating child epics.'));
};
export const createItem = ({ state, dispatch }, { itemTitle }) => {
export const createItem = ({ state, dispatch }, { itemTitle, groupFullPath }) => {
dispatch('requestCreateItem');
Api.createChildEpic({
confidential: state.parentItem.confidential,
groupId: state.parentItem.fullPath,
parentEpicIid: state.parentItem.iid,
groupId: groupFullPath || state.parentItem.fullPath,
parentEpicId: Number(state.parentItem.id.match(/\d.*/)),
title: itemTitle,
})
.then(({ data }) => {
......@@ -404,6 +404,9 @@ export const createItem = ({ state, dispatch }, { itemTitle }) => {
});
dispatch('receiveCreateItemSuccess', { rawItem: data });
dispatch('fetchItems', {
parentItem: state.parentItem,
});
})
.catch(() => {
dispatch('receiveCreateItemFailure');
......@@ -590,3 +593,15 @@ export const fetchProjects = ({ state, dispatch }, searchKey = '') => {
})
.catch(() => dispatch('receiveProjectsFailure'));
};
export const fetchDescendantGroups = ({ commit }, { groupId, search = '' }) => {
commit(types.REQUEST_DESCENDANT_GROUPS);
return Api.descendantGroups({ groupId, search })
.then(({ data }) => {
commit(types.RECEIVE_DESCENDANT_GROUPS_SUCCESS, data);
})
.catch(() => {
commit(types.RECEIVE_DESCENDANT_GROUPS_FAILURE);
});
};
......@@ -48,3 +48,7 @@ export const SET_PROJECTS = 'SET_PROJECTS';
export const REQUEST_PROJECTS = 'REQUEST_PROJECTS';
export const RECIEVE_PROJECTS_SUCCESS = 'RECIEVE_PROJECTS_SUCCESS';
export const RECIEVE_PROJECTS_FAILURE = 'RECIEVE_PROJECTS_FAILURE';
export const REQUEST_DESCENDANT_GROUPS = 'REQUEST_DESCENDANT_GROUPS';
export const RECEIVE_DESCENDANT_GROUPS_SUCCESS = 'RECEIVE_DESCENDANT_GROUPS_SUCCESS';
export const RECEIVE_DESCENDANT_GROUPS_FAILURE = 'RECEIVE_DESCENDANT_GROUPS_FAILURE';
......@@ -262,4 +262,15 @@ export default {
[types.RECIEVE_PROJECTS_FAILURE](state) {
state.projectsFetchInProgress = false;
},
[types.REQUEST_DESCENDANT_GROUPS](state) {
state.descendantGroupsFetchInProgress = true;
},
[types.RECEIVE_DESCENDANT_GROUPS_SUCCESS](state, descendantGroups) {
state.descendantGroups = descendantGroups;
state.descendantGroupsFetchInProgress = false;
},
[types.RECEIVE_DESCENDANT_GROUPS_FAILURE](state) {
state.descendantGroupsFetchInProgress = false;
},
};
......@@ -52,4 +52,7 @@ export default () => ({
},
projects: [],
descendantGroups: [],
descendantGroupsFetchInProgress: false,
});
......@@ -14,9 +14,11 @@ module EE
super(confirmed: confirmed) + ", experiments:#{experiments}"
end
def sign_up_params
def update_registration_params
clean_params = super.merge(params.require(:user).permit(:email_opted_in))
clean_params[:email_opted_in] = '1' if clean_params[:setup_for_company] == 'true'
if clean_params[:email_opted_in] == '1'
clean_params[:email_opted_in_ip] = request.remote_ip
clean_params[:email_opted_in_source_id] = User::EMAIL_OPT_IN_SOURCE_ID_GITLAB_COM
......
- if Gitlab.com?
.form-group
= f.check_box :email_opted_in
= f.label :email_opted_in, "I'd like to receive updates via email about GitLab."
- return unless Gitlab.dev_env_or_com?
- is_hidden = local_assigns.fetch(:hidden, false)
.js-email-opt-in{ class: is_hidden ? 'hidden' : '' }
.gl-font-weight-bold.gl-mb-3.gl-mt-3
= _('Email updates (optional)')
= f.check_box :email_opted_in
= f.label :email_opted_in, _("I'd like to receive updates about GitLab via email"), class: 'gl-font-weight-normal'
......@@ -39,7 +39,9 @@
.row
%section.col-md-12
#js-tree{ data: { id: @epic.to_global_id,
numerical_id: @epic.id,
iid: @epic.iid,
group_id: @group.id,
full_path: @group.full_path,
auto_complete_epics: 'true',
auto_complete_issues: 'true',
......
......@@ -8,8 +8,9 @@
= f.label :setup_for_company, setup_for_company_label_text, class: 'label-bold'
.d-flex.flex-column.flex-lg-row
.flex-grow-1
= f.radio_button :setup_for_company, true
= f.radio_button :setup_for_company, true, class: 'js-setup-for-company'
= f.label :setup_for_company, _('My company or team'), class: 'normal', value: 'true'
.flex-grow-1
= f.radio_button :setup_for_company, false
= f.radio_button :setup_for_company, false, class: 'js-setup-for-me'
= f.label :setup_for_company, _('Just me'), class: 'normal', value: 'false'
= render_if_exists 'devise/shared/email_opted_in', f: f, hidden: true
......@@ -27,9 +27,6 @@
= f.label :password, for: 'new_user_password', class: 'label-bold'
= f.password_field :password, class: 'form-control bottom', data: { qa_selector: 'new_user_password_field' }, required: true, pattern: ".{#{@minimum_password_length},}", title: _("Minimum length is %{minimum_password_length} characters.") % { minimum_password_length: @minimum_password_length }
%p.gl-field-hint.text-secondary= _('Minimum length is %{minimum_password_length} characters') % { minimum_password_length: @minimum_password_length }
.form-group
= f.check_box :email_opted_in, data: { qa_selector: 'new_user_email_opted_in_checkbox' }
= f.label :email_opted_in, _("I'd like to receive updates via email about GitLab"), class: 'form-check-label'
%div
- if show_recaptcha_sign_up?
= recaptcha_tags
......
---
title: Update API Fuzzing template to support use of a Postman Collection
merge_request: 46476
author:
type: added
---
title: Specify group when creating epic
merge_request: 45741
author:
type: added
---
title: Move marketing opt in to welcome page and opt in by default when setting up for a company
merge_request: 46446
author:
type: changed
......@@ -9,32 +9,6 @@ RSpec.describe RegistrationsController do
let(:base_user_params) { build_stubbed(:user).slice(:first_name, :last_name, :username, :email, :password) }
let(:user_params) { { user: base_user_params } }
context 'when the user opted-in' do
let(:user_params) { { user: base_user_params.merge(email_opted_in: '1') } }
it 'sets the rest of the email_opted_in fields' do
post :create, params: user_params
user = User.find_by_username!(user_params[:user][:username])
expect(user.email_opted_in).to be_truthy
expect(user.email_opted_in_ip).to be_present
expect(user.email_opted_in_source).to eq('GitLab.com')
expect(user.email_opted_in_at).not_to be_nil
end
end
context 'when the user opted-out' do
let(:user_params) { { user: base_user_params.merge(email_opted_in: '0') } }
it 'does not set the rest of the email_opted_in fields' do
post :create, params: user_params
user = User.find_by_username!(user_params[:user][:username])
expect(user.email_opted_in).to be_falsey
expect(user.email_opted_in_ip).to be_blank
expect(user.email_opted_in_source).to be_blank
expect(user.email_opted_in_at).to be_nil
end
end
context 'when reCAPTCHA experiment enabled' do
it "logs a 'User Created' message including the experiment state" do
allow_any_instance_of(EE::RecaptchaExperimentHelper).to receive(:show_recaptcha_sign_up?).and_return(true)
......@@ -59,7 +33,18 @@ RSpec.describe RegistrationsController do
end
describe '#update_registration' do
subject(:update_registration) { patch :update_registration, params: { user: { role: 'software_developer', setup_for_company: 'false' } } }
let(:setup_for_company) { 'false' }
let(:email_opted_in) { '0' }
subject(:update_registration) do
patch :update_registration, params: {
user: {
role: 'software_developer',
setup_for_company: setup_for_company,
email_opted_in: email_opted_in
}
}
end
context 'without a signed in user' do
it { is_expected.to redirect_to new_user_registration_path }
......@@ -70,6 +55,49 @@ RSpec.describe RegistrationsController do
sign_in(user)
end
context 'email updates' do
context 'when setup for company is false' do
context 'when the user opted in' do
let(:email_opted_in) { '1' }
it 'sets the email_opted_in fields' do
subject
expect(controller.current_user.email_opted_in).to be_truthy
expect(controller.current_user.email_opted_in_ip).to be_present
expect(controller.current_user.email_opted_in_source).to eq('GitLab.com')
expect(controller.current_user.email_opted_in_at).not_to be_nil
end
end
context 'when user opted out' do
let(:email_opted_in) { '0' }
it 'does not set the rest of the email_opted_in fields' do
subject
expect(controller.current_user.email_opted_in).to be_falsey
expect(controller.current_user.email_opted_in_ip).to be_blank
expect(controller.current_user.email_opted_in_source).to be_blank
expect(controller.current_user.email_opted_in_at).to be_nil
end
end
end
context 'when setup for company is true' do
let(:setup_for_company) { 'true' }
it 'sets email_opted_in fields' do
subject
expect(controller.current_user.email_opted_in).to be_truthy
expect(controller.current_user.email_opted_in_ip).to be_present
expect(controller.current_user.email_opted_in_source).to eq('GitLab.com')
expect(controller.current_user.email_opted_in_at).not_to be_nil
end
end
end
describe 'redirection' do
it { is_expected.to redirect_to dashboard_projects_path }
......
......@@ -18,14 +18,37 @@ RSpec.describe 'Signup on EE' do
expect(Gitlab).to receive(:com?).and_return(true).at_least(:once)
end
context 'when the user sets it up for the company' do
it 'creates the user and sets the email_opted_in field truthy' do
visit root_path
fill_in_signup_form
click_button "Register"
select 'Software Developer', from: 'user_role'
choose 'user_setup_for_company_true'
click_button 'Get started!'
user = User.find_by_username!(new_user[:username])
expect(user.email_opted_in).to be_truthy
expect(user.email_opted_in_ip).to be_present
expect(user.email_opted_in_source).to eq('GitLab.com')
expect(user.email_opted_in_at).not_to be_nil
end
end
context 'when the user checks the opt-in to email updates box' do
it 'creates the user and sets the email_opted_in field truthy' do
visit root_path
fill_in_signup_form
check 'new_user_email_opted_in'
click_button "Register"
select 'Software Developer', from: 'user_role'
choose 'user_setup_for_company_false'
check 'user_email_opted_in'
click_button 'Get started!'
user = User.find_by_username!(new_user[:username])
expect(user.email_opted_in).to be_truthy
expect(user.email_opted_in_ip).to be_present
......@@ -41,6 +64,10 @@ RSpec.describe 'Signup on EE' do
fill_in_signup_form
click_button "Register"
select 'Software Developer', from: 'user_role'
choose 'user_setup_for_company_false'
click_button 'Get started!'
user = User.find_by_username!(new_user[:username])
expect(user.email_opted_in).to be_falsey
expect(user.email_opted_in_ip).to be_blank
......
......@@ -66,22 +66,22 @@ describe('Api', () => {
describe('createChildEpic', () => {
it('calls `axios.post` using params `groupId`, `parentEpicIid` and title', done => {
const groupId = 'gitlab-org';
const parentEpicIid = 1;
const parentEpicId = 1;
const title = 'Sample epic';
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}/epics/${parentEpicIid}/epics`;
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}/epics`;
const expectedRes = {
title,
id: 20,
iid: 5,
parentId: 5,
};
mock.onPost(expectedUrl).reply(httpStatus.OK, expectedRes);
Api.createChildEpic({ groupId, parentEpicIid, title })
Api.createChildEpic({ groupId, parentEpicId, title })
.then(({ data }) => {
expect(data.title).toBe(expectedRes.title);
expect(data.id).toBe(expectedRes.id);
expect(data.iid).toBe(expectedRes.iid);
expect(data.parentId).toBe(expectedRes.parentId);
})
.then(done)
.catch(done.fail);
......
import { createLocalVue, shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
import { GlButton } from '@gitlab/ui';
import { GlFormInput, GlButton } from '@gitlab/ui';
import CreateEpicForm from 'ee/related_items_tree/components/create_epic_form.vue';
import createDefaultStore from 'ee/related_items_tree/store';
......@@ -74,18 +74,29 @@ describe('RelatedItemsTree', () => {
expect(wrapper.vm.buttonLabel).toBe('Create epic');
});
});
describe('dropdownPlaceholderText', () => {
it('returns placeholder when no group is selected', () => {
expect(wrapper.vm.dropdownPlaceholderText).toBe('Search a group');
});
it('returns group name when a group is selected', () => {
const group = { name: 'Group 1' };
wrapper.setData({ selectedGroup: group });
expect(wrapper.vm.dropdownPlaceholderText).toBe(group.name);
});
});
});
describe('methods', () => {
describe('onFormSubmit', () => {
it('emits `createEpicFormSubmit` event on component with input value as param', () => {
const value = 'foo';
wrapper.find('input.form-control').setValue(value);
wrapper.find(GlFormInput).vm.$emit('input', value);
wrapper.vm.onFormSubmit();
expect(wrapper.emitted().createEpicFormSubmit).toBeTruthy();
expect(wrapper.emitted().createEpicFormSubmit[0]).toEqual([value]);
expect(wrapper.emitted().createEpicFormSubmit[0]).toEqual([value, undefined]);
});
});
......@@ -96,11 +107,26 @@ describe('RelatedItemsTree', () => {
expect(wrapper.emitted().createEpicFormCancel).toBeTruthy();
});
});
describe('handleDropdownShow', () => {
it('fetches descendant groups based on searchTerm', () => {
const handleDropdownShow = jest
.spyOn(wrapper.vm, 'fetchDescendantGroups')
.mockImplementation(jest.fn());
wrapper.vm.handleDropdownShow();
expect(handleDropdownShow).toHaveBeenCalledWith({
groupId: mockParentItem.groupId,
search: wrapper.vm.searchTerm,
});
});
});
});
describe('template', () => {
it('renders input element within form', () => {
const inputEl = wrapper.find('input.form-control');
const inputEl = wrapper.find(GlFormInput);
expect(inputEl.attributes('placeholder')).toBe('New epic title');
});
......
......@@ -1082,6 +1082,14 @@ describe('RelatedItemTree', () => {
rawItem: { ...mockEpic1, path: '', state: ChildState.Open, created_at: '' },
},
},
{
type: 'fetchItems',
payload: {
parentItem: {
...mockParentItem,
},
},
},
],
);
});
......
......@@ -60,8 +60,8 @@ RSpec.describe 'API-Fuzzing.gitlab-ci.yml' do
end
context 'by default' do
it 'includes no job' do
expect { pipeline }.to raise_error(Ci::CreatePipelineService::CreateError)
it 'includes a job' do
expect(build_names).to match_array(%w[apifuzzer_fuzz])
end
end
......@@ -87,6 +87,17 @@ RSpec.describe 'API-Fuzzing.gitlab-ci.yml' do
end
end
context 'when configured with Postman' do
before do
create(:ci_variable, project: project, key: 'FUZZAPI_POSTMAN_COLLECTION', value: 'testing.json')
create(:ci_variable, project: project, key: 'FUZZAPI_TARGET_URL', value: 'http://example.com')
end
it 'includes job' do
expect(build_names).to match_array(%w[apifuzzer_fuzz])
end
end
context 'when FUZZAPI_D_TARGET_IMAGE is present' do
before do
create(:ci_variable, project: project, key: 'FUZZAPI_D_TARGET_IMAGE', value: 'imagename:latest')
......
......@@ -61,11 +61,17 @@ apifuzzer_fuzz:
- if: $API_FUZZING_DISABLED_FOR_DEFAULT_BRANCH &&
$CI_DEFAULT_BRANCH == $CI_COMMIT_REF_NAME
when: never
- if: $FUZZAPI_HAR == null && $FUZZAPI_OPENAPI == null
when: never
- if: $GITLAB_FEATURES =~ /\bapi_fuzzing\b/
script:
#
# Validate options
- |
if [ "$FUZZAPI_HAR$FUZZAPI_OPENAPI$FUZZAPI_POSTMAN_COLLECTION" == "" ]; then \
echo "Error: One of FUZZAPI_HAR, FUZZAPI_OPENAPI, or FUZZAPI_POSTMAN_COLLECTION must be provided."; \
echo "See https://docs.gitlab.com/ee/user/application_security/api_fuzzing/ for information on how to configure API Fuzzing."; \
exit 1; \
fi
#
# Run user provided pre-script
- sh -c "$FUZZAPI_PRE_SCRIPT"
#
......@@ -96,8 +102,6 @@ apifuzzer_fuzz_dnd:
- if: $API_FUZZING_DISABLED_FOR_DEFAULT_BRANCH &&
$CI_DEFAULT_BRANCH == $CI_COMMIT_REF_NAME
when: never
- if: $FUZZAPI_HAR == null && $FUZZAPI_OPENAPI == null
when: never
- if: $GITLAB_FEATURES =~ /\bapi_fuzzing\b/
services:
- docker:19.03.12-dind
......@@ -142,7 +146,7 @@ apifuzzer_fuzz_dnd:
# Start worker container if provided
- |
if [ "$FUZZAPI_D_WORKER_IMAGE" != "" ]; then \
echo "Starting worker image $FUZZAPI_D_WORKER_IMAGE" \
echo "Starting worker image $FUZZAPI_D_WORKER_IMAGE"; \
docker run \
--name worker \
--network $FUZZAPI_D_NETWORK \
......@@ -153,6 +157,7 @@ apifuzzer_fuzz_dnd:
-e FUZZAPI_REPORT \
-e FUZZAPI_HAR \
-e FUZZAPI_OPENAPI \
-e FUZZAPI_POSTMAN_COLLECTION \
-e FUZZAPI_TARGET_URL \
-e FUZZAPI_OVERRIDES_FILE \
-e FUZZAPI_OVERRIDES_ENV \
......@@ -174,6 +179,11 @@ apifuzzer_fuzz_dnd:
# Start API Fuzzing provided worker if no other worker present
- |
if [ "$FUZZAPI_D_WORKER_IMAGE" == "" ]; then \
if [ "$FUZZAPI_HAR$FUZZAPI_OPENAPI$FUZZAPI_POSTMAN_COLLECTION" == "" ]; then \
echo "Error: One of FUZZAPI_HAR, FUZZAPI_OPENAPI, or FUZZAPI_POSTMAN_COLLECTION must be provided."; \
echo "See https://docs.gitlab.com/ee/user/application_security/api_fuzzing/ for information on how to configure API Fuzzing."; \
exit 1; \
fi; \
docker run \
--name worker \
--network $FUZZAPI_D_NETWORK \
......@@ -185,6 +195,7 @@ apifuzzer_fuzz_dnd:
-e FUZZAPI_REPORT \
-e FUZZAPI_HAR \
-e FUZZAPI_OPENAPI \
-e FUZZAPI_POSTMAN_COLLECTION \
-e FUZZAPI_TARGET_URL \
-e FUZZAPI_OVERRIDES_FILE \
-e FUZZAPI_OVERRIDES_ENV \
......
......@@ -1062,16 +1062,6 @@ msgstr ""
msgid "0t1DgySidms"
msgstr ""
msgid "1 %{type} addition"
msgid_plural "%{count} %{type} additions"
msgstr[0] ""
msgstr[1] ""
msgid "1 %{type} modification"
msgid_plural "%{count} %{type} modifications"
msgstr[0] ""
msgstr[1] ""
msgid "1 Day"
msgid_plural "%d Days"
msgstr[0] ""
......@@ -9828,6 +9818,9 @@ msgstr ""
msgid "Email the pipelines status to a list of recipients."
msgstr ""
msgid "Email updates (optional)"
msgstr ""
msgid "EmailError|It appears that the email is blank. Make sure your reply is at the top of the email, we can't process inline replies."
msgstr ""
......@@ -13641,7 +13634,7 @@ msgstr ""
msgid "I have read and agree to the Let's Encrypt %{link_start}Terms of Service%{link_end} (PDF)"
msgstr ""
msgid "I'd like to receive updates via email about GitLab"
msgid "I'd like to receive updates about GitLab via email"
msgstr ""
msgid "ID"
......@@ -23325,6 +23318,9 @@ msgstr ""
msgid "Search Jira issues"
msgstr ""
msgid "Search a group"
msgstr ""
msgid "Search an environment spec"
msgstr ""
......
......@@ -65,7 +65,7 @@ COPY VERSION ./ee/app/models/license.r[b] /home/gitlab/ee/app/models/
COPY ./lib/gitlab.rb /home/gitlab/lib/
COPY ./lib/gitlab/utils.rb /home/gitlab/lib/gitlab/
COPY ./INSTALLATION_TYPE ./VERSION /home/gitlab/
RUN cd /home/gitlab/qa/ && gem install bundler:1.17.3 && bundle install --jobs=$(nproc) --retry=3 --without=development --quiet
RUN cd /home/gitlab/qa/ && bundle install --jobs=$(nproc) --retry=3 --without=development --quiet
COPY ./qa /home/gitlab/qa
ENTRYPOINT ["bin/test"]
......@@ -175,4 +175,4 @@ DEPENDENCIES
timecop (~> 0.9.1)
BUNDLED WITH
1.17.3
2.1.4
......@@ -5,8 +5,6 @@ export USE_BUNDLE_INSTALL=${USE_BUNDLE_INSTALL:-true}
export BUNDLE_INSTALL_FLAGS=${BUNDLE_INSTALL_FLAGS:-"--without=production development --jobs=$(nproc) --path=vendor --retry=3 --quiet"}
if [ "$USE_BUNDLE_INSTALL" != "false" ]; then
# This is for backwards compatibility for Gitaly
run_timed_command "gem install bundler:1.17.3"
bundle --version
run_timed_command "bundle install --clean ${BUNDLE_INSTALL_FLAGS}"
run_timed_command "bundle check"
......
......@@ -46,6 +46,20 @@ RSpec.describe 'IDE user sees editor info', :js do
end
end
it 'persists position after rename' do
ide_open_file('README.md')
ide_set_editor_position(4, 10)
ide_open_file('files/js/application.js')
ide_rename_file('README.md', 'READING_RAINBOW.md')
ide_open_file('READING_RAINBOW.md')
within find('.ide-status-bar') do
expect(page).to have_content('4:10')
end
end
it 'persists position' do
ide_open_file('README.md')
ide_set_editor_position(4, 10)
......
import Vue from 'vue';
import { createComponentWithStore } from 'helpers/vue_mount_component_helper';
import { createStore } from '~/ide/stores';
import listCollapsed from '~/ide/components/commit_sidebar/list_collapsed.vue';
import { file } from '../../helpers';
import { removeWhitespace } from '../../../helpers/text_helper';
describe('Multi-file editor commit sidebar list collapsed', () => {
let vm;
let store;
beforeEach(() => {
store = createStore();
const Component = Vue.extend(listCollapsed);
vm = createComponentWithStore(Component, store, {
files: [
{
...file('file1'),
tempFile: true,
},
file('file2'),
],
iconName: 'staged',
title: 'Staged',
});
vm.$mount();
});
afterEach(() => {
vm.$destroy();
});
it('renders added & modified files count', () => {
expect(removeWhitespace(vm.$el.textContent).trim()).toBe('1 1');
});
describe('addedFilesLength', () => {
it('returns an length of temp files', () => {
expect(vm.addedFilesLength).toBe(1);
});
});
describe('modifiedFilesLength', () => {
it('returns an length of modified files', () => {
expect(vm.modifiedFilesLength).toBe(1);
});
});
describe('addedFilesIconClass', () => {
it('includes multi-file-addition when addedFiles is not empty', () => {
expect(vm.addedFilesIconClass).toContain('multi-file-addition');
});
it('excludes multi-file-addition when addedFiles is empty', () => {
vm.files = [];
expect(vm.addedFilesIconClass).not.toContain('multi-file-addition');
});
});
describe('modifiedFilesClass', () => {
it('includes multi-file-modified when addedFiles is not empty', () => {
expect(vm.modifiedFilesClass).toContain('multi-file-modified');
});
it('excludes multi-file-modified when addedFiles is empty', () => {
vm.files = [];
expect(vm.modifiedFilesClass).not.toContain('multi-file-modified');
});
});
});
......@@ -6,17 +6,21 @@ import TerminalSyncStatusSafe from '~/ide/components/terminal_sync/terminal_sync
const TEST_FILE = {
name: 'lorem.md',
editorRow: 3,
editorColumn: 23,
fileLanguage: 'markdown',
content: 'abc\nndef',
permalink: '/lorem.md',
};
const TEST_FILE_EDITOR = {
fileLanguage: 'markdown',
editorRow: 3,
editorColumn: 23,
};
const TEST_EDITOR_POSITION = `${TEST_FILE_EDITOR.editorRow}:${TEST_FILE_EDITOR.editorColumn}`;
const localVue = createLocalVue();
localVue.use(Vuex);
describe('ide/components/ide_status_list', () => {
let activeFileEditor;
let activeFile;
let store;
let wrapper;
......@@ -27,6 +31,14 @@ describe('ide/components/ide_status_list', () => {
getters: {
activeFile: () => activeFile,
},
modules: {
editor: {
namespaced: true,
getters: {
activeFileEditor: () => activeFileEditor,
},
},
},
});
wrapper = shallowMount(IdeStatusList, {
......@@ -38,6 +50,7 @@ describe('ide/components/ide_status_list', () => {
beforeEach(() => {
activeFile = TEST_FILE;
activeFileEditor = TEST_FILE_EDITOR;
});
afterEach(() => {
......@@ -47,8 +60,6 @@ describe('ide/components/ide_status_list', () => {
wrapper = null;
});
const getEditorPosition = file => `${file.editorRow}:${file.editorColumn}`;
describe('with regular file', () => {
beforeEach(() => {
createComponent();
......@@ -65,11 +76,11 @@ describe('ide/components/ide_status_list', () => {
});
it('shows file editor position', () => {
expect(wrapper.text()).toContain(getEditorPosition(TEST_FILE));
expect(wrapper.text()).toContain(TEST_EDITOR_POSITION);
});
it('shows file language', () => {
expect(wrapper.text()).toContain(TEST_FILE.fileLanguage);
expect(wrapper.text()).toContain(TEST_FILE_EDITOR.fileLanguage);
});
});
......@@ -81,7 +92,7 @@ describe('ide/components/ide_status_list', () => {
});
it('does not show file editor position', () => {
expect(wrapper.text()).not.toContain(getEditorPosition(TEST_FILE));
expect(wrapper.text()).not.toContain(TEST_EDITOR_POSITION);
});
});
......
......@@ -55,7 +55,6 @@ describe('RepoEditor', () => {
beforeEach(() => {
const f = {
...file('file.txt'),
viewMode: FILE_VIEW_MODE_EDITOR,
content: 'hello world',
};
......@@ -92,6 +91,8 @@ describe('RepoEditor', () => {
});
const findEditor = () => vm.$el.querySelector('.multi-file-editor-holder');
const changeViewMode = viewMode =>
store.dispatch('editor/updateFileEditor', { path: vm.file.path, data: { viewMode } });
describe('default', () => {
beforeEach(() => {
......@@ -409,7 +410,7 @@ describe('RepoEditor', () => {
describe('when files view mode is preview', () => {
beforeEach(done => {
jest.spyOn(vm.editor, 'updateDimensions').mockImplementation();
vm.file.viewMode = FILE_VIEW_MODE_PREVIEW;
changeViewMode(FILE_VIEW_MODE_PREVIEW);
vm.file.name = 'myfile.md';
vm.file.content = 'hello world';
......@@ -423,7 +424,7 @@ describe('RepoEditor', () => {
describe('when file view mode changes to editor', () => {
it('should update dimensions', () => {
vm.file.viewMode = FILE_VIEW_MODE_EDITOR;
changeViewMode(FILE_VIEW_MODE_EDITOR);
return vm.$nextTick().then(() => {
expect(vm.editor.updateDimensions).toHaveBeenCalled();
......
import * as pathUtils from 'path';
import { decorateData } from '~/ide/stores/utils';
import { commitActionTypes } from '~/ide/constants';
export const file = (name = 'name', id = name, type = '', parent = null) =>
decorateData({
......@@ -28,3 +29,17 @@ export const createEntriesFromPaths = paths =>
...entries,
};
}, {});
export const createTriggerChangeAction = payload => ({
type: 'triggerFilesChange',
...(payload ? { payload } : {}),
});
export const createTriggerRenamePayload = (path, newPath) => ({
type: commitActionTypes.move,
path,
newPath,
});
export const createTriggerRenameAction = (path, newPath) =>
createTriggerChangeAction(createTriggerRenamePayload(path, newPath));
......@@ -7,7 +7,7 @@ import * as types from '~/ide/stores/mutation_types';
import service from '~/ide/services';
import { createRouter } from '~/ide/ide_router';
import eventHub from '~/ide/eventhub';
import { file } from '../../helpers';
import { file, createTriggerRenameAction } from '../../helpers';
const ORIGINAL_CONTENT = 'original content';
const RELATIVE_URL_ROOT = '/gitlab';
......@@ -785,13 +785,19 @@ describe('IDE store file actions', () => {
});
describe('triggerFilesChange', () => {
const { payload: renamePayload } = createTriggerRenameAction('test', '123');
beforeEach(() => {
jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
});
it('emits event that files have changed', () => {
return store.dispatch('triggerFilesChange').then(() => {
expect(eventHub.$emit).toHaveBeenCalledWith('ide.files.change');
it.each`
args | payload
${[]} | ${{}}
${[renamePayload]} | ${renamePayload}
`('emits event that files have changed (args=$args)', ({ args, payload }) => {
return store.dispatch('triggerFilesChange', ...args).then(() => {
expect(eventHub.$emit).toHaveBeenCalledWith('ide.files.change', payload);
});
});
});
......
......@@ -19,7 +19,7 @@ import {
} from '~/ide/stores/actions';
import axios from '~/lib/utils/axios_utils';
import * as types from '~/ide/stores/mutation_types';
import { file } from '../helpers';
import { file, createTriggerRenameAction, createTriggerChangeAction } from '../helpers';
import testAction from '../../helpers/vuex_action_helper';
import eventHub from '~/ide/eventhub';
......@@ -522,7 +522,7 @@ describe('Multi-file store actions', () => {
'path',
store.state,
[{ type: types.DELETE_ENTRY, payload: 'path' }],
[{ type: 'stageChange', payload: 'path' }, { type: 'triggerFilesChange' }],
[{ type: 'stageChange', payload: 'path' }, createTriggerChangeAction()],
done,
);
});
......@@ -551,7 +551,7 @@ describe('Multi-file store actions', () => {
[{ type: types.DELETE_ENTRY, payload: 'testFolder/entry-to-delete' }],
[
{ type: 'stageChange', payload: 'testFolder/entry-to-delete' },
{ type: 'triggerFilesChange' },
createTriggerChangeAction(),
],
done,
);
......@@ -614,7 +614,7 @@ describe('Multi-file store actions', () => {
testEntry.path,
store.state,
[{ type: types.DELETE_ENTRY, payload: testEntry.path }],
[{ type: 'stageChange', payload: testEntry.path }, { type: 'triggerFilesChange' }],
[{ type: 'stageChange', payload: testEntry.path }, createTriggerChangeAction()],
done,
);
});
......@@ -754,7 +754,7 @@ describe('Multi-file store actions', () => {
payload: origEntry,
},
],
[{ type: 'triggerFilesChange' }],
[createTriggerRenameAction('renamed', 'orig')],
done,
);
});
......@@ -767,7 +767,7 @@ describe('Multi-file store actions', () => {
{ path: 'orig', name: 'renamed' },
store.state,
[expect.objectContaining({ type: types.RENAME_ENTRY })],
[{ type: 'triggerFilesChange' }],
[createTriggerRenameAction('orig', 'renamed')],
done,
);
});
......
import testAction from 'helpers/vuex_action_helper';
import * as types from '~/ide/stores/modules/editor/mutation_types';
import * as actions from '~/ide/stores/modules/editor/actions';
import { createTriggerRenamePayload } from '../../../helpers';
describe('~/ide/stores/modules/editor/actions', () => {
describe('updateFileEditor', () => {
it('commits with payload', () => {
const payload = {};
testAction(actions.updateFileEditor, payload, {}, [
{ type: types.UPDATE_FILE_EDITOR, payload },
]);
});
});
describe('removeFileEditor', () => {
it('commits with payload', () => {
const payload = 'path/to/file.txt';
testAction(actions.removeFileEditor, payload, {}, [
{ type: types.REMOVE_FILE_EDITOR, payload },
]);
});
});
describe('renameFileEditor', () => {
it('commits with payload', () => {
const payload = createTriggerRenamePayload('test', 'test123');
testAction(actions.renameFileEditor, payload, {}, [
{ type: types.RENAME_FILE_EDITOR, payload },
]);
});
});
});
import { createDefaultFileEditor } from '~/ide/stores/modules/editor/utils';
import * as getters from '~/ide/stores/modules/editor/getters';
const TEST_PATH = 'test/path.md';
const TEST_FILE_EDITOR = {
...createDefaultFileEditor(),
editorRow: 7,
editorColumn: 8,
fileLanguage: 'markdown',
};
describe('~/ide/stores/modules/editor/getters', () => {
describe('activeFileEditor', () => {
it.each`
activeFile | fileEditors | expected
${null} | ${{}} | ${null}
${{}} | ${{}} | ${createDefaultFileEditor()}
${{ path: TEST_PATH }} | ${{}} | ${createDefaultFileEditor()}
${{ path: TEST_PATH }} | ${{ bogus: createDefaultFileEditor(), [TEST_PATH]: TEST_FILE_EDITOR }} | ${TEST_FILE_EDITOR}
`(
'with activeFile=$activeFile and fileEditors=$fileEditors',
({ activeFile, fileEditors, expected }) => {
const rootGetters = { activeFile };
const state = { fileEditors };
const result = getters.activeFileEditor(state, {}, {}, rootGetters);
expect(result).toEqual(expected);
},
);
});
});
import { createDefaultFileEditor } from '~/ide/stores/modules/editor/utils';
import * as types from '~/ide/stores/modules/editor/mutation_types';
import mutations from '~/ide/stores/modules/editor/mutations';
import { createTriggerRenamePayload } from '../../../helpers';
const TEST_PATH = 'test/path.md';
describe('~/ide/stores/modules/editor/mutations', () => {
describe(types.UPDATE_FILE_EDITOR, () => {
it('with path that does not exist, should initialize with default values', () => {
const state = { fileEditors: {} };
const data = { fileLanguage: 'markdown' };
mutations[types.UPDATE_FILE_EDITOR](state, { path: TEST_PATH, data });
expect(state.fileEditors).toEqual({
[TEST_PATH]: {
...createDefaultFileEditor(),
...data,
},
});
});
it('with existing path, should overwrite values', () => {
const state = {
fileEditors: {
foo: {},
[TEST_PATH]: { ...createDefaultFileEditor(), editorRow: 7, editorColumn: 7 },
},
};
mutations[types.UPDATE_FILE_EDITOR](state, {
path: TEST_PATH,
data: { fileLanguage: 'markdown' },
});
expect(state).toEqual({
fileEditors: {
foo: {},
[TEST_PATH]: {
...createDefaultFileEditor(),
editorRow: 7,
editorColumn: 7,
fileLanguage: 'markdown',
},
},
});
});
});
describe(types.REMOVE_FILE_EDITOR, () => {
it.each`
fileEditors | path | expected
${{}} | ${'does/not/exist.txt'} | ${{}}
${{ foo: {}, [TEST_PATH]: {} }} | ${TEST_PATH} | ${{ foo: {} }}
`('removes file $path', ({ fileEditors, path, expected }) => {
const state = { fileEditors };
mutations[types.REMOVE_FILE_EDITOR](state, path);
expect(state).toEqual({ fileEditors: expected });
});
});
describe(types.RENAME_FILE_EDITOR, () => {
it.each`
fileEditors | payload | expected
${{ foo: {} }} | ${createTriggerRenamePayload('does/not/exist', 'abc')} | ${{ foo: {} }}
${{ foo: { a: 1 }, bar: {} }} | ${createTriggerRenamePayload('foo', 'abc/def')} | ${{ 'abc/def': { a: 1 }, bar: {} }}
`('renames fileEditor at $payload', ({ fileEditors, payload, expected }) => {
const state = { fileEditors };
mutations[types.RENAME_FILE_EDITOR](state, payload);
expect(state).toEqual({ fileEditors: expected });
});
});
});
import Vuex from 'vuex';
import eventHub from '~/ide/eventhub';
import { createStoreOptions } from '~/ide/stores';
import { setupFileEditorsSync } from '~/ide/stores/modules/editor/setup';
import { createTriggerRenamePayload } from '../../../helpers';
describe('~/ide/stores/modules/editor/setup', () => {
let store;
beforeEach(() => {
store = new Vuex.Store(createStoreOptions());
store.state.entries = {
foo: {},
bar: {},
};
store.state.editor.fileEditors = {
foo: {},
bizz: {},
};
setupFileEditorsSync(store);
});
it('when files change is emitted, removes unused fileEditors', () => {
eventHub.$emit('ide.files.change');
expect(store.state.entries).toEqual({
foo: {},
bar: {},
});
expect(store.state.editor.fileEditors).toEqual({
foo: {},
});
});
it('when files rename is emitted, renames fileEditor', () => {
eventHub.$emit('ide.files.change', createTriggerRenamePayload('foo', 'foo_new'));
expect(store.state.editor.fileEditors).toEqual({
foo_new: {},
bizz: {},
});
});
});
import mutations from '~/ide/stores/mutations/file';
import { createStore } from '~/ide/stores';
import { FILE_VIEW_MODE_PREVIEW } from '~/ide/constants';
import { file } from '../../helpers';
describe('IDE store file mutations', () => {
......@@ -532,17 +531,6 @@ describe('IDE store file mutations', () => {
});
});
describe('SET_FILE_VIEWMODE', () => {
it('updates file view mode', () => {
mutations.SET_FILE_VIEWMODE(localState, {
file: localFile,
viewMode: FILE_VIEW_MODE_PREVIEW,
});
expect(localFile.viewMode).toBe(FILE_VIEW_MODE_PREVIEW);
});
});
describe('ADD_PENDING_TAB', () => {
beforeEach(() => {
const f = { ...file('openFile'), path: 'openFile', active: true, opened: true };
......
......@@ -89,4 +89,4 @@ DEPENDENCIES
scss_lint (~> 0.56.0)
BUNDLED WITH
1.17.3
2.1.4
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment