Commit 03b14b48 authored by Mike Greiling's avatar Mike Greiling Committed by Phil Hughes

Resolve "Make project and features visibility settings less confusing"

parent 21b16c5a
......@@ -577,6 +577,9 @@ import initChangesDropdown from './init_changes_dropdown';
case 'edit':
shortcut_handler = new ShortcutsNavigation();
new ProjectNew();
import(/* webpackChunkName: 'project_permissions' */ './projects/permissions')
.then(permissions => permissions.default())
.catch(() => {});
break;
case 'new':
new ProjectNew();
......
<script>
import projectFeatureToggle from './project_feature_toggle.vue';
export default {
props: {
name: {
type: String,
required: false,
default: '',
},
options: {
type: Array,
required: false,
default: () => [],
},
value: {
type: Number,
required: false,
default: 0,
},
disabledInput: {
type: Boolean,
required: false,
default: false,
},
},
components: {
projectFeatureToggle,
},
computed: {
featureEnabled() {
return this.value !== 0;
},
displayOptions() {
if (this.featureEnabled) {
return this.options;
}
return [
[0, 'Enable feature to choose access level'],
];
},
displaySelectInput() {
return this.disabledInput || !this.featureEnabled || this.displayOptions.length < 2;
},
},
model: {
prop: 'value',
event: 'change',
},
methods: {
toggleFeature(featureEnabled) {
if (featureEnabled === false || this.options.length < 1) {
this.$emit('change', 0);
} else {
const [firstOptionValue] = this.options[this.options.length - 1];
this.$emit('change', firstOptionValue);
}
},
selectOption(e) {
this.$emit('change', Number(e.target.value));
},
},
};
</script>
<template>
<div class="project-feature-controls" :data-for="name">
<input
v-if="name"
type="hidden"
:name="name"
:value="value"
/>
<project-feature-toggle
:value="featureEnabled"
@change="toggleFeature"
:disabledInput="disabledInput"
/>
<div class="select-wrapper">
<select
class="form-control project-repo-select select-control"
@change="selectOption"
:disabled="displaySelectInput"
>
<option
v-for="[optionValue, optionName] in displayOptions"
:key="optionValue"
:value="optionValue"
:selected="optionValue === value"
>
{{optionName}}
</option>
</select>
<i aria-hidden="true" class="fa fa-chevron-down"></i>
</div>
</div>
</template>
<script>
export default {
props: {
name: {
type: String,
required: false,
default: '',
},
value: {
type: Boolean,
required: true,
},
disabledInput: {
type: Boolean,
required: false,
default: false,
},
},
model: {
prop: 'value',
event: 'change',
},
methods: {
toggleFeature() {
if (!this.disabledInput) this.$emit('change', !this.value);
},
},
};
</script>
<template>
<label class="toggle-wrapper">
<input
v-if="name"
type="hidden"
:name="name"
:value="value"
/>
<button
type="button"
aria-label="Toggle"
class="project-feature-toggle"
data-enabled-text="Enabled"
data-disabled-text="Disabled"
:class="{ checked: value, disabled: disabledInput }"
@click="toggleFeature"
/>
</label>
</template>
<script>
export default {
props: {
label: {
type: String,
required: false,
default: null,
},
helpPath: {
type: String,
required: false,
default: null,
},
helpText: {
type: String,
required: false,
default: null,
},
},
};
</script>
<template>
<div class="project-feature-row">
<label v-if="label" class="label-light">
{{label}}
<a v-if="helpPath" :href="helpPath" target="_blank">
<i aria-hidden="true" data-hidden="true" class="fa fa-question-circle"></i>
</a>
</label>
<span v-if="helpText" class="help-block">
{{helpText}}
</span>
<slot />
</div>
</template>
export const visibilityOptions = {
PRIVATE: 0,
INTERNAL: 10,
PUBLIC: 20,
};
export const visibilityLevelDescriptions = {
[visibilityOptions.PRIVATE]: 'The project is accessible only by members of the project. Access must be granted explicitly to each user.',
[visibilityOptions.INTERNAL]: 'The project can be accessed by any user who is logged in.',
[visibilityOptions.PUBLIC]: 'The project can be accessed by anyone, regardless of authentication.',
};
const selectorCache = [];
// workaround since we don't have a polyfill for classList.toggle 2nd parameter
export function toggleHiddenClass(element, hidden) {
if (hidden) {
element.classList.add('hidden');
} else {
element.classList.remove('hidden');
}
}
// hide external feature-specific settings when a given feature is disabled
export function toggleHiddenClassBySelector(selector, hidden) {
if (!selectorCache[selector]) {
selectorCache[selector] = document.querySelectorAll(selector);
}
selectorCache[selector].forEach(elm => toggleHiddenClass(elm, hidden));
}
import Vue from 'vue';
import settingsPanel from './components/settings_panel.vue';
export default function initProjectPermissionsSettings() {
const mountPoint = document.querySelector('.js-project-permissions-form');
const componentPropsEl = document.querySelector('.js-project-permissions-form-data');
const componentProps = JSON.parse(componentPropsEl.innerHTML);
return new Vue({
el: mountPoint,
render: createElement => createElement(settingsPanel, { props: { ...componentProps } }),
});
}
......@@ -579,6 +579,11 @@ $project-breadcrumb-color: #999;
$project-private-forks-notice-odd: $green-600;
$project-network-controls-color: #888;
$feature-toggle-color: #fff;
$feature-toggle-text-color: #fff;
$feature-toggle-color-disabled: #999;
$feature-toggle-color-enabled: #4a8bee;
/*
* Runners
*/
......
......@@ -10,41 +10,6 @@
.edit-project,
.import-project {
.sharing-and-permissions {
.header {
padding-top: $gl-vert-padding;
}
.label-light {
margin-bottom: 0;
}
.help-block {
margin-top: 0;
}
.form-group {
margin-bottom: 5px;
}
> .form-group {
padding-left: 0;
}
select option[disabled] {
display: none;
}
}
select {
transition: background 2s ease-out;
&.highlight-changes {
background: $highlight-changes-color;
transition: none;
}
}
.help-block {
margin-bottom: 10px;
}
......@@ -90,6 +55,162 @@
}
}
.toggle-wrapper {
margin-top: 5px;
}
.project-feature-row > .toggle-wrapper {
margin: 10px 0;
}
.project-visibility-setting,
.project-feature-settings {
border: 1px solid $border-color;
padding: 10px 32px;
@media (max-width: $screen-xs-min) {
padding: 10px 20px;
}
}
.project-visibility-setting .request-access {
line-height: 2;
}
.project-feature-settings {
background: $gray-lighter;
border-top: none;
margin-bottom: 16px;
}
.project-repo-select {
transition: background 2s ease-out;
&:disabled {
opacity: 0.75;
}
.highlight-changes & {
background: $highlight-changes-color;
transition: none;
}
}
.project-feature-controls {
display: flex;
align-items: center;
margin: 8px 0;
max-width: 432px;
.toggle-wrapper {
flex: 0;
margin-right: 10px;
}
.select-wrapper {
flex: 1;
}
}
.project-feature-setting-group {
padding-left: 32px;
.project-feature-controls {
max-width: 400px;
}
@media (max-width: $screen-xs-min) {
padding-left: 20px;
}
}
.project-feature-toggle {
position: relative;
border: none;
outline: 0;
display: block;
width: 100px;
height: 24px;
cursor: pointer;
user-select: none;
background: $feature-toggle-color-disabled;
border-radius: 12px;
padding: 3px;
transition: all .4s ease;
&::selection,
&::before::selection,
&::after::selection {
background: none;
}
&::before {
color: $feature-toggle-text-color;
font-size: 12px;
line-height: 24px;
position: absolute;
top: 0;
left: 25px;
right: 5px;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
animation: animate-disabled .2s ease-in;
content: attr(data-disabled-text);
}
&::after {
position: relative;
display: block;
content: "";
width: 22px;
height: 18px;
left: 0;
border-radius: 9px;
background: $feature-toggle-color;
transition: all .2s ease;
}
&.checked {
background: $feature-toggle-color-enabled;
&::before {
left: 5px;
right: 25px;
animation: animate-enabled .2s ease-in;
content: attr(data-enabled-text);
}
&::after {
left: calc(100% - 22px);
}
}
&.disabled {
opacity: 0.4;
cursor: not-allowed;
}
@media (max-width: $screen-xs-min) {
width: 50px;
&::before,
&.checked::before {
display: none;
}
}
@keyframes animate-enabled {
0%, 35% { opacity: 0; }
100% { opacity: 1; }
}
@keyframes animate-disabled {
0%, 35% { opacity: 0; }
100% { opacity: 1; }
}
}
.project-home-panel,
.group-home-panel {
padding-top: 24px;
......
......@@ -545,6 +545,43 @@ module ProjectsHelper
current_application_settings.restricted_visibility_levels || []
end
def project_permissions_settings(project)
feature = project.project_feature
{
visibilityLevel: project.visibility_level,
requestAccessEnabled: !!project.request_access_enabled,
issuesAccessLevel: feature.issues_access_level,
repositoryAccessLevel: feature.repository_access_level,
mergeRequestsAccessLevel: feature.merge_requests_access_level,
buildsAccessLevel: feature.builds_access_level,
wikiAccessLevel: feature.wiki_access_level,
snippetsAccessLevel: feature.snippets_access_level,
containerRegistryEnabled: !!project.container_registry_enabled,
lfsEnabled: !!project.lfs_enabled
}
end
def project_permissions_panel_data(project)
data = {
currentSettings: project_permissions_settings(project),
canChangeVisibilityLevel: can_change_visibility_level?(project, current_user),
allowedVisibilityOptions: project_allowed_visibility_levels(project),
visibilityHelpPath: help_page_path('public_access/public_access'),
registryAvailable: Gitlab.config.registry.enabled,
registryHelpPath: help_page_path('user/project/container_registry'),
lfsAvailable: Gitlab.config.lfs.enabled && current_user.admin?,
lfsHelpPath: help_page_path('workflow/lfs/manage_large_binaries_with_git_lfs')
}
data.to_json.html_safe
end
def project_allowed_visibility_levels(project)
Gitlab::VisibilityLevel.values.select do |level|
project.visibility_level_allowed?(level) && !restricted_levels.include?(level)
end
end
def find_file_path
return unless @project && !@project.empty_repo?
......
- form = local_assigns.fetch(:form)
.form-group
.checkbox.builds-feature
.checkbox.builds-feature{ class: ("hidden" if @project && @project.project_feature.send(:builds_access_level) == 0) }
= form.label :only_allow_merge_if_pipeline_succeeds do
= form.check_box :only_allow_merge_if_pipeline_succeeds
%strong Only allow merge requests to be merged if the pipeline succeeds
......
......@@ -66,90 +66,18 @@
%section.settings.sharing-permissions
.settings-header
%h4
Sharing and permissions
Permissions
%button.btn.js-settings-toggle
= expanded ? 'Collapse' : 'Expand'
%p
Enable or disable certain project features and choose access levels.
.settings-content.no-animate{ class: ('expanded' if expanded) }
= form_for [@project.namespace.becomes(Namespace), @project], remote: true, html: { multipart: true, class: "sharing-permissions-form" }, authenticity_token: true do |f|
.form_group.sharing-and-permissions
.row.js-visibility-select
.col-md-8
.label-light
= label_tag :project_visibility, 'Project Visibility', class: 'label-light', for: :project_visibility_level
= link_to icon('question-circle'), help_page_path("public_access/public_access")
%span.help-block
.col-md-4.visibility-select-container
= render('projects/visibility_select', model_method: :visibility_level, form: f, selected_level: @project.visibility_level)
= f.fields_for :project_feature do |feature_fields|
%fieldset.features
.row
.col-md-8.project-feature
= feature_fields.label :repository_access_level, "Repository", class: 'label-light'
%span.help-block View and edit files in this project
.col-md-4.js-repo-access-level
= project_feature_access_select(:repository_access_level)
.row
.col-md-8.project-feature.nested
= feature_fields.label :merge_requests_access_level, "Merge requests", class: 'label-light'
%span.help-block Submit changes to be merged upstream
.col-md-4
= project_feature_access_select(:merge_requests_access_level)
.row
.col-md-8.project-feature.nested
= feature_fields.label :builds_access_level, "Pipelines", class: 'label-light'
%span.help-block Build, test, and deploy your changes
.col-md-4
= project_feature_access_select(:builds_access_level)
.row
.col-md-8.project-feature
= feature_fields.label :snippets_access_level, "Snippets", class: 'label-light'
%span.help-block Share code pastes with others out of Git repository
.col-md-4
= project_feature_access_select(:snippets_access_level)
.row
.col-md-8.project-feature
= feature_fields.label :issues_access_level, "Issues", class: 'label-light'
%span.help-block Lightweight issue tracking system for this project
.col-md-4
= project_feature_access_select(:issues_access_level)
.row
.col-md-8.project-feature
= feature_fields.label :wiki_access_level, "Wiki", class: 'label-light'
%span.help-block Pages for project documentation
.col-md-4
= project_feature_access_select(:wiki_access_level)
.form-group
= render 'shared/allow_request_access', form: f
- if Gitlab.config.lfs.enabled && current_user.admin?
.row.js-lfs-enabled.form-group.sharing-and-permissions
.col-md-8
= f.label :lfs_enabled, 'Git Large File Storage', class: 'label-light'
= link_to icon('question-circle'), help_page_path('workflow/lfs/manage_large_binaries_with_git_lfs')
%span.help-block Manages large files such as audio, video and graphics files.
.col-md-4
.select-wrapper
= f.select :lfs_enabled, [%w(Enabled true), %w(Disabled false)], {}, selected: @project.lfs_enabled?, class: 'pull-right form-control project-repo-select select-control', data: { field: 'lfs_enabled' }
= icon('chevron-down')
- if Gitlab.config.registry.enabled
.form-group.js-container-registry{ style: ("display: none;" if @project.project_feature.send(:repository_access_level) == 0) }
.checkbox
= f.label :container_registry_enabled do
= f.check_box :container_registry_enabled
%strong Container Registry
%br
%span.descr Enable Container Registry for this project
= link_to icon('question-circle'), help_page_path('user/project/container_registry'), target: '_blank'
%script.js-project-permissions-form-data{ type: "application/json" }= project_permissions_panel_data(@project)
.js-project-permissions-form
= f.submit 'Save changes', class: "btn btn-save"
%section.settings.merge-requests-feature{ style: ("display: none;" if @project.project_feature.send(:merge_requests_access_level) == 0) }
%section.settings.merge-requests-feature{ class: ("hidden" if @project.project_feature.send(:merge_requests_access_level) == 0) }
.settings-header
%h4
Merge request settings
......
---
title: Redesign project feature permissions settings
merge_request: 14062
author:
type: changed
......@@ -6,7 +6,6 @@ class Spinach::Features::Project < Spinach::FeatureSteps
step 'change project settings' do
fill_in 'project_name_edit', with: 'NewName'
select 'Disabled', from: 'project_project_feature_attributes_issues_access_level'
end
step 'I save project' do
......
......@@ -89,7 +89,7 @@ module SharedProject
step 'I should see project settings' do
expect(current_path).to eq edit_project_path(@project)
expect(page).to have_content("Project name")
expect(page).to have_content("Sharing and permissions")
expect(page).to have_content("Permissions")
end
def current_project
......
require 'rails_helper'
feature 'Project edit', js: true do
let(:admin) { create(:admin) }
let(:user) { create(:user) }
let(:project) { create(:project) }
before do
project.team << [user, :master]
sign_in(user)
context 'feature visibility' do
before do
project.team << [user, :master]
sign_in(user)
visit edit_project_path(project)
end
visit edit_project_path(project)
end
context 'feature visibility' do
context 'merge requests select' do
it 'hides merge requests section' do
select('Disabled', from: 'project_project_feature_attributes_merge_requests_access_level')
find('.project-feature-controls[data-for="project[project_feature_attributes][merge_requests_access_level]"] .project-feature-toggle').click
expect(page).to have_selector('.merge-requests-feature', visible: false)
end
......@@ -30,7 +31,7 @@ feature 'Project edit', js: true do
context 'builds select' do
it 'hides builds select section' do
select('Disabled', from: 'project_project_feature_attributes_builds_access_level')
find('.project-feature-controls[data-for="project[project_feature_attributes][builds_access_level]"] .project-feature-toggle').click
expect(page).to have_selector('.builds-feature', visible: false)
end
......@@ -44,4 +45,18 @@ feature 'Project edit', js: true do
end
end
end
context 'LFS enabled setting' do
before do
sign_in(admin)
end
it 'displays the correct elements' do
allow(Gitlab.config.lfs).to receive(:enabled).and_return(true)
visit edit_project_path(project)
expect(page).to have_content('Git Large File Storage')
expect(page).to have_selector('input[name="project[lfs_enabled]"] + button', visible: true)
end
end
end
......@@ -19,21 +19,16 @@ describe 'Edit Project Settings' do
it 'toggles visibility' do
visit edit_project_path(project)
select 'Disabled', from: "project_project_feature_attributes_#{tool_name}_access_level"
# disable by clicking toggle
toggle_feature_off("project[project_feature_attributes][#{tool_name}_access_level]")