Commit 6912e757 authored by Michael Lunøe's avatar Michael Lunøe Committed by Jose Ivan Vargas

Refactor(profile preferences): migrate rails eval

Migrate rails eval'ed js to form event listeners
parent 8822a07e
<script>
import { s__ } from '~/locale';
import { GlButton } from '@gitlab/ui';
import createFlash, { FLASH_TYPES } from '~/flash';
import { INTEGRATION_VIEW_CONFIGS, i18n } from '../constants';
import IntegrationView from './integration_view.vue';
const INTEGRATION_VIEW_CONFIGS = {
sourcegraph: {
title: s__('ProfilePreferences|Sourcegraph'),
label: s__('ProfilePreferences|Enable integrated code intelligence on code views'),
formName: 'sourcegraph_enabled',
},
gitpod: {
title: s__('ProfilePreferences|Gitpod'),
label: s__('ProfilePreferences|Enable Gitpod integration'),
formName: 'gitpod_enabled',
},
};
function updateClasses(bodyClasses = '', applicationTheme, layout) {
// Remove body class for any previous theme, re-add current one
document.body.classList.remove(...bodyClasses.split(' '));
document.body.classList.add(applicationTheme);
// Toggle container-fluid class
if (layout === 'fluid') {
document
.querySelector('.content-wrapper .container-fluid')
.classList.remove('container-limited');
} else {
document.querySelector('.content-wrapper .container-fluid').classList.add('container-limited');
}
}
export default {
name: 'ProfilePreferences',
components: {
IntegrationView,
GlButton,
},
inject: {
integrationViews: {
default: [],
},
themes: {
default: [],
},
userFields: {
default: {},
},
formEl: 'formEl',
profilePreferencesPath: 'profilePreferencesPath',
bodyClasses: 'bodyClasses',
},
integrationViewConfigs: INTEGRATION_VIEW_CONFIGS,
i18n,
data() {
return {
isSubmitEnabled: true,
};
},
computed: {
applicationThemes() {
return this.themes.reduce((themes, theme) => {
const { id, ...rest } = theme;
return { ...themes, [id]: rest };
}, {});
},
},
created() {
this.formEl.addEventListener('ajax:beforeSend', this.handleLoading);
this.formEl.addEventListener('ajax:success', this.handleSuccess);
this.formEl.addEventListener('ajax:error', this.handleError);
},
beforeDestroy() {
this.formEl.removeEventListener('ajax:beforeSend', this.handleLoading);
this.formEl.removeEventListener('ajax:success', this.handleSuccess);
this.formEl.removeEventListener('ajax:error', this.handleError);
},
methods: {
handleLoading() {
this.isSubmitEnabled = false;
},
handleSuccess(customEvent) {
const formData = new FormData(this.formEl);
updateClasses(
this.bodyClasses,
this.applicationThemes[formData.get('user[theme_id]')].css_class,
this.selectedLayout,
);
const { message = this.$options.i18n.defaultSuccess, type = FLASH_TYPES.NOTICE } =
customEvent?.detail?.[0] || {};
createFlash({ message, type });
this.isSubmitEnabled = true;
},
handleError(customEvent) {
const { message = this.$options.i18n.defaultError, type = FLASH_TYPES.ALERT } =
customEvent?.detail?.[0] || {};
createFlash({ message, type });
this.isSubmitEnabled = true;
},
},
};
</script>
......@@ -36,10 +97,10 @@ export default {
</div>
<div v-if="integrationViews.length" class="col-lg-4 profile-settings-sidebar">
<h4 class="gl-mt-0" data-testid="profile-preferences-integrations-heading">
{{ s__('ProfilePreferences|Integrations') }}
{{ $options.i18n.integrations }}
</h4>
<p>
{{ s__('ProfilePreferences|Customize integrations with third party services.') }}
{{ $options.i18n.integrationsDescription }}
</p>
</div>
<div v-if="integrationViews.length" class="col-lg-8">
......@@ -52,5 +113,19 @@ export default {
:config="$options.integrationViewConfigs[view.name]"
/>
</div>
<div class="col-lg-4 profile-settings-sidebar"></div>
<div class="col-lg-8">
<div class="form-group">
<gl-button
variant="success"
name="commit"
type="submit"
:disabled="!isSubmitEnabled"
:value="$options.i18n.saveChanges"
>
{{ $options.i18n.saveChanges }}
</gl-button>
</div>
</div>
</div>
</template>
import { s__, __ } from '~/locale';
export const INTEGRATION_VIEW_CONFIGS = {
sourcegraph: {
title: s__('Preferences|Sourcegraph'),
label: s__('Preferences|Enable integrated code intelligence on code views'),
formName: 'sourcegraph_enabled',
},
gitpod: {
title: s__('Preferences|Gitpod'),
label: s__('Preferences|Enable Gitpod integration'),
formName: 'gitpod_enabled',
},
};
export const i18n = {
saveChanges: __('Save changes'),
defaultSuccess: __('Preferences saved.'),
defaultError: s__('Preferences|Failed to save preferences.'),
integrations: s__('Preferences|Integrations'),
integrationsDescription: s__('Preferences|Customize integrations with third party services.'),
};
......@@ -3,16 +3,20 @@ import ProfilePreferences from './components/profile_preferences.vue';
export default () => {
const el = document.querySelector('#js-profile-preferences-app');
const shouldParse = ['integrationViews', 'userFields'];
const formEl = document.querySelector('#profile-preferences-form');
const shouldParse = ['integrationViews', 'themes', 'userFields'];
const provide = Object.keys(el.dataset).reduce((memo, key) => {
const provide = Object.keys(el.dataset).reduce(
(memo, key) => {
let value = el.dataset[key];
if (shouldParse.includes(key)) {
value = JSON.parse(value);
}
return { ...memo, [key]: value };
}, {});
},
{ formEl },
);
return new Vue({
el,
......
......@@ -9,23 +9,18 @@ class Profiles::PreferencesController < Profiles::ApplicationController
end
def update
begin
result = Users::UpdateService.new(current_user, preferences_params.merge(user: user)).execute
if result[:status] == :success
flash[:notice] = _('Preferences saved.')
message = _('Preferences saved.')
render json: { type: :notice, message: message }
else
flash[:alert] = _('Failed to save preferences.')
render status: :bad_request, json: { type: :alert, message: _('Failed to save preferences.') }
end
rescue ArgumentError => e
# Raised when `dashboard` is given an invalid value.
flash[:alert] = _("Failed to save preferences (%{error_message}).") % { error_message: e.message }
end
respond_to do |format|
format.html { redirect_to profile_preferences_path }
format.js
end
message = _("Failed to save preferences (%{error_message}).") % { error_message: e.message }
render status: :bad_request, json: { type: :alert, message: message }
end
private
......
- page_title _('Preferences')
- @content_class = "limit-container-width" unless fluid_layout
- user_fields = { gitpod_enabled: @user.gitpod_enabled, sourcegraph_enabled: @user.sourcegraph_enabled }
- user_theme_id = Gitlab::Themes.for_user(@user).id
- data_attributes = { integration_views: integration_views.to_json, user_fields: user_fields.to_json }
- user_fields = { theme: user_theme_id, gitpod_enabled: @user.gitpod_enabled, sourcegraph_enabled: @user.sourcegraph_enabled }.to_json
- @themes = Gitlab::Themes::THEMES.to_json
- data_attributes = { themes: @themes, integration_views: integration_views.to_json, user_fields: user_fields, body_classes: Gitlab::Themes.body_classes, profile_preferences_path: profile_preferences_path }
- Gitlab::Themes.each do |theme|
= stylesheet_link_tag "themes/#{theme.css_filename}" if theme.css_filename
= form_for @user, url: profile_preferences_path, remote: true, method: :put do |f|
= form_for @user, url: profile_preferences_path, remote: true, method: :put, html: { id: "profile-preferences-form" } do |f|
.row.gl-mt-3.js-preferences-form.js-search-settings-section
.col-lg-4.application-theme#navigation-theme
%h4.gl-mt-0
......@@ -143,10 +144,4 @@
.form-text.text-muted
= s_('Preferences|For example: 30 mins ago.')
#js-profile-preferences-app{ data: data_attributes, user_fields: user_fields.to_json }
.row.gl-mt-3.js-preferences-form
.col-lg-4.profile-settings-sidebar
.col-lg-8
.form-group
= f.submit _('Save changes'), class: 'gl-button btn btn-success'
#js-profile-preferences-app{ data: data_attributes }
// Remove body class for any previous theme, re-add current one
$('body').removeClass('<%= Gitlab::Themes.body_classes %>')
$('body').addClass('<%= user_application_theme %>')
// Toggle container-fluid class
if ('<%= current_user.layout %>' === 'fluid') {
$('.content-wrapper .container-fluid').removeClass('container-limited')
} else {
$('.content-wrapper .container-fluid').addClass('container-limited')
}
// Re-enable the "Save" button
$('input[type=submit]').enable()
// Show flash messages
<% if flash.notice %>
new Flash({ message: '<%= flash.discard(:notice) %>', type: 'notice'})
<% elsif flash.alert %>
new Flash({ message: '<%= flash.discard(:alert) %>', type: 'alert'})
<% end %>
......@@ -10,7 +10,7 @@ RSpec.describe Profiles::PreferencesController do
end
describe 'PATCH update' do
subject { patch :update, params: { user: { group_view: group_view } }, format: :js }
subject { patch :update, params: { user: { group_view: group_view } }, format: :json }
let(:group_view) { 'security_dashboard' }
......@@ -27,9 +27,12 @@ RSpec.describe Profiles::PreferencesController do
context 'and an invalid group view choice is submitted' do
let(:group_view) { 'foo' }
it 'sets the flash' do
it 'responds with an error message' do
subject
expect(flash[:alert]).to match(/Failed to save preferences/)
expect(response).to have_gitlab_http_status(:bad_request)
expect(response.parsed_body['message']).to match(/Failed to save preferences/)
expect(response.parsed_body['type']).to eq('alert')
end
end
end
......
......@@ -22218,21 +22218,39 @@ msgstr ""
msgid "Preferences|Choose what content you want to see on your homepage."
msgstr ""
msgid "Preferences|Customize integrations with third party services."
msgstr ""
msgid "Preferences|Customize the appearance of the application header and navigation sidebar."
msgstr ""
msgid "Preferences|Display time in 24-hour format"
msgstr ""
msgid "Preferences|Enable Gitpod integration"
msgstr ""
msgid "Preferences|Enable integrated code intelligence on code views"
msgstr ""
msgid "Preferences|Failed to save preferences."
msgstr ""
msgid "Preferences|For example: 30 mins ago."
msgstr ""
msgid "Preferences|Gitpod"
msgstr ""
msgid "Preferences|Homepage content"
msgstr ""
msgid "Preferences|Instead of all the files changed, show only one file at a time. To switch between files, use the file browser."
msgstr ""
msgid "Preferences|Integrations"
msgstr ""
msgid "Preferences|Layout width"
msgstr ""
......@@ -22254,6 +22272,9 @@ msgstr ""
msgid "Preferences|Show whitespace changes in diffs"
msgstr ""
msgid "Preferences|Sourcegraph"
msgstr ""
msgid "Preferences|Syntax highlighting theme"
msgstr ""
......@@ -22446,24 +22467,6 @@ msgstr ""
msgid "Profile Settings"
msgstr ""
msgid "ProfilePreferences|Customize integrations with third party services."
msgstr ""
msgid "ProfilePreferences|Enable Gitpod integration"
msgstr ""
msgid "ProfilePreferences|Enable integrated code intelligence on code views"
msgstr ""
msgid "ProfilePreferences|Gitpod"
msgstr ""
msgid "ProfilePreferences|Integrations"
msgstr ""
msgid "ProfilePreferences|Sourcegraph"
msgstr ""
msgid "ProfileSession|on"
msgstr ""
......
......@@ -24,7 +24,7 @@ RSpec.describe Profiles::PreferencesController do
end
describe 'PATCH update' do
def go(params: {}, format: :js)
def go(params: {}, format: :json)
params.reverse_merge!(
color_scheme_id: '1',
dashboard: 'stars',
......@@ -35,9 +35,12 @@ RSpec.describe Profiles::PreferencesController do
end
context 'on successful update' do
it 'sets the flash' do
it 'responds with success' do
go
expect(flash[:notice]).to eq _('Preferences saved.')
expect(response).to have_gitlab_http_status(:ok)
expect(response.parsed_body['message']).to eq _('Preferences saved.')
expect(response.parsed_body['type']).to eq('notice')
end
it "changes the user's preferences" do
......@@ -59,36 +62,26 @@ RSpec.describe Profiles::PreferencesController do
end
context 'on failed update' do
it 'sets the flash' do
it 'responds with error' do
expect(user).to receive(:save).and_return(false)
go
expect(flash[:alert]).to eq(_('Failed to save preferences.'))
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
context 'on invalid dashboard setting' do
it 'sets the flash' do
it 'responds with error' do
prefs = { dashboard: 'invalid' }
go params: prefs
expect(flash[:alert]).to match(/\AFailed to save preferences \(.+\)\.\z/)
end
end
context 'as js' do
it 'renders' do
go
expect(response).to render_template :update
end
end
context 'as html' do
it 'redirects' do
go format: :html
expect(response).to redirect_to(profile_preferences_path)
expect(response).to have_gitlab_http_status(:bad_request)
expect(response.parsed_body['message']).to match(/\AFailed to save preferences \(.+\)\.\z/)
expect(response.parsed_body['type']).to eq('alert')
end
end
end
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'User edit preferences profile' do
RSpec.describe 'User edit preferences profile', :js do
let(:user) { create(:user) }
before do
......@@ -53,7 +53,14 @@ RSpec.describe 'User edit preferences profile' do
fill_in 'Tab width', with: -1
click_button 'Save changes'
expect(page).to have_content('Failed to save preferences')
field = page.find_field('user[tab_width]')
message = field.native.attribute("validationMessage")
expect(message).to eq "Value must be greater than or equal to 1."
# User trying to hack an invalid value
page.execute_script("document.querySelector('#user_tab_width').setAttribute('min', '-1')")
click_button 'Save changes'
expect(page).to have_content('Failed to save preferences.')
end
end
......
......@@ -2,7 +2,7 @@
require 'spec_helper'
RSpec.describe 'User visits the profile preferences page' do
RSpec.describe 'User visits the profile preferences page', :js do
include Select2Helper
let(:user) { create(:user) }
......@@ -39,7 +39,7 @@ RSpec.describe 'User visits the profile preferences page' do
describe 'User changes their default dashboard', :js do
it 'creates a flash message' do
select2('stars', from: '#user_dashboard')
click_button 'Save'
click_button 'Save changes'
wait_for_requests
......@@ -48,7 +48,7 @@ RSpec.describe 'User visits the profile preferences page' do
it 'updates their preference' do
select2('stars', from: '#user_dashboard')
click_button 'Save'
click_button 'Save changes'
wait_for_requests
......@@ -67,7 +67,7 @@ RSpec.describe 'User visits the profile preferences page' do
describe 'User changes their language', :js do
it 'creates a flash message', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/31404' do
select2('en', from: '#user_preferred_language')
click_button 'Save'
click_button 'Save changes'
wait_for_requests
......@@ -77,7 +77,7 @@ RSpec.describe 'User visits the profile preferences page' do
it 'updates their preference' do
wait_for_requests
select2('pt_BR', from: '#user_preferred_language')
click_button 'Save'
click_button 'Save changes'
wait_for_requests
refresh
......@@ -94,6 +94,8 @@ RSpec.describe 'User visits the profile preferences page' do
click_button 'Save changes'
wait_for_requests
expect(user.reload.render_whitespace_in_code).to be(true)
expect(render_whitespace_field).to be_checked
end
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`IntegrationView component should render IntegrationView properly 1`] = `
<div
name="sourcegraph"
>
<label
class="label-bold"
>
Foo
</label>
<gl-link-stub
class="has-tooltip"
href="http://foo.com/help"
title="More information"
>
<gl-icon-stub
class="vertical-align-middle"
name="question-o"
size="16"
/>
</gl-link-stub>
<div
class="form-group form-check"
data-testid="profile-preferences-integration-form-group"
>
<input
data-testid="profile-preferences-integration-hidden-field"
name="user[foo_enabled]"
type="hidden"
value="0"
/>
<input
class="form-check-input"
data-testid="profile-preferences-integration-checkbox"
id="user_foo_enabled"
name="user[foo_enabled]"
type="checkbox"
value="1"
/>
<label
class="form-check-label"
for="user_foo_enabled"
>
Enable foo
</label>
<gl-form-text-stub
tag="div"
textvariant="muted"
>
<integration-help-text-stub
message="Click %{linkStart}Foo%{linkEnd}!"
messageurl="http://foo.com"
/>
</gl-form-text-stub>
</div>
</div>
`;
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ProfilePreferences component should render ProfilePreferences properly 1`] = `
<div
class="row gl-mt-3 js-preferences-form"
>
<div
class="col-sm-12"
>
<hr
data-testid="profile-preferences-integrations-rule"
/>
</div>
<div
class="col-lg-4 profile-settings-sidebar"
>
<h4
class="gl-mt-0"
data-testid="profile-preferences-integrations-heading"
>
Integrations
</h4>
<p>
Customize integrations with third party services.
</p>
</div>
<div
class="col-lg-8"
>
<integration-view-stub
config="[object Object]"
helplink="http://foo.com/help"
message="Click %{linkStart}Foo%{linkEnd}!"
messageurl="http://foo.com"
/>
<integration-view-stub
config="[object Object]"
helplink="http://bar.com/help"
message="Click %{linkStart}Bar%{linkEnd}!"
messageurl="http://bar.com"
/>
</div>
</div>
`;
......@@ -115,10 +115,4 @@ describe('IntegrationView component', () => {
expect(findFormGroupLabel().text()).toBe('Enable foo');
});
it('should render IntegrationView properly', () => {
wrapper = createComponent();
expect(wrapper.element).toMatchSnapshot();
});
});
import { GlButton } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import IntegrationView from '~/profile/preferences/components/integration_view.vue';
import ProfilePreferences from '~/profile/preferences/components/profile_preferences.vue';
import { integrationViews, userFields } from '../mock_data';
import { i18n } from '~/profile/preferences/constants';
import { integrationViews, userFields, bodyClasses } from '../mock_data';
const expectedUrl = '/foo';
describe('ProfilePreferences component', () => {
let wrapper;
const defaultProvide = {
integrationViews: [],
userFields,
bodyClasses,
themes: [{ id: 1, css_class: 'foo' }],
profilePreferencesPath: '/update-profile',
formEl: document.createElement('form'),
};
function createComponent(options = {}) {
const { props = {}, provide = {} } = options;
return shallowMount(ProfilePreferences, {
const { props = {}, provide = {}, attachTo } = options;
return extendedWrapper(
shallowMount(ProfilePreferences, {
provide: {
...defaultProvide,
...provide,
},
propsData: props,
});
attachTo,
}),
);
}
function findIntegrationsDivider() {
return wrapper.findByTestId('profile-preferences-integrations-rule');
}
function findIntegrationsHeading() {
return wrapper.findByTestId('profile-preferences-integrations-heading');
}
function findSubmitButton() {
return wrapper.findComponent(GlButton);
}
function findFlashError() {
return document.querySelector('.flash-container .flash-text');
}
beforeEach(() => {
setFixtures('<div class="flash-container"></div>');
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
......@@ -30,8 +61,8 @@ describe('ProfilePreferences component', () => {
it('should not render Integrations section', () => {
wrapper = createComponent();
const views = wrapper.findAll(IntegrationView);
const divider = wrapper.find('[data-testid="profile-preferences-integrations-rule"]');
const heading = wrapper.find('[data-testid="profile-preferences-integrations-heading"]');
const divider = findIntegrationsDivider();
const heading = findIntegrationsHeading();
expect(divider.exists()).toBe(false);
expect(heading.exists()).toBe(false);
......@@ -40,8 +71,8 @@ describe('ProfilePreferences component', () => {
it('should render Integration section', () => {
wrapper = createComponent({ provide: { integrationViews } });
const divider = wrapper.find('[data-testid="profile-preferences-integrations-rule"]');
const heading = wrapper.find('[data-testid="profile-preferences-integrations-heading"]');
const divider = findIntegrationsDivider();
const heading = findIntegrationsHeading();
const views = wrapper.findAll(IntegrationView);
expect(divider.exists()).toBe(true);
......@@ -49,9 +80,84 @@ describe('ProfilePreferences component', () => {
expect(views).toHaveLength(integrationViews.length);
});
it('should render ProfilePreferences properly', () => {
wrapper = createComponent({ provide: { integrationViews } });
describe('form submit', () => {
let form;
beforeEach(() => {
const div = document.createElement('div');
div.classList.add('container-fluid');
document.body.appendChild(div);
document.body.classList.add('content-wrapper');
form = document.createElement('form');
form.setAttribute('url', expectedUrl);
form.setAttribute('method', 'put');
const input = document.createElement('input');
input.setAttribute('name', 'user[theme_id]');
input.setAttribute('type', 'radio');
input.setAttribute('value', '1');
input.setAttribute('checked', 'checked');
form.appendChild(input);
wrapper = createComponent({ provide: { formEl: form }, attachTo: document.body });
const beforeSendEvent = new CustomEvent('ajax:beforeSend');
form.dispatchEvent(beforeSendEvent);
});
expect(wrapper.element).toMatchSnapshot();
it('disables the submit button', async () => {
await wrapper.vm.$nextTick();
const button = findSubmitButton();
expect(button.props('disabled')).toBe(true);
});
it('success re-enables the submit button', async () => {
const successEvent = new CustomEvent('ajax:success');
form.dispatchEvent(successEvent);
await wrapper.vm.$nextTick();
const button = findSubmitButton();
expect(button.props('disabled')).toBe(false);
});
it('error re-enables the submit button', async () => {
const errorEvent = new CustomEvent('ajax:error');
form.dispatchEvent(errorEvent);
await wrapper.vm.$nextTick();
const button = findSubmitButton();
expect(button.props('disabled')).toBe(false);
});
it('displays the default success message', () => {
const successEvent = new CustomEvent('ajax:success');
form.dispatchEvent(successEvent);
expect(findFlashError().innerText.trim()).toEqual(i18n.defaultSuccess);
});
it('displays the custom success message', () => {
const message = 'foo';
const successEvent = new CustomEvent('ajax:success', { detail: [{ message }] });
form.dispatchEvent(successEvent);
expect(findFlashError().innerText.trim()).toEqual(message);
});
it('displays the default error message', () => {
const errorEvent = new CustomEvent('ajax:error');
form.dispatchEvent(errorEvent);
expect(findFlashError().innerText.trim()).toEqual(i18n.defaultError);
});
it('displays the custom error message', () => {
const message = 'bar';
const errorEvent = new CustomEvent('ajax:error', { detail: [{ message }] });
form.dispatchEvent(errorEvent);
expect(findFlashError().innerText.trim()).toEqual(message);
});
});
});
......@@ -16,3 +16,5 @@ export const integrationViews = [
export const userFields = {
foo_enabled: true,
};
export const bodyClasses = 'ui-light-indigo ui-light gl-dark';
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