Commit 038cb601 authored by Jonas Wälter's avatar Jonas Wälter

Add user preferences to customize Diffs colors

Changelog: added

Add preview for diffs colors

Move diff color preview to top

Refresh page if color scheme changed

Refine wording of diff color settings

Fix specs & lint

Refine wording of diff color settings (2)

Refactor diff color pickers and preview

Use custom suggested colors

Rename suggested colors

Extend diff preview with idiff content

Apply 2 suggestion(s) to 2 file(s)

Extract util function

Add preference specs

Use lock-retry methodology in DB migration

Polish UserPreference specs

Refactor diff colors

Polish PreferencesHelper specs

Revert removal of !important due to Dark theme

Add frontend specs

Fix RuboCop 'Layout/LineLength'

Adjust setting description

Polish frontend specs

Use Snapshot for diff preview test
parent 570d2628
...@@ -19,3 +19,7 @@ export function loadCSSFile(path) { ...@@ -19,3 +19,7 @@ export function loadCSSFile(path) {
} }
}); });
} }
export function getCssVariable(variable) {
return getComputedStyle(document.documentElement).getPropertyValue(variable).trim();
}
import initProfilePreferences from '~/profile/preferences/profile_preferences_bundle'; import initProfilePreferences from '~/profile/preferences/profile_preferences_bundle';
import initProfilePreferencesDiffsColors from '~/profile/preferences/profile_preferences_diffs_colors';
initProfilePreferences(); initProfilePreferences();
initProfilePreferencesDiffsColors();
<script>
import { validateHexColor, hexToRgb } from '~/lib/utils/color_utils';
import { s__ } from '~/locale';
import { getCssVariable } from '~/lib/utils/css_utils';
import ColorPicker from '~/vue_shared/components/color_picker/color_picker.vue';
import DiffsColorsPreview from './diffs_colors_preview.vue';
export default {
components: {
ColorPicker,
DiffsColorsPreview,
},
inject: ['deletion', 'addition'],
data() {
return {
deletionColor: this.deletion || '',
additionColor: this.addition || '',
defaultDeletionColor: getCssVariable('--default-diff-color-deletion'),
defaultAdditionColor: getCssVariable('--default-diff-color-addition'),
};
},
computed: {
suggestedColors() {
const colors = {
'#d99530': s__('SuggestedColors|Orange'),
'#1f75cb': s__('SuggestedColors|Blue'),
};
if (this.isValidColor(this.deletion)) {
colors[this.deletion] = s__('SuggestedColors|Current removal color');
}
if (this.isValidColor(this.addition)) {
colors[this.addition] = s__('SuggestedColors|Current addition color');
}
if (this.isValidColor(this.defaultDeletionColor)) {
colors[this.defaultDeletionColor] = s__('SuggestedColors|Default removal color');
}
if (this.isValidColor(this.defaultAdditionColor)) {
colors[this.defaultAdditionColor] = s__('SuggestedColors|Default addition color');
}
return colors;
},
previewClasses() {
return {
'diff-custom-addition-color': this.isValidColor(this.additionColor),
'diff-custom-deletion-color': this.isValidColor(this.deletionColor),
};
},
previewStyle() {
let style = {};
if (this.isValidColor(this.deletionColor)) {
const colorRgb = hexToRgb(this.deletionColor).join();
style = {
...style,
'--diff-deletion-color': `rgba(${colorRgb},0.2)`,
};
}
if (this.isValidColor(this.additionColor)) {
const colorRgb = hexToRgb(this.additionColor).join();
style = {
...style,
'--diff-addition-color': `rgba(${colorRgb},0.2)`,
};
}
return style;
},
},
methods: {
isValidColor(color) {
return validateHexColor(color);
},
},
i18n: {
colorDeletionInputLabel: s__('Preferences|Color for removed lines'),
colorAdditionInputLabel: s__('Preferences|Color for added lines'),
previewLabel: s__('Preferences|Preview'),
},
};
</script>
<template>
<div :style="previewStyle" :class="previewClasses">
<diffs-colors-preview />
<color-picker
v-model="deletionColor"
:label="$options.i18n.colorDeletionInputLabel"
:state="isValidColor(deletionColor)"
:suggested-colors="suggestedColors"
/>
<input
id="user_diffs_deletion_color"
v-model="deletionColor"
name="user[diffs_deletion_color]"
type="hidden"
/>
<color-picker
v-model="additionColor"
:label="$options.i18n.colorAdditionInputLabel"
:state="isValidColor(additionColor)"
:suggested-colors="suggestedColors"
/>
<input
id="user_diffs_addition_color"
v-model="additionColor"
name="user[diffs_addition_color]"
type="hidden"
/>
</div>
</template>
<script>
import { s__ } from '~/locale';
export default {
computed: {
themeClass() {
return window.gon?.user_color_scheme;
},
},
i18n: {
previewLabel: s__('Preferences|Preview'),
},
};
</script>
<template>
<div class="form-group">
<label>{{ $options.i18n.previewLabel }}</label>
<!-- eslint-disable @gitlab/vue-require-i18n-strings -->
<table :class="themeClass" class="code">
<tbody>
<tr class="line_holder parallel">
<td class="old_line diff-line-num old">
<a data-linenumber="1"></a>
</td>
<td class="line_content parallel left-side old">
<span
><span class="c1"># <span class="idiff deletion">Removed</span> content</span></span
>
</td>
<td class="new_line diff-line-num new">
<a data-linenumber="1"></a>
</td>
<td class="line_content parallel right-side new">
<span
><span class="c1"># <span class="idiff addition">Added</span> content</span></span
>
</td>
</tr>
<tr class="line_holder parallel">
<td class="old_line diff-line-num old">
<a data-linenumber="2"></a>
</td>
<td class="line_content parallel left-side old">
<span><span class="n">v</span> <span class="o">=</span> <span class="mi">1</span></span>
</td>
<td class="new_line diff-line-num new">
<a data-linenumber="2"></a>
</td>
<td class="line_content parallel right-side new">
<span><span class="n">v</span> <span class="o">=</span> <span class="mi">1</span></span>
</td>
</tr>
<tr class="line_holder parallel">
<td class="old_line diff-line-num old">
<a data-linenumber="3"></a>
</td>
<td class="line_content parallel left-side old">
<span
><span class="n">s</span> <span class="o">=</span>
<span class="s">"string"</span></span
>
</td>
<td class="new_line diff-line-num new">
<a data-linenumber="3"></a>
</td>
<td class="line_content parallel right-side new">
<span
><span class="n">s</span> <span class="o">=</span>
<span class="s">"string"</span></span
>
</td>
</tr>
<tr class="line_holder parallel">
<td class="old_line diff-line-num old">
<a data-linenumber="4"></a>
</td>
<td class="line_content parallel left-side old"><span></span></td>
<td class="new_line diff-line-num new">
<a data-linenumber="4"></a>
</td>
<td class="line_content parallel right-side new"><span></span></td>
</tr>
<tr class="line_holder parallel">
<td class="old_line diff-line-num old">
<a data-linenumber="5"></a>
</td>
<td class="line_content parallel left-side old">
<span
><span class="k">for</span> <span class="n">i</span> <span class="ow">in</span>
<span class="nb">range</span><span class="p">(</span><span class="o">-</span
><span class="mi">10</span><span class="p">,</span> <span class="mi">10</span
><span class="p">):</span></span
>
</td>
<td class="new_line diff-line-num new">
<a data-linenumber="5"></a>
</td>
<td class="line_content parallel right-side new">
<span
><span class="k">for</span> <span class="n">i</span> <span class="ow">in</span>
<span class="nb">range</span><span class="p">(</span><span class="o">-</span
><span class="mi">10</span><span class="p">,</span> <span class="mi">10</span
><span class="p">):</span></span
>
</td>
</tr>
<tr class="line_holder parallel">
<td class="old_line diff-line-num old">
<a data-linenumber="6"></a>
</td>
<td class="line_content parallel left-side old">
<span>
<span>{{ ' ' }}</span>
<span class="k">print</span><span class="p">(</span><span class="n">i</span>
<span class="o">+</span> <span class="mi">1</span><span class="p">)</span></span
>
</td>
<td class="new_line diff-line-num new">
<a data-linenumber="6"></a>
</td>
<td class="line_content parallel right-side new">
<span>
<span>{{ ' ' }}</span>
<span class="k">print</span><span class="p">(</span><span class="n">i</span>
<span class="o">+</span> <span class="mi">1</span><span class="p">)</span></span
>
</td>
</tr>
<tr class="line_holder parallel">
<td class="old_line diff-line-num old">
<a data-linenumber="7"></a>
</td>
<td class="line_content parallel left-side old"><span></span></td>
<td class="new_line diff-line-num new">
<a data-linenumber="7"></a>
</td>
<td class="line_content parallel right-side new"><span></span></td>
</tr>
<tr class="line_holder parallel">
<td class="old_line diff-line-num old">
<a data-linenumber="8"></a>
</td>
<td class="line_content parallel left-side old">
<span
><span class="k">class</span> <span class="nc">LinkedList</span
><span class="p">(</span><span class="nb">object</span><span class="p">):</span></span
>
</td>
<td class="new_line diff-line-num new">
<a data-linenumber="8"></a>
</td>
<td class="line_content parallel right-side new">
<span
><span class="k">class</span> <span class="nc">LinkedList</span
><span class="p">(</span><span class="nb">object</span><span class="p">):</span></span
>
</td>
</tr>
<tr class="line_holder parallel">
<td class="old_line diff-line-num old">
<a data-linenumber="9"></a>
</td>
<td class="line_content parallel left-side old">
<span>
<span>{{ ' ' }}</span>
<span class="k">def</span> <span class="nf">__init__</span><span class="p">(</span
><span class="bp">self</span><span class="p">,</span> <span class="n">x</span
><span class="p">):</span></span
>
</td>
<td class="new_line diff-line-num new">
<a data-linenumber="9"></a>
</td>
<td class="line_content parallel right-side new">
<span>
<span>{{ ' ' }}</span>
<span class="k">def</span> <span class="nf">__init__</span><span class="p">(</span
><span class="bp">self</span><span class="p">,</span> <span class="n">x</span
><span class="p">):</span></span
>
</td>
</tr>
<tr class="line_holder parallel">
<td class="old_line diff-line-num old">
<a data-linenumber="10"></a>
</td>
<td class="line_content parallel left-side old">
<span>
<span>{{ ' ' }}</span>
<span class="bp">self</span><span class="p">.</span><span class="n">val</span>
<span class="o">=</span> <span class="n">x</span></span
>
</td>
<td class="new_line diff-line-num new">
<a data-linenumber="10"></a>
</td>
<td class="line_content parallel right-side new">
<span>
<span>{{ ' ' }}</span>
<span class="bp">self</span><span class="p">.</span><span class="n">val</span>
<span class="o">=</span> <span class="n">x</span></span
>
</td>
</tr>
<tr class="line_holder parallel">
<td class="old_line diff-line-num old">
<a data-linenumber="11"></a>
</td>
<td class="line_content parallel left-side old">
<span>
<span>{{ ' ' }}</span>
<span class="bp">self</span><span class="p">.</span><span class="nb">next</span>
<span class="o">=</span> <span class="bp">None</span></span
>
</td>
<td class="new_line diff-line-num new">
<a data-linenumber="11"></a>
</td>
<td class="line_content parallel right-side new">
<span>
<span>{{ ' ' }}</span>
<span class="bp">self</span><span class="p">.</span><span class="nb">next</span>
<span class="o">=</span> <span class="bp">None</span></span
>
</td>
</tr>
</tbody>
</table>
<!-- eslint-enable @gitlab/vue-require-i18n-strings -->
</div>
</template>
...@@ -45,7 +45,7 @@ export default { ...@@ -45,7 +45,7 @@ export default {
return { return {
isSubmitEnabled: true, isSubmitEnabled: true,
darkModeOnCreate: null, darkModeOnCreate: null,
darkModeOnSubmit: null, schemeOnCreate: null,
}; };
}, },
computed: { computed: {
...@@ -61,6 +61,7 @@ export default { ...@@ -61,6 +61,7 @@ export default {
this.formEl.addEventListener('ajax:success', this.handleSuccess); this.formEl.addEventListener('ajax:success', this.handleSuccess);
this.formEl.addEventListener('ajax:error', this.handleError); this.formEl.addEventListener('ajax:error', this.handleError);
this.darkModeOnCreate = this.darkModeSelected(); this.darkModeOnCreate = this.darkModeSelected();
this.schemeOnCreate = this.getSelectedScheme();
}, },
beforeDestroy() { beforeDestroy() {
this.formEl.removeEventListener('ajax:beforeSend', this.handleLoading); this.formEl.removeEventListener('ajax:beforeSend', this.handleLoading);
...@@ -76,15 +77,19 @@ export default { ...@@ -76,15 +77,19 @@ export default {
const themeId = new FormData(this.formEl).get('user[theme_id]'); const themeId = new FormData(this.formEl).get('user[theme_id]');
return this.applicationThemes[themeId] ?? null; return this.applicationThemes[themeId] ?? null;
}, },
getSelectedScheme() {
return new FormData(this.formEl).get('user[color_scheme_id]');
},
handleLoading() { handleLoading() {
this.isSubmitEnabled = false; this.isSubmitEnabled = false;
this.darkModeOnSubmit = this.darkModeSelected();
}, },
handleSuccess(customEvent) { handleSuccess(customEvent) {
// Reload the page if the theme has changed from light to dark mode or vice versa // Reload the page if the theme has changed from light to dark mode or vice versa
// to correctly load all required styles. // or if color scheme has changed to correctly load all required styles.
const modeChanged = this.darkModeOnCreate ? !this.darkModeOnSubmit : this.darkModeOnSubmit; if (
if (modeChanged) { this.darkModeOnCreate !== this.darkModeSelected() ||
this.schemeOnCreate !== this.getSelectedScheme()
) {
window.location.reload(); window.location.reload();
return; return;
} }
......
import Vue from 'vue';
import DiffsColors from './components/diffs_colors.vue';
export default () => {
const el = document.querySelector('#js-profile-preferences-diffs-colors-app');
if (!el) return false;
const { deletion, addition } = el.dataset;
return new Vue({
el,
provide: {
deletion,
addition,
},
render(createElement) {
return createElement(DiffsColors);
},
});
};
/**
* CSS variables used below are declared in `app/views/layouts/_diffs_colors_css.haml`
*/
.diff-custom-addition-color {
.code {
.line_holder {
.diff-line-num,
.line-coverage,
.line-codequality,
.line_content {
&.new {
&:not(.hll) {
background: var(--diff-addition-color);
}
&.line_content span.idiff {
background: var(--diff-addition-color) !important;
}
&::before,
a {
mix-blend-mode: luminosity;
}
}
}
}
.gd {
background-color: var(--diff-addition-color);
}
}
.idiff.addition {
background: var(--diff-addition-color) !important;
}
}
/**
* CSS variables used below are declared in `app/views/layouts/_diffs_colors_css.haml`
*/
.diff-custom-deletion-color {
.code {
.line_holder {
.diff-line-num,
.line-coverage,
.line-codequality,
.line_content {
&.old {
&:not(.hll) {
background: var(--diff-deletion-color);
}
&.line_content span.idiff {
background: var(--diff-deletion-color) !important;
}
&::before,
a {
mix-blend-mode: luminosity;
}
}
}
}
.gd {
background-color: var(--diff-deletion-color);
}
}
.idiff.deletion {
background: var(--diff-deletion-color) !important;
}
}
...@@ -120,6 +120,8 @@ $dark-il: #de935f; ...@@ -120,6 +120,8 @@ $dark-il: #de935f;
--color-hljs-selector-id: #{$dark-nn}; --color-hljs-selector-id: #{$dark-nn};
--color-hljs-selector-attr: #{$dark-nt}; --color-hljs-selector-attr: #{$dark-nt};
--color-hljs-selector-pseudo: #{$dark-nd}; --color-hljs-selector-pseudo: #{$dark-nd};
--default-diff-color-deletion: #ff3333;
--default-diff-color-addition: #288f2a;
} }
.code.dark { .code.dark {
......
...@@ -89,6 +89,11 @@ $monokai-gd: #f92672; ...@@ -89,6 +89,11 @@ $monokai-gd: #f92672;
$monokai-gi: #a6e22e; $monokai-gi: #a6e22e;
$monokai-gh: #75715e; $monokai-gh: #75715e;
:root {
--default-diff-color-deletion: #c87872;
--default-diff-color-addition: #678528;
}
.code.monokai { .code.monokai {
// Line numbers // Line numbers
.file-line-num { .file-line-num {
......
...@@ -9,6 +9,11 @@ ...@@ -9,6 +9,11 @@
background-color: $white-normal; background-color: $white-normal;
} }
:root {
--default-diff-color-deletion: #b4b4b4;
--default-diff-color-addition: #b4b4b4;
}
.code.none { .code.none {
// Line numbers // Line numbers
.file-line-num { .file-line-num {
......
...@@ -92,6 +92,11 @@ $solarized-dark-vg: #268bd2; ...@@ -92,6 +92,11 @@ $solarized-dark-vg: #268bd2;
$solarized-dark-vi: #268bd2; $solarized-dark-vi: #268bd2;
$solarized-dark-il: #2aa198; $solarized-dark-il: #2aa198;
:root {
--default-diff-color-deletion: #ff362c;
--default-diff-color-addition: #647e0e;
}
.code.solarized-dark { .code.solarized-dark {
// Line numbers // Line numbers
.file-line-num { .file-line-num {
......
...@@ -94,6 +94,11 @@ $solarized-light-vg: #268bd2; ...@@ -94,6 +94,11 @@ $solarized-light-vg: #268bd2;
$solarized-light-vi: #268bd2; $solarized-light-vi: #268bd2;
$solarized-light-il: #2aa198; $solarized-light-il: #2aa198;
:root {
--default-diff-color-deletion: #dc322f;
--default-diff-color-addition: #859900;
}
@mixin match-line { @mixin match-line {
color: $black-transparent; color: $black-transparent;
background: $solarized-light-matchline-bg; background: $solarized-light-matchline-bg;
......
...@@ -3,3 +3,8 @@ ...@@ -3,3 +3,8 @@
@include conflict-colors('white'); @include conflict-colors('white');
} }
:root {
--default-diff-color-deletion: #eb919b;
--default-diff-color-addition: #a0f5b4;
}
\ No newline at end of file
...@@ -149,7 +149,6 @@ pre.code, ...@@ -149,7 +149,6 @@ pre.code,
.diff-line-num { .diff-line-num {
&.old { &.old {
background-color: $line-number-old; background-color: $line-number-old;
border-color: $line-removed-dark;
a { a {
color: scale-color($line-number-old, $red: -30%, $green: -30%, $blue: -30%); color: scale-color($line-number-old, $red: -30%, $green: -30%, $blue: -30%);
...@@ -158,7 +157,6 @@ pre.code, ...@@ -158,7 +157,6 @@ pre.code,
&.new { &.new {
background-color: $line-number-new; background-color: $line-number-new;
border-color: $line-added-dark;
a { a {
color: scale-color($line-number-new, $red: -30%, $green: -30%, $blue: -30%); color: scale-color($line-number-new, $red: -30%, $green: -30%, $blue: -30%);
......
...@@ -36,6 +36,8 @@ class Profiles::PreferencesController < Profiles::ApplicationController ...@@ -36,6 +36,8 @@ class Profiles::PreferencesController < Profiles::ApplicationController
def preferences_param_names def preferences_param_names
[ [
:color_scheme_id, :color_scheme_id,
:diffs_deletion_color,
:diffs_addition_color,
:layout, :layout,
:dashboard, :dashboard,
:project_view, :project_view,
......
# frozen_string_literal: true
module ColorsHelper
HEX_COLOR_PATTERN = /\A\#(?:[0-9A-Fa-f]{3}){1,2}\Z/.freeze
def hex_color_to_rgb_array(hex_color)
raise ArgumentError, "invalid hex color `#{hex_color}`" unless hex_color =~ HEX_COLOR_PATTERN
hex_color.length == 7 ? hex_color[1, 7].scan(/.{2}/).map(&:hex) : hex_color[1, 4].scan(/./).map { |v| (v * 2).hex }
end
def rgb_array_to_hex_color(rgb_array)
raise ArgumentError, "invalid RGB array `#{rgb_array}`" unless rgb_array_valid?(rgb_array)
"##{rgb_array.map{ "%02x" % _1 }.join}"
end
private
def rgb_array_valid?(rgb_array)
rgb_array.is_a?(Array) && rgb_array.length == 3 && rgb_array.all?{ _1 >= 0 && _1 <= 255 }
end
end
...@@ -82,6 +82,22 @@ module PreferencesHelper ...@@ -82,6 +82,22 @@ module PreferencesHelper
Gitlab::TabWidth.css_class_for_user(current_user) Gitlab::TabWidth.css_class_for_user(current_user)
end end
def user_diffs_colors
{
deletion: current_user&.diffs_deletion_color.presence,
addition: current_user&.diffs_addition_color.presence
}.compact
end
def custom_diff_color_classes
return if request.path == profile_preferences_path
classes = []
classes << 'diff-custom-addition-color' if current_user&.diffs_addition_color.presence
classes << 'diff-custom-deletion-color' if current_user&.diffs_deletion_color.presence
classes
end
def language_choices def language_choices
options_for_select( options_for_select(
selectable_locales_with_translation_level.sort, selectable_locales_with_translation_level.sort,
......
...@@ -324,6 +324,8 @@ class User < ApplicationRecord ...@@ -324,6 +324,8 @@ class User < ApplicationRecord
:setup_for_company, :setup_for_company=, :setup_for_company, :setup_for_company=,
:render_whitespace_in_code, :render_whitespace_in_code=, :render_whitespace_in_code, :render_whitespace_in_code=,
:markdown_surround_selection, :markdown_surround_selection=, :markdown_surround_selection, :markdown_surround_selection=,
:diffs_deletion_color, :diffs_deletion_color=,
:diffs_addition_color, :diffs_addition_color=,
to: :user_preference to: :user_preference
delegate :path, to: :namespace, allow_nil: true, prefix: true delegate :path, to: :namespace, allow_nil: true, prefix: true
......
...@@ -19,6 +19,9 @@ class UserPreference < ApplicationRecord ...@@ -19,6 +19,9 @@ class UserPreference < ApplicationRecord
greater_than_or_equal_to: Gitlab::TabWidth::MIN, greater_than_or_equal_to: Gitlab::TabWidth::MIN,
less_than_or_equal_to: Gitlab::TabWidth::MAX less_than_or_equal_to: Gitlab::TabWidth::MAX
} }
validates :diffs_deletion_color, :diffs_addition_color,
format: { with: ColorsHelper::HEX_COLOR_PATTERN },
allow_blank: true
ignore_columns :experience_level, remove_with: '14.10', remove_after: '2021-03-22' ignore_columns :experience_level, remove_with: '14.10', remove_after: '2021-03-22'
......
- deletion_color = local_assigns.fetch(:deletion, nil)
- addition_color = local_assigns.fetch(:addition, nil)
- if deletion_color.present? || request.path == profile_preferences_path
= stylesheet_link_tag_defer "highlight/diff_custom_colors_deletion"
- if deletion_color.present?
- deletion_color_rgb = hex_color_to_rgb_array(deletion_color).join(',')
:css
:root {
--diff-deletion-color: rgba(#{deletion_color_rgb},0.2);
}
- if addition_color.present? || request.path == profile_preferences_path
= stylesheet_link_tag_defer "highlight/diff_custom_colors_addition"
- if addition_color.present?
- addition_color_rgb = hex_color_to_rgb_array(addition_color).join(',')
:css
:root {
--diff-addition-color: rgba(#{addition_color_rgb},0.2);
}
- startup_filename_default = user_application_theme == 'gl-dark' ? 'dark' : 'general' - startup_filename_default = user_application_theme == 'gl-dark' ? 'dark' : 'general'
- startup_filename = local_assigns.fetch(:startup_filename, nil) || startup_filename_default - startup_filename = local_assigns.fetch(:startup_filename, nil) || startup_filename_default
- diffs_colors = user_diffs_colors
%style %style
= Rails.application.assets_manifest.find_sources("themes/#{user_application_theme_css_filename}.css").first.to_s.html_safe if user_application_theme_css_filename = Rails.application.assets_manifest.find_sources("themes/#{user_application_theme_css_filename}.css").first.to_s.html_safe if user_application_theme_css_filename
= Rails.application.assets_manifest.find_sources("startup/startup-#{startup_filename}.css").first.to_s.html_safe = Rails.application.assets_manifest.find_sources("startup/startup-#{startup_filename}.css").first.to_s.html_safe
= render 'layouts/diffs_colors_css', diffs_colors if diffs_colors.present? || request.path == profile_preferences_path
- page_classes = page_class << @html_class - page_classes = page_class << @html_class
- page_classes = page_classes.flatten.compact - page_classes = page_classes.flatten.compact
- body_classes = [user_application_theme, user_tab_width, @body_class, client_class_list] - body_classes = [user_application_theme, user_tab_width, @body_class, client_class_list, *custom_diff_color_classes]
!!! 5 !!! 5
%html{ lang: I18n.locale, class: page_classes } %html{ lang: I18n.locale, class: page_classes }
......
...@@ -44,6 +44,19 @@ ...@@ -44,6 +44,19 @@
.col-sm-12 .col-sm-12
%hr %hr
.row.js-preferences-form.js-search-settings-section
.col-lg-4.profile-settings-sidebar#diffs-colors
%h4.gl-mt-0
= s_('Preferences|Diff colors')
%p
= s_('Preferences|Customize the colors of removed and added lines in diffs.')
.col-lg-8
.form-group
#js-profile-preferences-diffs-colors-app{ data: user_diffs_colors }
.col-sm-12
%hr
.row.js-preferences-form.js-search-settings-section .row.js-preferences-form.js-search-settings-section
.col-lg-4.profile-settings-sidebar#behavior .col-lg-4.profile-settings-sidebar#behavior
%h4.gl-mt-0 %h4.gl-mt-0
......
...@@ -314,6 +314,8 @@ module Gitlab ...@@ -314,6 +314,8 @@ module Gitlab
config.assets.precompile << "themes/*.css" config.assets.precompile << "themes/*.css"
config.assets.precompile << "highlight/themes/*.css" config.assets.precompile << "highlight/themes/*.css"
config.assets.precompile << "highlight/diff_custom_colors_addition.css"
config.assets.precompile << "highlight/diff_custom_colors_deletion.css"
# Import gitlab-svgs directly from vendored directory # Import gitlab-svgs directly from vendored directory
config.assets.paths << "#{config.root}/node_modules/@gitlab/svgs/dist" config.assets.paths << "#{config.root}/node_modules/@gitlab/svgs/dist"
......
# frozen_string_literal: true
class AddDiffsColorsToUserPreferences < Gitlab::Database::Migration[1.0]
enable_lock_retries!
# rubocop:disable Migration/AddLimitToTextColumns
# limit is added in 20220113164901_add_text_limit_to_user_preferences_diffs_colors.rb
def change
add_column :user_preferences, :diffs_deletion_color, :text
add_column :user_preferences, :diffs_addition_color, :text
end
# rubocop:enable Migration/AddLimitToTextColumns
end
# frozen_string_literal: true
class AddTextLimitToUserPreferencesDiffsColors < Gitlab::Database::Migration[1.0]
disable_ddl_transaction!
def up
add_text_limit :user_preferences, :diffs_deletion_color, 7
add_text_limit :user_preferences, :diffs_addition_color, 7
end
def down
remove_text_limit :user_preferences, :diffs_addition_color
remove_text_limit :user_preferences, :diffs_deletion_color
end
end
71526ea198c64d23a35f06804f30068591e937df22d74c262fdec9ecf04bf7d4
\ No newline at end of file
b157cec5eab77665ae57f02647c39dc0fb167d78e1894b395c46f59d791ab3e0
\ No newline at end of file
...@@ -21335,7 +21335,11 @@ CREATE TABLE user_preferences ( ...@@ -21335,7 +21335,11 @@ CREATE TABLE user_preferences (
experience_level smallint, experience_level smallint,
view_diffs_file_by_file boolean DEFAULT false NOT NULL, view_diffs_file_by_file boolean DEFAULT false NOT NULL,
gitpod_enabled boolean DEFAULT false NOT NULL, gitpod_enabled boolean DEFAULT false NOT NULL,
markdown_surround_selection boolean DEFAULT true NOT NULL markdown_surround_selection boolean DEFAULT true NOT NULL,
diffs_deletion_color text,
diffs_addition_color text,
CONSTRAINT check_89bf269f41 CHECK ((char_length(diffs_deletion_color) <= 7)),
CONSTRAINT check_d07ccd35f7 CHECK ((char_length(diffs_addition_color) <= 7))
); );
CREATE SEQUENCE user_preferences_id_seq CREATE SEQUENCE user_preferences_id_seq
...@@ -89,6 +89,20 @@ The default syntax theme is White, and you can choose among 5 different themes: ...@@ -89,6 +89,20 @@ The default syntax theme is White, and you can choose among 5 different themes:
Introduced in GitLab 13.6, the themes [Solarized](https://gitlab.com/gitlab-org/gitlab/-/issues/221034) and [Monokai](https://gitlab.com/gitlab-org/gitlab/-/issues/221034) also apply to the [Web IDE](../project/web_ide/index.md) and [Snippets](../snippets.md). Introduced in GitLab 13.6, the themes [Solarized](https://gitlab.com/gitlab-org/gitlab/-/issues/221034) and [Monokai](https://gitlab.com/gitlab-org/gitlab/-/issues/221034) also apply to the [Web IDE](../project/web_ide/index.md) and [Snippets](../snippets.md).
## Diff colors
A diff compares the old/removed content with the new/added content (e.g. when
[reviewing a merge request](../project/merge_requests/reviews/index.md#review-a-merge-request) or in a
[Markdown inline diff](../markdown.md#inline-diff)).
Typically, the colors red and green are used for removed and added lines in diffs.
The exact colors depend on the selected [syntax highlighting theme](#syntax-highlighting-theme).
The colors may lead to difficulties in case of red–green color blindness.
For this reason, you can customize the following colors:
- Color for removed lines
- Color for added lines
## Behavior ## Behavior
The following settings allow you to customize the behavior of the GitLab layout The following settings allow you to customize the behavior of the GitLab layout
......
...@@ -28363,6 +28363,12 @@ msgstr "" ...@@ -28363,6 +28363,12 @@ msgstr ""
msgid "Preferences|Choose what content you want to see on your homepage." msgid "Preferences|Choose what content you want to see on your homepage."
msgstr "" msgstr ""
msgid "Preferences|Color for added lines"
msgstr ""
msgid "Preferences|Color for removed lines"
msgstr ""
msgid "Preferences|Configure how dates and times display for you." msgid "Preferences|Configure how dates and times display for you."
msgstr "" msgstr ""
...@@ -28372,6 +28378,12 @@ msgstr "" ...@@ -28372,6 +28378,12 @@ msgstr ""
msgid "Preferences|Customize the appearance of the application header and navigation sidebar." msgid "Preferences|Customize the appearance of the application header and navigation sidebar."
msgstr "" msgstr ""
msgid "Preferences|Customize the colors of removed and added lines in diffs."
msgstr ""
msgid "Preferences|Diff colors"
msgstr ""
msgid "Preferences|Display time in 24-hour format" msgid "Preferences|Display time in 24-hour format"
msgstr "" msgstr ""
...@@ -28408,6 +28420,9 @@ msgstr "" ...@@ -28408,6 +28420,9 @@ msgstr ""
msgid "Preferences|Navigation theme" msgid "Preferences|Navigation theme"
msgstr "" msgstr ""
msgid "Preferences|Preview"
msgstr ""
msgid "Preferences|Project overview content" msgid "Preferences|Project overview content"
msgstr "" msgstr ""
...@@ -36507,6 +36522,12 @@ msgstr "" ...@@ -36507,6 +36522,12 @@ msgstr ""
msgid "SuggestedColors|Crimson" msgid "SuggestedColors|Crimson"
msgstr "" msgstr ""
msgid "SuggestedColors|Current addition color"
msgstr ""
msgid "SuggestedColors|Current removal color"
msgstr ""
msgid "SuggestedColors|Dark coral" msgid "SuggestedColors|Dark coral"
msgstr "" msgstr ""
...@@ -36522,6 +36543,12 @@ msgstr "" ...@@ -36522,6 +36543,12 @@ msgstr ""
msgid "SuggestedColors|Deep violet" msgid "SuggestedColors|Deep violet"
msgstr "" msgstr ""
msgid "SuggestedColors|Default addition color"
msgstr ""
msgid "SuggestedColors|Default removal color"
msgstr ""
msgid "SuggestedColors|Gray" msgid "SuggestedColors|Gray"
msgstr "" msgstr ""
...@@ -36540,6 +36567,9 @@ msgstr "" ...@@ -36540,6 +36567,9 @@ msgstr ""
msgid "SuggestedColors|Medium sea green" msgid "SuggestedColors|Medium sea green"
msgstr "" msgstr ""
msgid "SuggestedColors|Orange"
msgstr ""
msgid "SuggestedColors|Red" msgid "SuggestedColors|Red"
msgstr "" msgstr ""
......
...@@ -46,6 +46,8 @@ RSpec.describe Profiles::PreferencesController do ...@@ -46,6 +46,8 @@ RSpec.describe Profiles::PreferencesController do
it "changes the user's preferences" do it "changes the user's preferences" do
prefs = { prefs = {
color_scheme_id: '1', color_scheme_id: '1',
diffs_deletion_color: '#123456',
diffs_addition_color: '#abcdef',
dashboard: 'stars', dashboard: 'stars',
theme_id: '2', theme_id: '2',
first_day_of_week: '1', first_day_of_week: '1',
...@@ -84,5 +86,27 @@ RSpec.describe Profiles::PreferencesController do ...@@ -84,5 +86,27 @@ RSpec.describe Profiles::PreferencesController do
expect(response.parsed_body['type']).to eq('alert') expect(response.parsed_body['type']).to eq('alert')
end end
end end
context 'on invalid diffs colors setting' do
it 'responds with error for diffs_deletion_color' do
prefs = { diffs_deletion_color: '#1234567' }
go params: prefs
expect(response).to have_gitlab_http_status(:bad_request)
expect(response.parsed_body['message']).to eq _('Failed to save preferences.')
expect(response.parsed_body['type']).to eq('alert')
end
it 'responds with error for diffs_addition_color' do
prefs = { diffs_addition_color: '#1234567' }
go params: prefs
expect(response).to have_gitlab_http_status(:bad_request)
expect(response.parsed_body['message']).to eq _('Failed to save preferences.')
expect(response.parsed_body['type']).to eq('alert')
end
end
end end
end end
...@@ -18,14 +18,6 @@ RSpec.describe 'User visits the profile preferences page', :js do ...@@ -18,14 +18,6 @@ RSpec.describe 'User visits the profile preferences page', :js do
end end
describe 'User changes their syntax highlighting theme', :js do describe 'User changes their syntax highlighting theme', :js do
it 'creates a flash message' do
choose 'user_color_scheme_id_5'
wait_for_requests
expect_preferences_saved_message
end
it 'updates their preference' do it 'updates their preference' do
choose 'user_color_scheme_id_5' choose 'user_color_scheme_id_5'
......
import { shallowMount } from '@vue/test-utils';
import DiffsColorsPreview from '~/profile/preferences/components/diffs_colors_preview.vue';
describe('DiffsColorsPreview component', () => {
let wrapper;
function createComponent() {
wrapper = shallowMount(DiffsColorsPreview);
}
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('renders diff colors preview', () => {
expect(wrapper.element).toMatchSnapshot();
});
});
import { shallowMount } from '@vue/test-utils';
import { s__ } from '~/locale';
import ColorPicker from '~/vue_shared/components/color_picker/color_picker.vue';
import DiffsColors from '~/profile/preferences/components/diffs_colors.vue';
import DiffsColorsPreview from '~/profile/preferences/components/diffs_colors_preview.vue';
import * as CssUtils from '~/lib/utils/css_utils';
describe('DiffsColors component', () => {
let wrapper;
const defaultInjectedProps = {
addition: '#00ff00',
deletion: '#ff0000',
};
const initialSuggestedColors = {
'#d99530': s__('SuggestedColors|Orange'),
'#1f75cb': s__('SuggestedColors|Blue'),
};
const findColorPickers = () => wrapper.findAllComponents(ColorPicker);
function createComponent(provide = {}) {
wrapper = shallowMount(DiffsColors, {
provide: {
...defaultInjectedProps,
...provide,
},
});
}
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('mounts', () => {
createComponent();
expect(wrapper.exists()).toBe(true);
});
describe('preview', () => {
it('should render preview', () => {
createComponent();
expect(wrapper.findComponent(DiffsColorsPreview).exists()).toBe(true);
});
it('should set preview classes', () => {
createComponent();
expect(wrapper.attributes('class')).toBe(
'diff-custom-addition-color diff-custom-deletion-color',
);
});
it.each([
[{ addition: null }, 'diff-custom-deletion-color'],
[{ deletion: null }, 'diff-custom-addition-color'],
])('should not set preview class if color not set', (provide, expectedClass) => {
createComponent(provide);
expect(wrapper.attributes('class')).toBe(expectedClass);
});
it.each([
[{}, '--diff-deletion-color: rgba(255,0,0,0.2); --diff-addition-color: rgba(0,255,0,0.2);'],
[{ addition: null }, '--diff-deletion-color: rgba(255,0,0,0.2);'],
[{ deletion: null }, '--diff-addition-color: rgba(0,255,0,0.2);'],
])('should set correct CSS variables', (provide, expectedStyle) => {
createComponent(provide);
expect(wrapper.attributes('style')).toBe(expectedStyle);
});
});
describe('color pickers', () => {
it('should render both color pickers', () => {
createComponent();
const colorPickers = findColorPickers();
expect(colorPickers.length).toBe(2);
expect(colorPickers.at(0).props()).toMatchObject({
label: s__('Preferences|Color for removed lines'),
value: '#ff0000',
state: true,
});
expect(colorPickers.at(1).props()).toMatchObject({
label: s__('Preferences|Color for added lines'),
value: '#00ff00',
state: true,
});
});
describe('suggested colors', () => {
const suggestedColors = () => findColorPickers().at(0).props('suggestedColors');
it('contains initial suggested colors', () => {
createComponent();
expect(suggestedColors()).toMatchObject(initialSuggestedColors);
});
it('contains default diff colors of theme', () => {
jest.spyOn(CssUtils, 'getCssVariable').mockImplementation((variable) => {
if (variable === '--default-diff-color-addition') return '#111111';
if (variable === '--default-diff-color-deletion') return '#222222';
return '#000000';
});
createComponent();
expect(suggestedColors()).toMatchObject({
'#111111': s__('SuggestedColors|Default addition color'),
'#222222': s__('SuggestedColors|Default removal color'),
});
});
it('contains current diff colors if set', () => {
createComponent();
expect(suggestedColors()).toMatchObject({
[defaultInjectedProps.addition]: s__('SuggestedColors|Current addition color'),
[defaultInjectedProps.deletion]: s__('SuggestedColors|Current removal color'),
});
});
it.each([
[
{ addition: null },
s__('SuggestedColors|Current removal color'),
s__('SuggestedColors|Current addition color'),
],
[
{ deletion: null },
s__('SuggestedColors|Current addition color'),
s__('SuggestedColors|Current removal color'),
],
])(
'does not contain current diff color if not set %p',
(provide, expectedToContain, expectNotToContain) => {
createComponent(provide);
const suggestedColorsLabels = Object.values(suggestedColors());
expect(suggestedColorsLabels).toContain(expectedToContain);
expect(suggestedColorsLabels).not.toContain(expectNotToContain);
},
);
});
});
});
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe ColorsHelper do
using RSpec::Parameterized::TableSyntax
describe '#hex_color_to_rgb_array' do
context 'valid hex color' do
where(:hex_color, :rgb_array) do
'#000000' | [0, 0, 0]
'#aaaaaa' | [170, 170, 170]
'#cCcCcC' | [204, 204, 204]
'#FFFFFF' | [255, 255, 255]
'#000abc' | [0, 10, 188]
'#123456' | [18, 52, 86]
'#a1b2c3' | [161, 178, 195]
'#000' | [0, 0, 0]
'#abc' | [170, 187, 204]
'#321' | [51, 34, 17]
'#7E2' | [119, 238, 34]
'#fFf' | [255, 255, 255]
end
with_them do
it 'returns correct RGB array' do
expect(helper.hex_color_to_rgb_array(hex_color)).to eq(rgb_array)
end
end
end
context 'invalid hex color' do
where(:hex_color) { ['', '0', '#00', '#ffff', '#1234567', 'invalid', [], 1, nil] }
with_them do
it 'raise ArgumentError' do
expect { helper.hex_color_to_rgb_array(hex_color) }.to raise_error(ArgumentError)
end
end
end
end
describe '#rgb_array_to_hex_color' do
context 'valid RGB array' do
where(:rgb_array, :hex_color) do
[0, 0, 0] | '#000000'
[0, 0, 255] | '#0000ff'
[0, 255, 0] | '#00ff00'
[255, 0, 0] | '#ff0000'
[12, 34, 56] | '#0c2238'
[222, 111, 88] | '#de6f58'
[255, 255, 255] | '#ffffff'
end
with_them do
it 'returns correct hex color' do
expect(helper.rgb_array_to_hex_color(rgb_array)).to eq(hex_color)
end
end
end
context 'invalid RGB array' do
where(:rgb_array) do
[
'',
'#000000',
0,
nil,
[],
[0],
[0, 0],
[0, 0, 0, 0],
[-1, 0, 0],
[0, -1, 0],
[0, 0, -1],
[256, 0, 0],
[0, 256, 0],
[0, 0, 256]
]
end
with_them do
it 'raise ArgumentError' do
expect { helper.rgb_array_to_hex_color(rgb_array) }.to raise_error(ArgumentError)
end
end
end
end
end
...@@ -145,6 +145,67 @@ RSpec.describe PreferencesHelper do ...@@ -145,6 +145,67 @@ RSpec.describe PreferencesHelper do
end end
end end
describe '#user_diffs_colors' do
context 'with a user' do
it "returns user's diffs colors" do
stub_user(diffs_addition_color: '#123456', diffs_deletion_color: '#abcdef')
expect(helper.user_diffs_colors).to eq({ addition: '#123456', deletion: '#abcdef' })
end
it 'omits property if nil' do
stub_user(diffs_addition_color: '#123456', diffs_deletion_color: nil)
expect(helper.user_diffs_colors).to eq({ addition: '#123456' })
end
it 'omits property if blank' do
stub_user(diffs_addition_color: '', diffs_deletion_color: '#abcdef')
expect(helper.user_diffs_colors).to eq({ deletion: '#abcdef' })
end
end
context 'without a user' do
it 'returns no properties' do
stub_user
expect(helper.user_diffs_colors).to eq({})
end
end
end
describe '#custom_diff_color_classes' do
context 'with a user' do
it 'returns color classes' do
stub_user(diffs_addition_color: '#123456', diffs_deletion_color: '#abcdef')
expect(helper.custom_diff_color_classes)
.to match_array(%w[diff-custom-addition-color diff-custom-deletion-color])
end
it 'omits property if nil' do
stub_user(diffs_addition_color: '#123456', diffs_deletion_color: nil)
expect(helper.custom_diff_color_classes).to match_array(['diff-custom-addition-color'])
end
it 'omits property if blank' do
stub_user(diffs_addition_color: '', diffs_deletion_color: '#abcdef')
expect(helper.custom_diff_color_classes).to match_array(['diff-custom-deletion-color'])
end
end
context 'without a user' do
it 'returns no classes' do
stub_user
expect(helper.custom_diff_color_classes).to match_array([])
end
end
end
describe '#language_choices' do describe '#language_choices' do
include StubLanguagesTranslationPercentage include StubLanguagesTranslationPercentage
......
...@@ -5,6 +5,48 @@ require 'spec_helper' ...@@ -5,6 +5,48 @@ require 'spec_helper'
RSpec.describe UserPreference do RSpec.describe UserPreference do
let(:user_preference) { create(:user_preference) } let(:user_preference) { create(:user_preference) }
describe 'validations' do
describe 'diffs_deletion_color and diffs_addition_color' do
using RSpec::Parameterized::TableSyntax
where(color: [
'#000000',
'#123456',
'#abcdef',
'#AbCdEf',
'#ffffff',
'#fFfFfF',
'#000',
'#123',
'#abc',
'#AbC',
'#fff',
'#fFf',
''
])
with_them do
it { is_expected.to allow_value(color).for(:diffs_deletion_color) }
it { is_expected.to allow_value(color).for(:diffs_addition_color) }
end
where(color: [
'#1',
'#12',
'#1234',
'#12345',
'#1234567',
'123456',
'#12345x'
])
with_them do
it { is_expected.not_to allow_value(color).for(:diffs_deletion_color) }
it { is_expected.not_to allow_value(color).for(:diffs_addition_color) }
end
end
end
describe 'notes filters global keys' do describe 'notes filters global keys' do
it 'contains expected values' do it 'contains expected values' do
expect(UserPreference::NOTES_FILTERS.keys).to match_array([:all_notes, :only_comments, :only_activity]) expect(UserPreference::NOTES_FILTERS.keys).to match_array([:all_notes, :only_comments, :only_activity])
......
...@@ -69,6 +69,12 @@ RSpec.describe User do ...@@ -69,6 +69,12 @@ RSpec.describe User do
it { is_expected.to delegate_method(:markdown_surround_selection).to(:user_preference) } it { is_expected.to delegate_method(:markdown_surround_selection).to(:user_preference) }
it { is_expected.to delegate_method(:markdown_surround_selection=).to(:user_preference).with_arguments(:args) } it { is_expected.to delegate_method(:markdown_surround_selection=).to(:user_preference).with_arguments(:args) }
it { is_expected.to delegate_method(:diffs_deletion_color).to(:user_preference) }
it { is_expected.to delegate_method(:diffs_deletion_color=).to(:user_preference).with_arguments(:args) }
it { is_expected.to delegate_method(:diffs_addition_color).to(:user_preference) }
it { is_expected.to delegate_method(:diffs_addition_color=).to(:user_preference).with_arguments(:args) }
it { is_expected.to delegate_method(:job_title).to(:user_detail).allow_nil } it { is_expected.to delegate_method(:job_title).to(:user_detail).allow_nil }
it { is_expected.to delegate_method(:job_title=).to(:user_detail).with_arguments(:args).allow_nil } it { is_expected.to delegate_method(:job_title=).to(:user_detail).with_arguments(:args).allow_nil }
......
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