Commit cef19348 authored by Douglas Barbosa Alexandre's avatar Douglas Barbosa Alexandre

Merge branch '6861-group-level-project-templates' into 'master'

Add support for Group-level project templates

Closes #6861

See merge request gitlab-org/gitlab-ee!6878
parents 558552a0 40a207d8
......@@ -506,7 +506,6 @@
}
.project-template {
> .form-group {
margin-bottom: 0;
}
......
......@@ -63,11 +63,6 @@ scope(constraints: { username: Gitlab::PathRegex.root_namespace_route_regex }) d
get :exists
get :activity
get '/', to: redirect('%{username}'), as: nil
## EE-specific START
get :available_templates, format: :js
get :pipelines_quota
## EE-specific END
end
# Compatibility with old routing
......
......@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 20181203154104) do
ActiveRecord::Schema.define(version: 20181204135932) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
......@@ -1803,7 +1803,9 @@ ActiveRecord::Schema.define(version: 20181203154104) do
t.integer "file_template_project_id"
t.string "saml_discovery_token"
t.string "runners_token_encrypted"
t.integer "custom_project_templates_group_id"
t.index ["created_at"], name: "index_namespaces_on_created_at", using: :btree
t.index ["custom_project_templates_group_id", "type"], name: "index_namespaces_on_custom_project_templates_group_id_and_type", where: "(custom_project_templates_group_id IS NOT NULL)", using: :btree
t.index ["file_template_project_id"], name: "index_namespaces_on_file_template_project_id", using: :btree
t.index ["ldap_sync_last_successful_update_at"], name: "index_namespaces_on_ldap_sync_last_successful_update_at", using: :btree
t.index ["ldap_sync_last_update_at"], name: "index_namespaces_on_ldap_sync_last_update_at", using: :btree
......@@ -3258,6 +3260,7 @@ ActiveRecord::Schema.define(version: 20181203154104) do
add_foreign_key "milestones", "namespaces", column: "group_id", name: "fk_95650a40d4", on_delete: :cascade
add_foreign_key "milestones", "projects", name: "fk_9bd0a0c791", on_delete: :cascade
add_foreign_key "namespace_statistics", "namespaces", on_delete: :cascade
add_foreign_key "namespaces", "namespaces", column: "custom_project_templates_group_id", name: "fk_e7a0b20a6b", on_delete: :nullify
add_foreign_key "namespaces", "plans", name: "fk_fdd12e5b80", on_delete: :nullify
add_foreign_key "namespaces", "projects", column: "file_template_project_id", name: "fk_319256d87a", on_delete: :nullify
add_foreign_key "note_diff_files", "notes", column: "diff_note_id", on_delete: :cascade
......
......@@ -20,4 +20,4 @@ Projects of nested subgroups of a selected template source cannot be used.
Repository and database information that are copied over to each new project are
identical to the data exported with [GitLab's Project Import/Export](../project/settings/import_export.md).
If you would like to set project templates at an instance level, please see [Custom instance-level project templates](../admin_area/custom_project_templates.md).
\ No newline at end of file
If you would like to set project templates at an instance level, please see [Custom instance-level project templates](../admin_area/custom_project_templates.md).
......@@ -11,6 +11,8 @@ const bindEvents = () => {
const $changeTemplateBtn = $('.change-template');
const $projectTemplateButtons = $('.project-templates-buttons');
const $projectFieldsFormInput = $('.project-fields-form input#project_use_custom_template');
const $subgroupWithTemplatesIdInput = $('.js-project-group-with-project-templates-id');
const $namespaceSelect = $projectFieldsForm.find('.js-select-namespace');
if ($newProjectForm.length !== 1 || $useCustomTemplateBtn.length === 0) {
return;
......@@ -24,14 +26,52 @@ const bindEvents = () => {
$projectFieldsFormInput.val(false);
}
function hideNonRootParentPathOptions() {
const rootParent = `/${
$namespaceSelect
.find('option:selected')
.data('show-path')
.split('/')[1]
}`;
$namespaceSelect
.find('option')
.filter(function doesNotMatchParent() {
return !$(this)
.data('show-path')
.includes(rootParent);
})
.addClass('hidden');
}
function hideOptionlessOptgroups() {
$namespaceSelect
.find('optgroup')
.filter(function noVisibleOptions() {
return !$(this).find('option:not(.hidden)').length;
})
.addClass('hidden');
}
function chooseTemplate() {
const value = $(this).val();
const subgroupId = $(this).data('subgroup-id');
if (subgroupId) {
$subgroupWithTemplatesIdInput.val(subgroupId);
$namespaceSelect.val(subgroupId).trigger('change');
hideNonRootParentPathOptions();
hideOptionlessOptgroups();
}
$projectTemplateButtons.addClass('hidden');
$projectFieldsForm.addClass('selected');
$selectedIcon.empty();
const value = $(this).val();
$selectedTemplateText.text(value);
$(this)
.parents('.template-option')
.find('.avatar')
......@@ -49,6 +89,8 @@ const bindEvents = () => {
$activeTabProjectName.keyup(() =>
projectNew.onProjectNameChange($activeTabProjectName, $activeTabProjectPath),
);
$projectFieldsForm.find('.js-select-namespace:first').val(subgroupId);
}
$useCustomTemplateBtn.on('change', chooseTemplate);
......@@ -56,19 +98,34 @@ const bindEvents = () => {
$changeTemplateBtn.on('click', () => {
$projectTemplateButtons.removeClass('hidden');
$useCustomTemplateBtn.prop('checked', false);
$namespaceSelect
.val($namespaceSelect.find('option[data-options-parent="users"]').val())
.trigger('change');
$namespaceSelect.find('option, optgroup').removeClass('hidden');
disableCustomTemplate();
});
$(document).on('click', '.js-template-group-options', function toggleExpandedClass() {
$(this).toggleClass('expanded');
});
};
export default () => {
const $navElement = $('.nav-link[href="#custom-templates"]');
const $tabContent = $('.project-templates-buttons#custom-templates');
const $navElement = $('.js-custom-instance-project-templates-nav-link');
const $tabContent = $('.js-custom-instance-project-templates-tab-content');
const $groupNavElement = $('.js-custom-group-project-templates-nav-link');
const $groupTabContent = $('.js-custom-group-project-templates-tab-content');
$tabContent.on('ajax:success', bindEvents);
$groupTabContent.on('ajax:success', bindEvents);
$navElement.one('click', () => {
$.get($tabContent.data('initialTemplates'));
});
$groupNavElement.one('click', () => {
$.get($groupTabContent.data('initialTemplates'));
});
bindEvents();
};
......@@ -134,6 +134,50 @@
}
.project-template {
.template-header,
.template-option {
padding: $gl-padding $gl-padding-8;
border-top: 1px solid $border-color;
}
.template-header {
cursor: pointer;
.template-options-icon-container {
width: 24px;
height: 24px;
}
}
.template-group-options {
.options-expanded-icon {
display: none;
}
.template-option {
display: none;
background: $gray-light;
.avatar-container {
margin-left: 34px;
}
}
&.expanded {
.template-option {
display: flex;
}
.options-expanded-icon {
display: block;
}
.options-collapsed-icon {
display: none;
}
}
}
.template-input-group {
.selected-icon {
.avatar {
......
......@@ -17,6 +17,7 @@ module EE
].tap do |params_ee|
params_ee << :project_creation_level if current_group&.feature_available?(:project_creation_level)
params_ee << :file_template_project_id if current_group&.feature_available?(:custom_file_templates_for_namespace)
params_ee << :custom_project_templates_group_id if License.feature_available?(:custom_project_templates)
end
end
......
......@@ -26,6 +26,7 @@ module EE
use_custom_template
packages_enabled
merge_requests_author_approval
group_with_project_templates_id
]
if allow_mirror_params?
......
......@@ -2,14 +2,24 @@
module EE
module UsersController
def available_templates
def available_project_templates
load_custom_project_templates
end
def available_group_templates
load_group_project_templates
end
private
def load_custom_project_templates
@custom_project_templates ||= user.available_custom_project_templates(search: params[:search]).page(params[:page])
end
def load_group_project_templates
@groups_with_project_templates ||=
user.available_subgroups_with_custom_project_templates(params[:group_id])
.page(params[:page])
end
end
end
......@@ -135,6 +135,12 @@ module EE
end
end
def group_project_templates_count(group_id)
allowed_subgroups = current_user.available_subgroups_with_custom_project_templates(group_id)
::Project.in_namespace(allowed_subgroups).count
end
def share_project_description
share_with_group = @project.allowed_to_share_with_group?
share_with_members = !membership_locked?
......
......@@ -190,8 +190,10 @@ module EE
custom_project_templates_enabled? && super
end
def available_custom_project_templates
return [] unless group_id = custom_project_templates_group_id
def available_custom_project_templates(subgroup_id = nil)
group_id = subgroup_id || custom_project_templates_group_id
return ::Project.none unless group_id
::Project.where(namespace_id: group_id)
end
......
......@@ -25,6 +25,8 @@ module EE
# here since Group inherits from Namespace, the entity_type would be set to `Namespace`.
has_many :audit_events, -> { where(entity_type: ::Group.name) }, foreign_key: 'entity_id'
has_many :project_templates, through: :projects, foreign_key: 'custom_project_templates_group_id'
belongs_to :file_template_project, class_name: "Project"
# Use +checked_file_template_project+ instead, which implements important
......@@ -34,10 +36,17 @@ module EE
validates :repository_size_limit,
numericality: { only_integer: true, greater_than_or_equal_to: 0, allow_nil: true }
validate :custom_project_templates_group_allowed, if: :custom_project_templates_group_id_changed?
scope :where_group_links_with_provider, ->(provider) do
joins(:ldap_group_links).where(ldap_group_links: { provider: provider })
end
scope :with_project_templates, -> do
joins("INNER JOIN projects ON projects.namespace_id = namespaces.custom_project_templates_group_id")
.distinct
end
scope :with_custom_file_templates, -> do
preload(
file_template_project: :route,
......@@ -165,5 +174,14 @@ module EE
project
end
private
def custom_project_templates_group_allowed
return if custom_project_templates_group_id.blank?
return if descendants.exists?(id: custom_project_templates_group_id)
errors.add(:custom_project_templates_group_id, "has to be a descendant of the group")
end
end
end
......@@ -129,8 +129,8 @@ module EE
email_opted_in_source_id == EMAIL_OPT_IN_SOURCE_ID_GITLAB_COM ? 'GitLab.com' : ''
end
def available_custom_project_templates(search: nil)
templates = ::Gitlab::CurrentSettings.available_custom_project_templates
def available_custom_project_templates(search: nil, subgroup_id: nil)
templates = ::Gitlab::CurrentSettings.available_custom_project_templates(subgroup_id)
::ProjectsFinder.new(current_user: self,
project_ids_relation: templates,
......@@ -138,6 +138,17 @@ module EE
.execute
end
def available_subgroups_with_custom_project_templates(group_id = nil)
groups = group_id ? ::Group.find(group_id).self_and_ancestors : ::Group.all
GroupsFinder.new(self, min_access_level: ::Gitlab::Access::MAINTAINER)
.execute
.where(id: groups.with_project_templates.select(:custom_project_templates_group_id))
.includes(:projects)
.reorder(nil)
.distinct
end
def roadmap_layout
super || DEFAULT_ROADMAP_LAYOUT
end
......
......@@ -26,7 +26,8 @@ module EE
def template_project
strong_memoize(:template_project) do
current_user.available_custom_project_templates(search: template_name).first
current_user.available_custom_project_templates(search: template_name, subgroup_id: params[:group_with_project_templates_id])
.first
end
end
end
......
......@@ -11,6 +11,7 @@ module EE
mirror_user_id = current_user.id if mirror
mirror_trigger_builds = params.delete(:mirror_trigger_builds)
ci_cd_only = ::Gitlab::Utils.to_boolean(params.delete(:ci_cd_only))
group_with_project_templates_id = params.delete(:group_with_project_templates_id) if params[:template_name].blank?
project = super do |project|
# Repository size limit comes as MB from the view
......@@ -23,6 +24,7 @@ module EE
end
validate_classification_label(project, :external_authorization_classification_label)
validate_namespace_used_with_template(project, group_with_project_templates_id)
end
if project&.persisted?
......@@ -61,6 +63,21 @@ module EE
project.push_rule = push_rule
end
end
# When using a project template from a Group, the new project can only be created
# under the top level group or any subgroup
def validate_namespace_used_with_template(project, group_with_project_templates_id)
return unless project.group
subgroup_with_templates_id = group_with_project_templates_id || params[:group_with_project_templates_id]
return if subgroup_with_templates_id.blank?
templates_owner = ::Group.find(subgroup_with_templates_id)
unless templates_owner.self_and_hierarchy.exists?(id: project.namespace_id)
project.errors.add(:namespace, _("is out of the hierarchy of the Group owning the template"))
end
end
# rubocop: enable CodeReuse/ActiveRecord
def setup_ci_cd_project
......
- return unless ::Gitlab::CurrentSettings.custom_project_templates_enabled?
- expanded = Rails.env.test?
%section.settings.no-animate{ class: ('expanded' if expanded) }
.settings-header
%h4
= s_('GroupSettings|Custom project templates')
%button.btn.js-settings-toggle{ type: 'button' }
= expanded ? _('Collapse') : _('Expand')
%p
= s_('GroupSettings|Select a sub-group as the custom project template source for this group.')
= link_to s_('GroupSettings|Learn more about group-level project templates.'), help_page_path('user/group/custom_project_templates')
.settings-content
= form_for @group, html: { multipart: true, class: 'gl-show-field-errors' }, authenticity_token: true do |f|
%input{ type: 'hidden', name: 'update_section', value: 'js-custom-project-templates-settings' }
= form_errors(@group)
%fieldset
.form-group
= f.label :custom_project_templates_group_id, class: 'label-bold' do
= _('Custom project templates')
= groups_select_tag('group[custom_project_templates_group_id]', data: { parent_id: @group.id }, selected: @group.custom_project_templates_group_id, class: 'input-clamp allowClear', multiple: false)
= f.submit _('Save changes'), class: 'btn btn-success'
- project_template_count = current_user.available_custom_project_templates.count
- if ::Gitlab::CurrentSettings.custom_project_templates_enabled?
- project_template_count = current_user.available_custom_project_templates.count
- group_id = params[:namespace_id]
- if ::Gitlab::CurrentSettings.custom_project_templates_enabled? && project_template_count > 0
.project-templates-buttons.col-sm-12
%ul.nav-tabs.nav-links.nav.scrolling-tabs
%li.built-in-tab
%a.nav-link.active{ href: "#built-in", data: { toggle: 'tab'} }
= _('Built-in')
%span.badge.badge-pill= Gitlab::ProjectTemplate.all.count
%li.custom-templates-tab
%a.nav-link{ href: "#custom-templates", data: { toggle: 'tab'} }
= _('Custom')
%li.custom-instance-project-templates-tab
%a.nav-link.js-custom-instance-project-templates-nav-link{ href: "#custom-instance-project-templates", data: { toggle: 'tab'} }
= _('Instance')
%span.badge.badge-pill= project_template_count
%li.custom-group-project-templates-tab
%a.nav-link.js-custom-group-project-templates-nav-link{ href: "#custom-group-project-templates", data: { toggle: 'tab'} }
= _('Group')
%span.badge.badge-pill
= group_project_templates_count(group_id)
.tab-content
.project-templates-buttons.import-buttons.tab-pane.active#built-in
= render 'projects/project_templates/built_in_templates'
.project-templates-buttons.import-buttons.tab-pane#custom-templates{ data: {initial_templates: user_available_templates_path(current_user)} }
= icon("spin spinner 2x")
.project-templates-buttons.import-buttons.tab-pane.js-custom-instance-project-templates-tab-content#custom-instance-project-templates{ data: {initial_templates: user_available_project_templates_path(current_user)} }
.text-center.m-4
= icon("spin spinner 2x")
.project-templates-buttons.import-buttons.tab-pane.js-custom-group-project-templates-tab-content#custom-group-project-templates{ data: {initial_templates: user_available_group_templates_path(current_user, group_id: group_id)} }
.text-center.m-4
= icon("spin spinner 2x")
.project-fields-form
= render 'projects/project_templates/project_fields_form'
= f.hidden_field(:use_custom_template, value: false)
= f.hidden_field(:group_with_project_templates_id, value: nil, class: 'js-project-group-with-project-templates-id')
= render 'projects/new_project_fields', f: f, project_name_id: "template-project-name", hide_init_with_readme: true, track_label: "create_from_template"
- else
......
.custom-project-templates
- custom_project_templates.each do |template|
.template-option.d-flex.align-items-center
.avatar-container.s40
= project_icon(template, alt: template.name, class: 'btn-template-icon avatar s40 avatar-tile', lazy: false)
.description
%strong
= template.title
%br
.text-muted
= template.description
.controls.d-flex.align-items-baseline
%label.btn.btn-success.custom-template-button.choose-template.append-right-10.append-bottom-0{ for: template.name }
%input{ type: "radio", autocomplete: "off", name: "project[template_name]", id: template.name, value: template.name }
%span
= _('Use template')
%a.btn.btn-default{ href: project_path(template), rel: 'noopener noreferrer', target: '_blank' } Preview
- if custom_project_templates.present?
- custom_project_templates.each do |template|
.template-option.d-flex.align-items-center
.avatar-container.s40
= project_icon(template, alt: template.name, class: 'btn-template-icon avatar s40 avatar-tile', lazy: false)
.description
%strong
= template.title
%br
.text-muted
= template.description
.controls.d-flex.align-items-baseline
%a.btn.btn-default.append-right-10{ href: project_path(template), rel: 'noopener noreferrer', target: '_blank' }
= _('Preview')
%label.btn.btn-success.custom-template-button.choose-template.append-bottom-0{ for: template.name }
%input{ type: "radio", autocomplete: "off", name: "project[template_name]", id: template.name, value: template.name }
%span
= _('Use template')
= paginate custom_project_templates, params: {controller: 'users', action: 'available_templates', username: current_user.username}, theme: 'gitlab', remote: true
= paginate custom_project_templates, params: {controller: 'users', action: 'available_project_templates', username: current_user.username}, theme: 'gitlab', remote: true
- else
.bs-callout.bs-callout-warning
%p
= _("There are no custom project templates set up for this GitLab instance. They are enabled from GitLab's Admin Area. Contact your GitLab instance administrator to setup custom project templates.")
%strong
= link_to _("Learn more about custom project templates"), help_page_path("user/admin_area/custom_project_templates")
.custom-project-templates
- if groups_with_project_templates.present?
- groups_with_project_templates.each do |group|
.template-group-options.js-template-group-options{ class: ('expanded border-top-0' if groups_with_project_templates.first == group) }
.template-header.d-flex.align-items-center
.template-subgroup.d-flex.flex-fill.align-items-center
.template-options-icon-container.d-flex.justify-content-center.align-items-center.append-right-10
= sprite_icon('angle-down', css_class: 's16 options-expanded-icon')
= sprite_icon('angle-right', css_class: 's16 options-collapsed-icon')
.avatar-container.s40
= group_icon(group, alt: group.name, class: 'btn-template-icon avatar s40 avatar-tile', lazy: false)
.template-subgroup-name.prepend-left-5
%strong= group.name
- if group.description.present?
.text-muted
= group.description
.template-subgroup-project-count
%span.badge.badge-pill
%strong
= group.projects.count
- group.projects.each do |project|
.template-option.align-items-center
.avatar-container.s40
= project_icon(project, alt: project.name, class: 'btn-template-icon avatar s40 avatar-tile', lazy: false)
.description.prepend-left-5
%strong
= project.title
%br
.text-muted
= project.description
.controls.d-flex.align-items-baseline
%a.btn.btn-default.append-right-10{ href: project_path(project), rel: 'noopener noreferrer', target: '_blank' }
= _('Preview')
%label.btn.btn-success.custom-template-button.choose-template.append-bottom-0{ for: project.name }
%input{ type: "radio", autocomplete: "off", name: "project[template_name]", id: project.name, value: project.name, data: { subgroup_id: project.namespace_id } }
%span
= _('Use template')
= paginate groups_with_project_templates, params: {controller: 'users', action: 'available_templates', username: current_user.username}, theme: 'gitlab', remote: true
- else
.bs-callout.bs-callout-warning
%p
= _("Custom project templates have not been set up for groups that you are a member of. They are enabled from a group’s settings page. Contact your group’s Owner or Maintainer to setup custom project templates.")
%strong
= link_to _("Learn more about group-level project templates"), help_page_path("user/group/custom_project_templates")
:plain
var target = $(".project-templates-buttons#custom-group-project-templates");
target.empty();
target.html("#{escape_javascript(render 'custom_project_templates_from_groups', groups_with_project_templates: @groups_with_project_templates)}");
target.trigger("ajax:success");
:plain
var target = $(".project-templates-buttons#custom-templates");
var target = $(".project-templates-buttons#custom-instance-project-templates");
target.empty();
target.html("#{escape_javascript(render 'custom_project_templates', custom_project_templates: @custom_project_templates)}");
target.trigger("ajax:success");
---
title: Add support for Group-level project templates
merge_request: 6878
author:
type: added
# frozen_string_literal: true
scope(constraints: { username: Gitlab::PathRegex.root_namespace_route_regex }) do
scope(path: 'users/:username',
as: :user,
controller: :users) do
get :available_project_templates, format: :js
get :available_group_templates, format: :js
get :pipelines_quota
end
end
# frozen_string_literal: true
class AddCustomProjectTemplatesGroupIdToNamespaces < ActiveRecord::Migration[5.0]
DOWNTIME = false
def change
add_column :namespaces, :custom_project_templates_group_id, :integer
end
end
# frozen_string_literal: true
class AddIndexAndForeignKeyForCustomProjectTemplatesGroupIdOnNamespaces < ActiveRecord::Migration[5.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
add_concurrent_index(:namespaces, [:custom_project_templates_group_id, :type], where: "custom_project_templates_group_id IS NOT NULL")
add_concurrent_foreign_key(:namespaces, :namespaces, column: :custom_project_templates_group_id, on_delete: :nullify)
end
def down
# We need to remove the foreign key first, otherwise Mysql will fail with:
# Mysql2::Error: Cannot drop index 'index_namespaces_on_custom_project_templates_group_id': needed in a foreign key constraint
remove_foreign_key(:namespaces, column: :custom_project_templates_group_id)
remove_concurrent_index(:namespaces, [:custom_project_templates_group_id, :type])
end
end
......@@ -167,4 +167,83 @@ describe 'Edit group settings' do
end
end
end
context 'when custom_project_templates feature', :postgresql do
let!(:subgroup) { create(:group, :public, parent: group) }
let!(:subgroup_1) { create(:group, :public, parent: subgroup) }
shared_examples 'shows custom project templates settings' do
it 'shows the custom project templates selection menu' do
expect(page).to have_content('Custom project templates')
end
context 'group selection menu', :js do
before do
slow_requests do
find('#s2id_group_custom_project_templates_group_id').click
wait_for_all_requests
end
end
it 'shows only the subgroups' do
# the default value of 0.2 from the slow_requests helper isn't
# enough when this spec is exec along with other feature specs.
sleep 0.5
page.within('.select2-drop .select2-results') do
results = find_all('.select2-result')
expect(results.count).to eq(1)
expect(results.last.text).to eq "#{nested_group.full_name} #{nested_group.full_path}"
end
end
end
end
shared_examples 'does not show custom project templates settings' do
it 'does not show the custom project templates selection menu' do
expect(page).not_to have_content('Custom project templates')
end
end
context 'is enabled' do
before do
stub_licensed_features(custom_project_templates: true)
visit edit_group_path(selected_group)
end
context 'when the group is a top parent group' do
let(:selected_group) { group }
let(:nested_group) { subgroup }
it_behaves_like 'shows custom project templates settings'
end
context 'when the group is a subgroup' do
let(:selected_group) { subgroup }
let(:nested_group) { subgroup_1 }
it_behaves_like 'shows custom project templates settings'
end
end
context 'is disabled' do
before do
stub_licensed_features(custom_project_templates: false)
visit edit_group_path(selected_group)
end
context 'when the group is the top parent group' do
let(:selected_group) { group }
it_behaves_like 'does not show custom project templates settings'
end
context 'when the group is a subgroup' do
let(:selected_group) { subgroup }
it_behaves_like 'does not show custom project templates settings'
end
end
end
end
......@@ -3,7 +3,7 @@
require 'spec_helper'
describe 'Project' do
describe 'Custom projects templates' do
describe 'Custom instance-level projects templates' do
let(:user) { create(:user) }
let(:group) { create(:group) }
let!(:projects) { create_list(:project, 3, :public, namespace: group) }
......@@ -28,13 +28,13 @@ describe 'Project' do
end
it 'shows custom projects templates tab' do
page.within '.project-template .custom-templates-tab' do
expect(page).to have_content 'Custom'
page.within '.project-template .custom-instance-project-templates-tab' do
expect(page).to have_content 'Instance'
end
end
it 'displays the number of projects templates available to the user' do
page.within '.project-template .custom-templates-tab span.badge' do
page.within '.project-template .custom-instance-project-templates-tab span.badge' do
expect(page).to have_content '3'
end
end
......@@ -43,7 +43,7 @@ describe 'Project' do
new_name = 'example_custom_project_template'
find('#create-from-template-tab').click
find('.project-template .custom-templates-tab').click
find('.project-template .custom-instance-project-templates-tab').click
find("label[for='#{projects.first.name}']").click
page.within '.project-fields-form' do
......@@ -63,7 +63,7 @@ describe 'Project' do
last_project = "label[for='#{projects.last.name}']"
find('#create-from-template-tab').click
find('.project-template .custom-templates-tab').click
find('.project-template .custom-instance-project-templates-tab').click
expect(page).to have_css('.custom-project-templates .gl-pagination')
expect(page).not_to have_css(last_project)
......
......@@ -203,4 +203,204 @@ describe 'New project' do
end
end
end
context 'Group-level project templates', :js, :postgresql do
def visit_create_from_group_template_tab
visit url
click_link 'Create from template'
page.within('#create-from-template-pane') do
click_link 'Group'
wait_for_all_requests
end
end
let(:url) { new_project_path }
context 'when licensed' do
before do
stub_licensed_features(custom_project_templates: true)
end
it 'shows Group tab in Templates section' do
visit url
click_link 'Create from template'
expect(page).to have_css('.custom-group-project-templates-tab')
end
shared_examples 'group templates displayed' do
before do
visit_create_from_group_template_tab
end
it 'the tab badge displays the number of templates available' do
page.within('.custom-group-project-templates-tab') do
expect(page).to have_selector('span.badge', text: template_number)
end
end
it 'the tab shows the list of templates available' do
page.within('#custom-group-project-templates') do
# Show templates in case they're collapsed
page.all(:xpath, "//div[@class='js-template-group-options template-group-options']").each(&:click)
expect(page).to have_selector('.template-option', count: template_number)
end
end
end
shared_examples 'template selected' do
before do
visit_create_from_group_template_tab
page.within('.custom-project-templates') do
page.find(".template-option input[value='#{subgroup1_project1.name}']").first(:xpath, './/..').click
wait_for_all_requests
end
end
context 'when template is selected' do
context 'namespace selector' do
it "only shows the template's group hierarchy options" do
page.within('#create-from-template-pane') do
elements = page.find_all("#project_namespace_id option:not(.hidden)", visible: false).map { |e| e['data-name'] }
expect(elements).to contain_exactly(group1.name, subgroup1.name, subsubgroup1.name)
end
end
it 'does not show the user namespace options' do
page.within('#create-from-template-pane') do
expect(page.find_all("#project_namespace_id optgroup.hidden[label='Users']", visible: false)).not_to be_empty
end
end
end
end
context 'when user changes template' do
let(:url) { new_project_path }
before do
page.within('#create-from-template-pane') do
click_button 'Change template'
page.find(:xpath, "//input[@type='radio' and @value='#{subgroup1_project1.name}']/..").click
wait_for_all_requests
end
end
it 'list the appropriate groups' do
page.within('#create-from-template-pane') do
elements = page.find_all("#project_namespace_id option:not(.hidden)", visible: false).map { |e| e['data-name'] }
expect(elements).to contain_exactly(group1.name, subgroup1.name, subsubgroup1.name)
end
end
end
end
context 'when custom project group template is set' do
let(:group1) { create(:group) }
let(:group2) { create(:group) }
let(:group3) { create(:group) }
let(:group4) { create(:group) }
let(:subgroup1) { create(:group, parent: group1) }
let(:subgroup2) { create(:group, parent: group2) }
let(:subgroup4) { create(:group, parent: group4) }
let(:subsubgroup1) { create(:group, parent: subgroup1) }
let(:subsubgroup4) { create(:group, parent: subgroup4) }
let!(:subgroup1_project1) { create(:project, namespace: subgroup1) }
let!(:subgroup1_project2) { create(:project, namespace: subgroup1) }
let!(:subgroup2_project) { create(:project, namespace: subgroup2) }
let!(:subsubgroup1_project) { create(:project, namespace: subsubgroup1) }
let!(:subsubgroup4_project1) { create(:project, namespace: subsubgroup4) }
let!(:subsubgroup4_project2) { create(:project, namespace: subsubgroup4) }
before do
group1.add_owner(user)
group2.add_owner(user)
group4.add_owner(user)
group1.update(custom_project_templates_group_id: subgroup1.id)
group2.update(custom_project_templates_group_id: subgroup2.id)
subgroup4.update(custom_project_templates_group_id: subsubgroup4.id)
end
context 'when top level context' do
it_behaves_like 'group templates displayed' do
let(:template_number) { 5 }
end
it_behaves_like 'template selected'
end
context 'when namespace context' do
let(:url) { new_project_path(namespace_id: group1.id) }
it_behaves_like 'group templates displayed' do
let(:template_number) { 2 }
end
it_behaves_like 'template selected'
end
context 'when creating project from subgroup when template set on top-level group' do
let(:url) { new_project_path(namespace_id: subgroup1.id) }
it_behaves_like 'group templates displayed' do
let(:template_number) { 2 }
end
it_behaves_like 'template selected'
end
context 'when creating project from top-level group when template set on a sub-subgroup' do
let(:url) { new_project_path(namespace_id: group4.id) }
it_behaves_like 'group templates displayed' do
let(:template_number) { 0 }
end
end
context 'when using a Group without a custom project template' do
let(:url) { new_project_path(namespace_id: group3.id) }
before do
visit_create_from_group_template_tab
end
it 'shows a total of 0 templates' do
page.within('.custom-group-project-templates-tab') do
expect(page).to have_selector('span.badge', text: 0)
end
end
it 'does not list any templates' do
page.within('#custom-group-project-templates') do
expect(page).to have_selector('.template-option', count: 0)
end
end
end
end
context 'when group template is not set' do
it_behaves_like 'group templates displayed' do
let(:template_number) { 0 }
end
end
end
context 'when unlicensed' do
before do
stub_licensed_features(custom_project_templates: false)
end
it 'does not show Group tab in Templates section' do
visit url
click_link 'Create from template'
expect(page).not_to have_css('.custom-group-project-templates-tab')
end
end
end
end
# frozen_string_literal: true
# rubocop:disable RSpec/FactoriesInMigrationSpecs
require 'spec_helper'
require Rails.root.join('db', 'post_migrate', '20180723130817_delete_inconsistent_internal_id_records.rb')
describe DeleteInconsistentInternalIdRecords, :migration do
context 'for milestones (by group)' do
# milestones (by group) is a little different than most of the other models
let(:groups) { table(:namespaces) }
let(:group1) { groups.create(name: 'Group 1', type: 'Group', path: 'group_1') }
let(:group2) { groups.create(name: 'Group 2', type: 'Group', path: 'group_2') }
let(:group3) { groups.create(name: 'Group 2', type: 'Group', path: 'group_3') }
let(:internal_id_query) { ->(group) { InternalId.where(usage: InternalId.usages['milestones'], namespace: group) } }
before do
3.times { create(:milestone, group_id: group1.id) }
3.times { create(:milestone, group_id: group2.id) }
3.times { create(:milestone, group_id: group3.id) }
internal_id_query.call(group1).first.tap do |iid|
iid.last_value = iid.last_value - 2
# This is an inconsistent record
iid.save!
end
internal_id_query.call(group3).first.tap do |iid|
iid.last_value = iid.last_value + 2
# This is a consistent record
iid.save!
end
end
it "deletes inconsistent issues" do
expect { migrate! }.to change { internal_id_query.call(group1).size }.from(1).to(0)
end
it "retains consistent issues" do
expect { migrate! }.not_to change { internal_id_query.call(group2).size }
end
it "retains consistent records, especially those with a greater last_value" do
expect { migrate! }.not_to change { internal_id_query.call(group3).size }
end
end
end
......@@ -230,6 +230,83 @@ describe EE::User do
end
end
describe '#available_subgroups_with_custom_project_templates', :postgresql do
let(:user) { create(:user) }
context 'without Groups with custom project templates' do
before do
group = create(:group)
group.add_maintainer(user)
end
it 'returns an empty collection' do
expect(user.available_subgroups_with_custom_project_templates).to be_empty
end
end
context 'with Groups with custom project templates' do
let!(:group_1) { create(:group, name: 'group-1') }
let!(:group_2) { create(:group, name: 'group-2') }
let!(:group_3) { create(:group, name: 'group-3') }
let!(:subgroup_1) { create(:group, parent: group_1, name: 'subgroup-1') }
let!(:subgroup_2) { create(:group, parent: group_2, name: 'subgroup-2') }
let!(:subgroup_3) { create(:group, parent: group_3, name: 'subgroup-3') }
before do
group_1.update!(custom_project_templates_group_id: subgroup_1.id)
group_2.update!(custom_project_templates_group_id: subgroup_2.id)
group_3.update!(custom_project_templates_group_id: subgroup_3.id)
create(:project, namespace: subgroup_1)
create(:project, namespace: subgroup_2)
end
context 'when the access level of the user is below the required one' do
before do
group_1.add_developer(user)
end
it 'returns an empty collection' do
expect(user.available_subgroups_with_custom_project_templates).to be_empty
end
end
context 'when the access level of the user is the correct' do
before do
group_1.add_maintainer(user)
group_2.add_maintainer(user)
group_3.add_maintainer(user)
end
context 'when a Group ID is passed' do
it 'returns a single Group' do
groups = user.available_subgroups_with_custom_project_templates(group_1.id)
expect(groups.size).to eq(1)
expect(groups.first.name).to eq('subgroup-1')
end
end
context 'when a Group ID is not passed' do
it 'returns all available Groups' do
groups = user.available_subgroups_with_custom_project_templates
expect(groups.size).to eq(2)
expect(groups.map(&:name)).to include('subgroup-1', 'subgroup-2')
end
it 'excludes Groups with the configured setting but without projects' do
groups = user.available_subgroups_with_custom_project_templates
expect(groups.map(&:name)).not_to include('subgroup-3')
end
end
end
end
end
describe '#roadmap_layout' do
context 'not set' do
subject { build(:user, roadmap_layout: nil) }
......
......@@ -36,6 +36,42 @@ describe Group do
end
end
describe 'validations' do
context 'validates if custom_project_templates_group_id is allowed' do
let(:subgroup_1) { create(:group, parent: group) }
it 'rejects change if the assigned group is not a descendant' do
group.custom_project_templates_group_id = create(:group).id
expect(group).not_to be_valid
expect(group.errors.messages[:custom_project_templates_group_id]).to eq ['has to be a descendant of the group']
end
it 'allows value if the current group is a top parent and the value is from a descendant' do
subgroup = create(:group, parent: group)
group.custom_project_templates_group_id = subgroup.id
expect(group).to be_valid
end
it 'allows value if the current group is a subgroup and the value is from a descendant' do
subgroup_1_1 = create(:group, parent: subgroup_1)
subgroup_1.custom_project_templates_group_id = subgroup_1_1.id
expect(group).to be_valid
end
it 'allows value when it is blank' do
subgroup = create(:group, parent: group)
group.update!(custom_project_templates_group_id: subgroup.id)
group.custom_project_templates_group_id = ""
expect(group).to be_valid
end
end
end
describe 'states' do
it { is_expected.to be_ldap_sync_ready }
......
......@@ -6,13 +6,22 @@ describe Projects::CreateFromTemplateService do
let(:user) { create(:user) }
let(:project_name) { project.name }
let(:use_custom_template) { true }
let(:subgroup_1) { create(:group, parent: group) }
let(:subgroup_1_1) { create(:group, parent: subgroup_1) }
let(:project_template) { create(:project, :public, namespace: subgroup_1) }
let(:namespace_id) { nil }
let(:group_with_project_templates_id) { nil }
let(:project_params) do
{
path: user.to_param,
template_name: project_name,
description: 'project description',
visibility_level: Gitlab::VisibilityLevel::PUBLIC,
use_custom_template: use_custom_template
use_custom_template: use_custom_template,
namespace_id: namespace_id,
group_with_project_templates_id: group_with_project_templates_id
}
end
......@@ -81,5 +90,54 @@ describe Projects::CreateFromTemplateService do
end
end
end
describe 'creating project from a Group project template', :postgresql do
let(:project_name) { project_template.name }
let(:group_with_project_templates_id) { subgroup_1.id }
let(:group2) { create(:group) }
before do
group.add_maintainer(user)
group2.add_maintainer(user)
end
context "when the namespace is out of the hierarchy of the Group's owning the template" do
let(:namespace_id) { group2.id }
it "isn't persisted" do
project = subject.execute
expect(project).not_to be_saved
expect(project.repository.empty?).to eq(true)
end
end
shared_examples 'a persisted project' do
it "is persisted" do
project = subject.execute
expect(project).to be_saved
expect(project.import_scheduled?).to be(true)
end
end
context 'when project is created under a top level group' do
let(:namespace_id) { group.id }
it_behaves_like 'a persisted project'
end
context 'when project is created under a subgroup' do
let(:namespace_id) { subgroup_1.id }
it_behaves_like 'a persisted project'
end
context 'when project is created under a nested subgroup' do
let(:namespace_id) { subgroup_1_1.id }
it_behaves_like 'a persisted project'
end
end
end
end
......@@ -2597,9 +2597,6 @@ msgstr ""
msgid "CurrentUser|Settings"
msgstr ""
msgid "Custom"
msgstr ""
msgid "Custom CI config path"
msgstr ""
......@@ -2615,6 +2612,9 @@ msgstr ""
msgid "Custom project templates"
msgstr ""
msgid "Custom project templates have not been set up for groups that you are a member of. They are enabled from a group’s settings page. Contact your group’s Owner or Maintainer to setup custom project templates."
msgstr ""
msgid "Customize colors"
msgstr ""
......@@ -4261,15 +4261,24 @@ msgstr ""
msgid "GroupSettings|Badges"
msgstr ""
msgid "GroupSettings|Custom project templates"
msgstr ""
msgid "GroupSettings|Customize your group badges."
msgstr ""
msgid "GroupSettings|Learn more about badges."
msgstr ""
msgid "GroupSettings|Learn more about group-level project templates."
msgstr ""
msgid "GroupSettings|Prevent sharing a project within %{group} with other groups"
msgstr ""
msgid "GroupSettings|Select a sub-group as the custom project template source for this group."
msgstr ""
msgid "GroupSettings|This setting is applied on %{ancestor_group} and has been overridden on this subgroup."
msgstr ""
......@@ -4921,6 +4930,12 @@ msgstr ""
msgid "Learn more about Kubernetes"
msgstr ""
msgid "Learn more about custom project templates"
msgstr ""
msgid "Learn more about group-level project templates"
msgstr ""
msgid "Learn more about protected branches"
msgstr ""
......@@ -8358,6 +8373,9 @@ msgstr ""
msgid "There are no archived projects yet"
msgstr ""
msgid "There are no custom project templates set up for this GitLab instance. They are enabled from GitLab's Admin Area. Contact your GitLab instance administrator to setup custom project templates."
msgstr ""
msgid "There are no issues to show"
msgstr ""
......@@ -10046,6 +10064,9 @@ msgstr ""
msgid "is not a valid X509 certificate."
msgstr ""
msgid "is out of the hierarchy of the Group owning the template"
msgstr ""
msgid "issue boards"
msgstr ""
......
......@@ -94,50 +94,11 @@ describe DeleteInconsistentInternalIdRecords, :migration do
end
context 'for milestones (by group)' do
# milestones (by group) is a little different than most of the other models
# epics (by group) is a little different than most of the other models
let(:groups) { table(:namespaces) }
let(:group1) { groups.create(name: 'Group 1', type: 'Group', path: 'group_1') }
let(:group2) { groups.create(name: 'Group 2', type: 'Group', path: 'group_2') }
let(:group3) { groups.create(name: 'Group 2', type: 'Group', path: 'group_3') }
let(:internal_id_query) { ->(group) { InternalId.where(usage: InternalId.usages['milestones'], namespace: group) } }
before do
3.times { create(:milestone, group_id: group1.id) }
3.times { create(:milestone, group_id: group2.id) }
3.times { create(:milestone, group_id: group3.id) }
internal_id_query.call(group1).first.tap do |iid|
iid.last_value = iid.last_value - 2
# This is an inconsistent record
iid.save!
end
internal_id_query.call(group3).first.tap do |iid|
iid.last_value = iid.last_value + 2
# This is a consistent record
iid.save!
end
end
it "deletes inconsistent issues" do
expect { migrate! }.to change { internal_id_query.call(group1).size }.from(1).to(0)
end
it "retains consistent issues" do
expect { migrate! }.not_to change { internal_id_query.call(group2).size }
end
it "retains consistent records, especially those with a greater last_value" do
expect { migrate! }.not_to change { internal_id_query.call(group3).size }
end
end
context 'for milestones (by group)' do
# epics (by group) is a little different than most of the other models
let!(:group1) { create(:group) }
let!(:group2) { create(:group) }
let!(:group3) { create(:group) }
let!(:user) { create(:user) }
let(:internal_id_query) { ->(group) { InternalId.where(usage: InternalId.usages['epics'], namespace: group) } }
......
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