Commit 34c2f4b7 authored by Valery Sizov's avatar Valery Sizov

Merge branch 'ce-to-ee' into 'master'

CE Upstream - Monday

Closes gitlab-ce#30637

See merge request !1708
parents 84fecbf5 3f852451
9.1.0-pre 9.2.0-pre
...@@ -170,8 +170,9 @@ class DueDateSelectors { ...@@ -170,8 +170,9 @@ class DueDateSelectors {
const $datePicker = $(this); const $datePicker = $(this);
const calendar = new Pikaday({ const calendar = new Pikaday({
field: $datePicker.get(0), field: $datePicker.get(0),
theme: 'gitlab-theme', theme: 'gitlab-theme animate-picker',
format: 'yyyy-mm-dd', format: 'yyyy-mm-dd',
container: $datePicker.parent().get(0),
onSelect(dateText) { onSelect(dateText) {
$datePicker.val(dateFormat(new Date(dateText), 'yyyy-mm-dd')); $datePicker.val(dateFormat(new Date(dateText), 'yyyy-mm-dd'));
} }
......
...@@ -4,9 +4,9 @@ import '../../lib/utils/text_utility'; ...@@ -4,9 +4,9 @@ import '../../lib/utils/text_utility';
import ActionsComponent from './environment_actions.vue'; import ActionsComponent from './environment_actions.vue';
import ExternalUrlComponent from './environment_external_url.vue'; import ExternalUrlComponent from './environment_external_url.vue';
import StopComponent from './environment_stop.vue'; import StopComponent from './environment_stop.vue';
import RollbackComponent from './environment_rollback'; import RollbackComponent from './environment_rollback.vue';
import TerminalButtonComponent from './environment_terminal_button.vue'; import TerminalButtonComponent from './environment_terminal_button.vue';
import MonitoringButtonComponent from './environment_monitoring'; import MonitoringButtonComponent from './environment_monitoring.vue';
import CommitComponent from '../../vue_shared/components/commit'; import CommitComponent from '../../vue_shared/components/commit';
import eventHub from '../event_hub'; import eventHub from '../event_hub';
......
<script>
/** /**
* Renders the Monitoring (Metrics) link in environments table. * Renders the Monitoring (Metrics) link in environments table.
*/ */
...@@ -5,7 +6,6 @@ export default { ...@@ -5,7 +6,6 @@ export default {
props: { props: {
monitoringUrl: { monitoringUrl: {
type: String, type: String,
default: '',
required: true, required: true,
}, },
}, },
...@@ -15,16 +15,19 @@ export default { ...@@ -15,16 +15,19 @@ export default {
return 'Monitoring'; return 'Monitoring';
}, },
}, },
template: `
<a
class="btn monitoring-url has-tooltip"
data-container="body"
:href="monitoringUrl"
rel="noopener noreferrer nofollow"
:title="title"
:aria-label="title">
<i class="fa fa-area-chart" aria-hidden="true"></i>
</a>
`,
}; };
</script>
<template>
<a
class="btn monitoring-url has-tooltip"
data-container="body"
target="_blank"
rel="noopener noreferrer nofollow"
:href="monitoringUrl"
:title="title"
:aria-label="title">
<i
class="fa fa-area-chart"
aria-hidden="true" />
</a>
</template>
<script>
/* global Flash */ /* global Flash */
/* eslint-disable no-new */ /* eslint-disable no-new */
/** /**
...@@ -50,21 +51,25 @@ export default { ...@@ -50,21 +51,25 @@ export default {
}); });
}, },
}, },
};
</script>
<template>
<button
type="button"
class="btn"
@click="onClick"
:disabled="isLoading">
template: ` <span v-if="isLastDeployment">
<button type="button" Re-deploy
class="btn" </span>
@click="onClick" <span v-else>
:disabled="isLoading"> Rollback
</span>
<span v-if="isLastDeployment">
Re-deploy
</span>
<span v-else>
Rollback
</span>
<i v-if="isLoading" class="fa fa-spinner fa-spin" aria-hidden="true"></i> <i
</button> v-if="isLoading"
`, class="fa fa-spinner fa-spin"
}; aria-hidden="true" />
</button>
</template>
...@@ -41,8 +41,9 @@ ...@@ -41,8 +41,9 @@
if ($issuableDueDate.length) { if ($issuableDueDate.length) {
calendar = new Pikaday({ calendar = new Pikaday({
field: $issuableDueDate.get(0), field: $issuableDueDate.get(0),
theme: 'gitlab-theme', theme: 'gitlab-theme animate-picker',
format: 'yyyy-mm-dd', format: 'yyyy-mm-dd',
container: $issuableDueDate.parent().get(0),
onSelect: function(dateText) { onSelect: function(dateText) {
$issuableDueDate.val(dateFormat(new Date(dateText), 'yyyy-mm-dd')); $issuableDueDate.val(dateFormat(new Date(dateText), 'yyyy-mm-dd'));
} }
......
...@@ -34,17 +34,6 @@ export default { ...@@ -34,17 +34,6 @@ export default {
}; };
}, },
methods: { methods: {
fetch() {
this.poll.makeRequest();
Visibility.change(() => {
if (!Visibility.hidden()) {
this.poll.restart();
} else {
this.poll.stop();
}
});
},
renderResponse(res) { renderResponse(res) {
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
this.triggerAnimation(body); this.triggerAnimation(body);
...@@ -71,7 +60,17 @@ export default { ...@@ -71,7 +60,17 @@ export default {
}, },
}, },
created() { created() {
this.fetch(); if (!Visibility.hidden()) {
this.poll.makeRequest();
}
Visibility.change(() => {
if (!Visibility.hidden()) {
this.poll.restart();
} else {
this.poll.stop();
}
});
}, },
}; };
</script> </script>
......
...@@ -169,7 +169,10 @@ ...@@ -169,7 +169,10 @@
w.gl.utils.getSelectedFragment = () => { w.gl.utils.getSelectedFragment = () => {
const selection = window.getSelection(); const selection = window.getSelection();
if (selection.rangeCount === 0) return null; if (selection.rangeCount === 0) return null;
const documentFragment = selection.getRangeAt(0).cloneContents(); const documentFragment = document.createDocumentFragment();
for (let i = 0; i < selection.rangeCount; i += 1) {
documentFragment.appendChild(selection.getRangeAt(i).cloneContents());
}
if (documentFragment.textContent.length === 0) return null; if (documentFragment.textContent.length === 0) return null;
return documentFragment; return documentFragment;
......
...@@ -18,9 +18,10 @@ ...@@ -18,9 +18,10 @@
const calendar = new Pikaday({ const calendar = new Pikaday({
field: $input.get(0), field: $input.get(0),
theme: 'gitlab-theme', theme: 'gitlab-theme animate-picker',
format: 'yyyy-mm-dd', format: 'yyyy-mm-dd',
minDate: new Date(), minDate: new Date(),
container: $input.parent().get(0),
onSelect(dateText) { onSelect(dateText) {
$input.val(dateFormat(new Date(dateText), 'yyyy-mm-dd')); $input.val(dateFormat(new Date(dateText), 'yyyy-mm-dd'));
......
...@@ -57,8 +57,11 @@ import findAndFollowLink from './shortcuts_dashboard_navigation'; ...@@ -57,8 +57,11 @@ import findAndFollowLink from './shortcuts_dashboard_navigation';
Shortcuts.prototype.toggleMarkdownPreview = function(e) { Shortcuts.prototype.toggleMarkdownPreview = function(e) {
// Check if short-cut was triggered while in Write Mode // Check if short-cut was triggered while in Write Mode
if ($(e.target).hasClass('js-note-text')) { const $target = $(e.target);
$('.js-md-preview-button').focus(); const $form = $target.closest('form');
if ($target.hasClass('js-note-text')) {
$('.js-md-preview-button', $form).focus();
} }
return $(document).triggerHandler('markdown-preview:toggle', [e]); return $(document).triggerHandler('markdown-preview:toggle', [e]);
}; };
......
...@@ -94,15 +94,17 @@ content on the Users#show page. ...@@ -94,15 +94,17 @@ content on the Users#show page.
e.preventDefault(); e.preventDefault();
$('.tab-pane.active').empty(); $('.tab-pane.active').empty();
this.loadTab($(e.target).attr('href'), this.getCurrentAction()); const endpoint = $(e.target).attr('href');
this.loadTab(this.getCurrentAction(), endpoint);
} }
tabShown(event) { tabShown(event) {
const $target = $(event.target); const $target = $(event.target);
const action = $target.data('action'); const action = $target.data('action');
const source = $target.attr('href'); const source = $target.attr('href');
this.setTab(source, action); const endpoint = $target.data('endpoint');
return this.setCurrentAction(source, action); this.setTab(action, endpoint);
return this.setCurrentAction(source);
} }
activateTab(action) { activateTab(action) {
...@@ -110,27 +112,27 @@ content on the Users#show page. ...@@ -110,27 +112,27 @@ content on the Users#show page.
.tab('show'); .tab('show');
} }
setTab(source, action) { setTab(action, endpoint) {
if (this.loaded[action]) { if (this.loaded[action]) {
return; return;
} }
if (action === 'activity') { if (action === 'activity') {
this.loadActivities(source); this.loadActivities();
} }
const loadableActions = ['groups', 'contributed', 'projects', 'snippets']; const loadableActions = ['groups', 'contributed', 'projects', 'snippets'];
if (loadableActions.indexOf(action) > -1) { if (loadableActions.indexOf(action) > -1) {
return this.loadTab(source, action); return this.loadTab(action, endpoint);
} }
} }
loadTab(source, action) { loadTab(action, endpoint) {
return $.ajax({ return $.ajax({
beforeSend: () => this.toggleLoading(true), beforeSend: () => this.toggleLoading(true),
complete: () => this.toggleLoading(false), complete: () => this.toggleLoading(false),
dataType: 'json', dataType: 'json',
type: 'GET', type: 'GET',
url: source, url: endpoint,
success: (data) => { success: (data) => {
const tabSelector = `div#${action}`; const tabSelector = `div#${action}`;
this.$parentEl.find(tabSelector).html(data.html); this.$parentEl.find(tabSelector).html(data.html);
...@@ -140,7 +142,7 @@ content on the Users#show page. ...@@ -140,7 +142,7 @@ content on the Users#show page.
}); });
} }
loadActivities(source) { loadActivities() {
if (this.loaded['activity']) { if (this.loaded['activity']) {
return; return;
} }
...@@ -155,7 +157,7 @@ content on the Users#show page. ...@@ -155,7 +157,7 @@ content on the Users#show page.
.toggle(status); .toggle(status);
} }
setCurrentAction(source, action) { setCurrentAction(source) {
let new_state = source; let new_state = source;
new_state = new_state.replace(/\/+$/, ''); new_state = new_state.replace(/\/+$/, '');
new_state += this._location.search + this._location.hash; new_state += this._location.search + this._location.hash;
......
...@@ -230,7 +230,6 @@ ...@@ -230,7 +230,6 @@
float: right; float: right;
margin-top: 8px; margin-top: 8px;
padding-bottom: 8px; padding-bottom: 8px;
border-bottom: 1px solid $border-color;
} }
} }
......
...@@ -14,14 +14,32 @@ ...@@ -14,14 +14,32 @@
} }
} }
@mixin set-visible {
transform: translateY(0);
visibility: visible;
opacity: 1;
transition-duration: 100ms, 150ms, 25ms;
transition-delay: 35ms, 50ms, 25ms;
}
@mixin set-invisible {
transform: translateY(-10px);
visibility: hidden;
opacity: 0;
transition-property: opacity, transform, visibility;
transition-duration: 70ms, 250ms, 250ms;
transition-timing-function: linear, $dropdown-animation-timing;
transition-delay: 25ms, 50ms, 0ms;
}
.open { .open {
.dropdown-menu, .dropdown-menu,
.dropdown-menu-nav { .dropdown-menu-nav {
display: block; display: block;
@include set-visible;
@media (max-width: $screen-xs-max) { @media (max-width: $screen-xs-max) {
width: 100%; width: 100%;
min-width: 240px;
} }
} }
...@@ -161,8 +179,9 @@ ...@@ -161,8 +179,9 @@
.dropdown-menu, .dropdown-menu,
.dropdown-menu-nav { .dropdown-menu-nav {
display: none; display: block;
position: absolute; position: absolute;
width: 100%;
top: 100%; top: 100%;
left: 0; left: 0;
z-index: 9; z-index: 9;
...@@ -176,6 +195,12 @@ ...@@ -176,6 +195,12 @@
border: 1px solid $dropdown-border-color; border: 1px solid $dropdown-border-color;
border-radius: $border-radius-base; border-radius: $border-radius-base;
box-shadow: 0 2px 4px $dropdown-shadow-color; box-shadow: 0 2px 4px $dropdown-shadow-color;
overflow: hidden;
@include set-invisible;
@media (max-width: $screen-sm-min) {
width: 100%;
}
&.is-loading { &.is-loading {
.dropdown-content { .dropdown-content {
...@@ -252,6 +277,23 @@ ...@@ -252,6 +277,23 @@
} }
} }
.filtered-search-box-input-container .dropdown-menu,
.filtered-search-box-input-container .dropdown-menu-nav,
.comment-type-dropdown .dropdown-menu {
display: none;
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.filtered-search-box-input-container {
.dropdown-menu,
.dropdown-menu-nav {
max-width: 280px;
width: auto;
}
}
.dropdown-menu-drop-up { .dropdown-menu-drop-up {
top: auto; top: auto;
bottom: 100%; bottom: 100%;
...@@ -326,6 +368,10 @@ ...@@ -326,6 +368,10 @@
.dropdown-select { .dropdown-select {
width: $dropdown-width; width: $dropdown-width;
@media (max-width: $screen-sm-min) {
width: 100%;
}
} }
.dropdown-menu-align-right { .dropdown-menu-align-right {
...@@ -568,3 +614,24 @@ ...@@ -568,3 +614,24 @@
.droplab-item-ignore { .droplab-item-ignore {
pointer-events: none; pointer-events: none;
} }
.pika-single.animate-picker.is-bound,
.pika-single.animate-picker.is-bound.is-hidden {
/*
* Having `!important` is not recommended but
* since `pikaday` sets positioning inline
* there's no way it can be gracefully overridden
* using config options.
*/
position: absolute !important;
display: block;
}
.pika-single.animate-picker.is-bound {
@include set-visible;
}
.pika-single.animate-picker.is-bound.is-hidden {
@include set-invisible;
overflow: hidden;
}
...@@ -329,6 +329,7 @@ header { ...@@ -329,6 +329,7 @@ header {
.header-user { .header-user {
.dropdown-menu-nav { .dropdown-menu-nav {
width: auto;
min-width: 140px; min-width: 140px;
margin-top: -5px; margin-top: -5px;
......
...@@ -110,7 +110,7 @@ ...@@ -110,7 +110,7 @@
.top-area { .top-area {
@include clearfix; @include clearfix;
border-bottom: 1px solid $white-normal; border-bottom: 1px solid $border-color;
.nav-text { .nav-text {
padding-top: 16px; padding-top: 16px;
......
...@@ -338,3 +338,32 @@ h4 { ...@@ -338,3 +338,32 @@ h4 {
.idiff.addition { .idiff.addition {
background: $line-added-dark; background: $line-added-dark;
} }
/**
* form text input i.e. search bar, comments, forms, etc.
*/
input,
textarea {
&::-webkit-input-placeholder {
color: $placeholder-text-color;
}
// support firefox 19+ vendor prefix
&::-moz-placeholder {
color: $placeholder-text-color;
opacity: 1; // FF defaults to 0.54
}
// scss-lint:disable PseudoElement
// support Edge vendor prefix
&::-ms-input-placeholder {
color: $placeholder-text-color;
}
// scss-lint:disable PseudoElement
// support IE vendor prefix
&:-ms-input-placeholder {
color: $placeholder-text-color;
}
}
...@@ -111,6 +111,7 @@ $gl-gray: $gl-text-color; ...@@ -111,6 +111,7 @@ $gl-gray: $gl-text-color;
$gl-gray-dark: #313236; $gl-gray-dark: #313236;
$gl-header-color: #4c4e54; $gl-header-color: #4c4e54;
$gl-header-nav-hover-color: #434343; $gl-header-nav-hover-color: #434343;
$placeholder-text-color: rgba(0, 0, 0, .42);
/* /*
* Lists * Lists
...@@ -564,3 +565,8 @@ $filter-name-text-color: rgba(0, 0, 0, 0.55); ...@@ -564,3 +565,8 @@ $filter-name-text-color: rgba(0, 0, 0, 0.55);
$filter-value-text-color: rgba(0, 0, 0, 0.85); $filter-value-text-color: rgba(0, 0, 0, 0.85);
$filter-name-selected-color: #ebebeb; $filter-name-selected-color: #ebebeb;
$filter-value-selected-color: #d7d7d7; $filter-value-selected-color: #d7d7d7;
/*
Animation Functions
*/
$dropdown-animation-timing: cubic-bezier(0.23, 1, 0.32, 1);
...@@ -124,7 +124,13 @@ input[type="checkbox"]:hover { ...@@ -124,7 +124,13 @@ input[type="checkbox"]:hover {
// Custom dropdown positioning // Custom dropdown positioning
.dropdown-menu { .dropdown-menu {
top: 37px; transition-property: opacity, transform;
transition-duration: 250ms, 250ms;
transition-delay: 0ms, 25ms;
transition-timing-function: $dropdown-animation-timing;
transform: translateY(0);
opacity: 0;
display: block;
left: -5px; left: -5px;
padding: 0; padding: 0;
...@@ -156,6 +162,13 @@ input[type="checkbox"]:hover { ...@@ -156,6 +162,13 @@ input[type="checkbox"]:hover {
color: $layout-link-gray; color: $layout-link-gray;
} }
} }
.dropdown-menu {
transition-duration: 100ms, 75ms;
transition-delay: 75ms, 100ms;
transform: translateY(13px);
opacity: 1;
}
} }
&.has-value { &.has-value {
......
...@@ -43,9 +43,13 @@ class Admin::GroupsController < Admin::ApplicationController ...@@ -43,9 +43,13 @@ class Admin::GroupsController < Admin::ApplicationController
end end
def members_update def members_update
@group.add_users(params[:user_ids].split(','), params[:access_level], current_user: current_user) status = Members::CreateService.new(@group, current_user, params).execute
redirect_to [:admin, @group], notice: 'Users were successfully added.' if status
redirect_to [:admin, @group], notice: 'Users were successfully added.'
else
redirect_to [:admin, @group], alert: 'No users specified.'
end
end end
def destroy def destroy
......
module MembershipActions module MembershipActions
extend ActiveSupport::Concern extend ActiveSupport::Concern
def create
status = Members::CreateService.new(membershipable, current_user, params).execute
redirect_url = members_page_url
if status
redirect_to redirect_url, notice: 'Users were successfully added.'
else
redirect_to redirect_url, alert: 'No users specified.'
end
end
def destroy
Members::DestroyService.new(membershipable, current_user, params).
execute(:all)
respond_to do |format|
format.html do
message = "User was successfully removed from #{source_type}."
redirect_to members_page_url, notice: message
end
format.js { head :ok }
end
end
def request_access def request_access
membershipable.request_access(current_user) membershipable.request_access(current_user)
...@@ -13,14 +39,13 @@ module MembershipActions ...@@ -13,14 +39,13 @@ module MembershipActions
log_audit_event(member, action: :create) log_audit_event(member, action: :create)
redirect_to polymorphic_url([membershipable, :members]) redirect_to members_page_url
end end
def leave def leave
member = Members::DestroyService.new(membershipable, current_user, user_id: current_user.id). member = Members::DestroyService.new(membershipable, current_user, user_id: current_user.id).
execute(:all) execute(:all)
source_type = membershipable.class.to_s.humanize(capitalize: false)
notice = notice =
if member.request? if member.request?
"Your access request to the #{source_type} has been withdrawn." "Your access request to the #{source_type} has been withdrawn."
...@@ -45,4 +70,16 @@ module MembershipActions ...@@ -45,4 +70,16 @@ module MembershipActions
AuditEventService.new(current_user, membershipable, options) AuditEventService.new(current_user, membershipable, options)
.for_member(member).security_event .for_member(member).security_event
end end
def members_page_url
if membershipable.is_a?(Project)
project_settings_members_path(membershipable)
else
polymorphic_url([membershipable, :members])
end
end
def source_type
@source_type ||= membershipable.class.to_s.humanize(capitalize: false)
end
end end
...@@ -24,27 +24,6 @@ class Groups::GroupMembersController < Groups::ApplicationController ...@@ -24,27 +24,6 @@ class Groups::GroupMembersController < Groups::ApplicationController
@group_member = @group.group_members.new @group_member = @group.group_members.new
end end
def create
if params[:user_ids].blank?
return redirect_to(group_group_members_path(@group), alert: 'No users specified.')
end
@group.add_users(
params[:user_ids].split(','),
params[:access_level],
current_user: current_user,
expires_at: params[:expires_at]
)
group_members = @group.group_members.where(user_id: params[:user_ids].split(','))
group_members.each do |group_member|
log_audit_event(group_member, action: :create)
end
redirect_to group_group_members_path(@group), notice: 'Users were successfully added.'
end
def update def update
@group_member = @group.group_members.find(params[:id]) @group_member = @group.group_members.find(params[:id])
...@@ -57,17 +36,6 @@ class Groups::GroupMembersController < Groups::ApplicationController ...@@ -57,17 +36,6 @@ class Groups::GroupMembersController < Groups::ApplicationController
end end
end end
def destroy
member = Members::DestroyService.new(@group, current_user, id: params[:id]).execute(:all)
log_audit_event(member, action: :destroy)
respond_to do |format|
format.html { redirect_to group_group_members_path(@group), notice: 'User was successfully removed from group.' }
format.js { head :ok }
end
end
def resend_invite def resend_invite
redirect_path = group_group_members_path(@group) redirect_path = group_group_members_path(@group)
......
...@@ -10,24 +10,6 @@ class Projects::ProjectMembersController < Projects::ApplicationController ...@@ -10,24 +10,6 @@ class Projects::ProjectMembersController < Projects::ApplicationController
redirect_to namespace_project_settings_members_path(@project.namespace, @project, sort: sort) redirect_to namespace_project_settings_members_path(@project.namespace, @project, sort: sort)
end end
def create
status = Members::CreateService.new(@project, current_user, params).execute
redirect_url = namespace_project_settings_members_path(@project.namespace, @project)
if status
members = @project.project_members.where(user_id: params[:user_ids].split(','))
members.each do |member|
log_audit_event(member, action: :create)
end
redirect_to redirect_url, notice: 'Users were successfully added.'
else
redirect_to redirect_url, alert: 'No users or groups specified.'
end
end
def update def update
@project_member = @project.project_members.find(params[:id]) @project_member = @project.project_members.find(params[:id])
...@@ -40,20 +22,6 @@ class Projects::ProjectMembersController < Projects::ApplicationController ...@@ -40,20 +22,6 @@ class Projects::ProjectMembersController < Projects::ApplicationController
end end
end end
def destroy
member = Members::DestroyService.new(@project, current_user, params).
execute(:all)
log_audit_event(member, action: :destroy)
respond_to do |format|
format.html do
redirect_to namespace_project_settings_members_path(@project.namespace, @project)
end
format.js { head :ok }
end
end
def resend_invite def resend_invite
redirect_path = namespace_project_settings_members_path(@project.namespace, @project) redirect_path = namespace_project_settings_members_path(@project.namespace, @project)
......
...@@ -175,6 +175,10 @@ module IssuablesHelper ...@@ -175,6 +175,10 @@ module IssuablesHelper
end end
end end
def assigned_issuables_count(issuable_type)
current_user.public_send("assigned_open_#{issuable_type}_count")
end
def issuable_filter_params def issuable_filter_params
[ [
:search, :search,
...@@ -196,10 +200,6 @@ module IssuablesHelper ...@@ -196,10 +200,6 @@ module IssuablesHelper
private private
def assigned_issuables_count(assignee, issuable_type, state)
assignee.public_send("assigned_#{issuable_type}").public_send(state).count
end
def sidebar_gutter_collapsed? def sidebar_gutter_collapsed?
cookies[:collapsed_gutter] == 'true' cookies[:collapsed_gutter] == 'true'
end end
......
...@@ -5,7 +5,7 @@ module SubmoduleHelper ...@@ -5,7 +5,7 @@ module SubmoduleHelper
def submodule_links(submodule_item, ref = nil, repository = @repository) def submodule_links(submodule_item, ref = nil, repository = @repository)
url = repository.submodule_url_for(ref, submodule_item.path) url = repository.submodule_url_for(ref, submodule_item.path)
return url, nil unless url =~ /([^\/:]+)\/([^\/]+\.git)\Z/ return url, nil unless url =~ /([^\/:]+)\/([^\/]+(?:\.git)?)\Z/
namespace = $1 namespace = $1
project = $2 project = $2
...@@ -37,14 +37,16 @@ module SubmoduleHelper ...@@ -37,14 +37,16 @@ module SubmoduleHelper
end end
def self_url?(url, namespace, project) def self_url?(url, namespace, project)
return true if url == [Gitlab.config.gitlab.url, '/', namespace, '/', url_no_dotgit = url.chomp('.git')
project, '.git'].join('') return true if url_no_dotgit == [Gitlab.config.gitlab.url, '/', namespace, '/',
url == gitlab_shell.url_to_repo([namespace, '/', project].join('')) project].join('')
url_with_dotgit = url_no_dotgit + '.git'
url_with_dotgit == gitlab_shell.url_to_repo([namespace, '/', project].join(''))
end end
def relative_self_url?(url) def relative_self_url?(url)
# (./)?(../repo.git) || (./)?(../../project/repo.git) ) # (./)?(../repo.git) || (./)?(../../project/repo.git) )
url =~ /\A((\.\/)?(\.\.\/))(?!(\.\.)|(.*\/)).*\.git\z/ || url =~ /\A((\.\/)?(\.\.\/){2})(?!(\.\.))([^\/]*)\/(?!(\.\.)|(.*\/)).*\.git\z/ url =~ /\A((\.\/)?(\.\.\/))(?!(\.\.)|(.*\/)).*(\.git)?\z/ || url =~ /\A((\.\/)?(\.\.\/){2})(?!(\.\.))([^\/]*)\/(?!(\.\.)|(.*\/)).*(\.git)?\z/
end end
def standard_links(host, namespace, project, commit) def standard_links(host, namespace, project, commit)
......
...@@ -8,6 +8,14 @@ ...@@ -8,6 +8,14 @@
# #
# Corresponding foo_html, bar_html and baz_html fields should exist. # Corresponding foo_html, bar_html and baz_html fields should exist.
module CacheMarkdownField module CacheMarkdownField
extend ActiveSupport::Concern
# Increment this number every time the renderer changes its output
CACHE_VERSION = 1
# changes to these attributes cause the cache to be invalidates
INVALIDATED_BY = %w[author project].freeze
# Knows about the relationship between markdown and html field names, and # Knows about the relationship between markdown and html field names, and
# stores the rendering contexts for the latter # stores the rendering contexts for the latter
class FieldData class FieldData
...@@ -30,60 +38,71 @@ module CacheMarkdownField ...@@ -30,60 +38,71 @@ module CacheMarkdownField
end end
end end
# Dynamic registries don't really work in Rails as it's not guaranteed that
# every class will be loaded, so hardcode the list.
CACHING_CLASSES = %w[
AbuseReport
Appearance
ApplicationSetting
BroadcastMessage
Issue
Label
MergeRequest
Milestone
Namespace
Note
Project
Release
Snippet
].freeze
def self.caching_classes
CACHING_CLASSES.map(&:constantize)
end
def skip_project_check? def skip_project_check?
false false
end end
extend ActiveSupport::Concern # Returns the default Banzai render context for the cached markdown field.
def banzai_render_context(field)
raise ArgumentError.new("Unknown field: #{field.inspect}") unless
cached_markdown_fields.markdown_fields.include?(field)
included do # Always include a project key, or Banzai complains
cattr_reader :cached_markdown_fields do project = self.project if self.respond_to?(:project)
FieldData.new context = cached_markdown_fields[field].merge(project: project)
end
# Returns the default Banzai render context for the cached markdown field. # Banzai is less strict about authors, so don't always have an author key
def banzai_render_context(field) context[:author] = self.author if self.respond_to?(:author)
raise ArgumentError.new("Unknown field: #{field.inspect}") unless
cached_markdown_fields.markdown_fields.include?(field)
# Always include a project key, or Banzai complains context
project = self.project if self.respond_to?(:project) end
context = cached_markdown_fields[field].merge(project: project)
# Banzai is less strict about authors, so don't always have an author key # Update every column in a row if any one is invalidated, as we only store
context[:author] = self.author if self.respond_to?(:author) # one version per row
def refresh_markdown_cache!(do_update: false)
options = { skip_project_check: skip_project_check? }
context updates = cached_markdown_fields.markdown_fields.map do |markdown_field|
end [
cached_markdown_fields.html_field(markdown_field),
Banzai::Renderer.cacheless_render_field(self, markdown_field, options)
]
end.to_h
updates['cached_markdown_version'] = CacheMarkdownField::CACHE_VERSION
# Allow callers to look up the cache field name, rather than hardcoding it updates.each {|html_field, data| write_attribute(html_field, data) }
def markdown_cache_field_for(field)
raise ArgumentError.new("Unknown field: #{field}") unless
cached_markdown_fields.markdown_fields.include?(field)
cached_markdown_fields.html_field(field) update_columns(updates) if persisted? && do_update
end
def cached_html_up_to_date?(markdown_field)
html_field = cached_markdown_fields.html_field(markdown_field)
markdown_changed = attribute_changed?(markdown_field) || false
html_changed = attribute_changed?(html_field) || false
CacheMarkdownField::CACHE_VERSION == cached_markdown_version &&
(html_changed || markdown_changed == html_changed)
end
def invalidated_markdown_cache?
cached_markdown_fields.html_fields.any? {|html_field| attribute_invalidated?(html_field) }
end
def attribute_invalidated?(attr)
__send__("#{attr}_invalidated?")
end
def cached_html_for(markdown_field)
raise ArgumentError.new("Unknown field: #{field}") unless
cached_markdown_fields.markdown_fields.include?(markdown_field)
__send__(cached_markdown_fields.html_field(markdown_field))
end
included do
cattr_reader :cached_markdown_fields do
FieldData.new
end end
# Always exclude _html fields from attributes (including serialization). # Always exclude _html fields from attributes (including serialization).
...@@ -92,12 +111,18 @@ module CacheMarkdownField ...@@ -92,12 +111,18 @@ module CacheMarkdownField
def attributes def attributes
attrs = attributes_before_markdown_cache attrs = attributes_before_markdown_cache
attrs.delete('cached_markdown_version')
cached_markdown_fields.html_fields.each do |field| cached_markdown_fields.html_fields.each do |field|
attrs.delete(field) attrs.delete(field)
end end
attrs attrs
end end
# Using before_update here conflicts with elasticsearch-model somehow
before_create :refresh_markdown_cache!, if: :invalidated_markdown_cache?
before_update :refresh_markdown_cache!, if: :invalidated_markdown_cache?
end end
class_methods do class_methods do
...@@ -107,31 +132,18 @@ module CacheMarkdownField ...@@ -107,31 +132,18 @@ module CacheMarkdownField
# a corresponding _html field. Any custom rendering options may be provided # a corresponding _html field. Any custom rendering options may be provided
# as a context. # as a context.
def cache_markdown_field(markdown_field, context = {}) def cache_markdown_field(markdown_field, context = {})
raise "Add #{self} to CacheMarkdownField::CACHING_CLASSES" unless
CacheMarkdownField::CACHING_CLASSES.include?(self.to_s)
cached_markdown_fields[markdown_field] = context cached_markdown_fields[markdown_field] = context
html_field = cached_markdown_fields.html_field(markdown_field) html_field = cached_markdown_fields.html_field(markdown_field)
cache_method = "#{markdown_field}_cache_refresh".to_sym
invalidation_method = "#{html_field}_invalidated?".to_sym invalidation_method = "#{html_field}_invalidated?".to_sym
define_method(cache_method) do
options = { skip_project_check: skip_project_check? }
html = Banzai::Renderer.cacheless_render_field(self, markdown_field, options)
__send__("#{html_field}=", html)
true
end
# The HTML becomes invalid if any dependent fields change. For now, assume # The HTML becomes invalid if any dependent fields change. For now, assume
# author and project invalidate the cache in all circumstances. # author and project invalidate the cache in all circumstances.
define_method(invalidation_method) do define_method(invalidation_method) do
changed_fields = changed_attributes.keys changed_fields = changed_attributes.keys
invalidations = changed_fields & [markdown_field.to_s, "author", "project"] invalidations = changed_fields & [markdown_field.to_s, *INVALIDATED_BY]
!invalidations.empty? !invalidations.empty? || !cached_html_up_to_date?(markdown_field)
end end
before_save cache_method, if: invalidation_method
end end
end end
end end
...@@ -219,7 +219,7 @@ class Issue < ActiveRecord::Base ...@@ -219,7 +219,7 @@ class Issue < ActiveRecord::Base
# Returns `true` if the current issue can be viewed by either a logged in User # Returns `true` if the current issue can be viewed by either a logged in User
# or an anonymous user. # or an anonymous user.
def visible_to_user?(user = nil) def visible_to_user?(user = nil)
return false unless project.feature_available?(:issues, user) return false unless project && project.feature_available?(:issues, user)
user ? readable_by?(user) : publicly_visible? user ? readable_by?(user) : publicly_visible?
end end
......
...@@ -190,7 +190,7 @@ class Project < ActiveRecord::Base ...@@ -190,7 +190,7 @@ class Project < ActiveRecord::Base
delegate :name, to: :owner, allow_nil: true, prefix: true delegate :name, to: :owner, allow_nil: true, prefix: true
delegate :count, to: :forks, prefix: true delegate :count, to: :forks, prefix: true
delegate :members, to: :team, prefix: true delegate :members, to: :team, prefix: true
delegate :add_user, to: :team delegate :add_user, :add_users, to: :team
delegate :add_guest, :add_reporter, :add_developer, :add_master, to: :team delegate :add_guest, :add_reporter, :add_developer, :add_master, to: :team
delegate :empty_repo?, to: :repository delegate :empty_repo?, to: :repository
......
...@@ -109,9 +109,6 @@ class User < ActiveRecord::Base ...@@ -109,9 +109,6 @@ class User < ActiveRecord::Base
has_many :protected_branch_push_access_levels, dependent: :destroy, class_name: ProtectedBranch::PushAccessLevel has_many :protected_branch_push_access_levels, dependent: :destroy, class_name: ProtectedBranch::PushAccessLevel
has_many :triggers, dependent: :destroy, class_name: 'Ci::Trigger', foreign_key: :owner_id has_many :triggers, dependent: :destroy, class_name: 'Ci::Trigger', foreign_key: :owner_id
has_many :assigned_issues, dependent: :nullify, foreign_key: :assignee_id, class_name: "Issue"
has_many :assigned_merge_requests, dependent: :nullify, foreign_key: :assignee_id, class_name: "MergeRequest"
# Issues that a user owns are expected to be moved to the "ghost" user before # Issues that a user owns are expected to be moved to the "ghost" user before
# the user is destroyed. If the user owns any issues during deletion, this # the user is destroyed. If the user owns any issues during deletion, this
# should be treated as an exceptional condition. # should be treated as an exceptional condition.
...@@ -921,20 +918,20 @@ class User < ActiveRecord::Base ...@@ -921,20 +918,20 @@ class User < ActiveRecord::Base
@global_notification_setting @global_notification_setting
end end
def assigned_open_merge_request_count(force: false) def assigned_open_merge_requests_count(force: false)
Rails.cache.fetch(['users', id, 'assigned_open_merge_request_count'], force: force) do Rails.cache.fetch(['users', id, 'assigned_open_merge_requests_count'], force: force) do
assigned_merge_requests.opened.count MergeRequestsFinder.new(self, assignee_id: self.id, state: 'opened').execute.count
end end
end end
def assigned_open_issues_count(force: false) def assigned_open_issues_count(force: false)
Rails.cache.fetch(['users', id, 'assigned_open_issues_count'], force: force) do Rails.cache.fetch(['users', id, 'assigned_open_issues_count'], force: force) do
assigned_issues.opened.count IssuesFinder.new(self, assignee_id: self.id, state: 'opened').execute.count
end end
end end
def update_cache_counts def update_cache_counts
assigned_open_merge_request_count(force: true) assigned_open_merge_requests_count(force: true)
assigned_open_issues_count(force: true) assigned_open_issues_count(force: true)
end end
......
...@@ -9,7 +9,11 @@ module Members ...@@ -9,7 +9,11 @@ module Members
def execute def execute
return false if member.is_a?(GroupMember) && member.source.last_owner?(member.user) return false if member.is_a?(GroupMember) && member.source.last_owner?(member.user)
member.destroy Member.transaction do
unassign_issues_and_merge_requests(member)
member.destroy
end
if member.request? && member.user != user if member.request? && member.user != user
notification_service.decline_access_request(member) notification_service.decline_access_request(member)
...@@ -17,5 +21,23 @@ module Members ...@@ -17,5 +21,23 @@ module Members
member member
end end
private
def unassign_issues_and_merge_requests(member)
if member.is_a?(GroupMember)
IssuesFinder.new(user, group_id: member.source_id, assignee_id: member.user_id).
execute.
update_all(assignee_id: nil)
MergeRequestsFinder.new(user, group_id: member.source_id, assignee_id: member.user_id).
execute.
update_all(assignee_id: nil)
else
project = member.source
project.issues.opened.assigned_to(member.user).update_all(assignee_id: nil)
project.merge_requests.opened.assigned_to(member.user).update_all(assignee_id: nil)
member.user.update_cache_counts
end
end
end end
end end
module Members module Members
class CreateService < BaseService class CreateService < BaseService
def initialize(source, current_user, params = {})
@source = source
@current_user = current_user
@params = params
end
def execute def execute
return false if params[:user_ids].blank? return false if params[:user_ids].blank?
project.team.add_users( members = @source.add_users(
params[:user_ids].split(','), params[:user_ids].split(','),
params[:access_level], params[:access_level],
expires_at: params[:expires_at], expires_at: params[:expires_at],
current_user: current_user current_user: current_user
) )
members.compact.each do |member|
AuditEventService.new(@current_user, @source, action: :create)
.for_member(member).security_event
end
true true
end end
end end
......
...@@ -20,6 +20,11 @@ module Members ...@@ -20,6 +20,11 @@ module Members
raise Gitlab::Access::AccessDeniedError unless can_destroy_member?(member) raise Gitlab::Access::AccessDeniedError unless can_destroy_member?(member)
AuthorizedDestroyService.new(member, current_user).execute AuthorizedDestroyService.new(member, current_user).execute
AuditEventService.new(@current_user, @source, action: :destroy)
.for_member(member).security_event
member
end end
private private
......
...@@ -47,13 +47,13 @@ ...@@ -47,13 +47,13 @@
%li %li
= link_to assigned_issues_dashboard_path, title: 'Issues', aria: { label: "Issues" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do = link_to assigned_issues_dashboard_path, title: 'Issues', aria: { label: "Issues" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= icon('hashtag fw') = icon('hashtag fw')
- issues_count = cached_assigned_issuables_count(current_user, :issues, :opened) - issues_count = assigned_issuables_count(:issues)
%span.badge.issues-count{ class: ('hidden' if issues_count.zero?) } %span.badge.issues-count{ class: ('hidden' if issues_count.zero?) }
= number_with_delimiter(issues_count) = number_with_delimiter(issues_count)
%li %li
= link_to assigned_mrs_dashboard_path, title: 'Merge requests', aria: { label: "Merge requests" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do = link_to assigned_mrs_dashboard_path, title: 'Merge requests', aria: { label: "Merge requests" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= custom_icon('mr_bold') = custom_icon('mr_bold')
- merge_requests_count = cached_assigned_issuables_count(current_user, :merge_requests, :opened) - merge_requests_count = assigned_issuables_count(:merge_requests)
%span.badge.merge-requests-count{ class: ('hidden' if merge_requests_count.zero?) } %span.badge.merge-requests-count{ class: ('hidden' if merge_requests_count.zero?) }
= number_with_delimiter(merge_requests_count) = number_with_delimiter(merge_requests_count)
%li %li
......
...@@ -44,7 +44,7 @@ ...@@ -44,7 +44,7 @@
I I
%span %span
Issues Issues
.badge= number_with_delimiter(cached_assigned_issuables_count(current_user, :issues, :opened)) .badge= number_with_delimiter(assigned_issuables_count(:issues))
= nav_link(path: 'dashboard#merge_requests') do = nav_link(path: 'dashboard#merge_requests') do
= link_to assigned_mrs_dashboard_path, title: 'Merge Requests', class: 'dashboard-shortcuts-merge_requests' do = link_to assigned_mrs_dashboard_path, title: 'Merge Requests', class: 'dashboard-shortcuts-merge_requests' do
.shortcut-mappings .shortcut-mappings
...@@ -53,7 +53,7 @@ ...@@ -53,7 +53,7 @@
M M
%span %span
Merge Requests Merge Requests
.badge= number_with_delimiter(cached_assigned_issuables_count(current_user, :merge_requests, :opened)) .badge= number_with_delimiter(assigned_issuables_count(:merge_requests))
= nav_link(controller: 'dashboard/snippets') do = nav_link(controller: 'dashboard/snippets') do
= link_to dashboard_snippets_path, class: 'dashboard-shortcuts-snippets', title: 'Snippets' do = link_to dashboard_snippets_path, class: 'dashboard-shortcuts-snippets', title: 'Snippets' do
.shortcut-mappings .shortcut-mappings
......
...@@ -5,7 +5,7 @@ ...@@ -5,7 +5,7 @@
%div{ class: container_class } %div{ class: container_class }
%h3.page-title %h3.page-title
Edit Milestone #{@milestone.to_reference} Edit Milestone
%hr %hr
......
...@@ -17,7 +17,7 @@ ...@@ -17,7 +17,7 @@
.header-text-content .header-text-content
%span.identifier %span.identifier
%strong %strong
Milestone #{@milestone.to_reference} Milestone
- if @milestone.due_date || @milestone.start_date - if @milestone.due_date || @milestone.start_date
= milestone_date_range(@milestone) = milestone_date_range(@milestone)
.milestone-buttons .milestone-buttons
......
...@@ -145,10 +145,11 @@ ...@@ -145,10 +145,11 @@
} }
}); });
$('#project_import_url').disable();
$('.import_git').click(function( event ) { $('.import_git').click(function( event ) {
$projectImportUrl = $('#project_import_url') $projectImportUrl = $('#project_import_url');
$projectMirror = $('#project_mirror') $projectMirror = $('#project_mirror');
$projectImportUrl.attr('disabled', !$projectImportUrl.attr('disabled')) $projectImportUrl.attr('disabled', !$projectImportUrl.attr('disabled'));
$projectMirror.attr('disabled', !$projectMirror.attr('disabled')) $projectMirror.attr('disabled', !$projectMirror.attr('disabled'));
}); });
...@@ -19,7 +19,7 @@ ...@@ -19,7 +19,7 @@
%strong Schedule trigger (experimental) %strong Schedule trigger (experimental)
.help-block .help-block
If checked, this trigger will be executed periodically according to cron and timezone. If checked, this trigger will be executed periodically according to cron and timezone.
= link_to icon('question-circle'), help_page_path('ci/triggers', anchor: 'schedule') = link_to icon('question-circle'), help_page_path('ci/triggers/README', anchor: 'using-scheduled-triggers')
.form-group .form-group
= schedule_fields.label :cron, "Cron", class: "label-light" = schedule_fields.label :cron, "Cron", class: "label-light"
= schedule_fields.text_field :cron, class: "form-control", title: 'Cron specification is required.', placeholder: "0 1 * * *" = schedule_fields.text_field :cron, class: "form-control", title: 'Cron specification is required.', placeholder: "0 1 * * *"
......
...@@ -30,9 +30,10 @@ ...@@ -30,9 +30,10 @@
new Pikaday({ new Pikaday({
field: $dateField.get(0), field: $dateField.get(0),
theme: 'gitlab-theme', theme: 'gitlab-theme animate-picker',
format: 'yyyy-mm-dd', format: 'yyyy-mm-dd',
minDate: new Date(), minDate: new Date(),
container: $dateField.parent().get(0),
onSelect: function(dateText) { onSelect: function(dateText) {
$dateField.val(dateFormat(new Date(dateText), 'yyyy-mm-dd')); $dateField.val(dateFormat(new Date(dateText), 'yyyy-mm-dd'));
} }
......
<svg width="1792" height="1792" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg"><path d="M1280 896q0 14-9 23l-320 320q-9 9-23 9-13 0-22.5-9.5t-9.5-22.5v-192h-352q-13 0-22.5-9.5t-9.5-22.5v-192q0-13 9.5-22.5t22.5-9.5h352v-192q0-14 9-23t23-9q12 0 24 10l319 319q9 9 9 23zm160 0q0-148-73-273t-198-198-273-73-273 73-198 198-73 273 73 273 198 198 273 73 273-73 198-198 73-273zm224 0q0 209-103 385.5t-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103 385.5 103 279.5 279.5 103 385.5z"/></svg> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 14 14"><g fill-rule="evenodd"><path fill-rule="nonzero" d="m0 7c0-3.866 3.142-7 7-7 3.866 0 7 3.142 7 7 0 3.866-3.142 7-7 7-3.866 0-7-3.142-7-7m1 0c0 3.309 2.69 6 6 6 3.309 0 6-2.69 6-6 0-3.309-2.69-6-6-6-3.309 0-6 2.69-6 6"/><path d="m7 6h-2.702c-.154 0-.298.132-.298.295v1.41c0 .164.133.295.298.295h2.702v1.694c0 .18.095.209.213.09l2.539-2.568c.115-.116.118-.312 0-.432l-2.539-2.568c-.115-.116-.213-.079-.213.09v1.694"/></g></svg>
...@@ -3,10 +3,10 @@ ...@@ -3,10 +3,10 @@
= f.label :start_date, "Start Date", class: "control-label" = f.label :start_date, "Start Date", class: "control-label"
.col-sm-10 .col-sm-10
= f.text_field :start_date, class: "datepicker form-control", placeholder: "Select start date" = f.text_field :start_date, class: "datepicker form-control", placeholder: "Select start date"
%a.inline.prepend-top-5.js-clear-start-date{ href: "#" } Clear start date %a.inline.pull-right.prepend-top-5.js-clear-start-date{ href: "#" } Clear start date
.col-md-6 .col-md-6
.form-group .form-group
= f.label :due_date, "Due Date", class: "control-label" = f.label :due_date, "Due Date", class: "control-label"
.col-sm-10 .col-sm-10
= f.text_field :due_date, class: "datepicker form-control", placeholder: "Select due date" = f.text_field :due_date, class: "datepicker form-control", placeholder: "Select due date"
%a.inline.prepend-top-5.js-clear-due-date{ href: "#" } Clear due date %a.inline.pull-right.prepend-top-5.js-clear-due-date{ href: "#" } Clear due date
...@@ -84,19 +84,19 @@ ...@@ -84,19 +84,19 @@
.fade-right= icon('angle-right') .fade-right= icon('angle-right')
%ul.nav-links.center.user-profile-nav.scrolling-tabs %ul.nav-links.center.user-profile-nav.scrolling-tabs
%li.js-activity-tab %li.js-activity-tab
= link_to user_path, data: {target: 'div#activity', action: 'activity', toggle: 'tab'} do = link_to user_path, data: { target: 'div#activity', action: 'activity', toggle: 'tab' } do
Activity Activity
%li.js-groups-tab %li.js-groups-tab
= link_to user_groups_path, data: {target: 'div#groups', action: 'groups', toggle: 'tab'} do = link_to user_groups_path, data: { target: 'div#groups', action: 'groups', toggle: 'tab', endpoint: user_groups_path(format: :json) } do
Groups Groups
%li.js-contributed-tab %li.js-contributed-tab
= link_to user_contributed_projects_path, data: {target: 'div#contributed', action: 'contributed', toggle: 'tab'} do = link_to user_contributed_projects_path, data: { target: 'div#contributed', action: 'contributed', toggle: 'tab', endpoint: user_contributed_projects_path(format: :json) } do
Contributed projects Contributed projects
%li.js-projects-tab %li.js-projects-tab
= link_to user_projects_path, data: {target: 'div#projects', action: 'projects', toggle: 'tab'} do = link_to user_projects_path, data: { target: 'div#projects', action: 'projects', toggle: 'tab', endpoint: user_projects_path(format: :json) } do
Personal projects Personal projects
%li.js-snippets-tab %li.js-snippets-tab
= link_to user_snippets_path, data: {target: 'div#snippets', action: 'snippets', toggle: 'tab'} do = link_to user_snippets_path, data: { target: 'div#snippets', action: 'snippets', toggle: 'tab', endpoint: user_snippets_path(format: :json) } do
Snippets Snippets
%div{ class: container_class } %div{ class: container_class }
......
# This worker clears all cache fields in the database, working in batches.
class ClearDatabaseCacheWorker
include Sidekiq::Worker
include DedicatedSidekiqQueue
BATCH_SIZE = 1000
def perform
CacheMarkdownField.caching_classes.each do |kls|
fields = kls.cached_markdown_fields.html_fields
clear_cache_fields = fields.each_with_object({}) do |field, memo|
memo[field] = nil
end
Rails.logger.debug("Clearing Markdown cache for #{kls}: #{fields.inspect}")
kls.unscoped.in_batches(of: BATCH_SIZE) do |relation|
relation.update_all(clear_cache_fields)
end
end
nil
end
end
---
title: Add animations to all the dropdowns
merge_request: 8419
author:
---
title: Replace rake cache:clear:db with an automatic mechanism
merge_request: 10597
author:
---
title: fix inline diff copy in firefox
merge_request:
author:
---
title: Refactor Admin::GroupsController#members_update method and add some specs
merge_request: 10735
author:
---
title: Refactor code that creates project/group members
merge_request: 10735
author:
---
title: Prevent user profile tabs to display raw json when going back and forward in
browser history
merge_request:
author:
---
title: Fixued preview shortcut focusing wrong preview tab
merge_request:
author:
---
title: Removed the milestone references from the milestone views
merge_request:
author:
---
title: 'repository browser: handle submodule urls that don''t end with .git'
merge_request:
author: David Turner
---
title: Unassign all Issues and Merge Requests when member leaves a team
merge_request:
author:
...@@ -34,7 +34,6 @@ ...@@ -34,7 +34,6 @@
- [repository_fork, 1] - [repository_fork, 1]
- [repository_import, 1] - [repository_import, 1]
- [project_service, 1] - [project_service, 1]
- [clear_database_cache, 1]
- [delete_user, 1] - [delete_user, 1]
- [delete_merged_branches, 1] - [delete_merged_branches, 1]
- [authorized_projects, 1] - [authorized_projects, 1]
......
class AddVersionFieldToMarkdownCache < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
def change
%i[
abuse_reports
appearances
application_settings
broadcast_messages
issues
labels
merge_requests
milestones
namespaces
notes
projects
releases
snippets
].each do |table|
add_column table, :cached_markdown_version, :integer, limit: 4
end
end
end
...@@ -24,6 +24,7 @@ ActiveRecord::Schema.define(version: 20170421113144) do ...@@ -24,6 +24,7 @@ ActiveRecord::Schema.define(version: 20170421113144) do
t.datetime "created_at" t.datetime "created_at"
t.datetime "updated_at" t.datetime "updated_at"
t.text "message_html" t.text "message_html"
t.integer "cached_markdown_version"
end end
create_table "appearances", force: :cascade do |t| create_table "appearances", force: :cascade do |t|
...@@ -35,6 +36,7 @@ ActiveRecord::Schema.define(version: 20170421113144) do ...@@ -35,6 +36,7 @@ ActiveRecord::Schema.define(version: 20170421113144) do
t.datetime "updated_at" t.datetime "updated_at"
t.string "header_logo" t.string "header_logo"
t.text "description_html" t.text "description_html"
t.integer "cached_markdown_version"
end end
create_table "application_settings", force: :cascade do |t| create_table "application_settings", force: :cascade do |t|
...@@ -132,6 +134,7 @@ ActiveRecord::Schema.define(version: 20170421113144) do ...@@ -132,6 +134,7 @@ ActiveRecord::Schema.define(version: 20170421113144) do
t.string "uuid" t.string "uuid"
t.decimal "polling_interval_multiplier", default: 1.0, null: false t.decimal "polling_interval_multiplier", default: 1.0, null: false
t.boolean "elasticsearch_experimental_indexer" t.boolean "elasticsearch_experimental_indexer"
t.integer "cached_markdown_version"
end end
create_table "approvals", force: :cascade do |t| create_table "approvals", force: :cascade do |t|
...@@ -209,6 +212,7 @@ ActiveRecord::Schema.define(version: 20170421113144) do ...@@ -209,6 +212,7 @@ ActiveRecord::Schema.define(version: 20170421113144) do
t.string "color" t.string "color"
t.string "font" t.string "font"
t.text "message_html" t.text "message_html"
t.integer "cached_markdown_version"
end end
create_table "chat_names", force: :cascade do |t| create_table "chat_names", force: :cascade do |t|
...@@ -567,6 +571,7 @@ ActiveRecord::Schema.define(version: 20170421113144) do ...@@ -567,6 +571,7 @@ ActiveRecord::Schema.define(version: 20170421113144) do
t.integer "relative_position" t.integer "relative_position"
t.datetime "closed_at" t.datetime "closed_at"
t.string "service_desk_reply_to" t.string "service_desk_reply_to"
t.integer "cached_markdown_version"
end end
add_index "issues", ["assignee_id"], name: "index_issues_on_assignee_id", using: :btree add_index "issues", ["assignee_id"], name: "index_issues_on_assignee_id", using: :btree
...@@ -631,6 +636,7 @@ ActiveRecord::Schema.define(version: 20170421113144) do ...@@ -631,6 +636,7 @@ ActiveRecord::Schema.define(version: 20170421113144) do
t.text "description_html" t.text "description_html"
t.string "type" t.string "type"
t.integer "group_id" t.integer "group_id"
t.integer "cached_markdown_version"
end end
add_index "labels", ["group_id", "project_id", "title"], name: "index_labels_on_group_id_and_project_id_and_title", unique: true, using: :btree add_index "labels", ["group_id", "project_id", "title"], name: "index_labels_on_group_id_and_project_id_and_title", unique: true, using: :btree
...@@ -771,6 +777,7 @@ ActiveRecord::Schema.define(version: 20170421113144) do ...@@ -771,6 +777,7 @@ ActiveRecord::Schema.define(version: 20170421113144) do
t.text "description_html" t.text "description_html"
t.integer "time_estimate" t.integer "time_estimate"
t.boolean "squash", default: false, null: false t.boolean "squash", default: false, null: false
t.integer "cached_markdown_version"
end end
add_index "merge_requests", ["assignee_id"], name: "index_merge_requests_on_assignee_id", using: :btree add_index "merge_requests", ["assignee_id"], name: "index_merge_requests_on_assignee_id", using: :btree
...@@ -808,6 +815,7 @@ ActiveRecord::Schema.define(version: 20170421113144) do ...@@ -808,6 +815,7 @@ ActiveRecord::Schema.define(version: 20170421113144) do
t.text "title_html" t.text "title_html"
t.text "description_html" t.text "description_html"
t.date "start_date" t.date "start_date"
t.integer "cached_markdown_version"
end end
add_index "milestones", ["description"], name: "index_milestones_on_description_trigram", using: :gin, opclasses: {"description"=>"gin_trgm_ops"} add_index "milestones", ["description"], name: "index_milestones_on_description_trigram", using: :gin, opclasses: {"description"=>"gin_trgm_ops"}
...@@ -846,10 +854,11 @@ ActiveRecord::Schema.define(version: 20170421113144) do ...@@ -846,10 +854,11 @@ ActiveRecord::Schema.define(version: 20170421113144) do
t.text "description_html" t.text "description_html"
t.boolean "lfs_enabled" t.boolean "lfs_enabled"
t.integer "parent_id" t.integer "parent_id"
t.boolean "require_two_factor_authentication", default: false, null: false
t.integer "two_factor_grace_period", default: 48, null: false
t.integer "shared_runners_minutes_limit" t.integer "shared_runners_minutes_limit"
t.integer "repository_size_limit", limit: 8 t.integer "repository_size_limit", limit: 8
t.boolean "require_two_factor_authentication", default: false, null: false
t.integer "two_factor_grace_period", default: 48, null: false
t.integer "cached_markdown_version"
end end
add_index "namespaces", ["created_at"], name: "index_namespaces_on_created_at", using: :btree add_index "namespaces", ["created_at"], name: "index_namespaces_on_created_at", using: :btree
...@@ -886,6 +895,7 @@ ActiveRecord::Schema.define(version: 20170421113144) do ...@@ -886,6 +895,7 @@ ActiveRecord::Schema.define(version: 20170421113144) do
t.integer "resolved_by_id" t.integer "resolved_by_id"
t.string "discussion_id" t.string "discussion_id"
t.text "note_html" t.text "note_html"
t.integer "cached_markdown_version"
end end
add_index "notes", ["author_id"], name: "index_notes_on_author_id", using: :btree add_index "notes", ["author_id"], name: "index_notes_on_author_id", using: :btree
...@@ -1105,12 +1115,13 @@ ActiveRecord::Schema.define(version: 20170421113144) do ...@@ -1105,12 +1115,13 @@ ActiveRecord::Schema.define(version: 20170421113144) do
t.boolean "lfs_enabled" t.boolean "lfs_enabled"
t.text "description_html" t.text "description_html"
t.boolean "only_allow_merge_if_all_discussions_are_resolved" t.boolean "only_allow_merge_if_all_discussions_are_resolved"
t.integer "auto_cancel_pending_pipelines", default: 0, null: false
t.integer "repository_size_limit", limit: 8 t.integer "repository_size_limit", limit: 8
t.integer "sync_time", default: 60, null: false t.integer "sync_time", default: 60, null: false
t.boolean "printing_merge_request_link_enabled", default: true, null: false t.boolean "printing_merge_request_link_enabled", default: true, null: false
t.string "import_jid" t.integer "auto_cancel_pending_pipelines", default: 0, null: false
t.boolean "service_desk_enabled" t.boolean "service_desk_enabled"
t.string "import_jid"
t.integer "cached_markdown_version"
end end
add_index "projects", ["ci_id"], name: "index_projects_on_ci_id", using: :btree add_index "projects", ["ci_id"], name: "index_projects_on_ci_id", using: :btree
...@@ -1209,6 +1220,7 @@ ActiveRecord::Schema.define(version: 20170421113144) do ...@@ -1209,6 +1220,7 @@ ActiveRecord::Schema.define(version: 20170421113144) do
t.datetime "created_at" t.datetime "created_at"
t.datetime "updated_at" t.datetime "updated_at"
t.text "description_html" t.text "description_html"
t.integer "cached_markdown_version"
end end
add_index "releases", ["project_id", "tag"], name: "index_releases_on_project_id_and_tag", using: :btree add_index "releases", ["project_id", "tag"], name: "index_releases_on_project_id_and_tag", using: :btree
...@@ -1300,6 +1312,7 @@ ActiveRecord::Schema.define(version: 20170421113144) do ...@@ -1300,6 +1312,7 @@ ActiveRecord::Schema.define(version: 20170421113144) do
t.integer "visibility_level", default: 0, null: false t.integer "visibility_level", default: 0, null: false
t.text "title_html" t.text "title_html"
t.text "content_html" t.text "content_html"
t.integer "cached_markdown_version"
end end
add_index "snippets", ["author_id"], name: "index_snippets_on_author_id", using: :btree add_index "snippets", ["author_id"], name: "index_snippets_on_author_id", using: :btree
...@@ -1508,11 +1521,11 @@ ActiveRecord::Schema.define(version: 20170421113144) do ...@@ -1508,11 +1521,11 @@ ActiveRecord::Schema.define(version: 20170421113144) do
t.string "organization" t.string "organization"
t.boolean "authorized_projects_populated" t.boolean "authorized_projects_populated"
t.boolean "auditor", default: false, null: false t.boolean "auditor", default: false, null: false
t.boolean "require_two_factor_authentication_from_group", default: false, null: false
t.integer "two_factor_grace_period", default: 48, null: false
t.boolean "ghost" t.boolean "ghost"
t.date "last_activity_on" t.date "last_activity_on"
t.boolean "notified_of_own_activity" t.boolean "notified_of_own_activity"
t.boolean "require_two_factor_authentication_from_group", default: false, null: false
t.integer "two_factor_grace_period", default: 48, null: false
t.boolean "support_bot" t.boolean "support_bot"
end end
......
# PlantUML & GitLab # PlantUML & GitLab
> [Introduced][ce-7810] in GitLab 8.16. > [Introduced][ce-8537] in GitLab 8.16.
When [PlantUML](http://plantuml.com) integration is enabled and configured in When [PlantUML](http://plantuml.com) integration is enabled and configured in
GitLab we are able to create simple diagrams in AsciiDoc and Markdown documents GitLab we are able to create simple diagrams in AsciiDoc and Markdown documents
...@@ -28,7 +28,7 @@ using Tomcat: ...@@ -28,7 +28,7 @@ using Tomcat:
sudo apt-get install tomcat7 sudo apt-get install tomcat7
sudo cp target/plantuml.war /var/lib/tomcat7/webapps/plantuml.war sudo cp target/plantuml.war /var/lib/tomcat7/webapps/plantuml.war
sudo chown tomcat7:tomcat7 /var/lib/tomcat7/webapps/plantuml.war sudo chown tomcat7:tomcat7 /var/lib/tomcat7/webapps/plantuml.war
sudo service restart tomcat7 sudo service tomcat7 restart
``` ```
Once the Tomcat service restarts the PlantUML service will be ready and Once the Tomcat service restarts the PlantUML service will be ready and
...@@ -93,3 +93,5 @@ Some parameters can be added to the AsciiDoc block definition: ...@@ -93,3 +93,5 @@ Some parameters can be added to the AsciiDoc block definition:
- *height*: Height attribute added to the img tag. - *height*: Height attribute added to the img tag.
Markdown does not support any parameters and will always use PNG format. Markdown does not support any parameters and will always use PNG format.
[ce-8537]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/8537
\ No newline at end of file
...@@ -1029,7 +1029,7 @@ Parameters: ...@@ -1029,7 +1029,7 @@ Parameters:
| `from` | string | no | Date string in the format YEAR-MONTH-DAY, e.g. `2016-03-11`. Defaults to 6 months ago. | | `from` | string | no | Date string in the format YEAR-MONTH-DAY, e.g. `2016-03-11`. Defaults to 6 months ago. |
```bash ```bash
curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/user/activities curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/user/activities
``` ```
Example response: Example response:
......
# Technical Articles
[Technical Articles](../development/writing_documentation.md#technical-articles) are
topic-related documentation, written with an user-friendly approach and language, aiming
to provide the community with guidance on specific processes to achieve certain objectives.
They are written by members of the GitLab Team and by
[Community Writers](https://about.gitlab.com/handbook/product/technical-writing/community-writers/).
## GitLab Pages
- **GitLab Pages from A to Z**
- [Part 1: Static sites and GitLab Pages domains](../user/project/pages/getting_started_part_one.md)
- [Part 2: Quick start guide - Setting up GitLab Pages](../user/project/pages/getting_started_part_two.md)
- [Part 3: Setting Up Custom Domains - DNS Records and SSL/TLS Certificates](../user/project/pages/getting_started_part_three.md)
- [Part 4: Creating and tweaking `.gitlab-ci.yml` for GitLab Pages](../user/project/pages/getting_started_part_four.md)
...@@ -227,3 +227,31 @@ branch of project with ID `9` every night at `00:30`: ...@@ -227,3 +227,31 @@ branch of project with ID `9` every night at `00:30`:
``` ```
[ci-229]: https://gitlab.com/gitlab-org/gitlab-ci/merge_requests/229 [ci-229]: https://gitlab.com/gitlab-org/gitlab-ci/merge_requests/229
## Using scheduled triggers
> [Introduced][ci-10533] in GitLab CE 9.1 as experimental.
In order to schedule a trigger, navigate to your project's **Settings ➔ CI/CD Pipelines ➔ Triggers** and edit an existing trigger token.
![Triggers Schedule edit](img/trigger_schedule_edit.png)
To set up a scheduled trigger:
1. Check the **Schedule trigger (experimental)** checkbox
1. Enter a cron value for the frequency of the trigger ([learn more about cron notation](http://www.nncron.ru/help/EN/working/cron-format.htm))
1. Enter the timezone of the cron trigger ([see a list of timezones](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones))
1. Enter the branch or tag that the trigger will target
1. Hit **Save trigger** for the changes to take effect
![Triggers Schedule create](img/trigger_schedule_create.png)
You can check a next execution date of the scheduled trigger, which is automatically calculated by a server.
![Triggers Schedule create](img/trigger_schedule_updated_next_run_at.png)
> **Notes**:
- Those triggers won't be executed precicely. Because scheduled triggers are handled by Sidekiq, which runs according to its interval. For exmaple, if you set a trigger to be executed every minute (`* * * * *`) and the Sidekiq worker performs 00:00 and 12:00 o'clock every day (`0 */12 * * *`), then your trigger will be executed only 00:00 and 12:00 o'clock every day. To change the Sidekiq worker's frequency, you have to edit the `trigger_schedule_worker` value in `config/gitlab.yml` and restart GitLab. The Sidekiq worker's configuration on GiLab.com is able to be looked up at [here](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/config/gitlab.yml.example#L185).
- Cron notation is parsed by [Rufus-Scheduler](https://github.com/jmettraux/rufus-scheduler).
[ci-10533]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/10533
...@@ -29,7 +29,8 @@ The table below shows what kind of documentation goes where. ...@@ -29,7 +29,8 @@ The table below shows what kind of documentation goes where.
| `doc/legal/` | Legal documents about contributing to GitLab. | | `doc/legal/` | Legal documents about contributing to GitLab. |
| `doc/install/`| Probably the most visited directory, since `installation.md` is there. Ideally this should go under `doc/administration/`, but it's best to leave it as-is in order to avoid confusion (still debated though). | | `doc/install/`| Probably the most visited directory, since `installation.md` is there. Ideally this should go under `doc/administration/`, but it's best to leave it as-is in order to avoid confusion (still debated though). |
| `doc/update/` | Same with `doc/install/`. Should be under `administration/`, but this is a well known location, better leave as-is, at least for now. | | `doc/update/` | Same with `doc/install/`. Should be under `administration/`, but this is a well known location, better leave as-is, at least for now. |
| `doc/topics/` | Indexes per Topic (`doc/topics/topic-name/index.md`); Technical Articles: user guides, admin guides, technical overviews, tutorials (`doc/topics/topic-name/`). | | `doc/topics/` | Indexes per Topic (`doc/topics/topic-name/index.md`): all resources for that topic (user and admin documentation, articles, and third-party docs) |
| `doc/articles/` | [Technical Articles](writing_documentation.md#technical-articles): user guides, admin guides, technical overviews, tutorials (`doc/articles/article-title/index.md`). |
--- ---
...@@ -61,8 +62,8 @@ The table below shows what kind of documentation goes where. ...@@ -61,8 +62,8 @@ The table below shows what kind of documentation goes where.
located at `doc/user/admin_area/settings/visibility_and_access_controls.md`. located at `doc/user/admin_area/settings/visibility_and_access_controls.md`.
1. The `doc/topics/` directory holds topic-related technical content. Create 1. The `doc/topics/` directory holds topic-related technical content. Create
`doc/topics/topic-name/subtopic-name/index.md` when subtopics become necessary. `doc/topics/topic-name/subtopic-name/index.md` when subtopics become necessary.
Note that `topics` holds the index page per topic, and technical articles. General General user- and admin- related documentation, should be placed accordingly.
user- and admin- related documentation, should be placed accordingly. 1. For technical articles, place their images under `doc/articles/article-title/img/`.
--- ---
......
...@@ -25,6 +25,59 @@ Working with our frontend assets requires Node (v4.3 or greater) and Yarn ...@@ -25,6 +25,59 @@ Working with our frontend assets requires Node (v4.3 or greater) and Yarn
For our currently-supported browsers, see our [requirements][requirements]. For our currently-supported browsers, see our [requirements][requirements].
---
## Development Process
When you are assigned an issue please follow the next steps:
### Divide a big feature into small Merge Requests
1. Big Merge Request are painful to review. In order to make this process easier we
must break a big feature into smaller ones and create a Merge Request for each step.
1. First step is to create a branch from `master`, let's call it `new-feature`. This branch
will be the recipient of all the smaller Merge Requests. Only this one will be merged to master.
1. Don't do any work on this one, let's keep it synced with master.
1. Create a new branch from `new-feature`, let's call it `new-feature-step-1`. We advise you
to clearly identify which step the branch represents.
1. Do the first part of the modifications in this branch. The target branch of this Merge Request
should be `new-feature`.
1. Once `new-feature-step-1` gets merged into `new-feature` we can continue our work. Create a new
branch from `new-feature`, let's call it `new-feature-step-2` and repeat the process done before.
```shell
* master
|\
| * new-feature
| |\
| | * new-feature-step-1
| |\
| | * new-feature-step-2
| |\
| | * new-feature-step-3
```
**Tips**
- Make sure `new-feature` branch is always synced with `master`: merge master frequently.
- Do the same for the feature branch you have opened. This can be accomplished by merging `master` into `new-feature` and `new-feature` into `new-feature-step-*`
- Avoid rewriting history.
### Share your work early
1. Before writing code guarantee your vision of the architecture is aligned with
GitLab's architecture.
1. Add a diagram to the issue and ask a Frontend Architecture about it.
![Diagram of Issue Boards Architecture](img/boards_diagram.png)
1. Don't take more than one week between starting work on a feature and
sharing a Merge Request with a reviewer or a maintainer.
### Vue features
1. Follow the steps in [Vue.js Best Practices](vue.md)
1. Follow the style guide.
1. Only a handful of people are allowed to merge Vue related features.
Reach out to @jschatz, @iamphill, @fatihacet or @filipa early in this process.
--- ---
## [Architecture](architecture.md) ## [Architecture](architecture.md)
......
...@@ -58,7 +58,7 @@ See [our current .eslintrc][eslintrc] for specific rules and patterns. ...@@ -58,7 +58,7 @@ See [our current .eslintrc][eslintrc] for specific rules and patterns.
import Bar from './bar'; import Bar from './bar';
``` ```
- **Never** disable the `no-undef` rule. Declare globals with `/* global Foo */` instead. - **Never** disable the `no-undef` rule. Declare globals with `/* global Foo */` instead.
- When declaring multiple globals, always use one `/* global [name] */` line per variable. - When declaring multiple globals, always use one `/* global [name] */` line per variable.
...@@ -71,6 +71,16 @@ See [our current .eslintrc][eslintrc] for specific rules and patterns. ...@@ -71,6 +71,16 @@ See [our current .eslintrc][eslintrc] for specific rules and patterns.
/* global Cookies */ /* global Cookies */
/* global jQuery */ /* global jQuery */
``` ```
- Use up to 3 parameters for a function or class. If you need more accept an Object instead.
```javascript
// bad
fn(p1, p2, p3, p4) {}
// good
fn(options) {}
```
#### Modules, Imports, and Exports #### Modules, Imports, and Exports
- Use ES module syntax to import modules - Use ES module syntax to import modules
...@@ -168,6 +178,23 @@ See [our current .eslintrc][eslintrc] for specific rules and patterns. ...@@ -168,6 +178,23 @@ See [our current .eslintrc][eslintrc] for specific rules and patterns.
- Avoid constructors with side-effects - Avoid constructors with side-effects
- Prefer `.map`, `.reduce` or `.filter` over `.forEach`
A forEach will cause side effects, it will be mutating the array being iterated. Prefer using `.map`,
`.reduce` or `.filter`
```javascript
const users = [ { name: 'Foo' }, { name: 'Bar' } ];
// bad
users.forEach((user, index) => {
user.id = index;
});
// good
const usersWithId = users.map((user, index) => {
return Object.assign({}, user, { id: index });
});
```
#### Parse Strings into Numbers #### Parse Strings into Numbers
- `parseInt()` is preferable over `Number()` or `+` - `parseInt()` is preferable over `Number()` or `+`
...@@ -183,6 +210,19 @@ See [our current .eslintrc][eslintrc] for specific rules and patterns. ...@@ -183,6 +210,19 @@ See [our current .eslintrc][eslintrc] for specific rules and patterns.
parseInt('10', 10); parseInt('10', 10);
``` ```
#### CSS classes used for JavaScript
- If the class is being used in Javascript it needs to be prepend with `js-`
```html
// bad
<button class="add-user">
Add User
</button>
// good
<button class="js-add-user">
Add User
</button>
```
### Vue.js ### Vue.js
...@@ -200,6 +240,7 @@ See [our current .eslintrc][eslintrc] for specific rules and patterns. ...@@ -200,6 +240,7 @@ See [our current .eslintrc][eslintrc] for specific rules and patterns.
#### Naming #### Naming
- **Extensions**: Use `.vue` extension for Vue components. - **Extensions**: Use `.vue` extension for Vue components.
- **Reference Naming**: Use PascalCase for Vue components and camelCase for their instances: - **Reference Naming**: Use PascalCase for Vue components and camelCase for their instances:
```javascript ```javascript
// bad // bad
import cardBoard from 'cardBoard'; import cardBoard from 'cardBoard';
...@@ -217,6 +258,7 @@ See [our current .eslintrc][eslintrc] for specific rules and patterns. ...@@ -217,6 +258,7 @@ See [our current .eslintrc][eslintrc] for specific rules and patterns.
cardBoard: CardBoard cardBoard: CardBoard
}; };
``` ```
- **Props Naming:** - **Props Naming:**
- Avoid using DOM component prop names. - Avoid using DOM component prop names.
- Use kebab-case instead of camelCase to provide props in templates. - Use kebab-case instead of camelCase to provide props in templates.
...@@ -243,12 +285,18 @@ See [our current .eslintrc][eslintrc] for specific rules and patterns. ...@@ -243,12 +285,18 @@ See [our current .eslintrc][eslintrc] for specific rules and patterns.
<component v-if="bar" <component v-if="bar"
param="baz" /> param="baz" />
<button class="btn">Click me</button>
// good // good
<component <component
v-if="bar" v-if="bar"
param="baz" param="baz"
/> />
<button class="btn">
Click me
</button>
// if props fit in one line then keep it on the same line // if props fit in one line then keep it on the same line
<component bar="bar" /> <component bar="bar" />
``` ```
......
...@@ -26,6 +26,10 @@ browser and you will not have access to certain APIs, such as ...@@ -26,6 +26,10 @@ browser and you will not have access to certain APIs, such as
[`Notification`](https://developer.mozilla.org/en-US/docs/Web/API/notification), [`Notification`](https://developer.mozilla.org/en-US/docs/Web/API/notification),
which will have to be stubbed. which will have to be stubbed.
### Writing tests
### Vue.js unit tests
See this [section][vue-test].
### Running frontend tests ### Running frontend tests
`rake karma` runs the frontend-only (JavaScript) tests. `rake karma` runs the frontend-only (JavaScript) tests.
...@@ -134,3 +138,4 @@ Scenario: Developer can approve merge request ...@@ -134,3 +138,4 @@ Scenario: Developer can approve merge request
[jasmine-focus]: https://jasmine.github.io/2.5/focused_specs.html [jasmine-focus]: https://jasmine.github.io/2.5/focused_specs.html
[jasmine-jquery]: https://github.com/velesin/jasmine-jquery [jasmine-jquery]: https://github.com/velesin/jasmine-jquery
[karma]: http://karma-runner.github.io/ [karma]: http://karma-runner.github.io/
[vue-test]:https://docs.gitlab.com/ce/development/fe_guide/vue.html#testing-vue-components
This diff is collapsed.
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
- **General Documentation**: written by the developers responsible by creating features. Should be submitted in the same merge request containing code. Feature proposals (by GitLab contributors) should also be accompanied by its respective documentation. They can be later improved by PMs and Technical Writers. - **General Documentation**: written by the developers responsible by creating features. Should be submitted in the same merge request containing code. Feature proposals (by GitLab contributors) should also be accompanied by its respective documentation. They can be later improved by PMs and Technical Writers.
- **Technical Articles**: written by any [GitLab Team](https://about.gitlab.com/team/) member, GitLab contributors, or [Community Writers](https://about.gitlab.com/handbook/product/technical-writing/community-writers/). - **Technical Articles**: written by any [GitLab Team](https://about.gitlab.com/team/) member, GitLab contributors, or [Community Writers](https://about.gitlab.com/handbook/product/technical-writing/community-writers/).
- **Indexes per topic**: initially prepared by the Technical Writing Team, and kept up-to-date by developers and PMs, in the same merge request containing code. - **Indexes per topic**: initially prepared by the Technical Writing Team, and kept up-to-date by developers and PMs in the same merge request containing code. They gather all resources for that topic in a single page (user and admin documentation, articles, and third-party docs).
## Distinction between General Documentation and Technical Articles ## Distinction between General Documentation and Technical Articles
...@@ -18,7 +18,7 @@ They are topic-related documentation, written with an user-friendly approach and ...@@ -18,7 +18,7 @@ They are topic-related documentation, written with an user-friendly approach and
A technical article guides users and/or admins to achieve certain objectives (within guides and tutorials), or provide an overview of that particular topic or feature (within technical overviews). It can also describe the use, implementation, or integration of third-party tools with GitLab. A technical article guides users and/or admins to achieve certain objectives (within guides and tutorials), or provide an overview of that particular topic or feature (within technical overviews). It can also describe the use, implementation, or integration of third-party tools with GitLab.
They live under `doc/topics/topic-name/`, and can be searched per topic, within "Indexes per Topic" pages. The topics are listed on the main [Indexes per Topic](../topics/index.md) page. They live under `doc/articles/article-title/index.md`, and their images should be placed under `doc/articles/article-title/img/`. Find a list of existing [technical articles](../articles/index.md) here.
#### Types of Technical Articles #### Types of Technical Articles
......
...@@ -18,10 +18,12 @@ another is through backup restore. ...@@ -18,10 +18,12 @@ another is through backup restore.
To restore a backup, you will also need to restore `/etc/gitlab/gitlab-secrets.json` To restore a backup, you will also need to restore `/etc/gitlab/gitlab-secrets.json`
(for omnibus packages) or `/home/git/gitlab/.secret` (for installations (for omnibus packages) or `/home/git/gitlab/.secret` (for installations
from source). This file contains the database encryption key and CI secret from source). This file contains the database encryption key,
variables used for two-factor authentication. If you fail to restore this [CI secret variables](../ci/variables/README.md#secret-variables), and
encryption key file along with the application data backup, users with two-factor secret variables used for [two-factor authentication](../security/two_factor_authentication.md).
authentication enabled will lose access to your GitLab server. If you fail to restore this encryption key file along with the application data
backup, users with two-factor authentication enabled and GitLab Runners will
lose access to your GitLab server.
## Create a backup of the GitLab system ## Create a backup of the GitLab system
......
...@@ -333,7 +333,7 @@ A [platform](https://www.meteor.com) for building javascript apps. ...@@ -333,7 +333,7 @@ A [platform](https://www.meteor.com) for building javascript apps.
### Milestones ### Milestones
Allow you to [organize issues](https://docs.gitlab.com/ce/user/project/milestones/) and merge requests in GitLab into a cohesive group, optionally setting a due date. A common use is keeping track of an upcoming software version. Milestones are created per-project. Allow you to [organize issues](../../user/project/milestones/index.md) and merge requests in GitLab into a cohesive group, optionally setting a due date. A common use is keeping track of an upcoming software version. Milestones are created per-project.
### Mirror Repositories ### Mirror Repositories
......
# Cohorts # Cohorts
> **Notes:** > **Notes:**
- [Introduced][ce-23361] in GitLab 9.1. > [Introduced][ce-23361] in GitLab 9.1.
As a benefit of having the [usage ping active](settings/usage_statistics.md), As a benefit of having the [usage ping active](settings/usage_statistics.md),
GitLab lets you analyze the users' activities of your GitLab installation. GitLab lets you analyze the users' activities of your GitLab installation.
Under `/admin/cohorts`, when the usage ping is active, GitLab will show the Under `/admin/cohorts`, when the usage ping is active, GitLab will show the
monthly cohorts of new users and their activities over time. monthly cohorts of new users and their activities over time.
## Overview
How do we read the user cohorts table? Let's take an example with the following How do we read the user cohorts table? Let's take an example with the following
user cohorts. user cohorts.
![User cohort example](img/cohorts.png) ![User cohort example](img/cohorts.png)
For the cohort of June 2016, 163 users have been created on this server. One For the cohort of June 2016, 163 users have been added on this server and have
month later, in July 2016, 155 users (or 95% of the June cohort) are still been active since this month. One month later, in July 2016, out of
active. Two months later, 139 users (or 85%) are still active. 9 months later, these 163 users, 155 users (or 95% of the June cohort) are still active. Two
we can see that only 6% of this cohort are still active. months later, 139 users (or 85%) are still active. 9 months later, we can see
that only 6% of this cohort are still active.
The Inactive users column shows the number of users who have been added during
the month, but who have never actually had any activity in the instance.
How do we measure the activity of users? GitLab considers a user active if: How do we measure the activity of users? GitLab considers a user active if:
* the user signs in * the user signs in
* the user has Git activity (whether push or pull). * the user has Git activity (whether push or pull).
### Setup ## Setup
1. Activate the usage ping as defined [in the documentation](settings/usage_statistics.md) 1. [Activate the usage ping](settings/usage_statistics.md)
2. Go to `/admin/cohorts` to see the user cohorts of the server 2. Go to `/admin/cohorts` to see the user cohorts of the server
[ce-23361]: https://gitlab.com/gitlab-org/gitlab-ce/issues/23361 [ce-23361]: https://gitlab.com/gitlab-org/gitlab-ce/issues/23361
...@@ -7,6 +7,9 @@ project itself, the highest permission level is used. ...@@ -7,6 +7,9 @@ project itself, the highest permission level is used.
On public and internal projects the Guest role is not enforced. All users will On public and internal projects the Guest role is not enforced. All users will
be able to create issues, leave comments, and pull or download the project code. be able to create issues, leave comments, and pull or download the project code.
When a member leaves the team the all assigned Issues and Merge Requests
will be unassigned automatically.
GitLab administrators receive all permissions. GitLab administrators receive all permissions.
To add or import a user, you can follow the [project users and members To add or import a user, you can follow the [project users and members
......
...@@ -56,8 +56,12 @@ GitLab CI build environment: ...@@ -56,8 +56,12 @@ GitLab CI build environment:
- `KUBE_URL` - equal to the API URL - `KUBE_URL` - equal to the API URL
- `KUBE_TOKEN` - `KUBE_TOKEN`
- `KUBE_NAMESPACE` - `KUBE_NAMESPACE` - The Kubernetes namespace is auto-generated if not specified.
- `KUBE_CA_PEM_FILE` - only present if a custom CA bundle was specified. Path to a file containing PEM data. The default value is `<project_name>-<project_id>`. You can overwrite it to
use different one if needed, otherwise the `KUBE_NAMESPACE` variable will
receive the default value.
- `KUBE_CA_PEM_FILE` - only present if a custom CA bundle was specified. Path
to a file containing PEM data.
- `KUBE_CA_PEM` (deprecated)- only if a custom CA bundle was specified. Raw PEM data. - `KUBE_CA_PEM` (deprecated)- only if a custom CA bundle was specified. Raw PEM data.
## Web terminals ## Web terminals
......
# Microsoft Teams Service # Microsoft Teams service
## On Microsoft Teams ## On Microsoft Teams
To enable Microsoft Teams integration you must create an incoming webhook integration on Microsoft Teams by following the steps described in this [document](https://msdn.microsoft.com/en-us/microsoft-teams/connectors) To enable Microsoft Teams integration you must create an incoming webhook integration on Microsoft Teams by following the steps described in this [document](https://msdn.microsoft.com/en-us/microsoft-teams/connectors).
## On GitLab ## On GitLab
...@@ -30,4 +30,4 @@ At the end fill in your Microsoft Teams details: ...@@ -30,4 +30,4 @@ At the end fill in your Microsoft Teams details:
After you are all done, click **Save changes** for the changes to take effect. After you are all done, click **Save changes** for the changes to take effect.
![Microsoft Teams configuration](img/microsoft_teams_configuration.png) ![Microsoft Teams configuration](img/microsoft_teams_configuration.png)
\ No newline at end of file
...@@ -47,6 +47,7 @@ Click on the service links to see further configuration instructions and details ...@@ -47,6 +47,7 @@ Click on the service links to see further configuration instructions and details
| [Kubernetes](kubernetes.md) | A containerized deployment service | | [Kubernetes](kubernetes.md) | A containerized deployment service |
| [Mattermost slash commands](mattermost_slash_commands.md) | Mattermost chat and ChatOps slash commands | | [Mattermost slash commands](mattermost_slash_commands.md) | Mattermost chat and ChatOps slash commands |
| [Mattermost Notifications](mattermost.md) | Receive event notifications in Mattermost | | [Mattermost Notifications](mattermost.md) | Receive event notifications in Mattermost |
| [Microsoft teams](microsoft_teams.md) | Receive notifications for actions that happen on GitLab into a room on Microsoft Teams using Office 365 Connectors |
| Pipelines emails | Email the pipeline status to a list of recipients | | Pipelines emails | Email the pipeline status to a list of recipients |
| [Slack Notifications](slack.md) | Receive event notifications in Slack | | [Slack Notifications](slack.md) | Receive event notifications in Slack |
| [Slack slash commands](slack_slash_commands.md) | Slack chat and ChatOps slash commands | | [Slack slash commands](slack_slash_commands.md) | Slack chat and ChatOps slash commands |
......
# Milestones # Milestones
Milestones allow you to organize issues and merge requests into a cohesive group, optionally setting a due date. Milestones allow you to organize issues and merge requests into a cohesive group,
A common use is keeping track of an upcoming software version. Milestones are created per-project. optionally setting a due date. A common use is keeping track of an upcoming
software version. Milestones can be created per-project or per-group.
You can find the milestones page under your project's **Issues ➔ Milestones**. ## Creating a project milestone
## Creating a milestone >**Note:**
You need [Master permissions](../../permissions.md) in order to create a milestone.
You can find the milestones page under your project's **Issues ➔ Milestones**.
To create a new milestone, simply click the **New milestone** button when in the To create a new milestone, simply click the **New milestone** button when in the
milestones page. A milestone can have a title, a description and start/due dates. milestones page. A milestone can have a title, a description and start/due dates.
Once you fill in all the details, hit the **Create milestone** button. Once you fill in all the details, hit the **Create milestone** button.
...@@ -16,7 +19,10 @@ The start/due dates are required if you intend to use [Burndown charts](#burndow ...@@ -16,7 +19,10 @@ The start/due dates are required if you intend to use [Burndown charts](#burndow
![Creating a milestone](img/milestone_create.png) ![Creating a milestone](img/milestone_create.png)
## Groups and milestones ## Creating a group milestone
>**Note:**
You need [Master permissions](../../permissions.md) in order to create a milestone.
You can create a milestone for several projects in the same group simultaneously. You can create a milestone for several projects in the same group simultaneously.
On the group's **Issues ➔ Milestones** page, you will be able to see the status On the group's **Issues ➔ Milestones** page, you will be able to see the status
......
...@@ -4,40 +4,6 @@ Feature: Group Members ...@@ -4,40 +4,6 @@ Feature: Group Members
And "John Doe" is owner of group "Owned" And "John Doe" is owner of group "Owned"
And "John Doe" is guest of group "Guest" And "John Doe" is guest of group "Guest"
@javascript
Scenario: I should add user to group "Owned"
Given User "Mary Jane" exists
When I visit group "Owned" members page
And I select user "Mary Jane" from list with role "Reporter"
Then I should see user "Mary Jane" in team list
@javascript
Scenario: Add user to group
Given gitlab user "Mike"
When I visit group "Owned" members page
When I select "Mike" as "Reporter"
Then I should see "Mike" in team list as "Reporter"
@javascript
Scenario: Ignore add user to group when is already Owner
Given gitlab user "Mike"
When I visit group "Owned" members page
When I select "Mike" as "Reporter"
Then I should see "Mike" in team list as "Owner"
@javascript
Scenario: Invite user to group
When I visit group "Owned" members page
When I select "sjobs@apple.com" as "Reporter"
Then I should see "sjobs@apple.com" in team list as invited "Reporter"
@javascript
Scenario: Edit group member permissions
Given "Mary Jane" is guest of group "Owned"
And I visit group "Owned" members page
When I change the "Mary Jane" role to "Developer"
Then I should see "Mary Jane" as "Developer"
# Leave # Leave
@javascript @javascript
......
...@@ -7,26 +7,6 @@ Feature: Project Team Management ...@@ -7,26 +7,6 @@ Feature: Project Team Management
And "Dmitriy" is "Shop" developer And "Dmitriy" is "Shop" developer
And I visit project "Shop" team page And I visit project "Shop" team page
Scenario: See all team members
Then I should be able to see myself in team
And I should see "Dmitriy" in team list
@javascript
Scenario: Add user to project
When I select "Mike" as "Reporter"
Then I should see "Mike" in team list as "Reporter"
@javascript
Scenario: Invite user to project
When I select "sjobs@apple.com" as "Reporter"
Then I should see "sjobs@apple.com" in team list as invited "Reporter"
@javascript
Scenario: Update user access
Given I should see "Dmitriy" in team list as "Developer"
And I change "Dmitriy" role to "Reporter"
And I should see "Dmitriy" in team list as "Reporter"
Scenario: Cancel team member Scenario: Cancel team member
Given I click cancel link for "Dmitriy" Given I click cancel link for "Dmitriy"
Then I visit project "Shop" team page Then I visit project "Shop" team page
......
...@@ -4,71 +4,6 @@ class Spinach::Features::GroupMembers < Spinach::FeatureSteps ...@@ -4,71 +4,6 @@ class Spinach::Features::GroupMembers < Spinach::FeatureSteps
include SharedPaths include SharedPaths
include SharedGroup include SharedGroup
include SharedUser include SharedUser
include Select2Helper
step 'I select "Mike" as "Reporter"' do
user = User.find_by(name: "Mike")
page.within ".users-group-form" do
select2(user.id, from: "#user_ids", multiple: true)
select "Reporter", from: "access_level"
end
click_button "Add to group"
end
step 'I select "Mike" as "Master"' do
user = User.find_by(name: "Mike")
page.within ".users-group-form" do
select2(user.id, from: "#user_ids", multiple: true)
select "Master", from: "access_level"
end
click_button "Add to group"
end
step 'I should see "Mike" in team list as "Reporter"' do
page.within '.content-list' do
expect(page).to have_content('Mike')
expect(page).to have_content('Reporter')
end
end
step 'I should see "Mike" in team list as "Owner"' do
page.within '.content-list' do
expect(page).to have_content('Mike')
expect(page).to have_content('Owner')
end
end
step 'I select "sjobs@apple.com" as "Reporter"' do
page.within ".users-group-form" do
select2("sjobs@apple.com", from: "#user_ids", multiple: true)
select "Reporter", from: "access_level"
end
click_button "Add to group"
end
step 'I should see "sjobs@apple.com" in team list as invited "Reporter"' do
page.within '.content-list' do
expect(page).to have_content('sjobs@apple.com')
expect(page).to have_content('Invited')
expect(page).to have_content('Reporter')
end
end
step 'I select user "Mary Jane" from list with role "Reporter"' do
user = User.find_by(name: "Mary Jane") || create(:user, name: "Mary Jane")
page.within ".users-group-form" do
select2(user.id, from: "#user_ids", multiple: true)
select "Reporter", from: "access_level"
end
click_button "Add to group"
end
step 'I should see user "John Doe" in team list' do step 'I should see user "John Doe" in team list' do
expect(group_members_list).to have_content("John Doe") expect(group_members_list).to have_content("John Doe")
......
...@@ -178,11 +178,13 @@ class Spinach::Features::ProjectCommits < Spinach::FeatureSteps ...@@ -178,11 +178,13 @@ class Spinach::Features::ProjectCommits < Spinach::FeatureSteps
def select_using_dropdown(dropdown_type, selection, is_commit = false) def select_using_dropdown(dropdown_type, selection, is_commit = false)
dropdown = find(".js-compare-#{dropdown_type}-dropdown") dropdown = find(".js-compare-#{dropdown_type}-dropdown")
dropdown.find(".compare-dropdown-toggle").click dropdown.find(".compare-dropdown-toggle").click
dropdown.find('.dropdown-menu', visible: true)
dropdown.fill_in("Filter by Git revision", with: selection) dropdown.fill_in("Filter by Git revision", with: selection)
if is_commit if is_commit
dropdown.find('input[type="search"]').send_keys(:return) dropdown.find('input[type="search"]').send_keys(:return)
else else
find_link(selection, visible: true).click find_link(selection, visible: true).click
end end
dropdown.find('.dropdown-menu', visible: false)
end end
end end
...@@ -87,9 +87,9 @@ class Spinach::Features::ProjectSourceBrowseFiles < Spinach::FeatureSteps ...@@ -87,9 +87,9 @@ class Spinach::Features::ProjectSourceBrowseFiles < Spinach::FeatureSteps
step 'I fill the new branch name' do step 'I fill the new branch name' do
first('button.js-target-branch', visible: true).click first('button.js-target-branch', visible: true).click
first('.create-new-branch', visible: true).click find('.create-new-branch', visible: true).click
first('#new_branch_name', visible: true).set('new_branch_name') find('#new_branch_name', visible: true).set('new_branch_name')
first('.js-new-branch-btn', visible: true).click find('.js-new-branch-btn', visible: true).click
end end
step 'I fill the new file name with an illegal name' do step 'I fill the new file name with an illegal name' do
......
...@@ -4,25 +4,10 @@ class Spinach::Features::ProjectTeamManagement < Spinach::FeatureSteps ...@@ -4,25 +4,10 @@ class Spinach::Features::ProjectTeamManagement < Spinach::FeatureSteps
include SharedPaths include SharedPaths
include Select2Helper include Select2Helper
step 'I should be able to see myself in team' do step 'I should not see "Dmitriy" in team list' do
expect(page).to have_content(@user.name)
expect(page).to have_content(@user.username)
end
step 'I should see "Dmitriy" in team list' do
user = User.find_by(name: "Dmitriy") user = User.find_by(name: "Dmitriy")
expect(page).to have_content(user.name) expect(page).not_to have_content(user.name)
expect(page).to have_content(user.username) expect(page).not_to have_content(user.username)
end
step 'I select "Mike" as "Reporter"' do
user = User.find_by(name: "Mike")
page.within ".users-project-form" do
select2(user.id, from: "#user_ids", multiple: true)
select "Reporter", from: "access_level"
end
click_button "Add to project"
end end
step 'I should see "Mike" in team list as "Reporter"' do step 'I should see "Mike" in team list as "Reporter"' do
...@@ -34,60 +19,6 @@ class Spinach::Features::ProjectTeamManagement < Spinach::FeatureSteps ...@@ -34,60 +19,6 @@ class Spinach::Features::ProjectTeamManagement < Spinach::FeatureSteps
end end
end end
step 'I select "sjobs@apple.com" as "Reporter"' do
page.within ".users-project-form" do
find('#user_ids', visible: false).set('sjobs@apple.com')
select "Reporter", from: "access_level"
end
click_button "Add to project"
end
step 'I should see "sjobs@apple.com" in team list as invited "Reporter"' do
project_member = project.project_members.find_by(invite_email: 'sjobs@apple.com')
page.within "#project_member_#{project_member.id}" do
expect(page).to have_content('sjobs@apple.com')
expect(page).to have_content('Invited')
expect(page).to have_content('Reporter')
end
end
step 'I should see "Dmitriy" in team list as "Developer"' do
user = User.find_by(name: 'Dmitriy')
project_member = project.project_members.find_by(user_id: user.id)
page.within "#project_member_#{project_member.id}" do
expect(page).to have_content('Dmitriy')
expect(page).to have_content('Developer')
end
end
step 'I change "Dmitriy" role to "Reporter"' do
project = Project.find_by(name: "Shop")
user = User.find_by(name: 'Dmitriy')
project_member = project.project_members.find_by(user_id: user.id)
page.within "#project_member_#{project_member.id}" do
click_button project_member.human_access
page.within '.dropdown-menu' do
click_link 'Reporter'
end
end
end
step 'I should see "Dmitriy" in team list as "Reporter"' do
user = User.find_by(name: 'Dmitriy')
project_member = project.project_members.find_by(user_id: user.id)
page.within "#project_member_#{project_member.id}" do
expect(page).to have_content('Dmitriy')
expect(page).to have_content('Reporter')
end
end
step 'I should not see "Dmitriy" in team list' do
user = User.find_by(name: "Dmitriy")
expect(page).not_to have_content(user.name)
expect(page).not_to have_content(user.username)
end
step 'gitlab user "Mike"' do step 'gitlab user "Mike"' do
create(:user, name: "Mike") create(:user, name: "Mike")
end end
...@@ -113,7 +44,7 @@ class Spinach::Features::ProjectTeamManagement < Spinach::FeatureSteps ...@@ -113,7 +44,7 @@ class Spinach::Features::ProjectTeamManagement < Spinach::FeatureSteps
project.team << [user, :reporter] project.team << [user, :reporter]
end end
step 'I click link "Import team from another project"' do step 'I click link "Import team from another project"' do
page.within '.users-project-form' do page.within '.users-project-form' do
click_link "Import" click_link "Import"
end end
......
...@@ -11,7 +11,7 @@ module API ...@@ -11,7 +11,7 @@ module API
optional :issues_enabled, type: Boolean, desc: 'Flag indication if the issue tracker is enabled' optional :issues_enabled, type: Boolean, desc: 'Flag indication if the issue tracker is enabled'
optional :merge_requests_enabled, type: Boolean, desc: 'Flag indication if merge requests are enabled' optional :merge_requests_enabled, type: Boolean, desc: 'Flag indication if merge requests are enabled'
optional :wiki_enabled, type: Boolean, desc: 'Flag indication if the wiki is enabled' optional :wiki_enabled, type: Boolean, desc: 'Flag indication if the wiki is enabled'
optional :builds_enabled, type: Boolean, desc: 'Flag indication if builds are enabled' optional :jobs_enabled, type: Boolean, desc: 'Flag indication if jobs are enabled'
optional :snippets_enabled, type: Boolean, desc: 'Flag indication if snippets are enabled' optional :snippets_enabled, type: Boolean, desc: 'Flag indication if snippets are enabled'
optional :shared_runners_enabled, type: Boolean, desc: 'Flag indication if shared runners are enabled for that project' optional :shared_runners_enabled, type: Boolean, desc: 'Flag indication if shared runners are enabled for that project'
optional :container_registry_enabled, type: Boolean, desc: 'Flag indication if the container registry is enabled for that project' optional :container_registry_enabled, type: Boolean, desc: 'Flag indication if the container registry is enabled for that project'
...@@ -109,6 +109,7 @@ module API ...@@ -109,6 +109,7 @@ module API
end end
post do post do
attrs = declared_params(include_missing: false) attrs = declared_params(include_missing: false)
attrs[:builds_enabled] = attrs.delete(:jobs_enabled) if attrs.has_key?(:jobs_enabled)
project = ::Projects::CreateService.new(current_user, attrs).execute project = ::Projects::CreateService.new(current_user, attrs).execute
if project.saved? if project.saved?
...@@ -211,7 +212,7 @@ module API ...@@ -211,7 +212,7 @@ module API
# CE # CE
at_least_one_of_ce = at_least_one_of_ce =
[ [
:builds_enabled, :jobs_enabled,
:container_registry_enabled, :container_registry_enabled,
:default_branch, :default_branch,
:description, :description,
...@@ -248,6 +249,8 @@ module API ...@@ -248,6 +249,8 @@ module API
authorize! :rename_project, user_project if attrs[:name].present? authorize! :rename_project, user_project if attrs[:name].present?
authorize! :change_visibility_level, user_project if attrs[:visibility].present? authorize! :change_visibility_level, user_project if attrs[:visibility].present?
attrs[:builds_enabled] = attrs.delete(:jobs_enabled) if attrs.has_key?(:jobs_enabled)
result = ::Projects::UpdateService.new(user_project, current_user, attrs).execute result = ::Projects::UpdateService.new(user_project, current_user, attrs).execute
if result[:status] == :success if result[:status] == :success
......
module Banzai module Banzai
module Renderer module Renderer
module_function
# Convert a Markdown String into an HTML-safe String of HTML # Convert a Markdown String into an HTML-safe String of HTML
# #
# Note that while the returned HTML will have been sanitized of dangerous # Note that while the returned HTML will have been sanitized of dangerous
...@@ -16,7 +14,7 @@ module Banzai ...@@ -16,7 +14,7 @@ module Banzai
# context - Hash of context options passed to our HTML Pipeline # context - Hash of context options passed to our HTML Pipeline
# #
# Returns an HTML-safe String # Returns an HTML-safe String
def render(text, context = {}) def self.render(text, context = {})
cache_key = context.delete(:cache_key) cache_key = context.delete(:cache_key)
cache_key = full_cache_key(cache_key, context[:pipeline]) cache_key = full_cache_key(cache_key, context[:pipeline])
...@@ -35,24 +33,16 @@ module Banzai ...@@ -35,24 +33,16 @@ module Banzai
# of HTML. This method is analogous to calling render(object.field), but it # of HTML. This method is analogous to calling render(object.field), but it
# can cache the rendered HTML in the object, rather than Redis. # can cache the rendered HTML in the object, rather than Redis.
# #
# The context to use is learned from the passed-in object by calling # The context to use is managed by the object and cannot be changed.
# #banzai_render_context(field), and cannot be changed. Use #render, passing # Use #render, passing it the field text, if a custom rendering is needed.
# it the field text, if a custom rendering is needed. The generated context def self.render_field(object, field)
# is returned along with the HTML. object.refresh_markdown_cache!(do_update: update_object?(object)) unless object.cached_html_up_to_date?(field)
def render_field(object, field)
html_field = object.markdown_cache_field_for(field)
html = object.__send__(html_field)
return html if html.present?
html = cacheless_render_field(object, field)
update_object(object, html_field, html) unless object.new_record? || object.destroyed?
html object.cached_html_for(field)
end end
# Same as +render_field+, but without consulting or updating the cache field # Same as +render_field+, but without consulting or updating the cache field
def cacheless_render_field(object, field, options = {}) def self.cacheless_render_field(object, field, options = {})
text = object.__send__(field) text = object.__send__(field)
context = object.banzai_render_context(field).merge(options) context = object.banzai_render_context(field).merge(options)
...@@ -82,7 +72,7 @@ module Banzai ...@@ -82,7 +72,7 @@ module Banzai
# texts_and_contexts # texts_and_contexts
# => [{ text: '### Hello', # => [{ text: '### Hello',
# context: { cache_key: [note, :note] } }] # context: { cache_key: [note, :note] } }]
def cache_collection_render(texts_and_contexts) def self.cache_collection_render(texts_and_contexts)
items_collection = texts_and_contexts.each_with_index do |item, index| items_collection = texts_and_contexts.each_with_index do |item, index|
context = item[:context] context = item[:context]
cache_key = full_cache_multi_key(context.delete(:cache_key), context[:pipeline]) cache_key = full_cache_multi_key(context.delete(:cache_key), context[:pipeline])
...@@ -111,7 +101,7 @@ module Banzai ...@@ -111,7 +101,7 @@ module Banzai
items_collection.map { |item| item[:rendered] } items_collection.map { |item| item[:rendered] }
end end
def render_result(text, context = {}) def self.render_result(text, context = {})
text = Pipeline[:pre_process].to_html(text, context) if text text = Pipeline[:pre_process].to_html(text, context) if text
Pipeline[context[:pipeline]].call(text, context) Pipeline[context[:pipeline]].call(text, context)
...@@ -130,7 +120,7 @@ module Banzai ...@@ -130,7 +120,7 @@ module Banzai
# :user - User object # :user - User object
# #
# Returns an HTML-safe String # Returns an HTML-safe String
def post_process(html, context) def self.post_process(html, context)
context = Pipeline[context[:pipeline]].transform_context(context) context = Pipeline[context[:pipeline]].transform_context(context)
pipeline = Pipeline[:post_process] pipeline = Pipeline[:post_process]
...@@ -141,7 +131,7 @@ module Banzai ...@@ -141,7 +131,7 @@ module Banzai
end.html_safe end.html_safe
end end
def cacheless_render(text, context = {}) def self.cacheless_render(text, context = {})
Gitlab::Metrics.measure(:banzai_cacheless_render) do Gitlab::Metrics.measure(:banzai_cacheless_render) do
result = render_result(text, context) result = render_result(text, context)
...@@ -154,7 +144,7 @@ module Banzai ...@@ -154,7 +144,7 @@ module Banzai
end end
end end
def full_cache_key(cache_key, pipeline_name) def self.full_cache_key(cache_key, pipeline_name)
return unless cache_key return unless cache_key
["banzai", *cache_key, pipeline_name || :full] ["banzai", *cache_key, pipeline_name || :full]
end end
...@@ -162,13 +152,14 @@ module Banzai ...@@ -162,13 +152,14 @@ module Banzai
# To map Rails.cache.read_multi results we need to know the Rails.cache.expanded_key. # To map Rails.cache.read_multi results we need to know the Rails.cache.expanded_key.
# Other option will be to generate stringified keys on our side and don't delegate to Rails.cache.expanded_key # Other option will be to generate stringified keys on our side and don't delegate to Rails.cache.expanded_key
# method. # method.
def full_cache_multi_key(cache_key, pipeline_name) def self.full_cache_multi_key(cache_key, pipeline_name)
return unless cache_key return unless cache_key
Rails.cache.send(:expanded_key, full_cache_key(cache_key, pipeline_name)) Rails.cache.send(:expanded_key, full_cache_key(cache_key, pipeline_name))
end end
def update_object(object, html_field, html) # GitLab EE needs to disable updates on GET requests in Geo
object.update_column(html_field, html) unless Gitlab::Geo.secondary? def self.update_object?(object)
!Gitlab::Geo.secondary?
end end
end end
end end
...@@ -40,7 +40,13 @@ module Gitlab ...@@ -40,7 +40,13 @@ module Gitlab
def encode_utf8(message) def encode_utf8(message)
detect = CharlockHolmes::EncodingDetector.detect(message) detect = CharlockHolmes::EncodingDetector.detect(message)
if detect if detect
CharlockHolmes::Converter.convert(message, detect[:encoding], 'UTF-8') begin
CharlockHolmes::Converter.convert(message, detect[:encoding], 'UTF-8')
rescue ArgumentError => e
Rails.logger.warn("Ignoring error converting #{detect[:encoding]} into UTF8: #{e.message}")
''
end
else else
clean(message) clean(message)
end end
......
...@@ -138,6 +138,11 @@ module Gitlab ...@@ -138,6 +138,11 @@ module Gitlab
@series_prefix ||= Sidekiq.server? ? 'sidekiq_' : 'rails_' @series_prefix ||= Sidekiq.server? ? 'sidekiq_' : 'rails_'
end end
# Allow access from other metrics related middlewares
def self.current_transaction
Transaction.current
end
# When enabled this should be set before being used as the usual pattern # When enabled this should be set before being used as the usual pattern
# "@foo ||= bar" is _not_ thread-safe. # "@foo ||= bar" is _not_ thread-safe.
if enabled? if enabled?
...@@ -149,10 +154,5 @@ module Gitlab ...@@ -149,10 +154,5 @@ module Gitlab
new(udp: { host: host, port: port }) new(udp: { host: host, port: port })
end end
end end
# Allow access from other metrics related middlewares
def self.current_transaction
Transaction.current
end
end end
end end
...@@ -21,12 +21,7 @@ namespace :cache do ...@@ -21,12 +21,7 @@ namespace :cache do
end end
end end
desc "GitLab | Clear database cache (in the background)" task all: [:redis]
task db: :environment do
ClearDatabaseCacheWorker.perform_async
end
task all: [:db, :redis]
end end
task clear: 'cache:clear:redis' task clear: 'cache:clear:redis'
......
...@@ -22,4 +22,28 @@ describe Admin::GroupsController do ...@@ -22,4 +22,28 @@ describe Admin::GroupsController do
expect(response).to redirect_to(admin_groups_path) expect(response).to redirect_to(admin_groups_path)
end end
end end
describe 'PUT #members_update' do
let(:group_user) { create(:user) }
it 'adds user to members' do
put :members_update, id: group,
user_ids: group_user.id,
access_level: Gitlab::Access::GUEST
expect(response).to set_flash.to 'Users were successfully added.'
expect(response).to redirect_to(admin_group_path(group))
expect(group.users).to include group_user
end
it 'adds no user to members' do
put :members_update, id: group,
user_ids: '',
access_level: Gitlab::Access::GUEST
expect(response).to set_flash.to 'No users specified.'
expect(response).to redirect_to(admin_group_path(group))
expect(group.users).not_to include group_user
end
end
end end
...@@ -55,7 +55,7 @@ describe Projects::ProjectMembersController do ...@@ -55,7 +55,7 @@ describe Projects::ProjectMembersController do
user_ids: '', user_ids: '',
access_level: Gitlab::Access::GUEST access_level: Gitlab::Access::GUEST
expect(response).to set_flash.to 'No users or groups specified.' expect(response).to set_flash.to 'No users specified.'
expect(response).to redirect_to(namespace_project_settings_members_path(project.namespace, project)) expect(response).to redirect_to(namespace_project_settings_members_path(project.namespace, project))
end end
end end
...@@ -225,7 +225,7 @@ describe Projects::ProjectMembersController do ...@@ -225,7 +225,7 @@ describe Projects::ProjectMembersController do
id: member id: member
expect(response).to redirect_to( expect(response).to redirect_to(
namespace_project_project_members_path(project.namespace, project) namespace_project_settings_members_path(project.namespace, project)
) )
expect(project.members).to include member expect(project.members).to include member
end end
......
...@@ -13,9 +13,7 @@ feature 'Groups > Audit Events', js: true, feature: true do ...@@ -13,9 +13,7 @@ feature 'Groups > Audit Events', js: true, feature: true do
describe 'changing a user access level' do describe 'changing a user access level' do
it "appears in the group's audit events" do it "appears in the group's audit events" do
visit group_path(group) visit group_group_members_path(group)
click_link 'Members'
group_member = group.members.find_by(user_id: pete) group_member = group.members.find_by(user_id: pete)
......
require 'spec_helper' require 'spec_helper'
feature 'Groups members list', feature: true do feature 'Groups members list', feature: true do
include Select2Helper
let(:user1) { create(:user, name: 'John Doe') } let(:user1) { create(:user, name: 'John Doe') }
let(:user2) { create(:user, name: 'Mary Jane') } let(:user2) { create(:user, name: 'Mary Jane') }
let(:group) { create(:group) } let(:group) { create(:group) }
...@@ -30,7 +32,7 @@ feature 'Groups members list', feature: true do ...@@ -30,7 +32,7 @@ feature 'Groups members list', feature: true do
expect(second_row).to be_blank expect(second_row).to be_blank
end end
it 'updates user to owner level', :js do scenario 'update user to owner level', :js do
group.add_owner(user1) group.add_owner(user1)
group.add_developer(user2) group.add_developer(user2)
...@@ -38,13 +40,52 @@ feature 'Groups members list', feature: true do ...@@ -38,13 +40,52 @@ feature 'Groups members list', feature: true do
page.within(second_row) do page.within(second_row) do
click_button('Developer') click_button('Developer')
click_link('Owner') click_link('Owner')
expect(page).to have_button('Owner') expect(page).to have_button('Owner')
end end
end end
scenario 'add user to group', :js do
group.add_owner(user1)
visit group_group_members_path(group)
add_user(user2.id, 'Reporter')
page.within(second_row) do
expect(page).to have_content(user2.name)
expect(page).to have_button('Reporter')
end
end
scenario 'add yourself to group when already an owner', :js do
group.add_owner(user1)
visit group_group_members_path(group)
add_user(user1.id, 'Reporter')
page.within(first_row) do
expect(page).to have_content(user1.name)
expect(page).to have_content('Owner')
end
end
scenario 'invite user to group', :js do
group.add_owner(user1)
visit group_group_members_path(group)
add_user('test@example.com', 'Reporter')
page.within(second_row) do
expect(page).to have_content('test@example.com')
expect(page).to have_content('Invited')
expect(page).to have_button('Reporter')
end
end
def first_row def first_row
page.all('ul.content-list > li')[0] page.all('ul.content-list > li')[0]
end end
...@@ -52,4 +93,13 @@ feature 'Groups members list', feature: true do ...@@ -52,4 +93,13 @@ feature 'Groups members list', feature: true do
def second_row def second_row
page.all('ul.content-list > li')[1] page.all('ul.content-list > li')[1]
end end
def add_user(id, role)
page.within ".users-group-form" do
select2(id, from: "#user_ids", multiple: true)
select(role, from: "access_level")
end
click_button "Add to group"
end
end end
...@@ -425,7 +425,8 @@ describe 'Issues', feature: true do ...@@ -425,7 +425,8 @@ describe 'Issues', feature: true do
it 'will not send ajax request when no data is changed' do it 'will not send ajax request when no data is changed' do
page.within '.labels' do page.within '.labels' do
click_link 'Edit' click_link 'Edit'
first('.dropdown-menu-close').click
find('.dropdown-menu-close', match: :first).click
expect(page).not_to have_selector('.block-loading') expect(page).not_to have_selector('.block-loading')
end end
......
...@@ -20,13 +20,13 @@ feature 'Create New Merge Request', feature: true, js: true do ...@@ -20,13 +20,13 @@ feature 'Create New Merge Request', feature: true, js: true do
expect(page).to have_content('Target branch') expect(page).to have_content('Target branch')
first('.js-source-branch').click first('.js-source-branch').click
first('.dropdown-source-branch .dropdown-content a', text: 'v1.1.0').click find('.dropdown-source-branch .dropdown-content a', match: :first).click
expect(page).to have_content "b83d6e3" expect(page).to have_content "b83d6e3"
end end
it 'selects the target branch sha when a tag with the same name exists' do it 'selects the target branch sha when a tag with the same name exists' do
visit namespace_project_merge_requests_path(project.namespace, project) visit namespace_project_merge_requests_path(project.namespace, project)
click_link 'New merge request' click_link 'New merge request'
...@@ -46,8 +46,8 @@ feature 'Create New Merge Request', feature: true, js: true do ...@@ -46,8 +46,8 @@ feature 'Create New Merge Request', feature: true, js: true do
expect(page).to have_content('Source branch') expect(page).to have_content('Source branch')
expect(page).to have_content('Target branch') expect(page).to have_content('Target branch')
first('.js-source-branch').click find('.js-source-branch', match: :first).click
first('.dropdown-source-branch .dropdown-content a', text: 'orphaned-branch').click find('.dropdown-source-branch .dropdown-content a', text: 'orphaned-branch', match: :first).click
click_button "Compare branches" click_button "Compare branches"
click_link "Changes" click_link "Changes"
......
...@@ -107,14 +107,13 @@ feature 'Merge Request versions', js: true, feature: true do ...@@ -107,14 +107,13 @@ feature 'Merge Request versions', js: true, feature: true do
it 'should have 0 chages between versions' do it 'should have 0 chages between versions' do
page.within '.mr-version-compare-dropdown' do page.within '.mr-version-compare-dropdown' do
expect(page).to have_content 'version 1' expect(find('.dropdown-toggle')).to have_content 'version 1'
end end
page.within '.mr-version-dropdown' do page.within '.mr-version-dropdown' do
find('.btn-default').click find('.btn-default').click
find(:link, 'version 1').trigger('click') click_link 'version 1'
end end
expect(page).to have_content '0 changed files' expect(page).to have_content '0 changed files'
end end
end end
...@@ -129,12 +128,12 @@ feature 'Merge Request versions', js: true, feature: true do ...@@ -129,12 +128,12 @@ feature 'Merge Request versions', js: true, feature: true do
it 'should set the compared versions to be the same' do it 'should set the compared versions to be the same' do
page.within '.mr-version-compare-dropdown' do page.within '.mr-version-compare-dropdown' do
expect(page).to have_content 'version 2' expect(find('.dropdown-toggle')).to have_content 'version 2'
end end
page.within '.mr-version-dropdown' do page.within '.mr-version-dropdown' do
find('.btn-default').click find('.btn-default').click
find(:link, 'version 1').trigger('click') click_link 'version 1'
end end
page.within '.mr-version-compare-dropdown' do page.within '.mr-version-compare-dropdown' do
......
...@@ -163,12 +163,14 @@ feature 'issuable templates', feature: true, js: true do ...@@ -163,12 +163,14 @@ feature 'issuable templates', feature: true, js: true do
end end
def select_template(name) def select_template(name)
first('.js-issuable-selector').click find('.js-issuable-selector').click
first('.js-issuable-selector-wrap .dropdown-content a', text: name).click
find('.js-issuable-selector-wrap .dropdown-content a', text: name, match: :first).click
end end
def select_option(name) def select_option(name)
first('.js-issuable-selector').click find('.js-issuable-selector').click
first('.js-issuable-selector-wrap .dropdown-footer-list a', text: name).click
find('.js-issuable-selector-wrap .dropdown-footer-list a', text: name, match: :first).click
end end
end end
require 'spec_helper'
feature 'Project members list', feature: true do
include Select2Helper
let(:user1) { create(:user, name: 'John Doe') }
let(:user2) { create(:user, name: 'Mary Jane') }
let(:group) { create(:group) }
let(:project) { create(:project, namespace: group) }
background do
login_as(user1)
group.add_owner(user1)
end
scenario 'show members from project and group' do
project.add_developer(user2)
visit_members_page
expect(first_row.text).to include(user1.name)
expect(second_row.text).to include(user2.name)
end
scenario 'show user once if member of both group and project' do
project.add_developer(user1)
visit_members_page
expect(first_row.text).to include(user1.name)
expect(second_row).to be_blank
end
scenario 'update user acess level', :js do
project.add_developer(user2)
visit_members_page
page.within(second_row) do
click_button('Developer')
click_link('Reporter')
expect(page).to have_button('Reporter')
end
end
scenario 'add user to project', :js do
visit_members_page
add_user(user2.id, 'Reporter')
page.within(second_row) do
expect(page).to have_content(user2.name)
expect(page).to have_button('Reporter')
end
end
scenario 'invite user to project', :js do
visit_members_page
add_user('test@example.com', 'Reporter')
page.within(second_row) do
expect(page).to have_content('test@example.com')
expect(page).to have_content('Invited')
expect(page).to have_button('Reporter')
end
end
def first_row
page.all('ul.content-list > li')[0]
end
def second_row
page.all('ul.content-list > li')[1]
end
def add_user(id, role)
page.within ".users-project-form" do
select2(id, from: "#user_ids", multiple: true)
select(role, from: "access_level")
end
click_button "Add to project"
end
def visit_members_page
visit namespace_project_settings_members_path(project.namespace, project)
end
end
...@@ -70,10 +70,12 @@ describe SubmoduleHelper do ...@@ -70,10 +70,12 @@ describe SubmoduleHelper do
expect(submodule_links(submodule_item)).to eq(['https://github.com/gitlab-org/gitlab-ce', 'https://github.com/gitlab-org/gitlab-ce/tree/hash']) expect(submodule_links(submodule_item)).to eq(['https://github.com/gitlab-org/gitlab-ce', 'https://github.com/gitlab-org/gitlab-ce/tree/hash'])
end end
it 'returns original with non-standard url' do it 'handles urls with no .git on the end' do
stub_url('http://github.com/gitlab-org/gitlab-ce') stub_url('http://github.com/gitlab-org/gitlab-ce')
expect(submodule_links(submodule_item)).to eq([repo.submodule_url_for, nil]) expect(submodule_links(submodule_item)).to eq(['https://github.com/gitlab-org/gitlab-ce', 'https://github.com/gitlab-org/gitlab-ce/tree/hash'])
end
it 'returns original with non-standard url' do
stub_url('http://github.com/another/gitlab-org/gitlab-ce.git') stub_url('http://github.com/another/gitlab-org/gitlab-ce.git')
expect(submodule_links(submodule_item)).to eq([repo.submodule_url_for, nil]) expect(submodule_links(submodule_item)).to eq([repo.submodule_url_for, nil])
end end
...@@ -95,10 +97,12 @@ describe SubmoduleHelper do ...@@ -95,10 +97,12 @@ describe SubmoduleHelper do
expect(submodule_links(submodule_item)).to eq(['https://gitlab.com/gitlab-org/gitlab-ce', 'https://gitlab.com/gitlab-org/gitlab-ce/tree/hash']) expect(submodule_links(submodule_item)).to eq(['https://gitlab.com/gitlab-org/gitlab-ce', 'https://gitlab.com/gitlab-org/gitlab-ce/tree/hash'])
end end
it 'returns original with non-standard url' do it 'handles urls with no .git on the end' do
stub_url('http://gitlab.com/gitlab-org/gitlab-ce') stub_url('http://gitlab.com/gitlab-org/gitlab-ce')
expect(submodule_links(submodule_item)).to eq([repo.submodule_url_for, nil]) expect(submodule_links(submodule_item)).to eq(['https://gitlab.com/gitlab-org/gitlab-ce', 'https://gitlab.com/gitlab-org/gitlab-ce/tree/hash'])
end
it 'returns original with non-standard url' do
stub_url('http://gitlab.com/another/gitlab-org/gitlab-ce.git') stub_url('http://gitlab.com/another/gitlab-org/gitlab-ce.git')
expect(submodule_links(submodule_item)).to eq([repo.submodule_url_for, nil]) expect(submodule_links(submodule_item)).to eq([repo.submodule_url_for, nil])
end end
......
import Vue from 'vue'; import Vue from 'vue';
import monitoringComp from '~/environments/components/environment_monitoring'; import monitoringComp from '~/environments/components/environment_monitoring.vue';
describe('Monitoring Component', () => { describe('Monitoring Component', () => {
let MonitoringComponent; let MonitoringComponent;
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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