diff --git a/.eslintrc b/.eslintrc index c10f3cde9..fa33de0ff 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,11 +1,14 @@ { "env": { "jquery": true, - "browser": true, + "browser": true }, "extends": "airbnb-base", "globals": { "Bloodhound": false, + "layout_resizer": false, + "set_typeahead": false, + "open_close_icon": false }, "plugins": [ "import" diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index 96fd566a7..25be6827c 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -17,8 +17,6 @@ require('vendor/bootstrap-typeahead'); // Require tree. // NOTE: This should be moved into proper modules. require('./alert'); -require('./auth/cover'); -require('./auth/registrations'); require('./bootstrap'); require('./dashboard'); require('./includes/open_close_icon'); @@ -27,3 +25,7 @@ require('./namespaces'); require('./open_search'); require('./repositories'); require('./teams'); + +// new modules structure +require('./modules/users'); + diff --git a/app/assets/javascripts/auth/cover.js b/app/assets/javascripts/auth/cover.js deleted file mode 100644 index e2418d03f..000000000 --- a/app/assets/javascripts/auth/cover.js +++ /dev/null @@ -1,8 +0,0 @@ -jQuery(function ($) { - var rndNum; - - if ($('body section.sign-up, body section.login').length) { - rndNum = Math.floor((Math.random() * 2) + 1); - $('body').addClass('massive-background-' + rndNum).hide().fadeIn(1000); - } -}); diff --git a/app/assets/javascripts/auth/registrations.js b/app/assets/javascripts/auth/registrations.js deleted file mode 100644 index c91c4f123..000000000 --- a/app/assets/javascripts/auth/registrations.js +++ /dev/null @@ -1,55 +0,0 @@ -/* global layout_resizer */ - -jQuery(function ($) { - var email = $('#user_email').val(); - var display = $('#user_display_name').val(); - - $('#user_email').keyup(function () { - var val = $('#user_email').val(); - var dname = $('#user_display_name').val(); - - if (dname === display && (val === email || val === '')) { - $('#edit_user.profile .btn').attr('disabled', 'disabled'); - } else { - $('#edit_user.profile .btn').removeAttr('disabled'); - } - }); - - $('#user_display_name').keyup(function () { - var val = $('#user_display_name').val(); - var em = $('#user_email').val(); - - if (val === display && (em === email || em === '')) { - $('#edit_user.profile .btn').attr('disabled', 'disabled'); - } else { - $('#edit_user.profile .btn').removeAttr('disabled'); - } - }); - - $('#edit_user.password .form-control').keyup(function () { - var current = $('#user_current_password').val(); - var password = $('#user_password').val(); - var confirm = $('#user_password_confirmation').val(); - - if (current !== '' && password !== '' && confirm !== '' && password === confirm) { - $('#edit_user.password .btn').removeAttr('disabled'); - } else { - $('#edit_user.password .btn').attr('disabled', 'disabled'); - } - }); - - $('#add_application_token_btn').on('click', function (_event) { - $('#add_application_token_form').toggle(400, 'swing', function () { - if ($('#add_application_token_form').is(':visible')) { - $('#add_application_token_btn i').addClass('fa-minus-circle'); - $('#add_application_token_btn i').removeClass('fa-plus-circle'); - $('#application_token_application').val(''); - $('#application_token_application').focus(); - } else { - $('#add_application_token_btn i').removeClass('fa-minus-circle'); - $('#add_application_token_btn i').addClass('fa-plus-circle'); - } - layout_resizer(); - }); - }); -}); diff --git a/app/assets/javascripts/base/component.js b/app/assets/javascripts/base/component.js new file mode 100644 index 000000000..a6c6b692b --- /dev/null +++ b/app/assets/javascripts/base/component.js @@ -0,0 +1,39 @@ +/* eslint-disable class-methods-use-this */ + +// BaseComponent class to avoid boilerplate code +// into component classes and add some convention. +// +// The subclasses are not obligated to implement all +// of these methods but if they need to perform an action +// that fits one of the descriptions below, it's highly +// recommended to use the proper method. +class BaseComponent { + // Calls in order 'elements()', 'events()', 'beforeMount()', + // 'mount()' and 'mounted()'. + constructor(el) { + this.$el = el; + + this.elements(); + this.events(); + this.beforeMount(); + this.mount(); + this.mounted(); + } + + // Caches HTML elements for further use. + elements() { } + + // Attaches listeners to events triggered by HTML elements. + events() { } + + // Before mount hook. + beforeMount() { } + + // Renders the component. + mount() { } + + // After mount hook. + mounted() { } +} + +export default BaseComponent; diff --git a/app/assets/javascripts/modules/users/components/application-token-panel.js b/app/assets/javascripts/modules/users/components/application-token-panel.js new file mode 100644 index 000000000..6ffb5049e --- /dev/null +++ b/app/assets/javascripts/modules/users/components/application-token-panel.js @@ -0,0 +1,43 @@ +import BaseComponent from '~/base/component'; + +const TOGGLE_LINK = '#add_application_token_btn'; +const TOGGLE_LINK_ICON = `${TOGGLE_LINK} i`; +const APP_TOKEN_FORM = '#add_application_token_form'; +const APP_TOKEN_FIELD = '#application_token_application'; + +// ApplicationTokenPanel component that handles application +// token interactions. +class ApplicationTokenPanel extends BaseComponent { + elements() { + this.$toggle = this.$el.find(TOGGLE_LINK); + this.$toggleIcon = this.$el.find(TOGGLE_LINK_ICON); + this.$form = this.$el.find(APP_TOKEN_FORM); + this.$token = this.$el.find(APP_TOKEN_FIELD); + } + + events() { + this.$el.on('click', TOGGLE_LINK, e => this.onClick(e)); + } + + onClick() { + this.$form.toggle(400, 'swing', () => { + const visible = this.$form.is(':visible'); + + if (visible) { + this.clearFields(); + } + + this.$toggleIcon.toggleClass('fa-minus-circle', visible); + this.$toggleIcon.toggleClass('fa-plus-circle', !visible); + + layout_resizer(); + }); + } + + clearFields() { + this.$token.val(''); + this.$token.focus(); + } +} + +export default ApplicationTokenPanel; diff --git a/app/assets/javascripts/modules/users/components/password-form.js b/app/assets/javascripts/modules/users/components/password-form.js new file mode 100644 index 000000000..04b61573e --- /dev/null +++ b/app/assets/javascripts/modules/users/components/password-form.js @@ -0,0 +1,42 @@ +import BaseComponent from '~/base/component'; + +const CURRENT_PASSWORD_FIELD = '#user_current_password'; +const NEW_PASSWORD_FIELD = '#user_password'; +const NEW_CONFIRMATION_PASSWORD_FIELD = '#user_password_confirmation'; +const SUBMIT_BUTTON = 'input[type=submit]'; + +// UsersPasswordForm component that handles user password form +// interactions. +class UsersPasswordForm extends BaseComponent { + elements() { + this.$currentPassword = this.$el.find(CURRENT_PASSWORD_FIELD); + this.$newPassword = this.$el.find(NEW_PASSWORD_FIELD); + this.$newPasswordConfirmation = this.$el.find(NEW_CONFIRMATION_PASSWORD_FIELD); + this.$submit = this.$el.find(SUBMIT_BUTTON); + } + + events() { + this.$el.on('keyup', CURRENT_PASSWORD_FIELD, e => this.onKeyup(e)); + this.$el.on('keyup', NEW_PASSWORD_FIELD, e => this.onKeyup(e)); + this.$el.on('keyup', NEW_CONFIRMATION_PASSWORD_FIELD, e => this.onKeyup(e)); + } + + onKeyup() { + const currentPassword = this.$currentPassword.val(); + const newPassword = this.$newPassword.val(); + const newPasswordConfirmation = this.$newPasswordConfirmation.val(); + + const currentPasswordInvalid = !currentPassword; + const newPasswordInvalid = !newPassword; + const newPasswordConfirmationInvalid = !newPasswordConfirmation || + newPassword !== newPasswordConfirmation; + + if (currentPasswordInvalid || newPasswordInvalid || newPasswordConfirmationInvalid) { + this.$submit.attr('disabled', 'disabled'); + } else { + this.$submit.removeAttr('disabled'); + } + } +} + +export default UsersPasswordForm; diff --git a/app/assets/javascripts/modules/users/components/profile-form.js b/app/assets/javascripts/modules/users/components/profile-form.js new file mode 100644 index 000000000..8bf6fb246 --- /dev/null +++ b/app/assets/javascripts/modules/users/components/profile-form.js @@ -0,0 +1,42 @@ +import BaseComponent from '~/base/component'; + +const EMAIL_FIELD = '#user_email'; +const DISPLAY_NAME_FIELD = '#user_display_name'; +const SUBMIT_BUTTON = 'input[type=submit]'; + +// UsersProfileForm component handles user profile form +// interactions. +class UsersProfileForm extends BaseComponent { + elements() { + this.$email = this.$el.find(EMAIL_FIELD); + this.$displayName = this.$el.find(DISPLAY_NAME_FIELD); + this.$submit = this.$el.find(SUBMIT_BUTTON); + } + + events() { + this.$el.on('keyup', EMAIL_FIELD, e => this.onKeyup(e)); + this.$el.on('keyup', DISPLAY_NAME_FIELD, e => this.onKeyup(e)); + } + + onKeyup() { + const email = this.$email.val(); + const displayName = this.$displayName.val(); + + const emailInvalid = !email || email === this.originalEmail; + const displayNameInvalid = this.$displayName[0] && + (!displayName || displayName === this.originalDisplayName); + + if (emailInvalid || displayNameInvalid) { + this.$submit.attr('disabled', 'disabled'); + } else { + this.$submit.removeAttr('disabled'); + } + } + + mounted() { + this.originalEmail = this.$email.val(); + this.originalDisplayName = this.$displayName.val(); + } +} + +export default UsersProfileForm; diff --git a/app/assets/javascripts/modules/users/index.js b/app/assets/javascripts/modules/users/index.js new file mode 100644 index 000000000..3bb946d6d --- /dev/null +++ b/app/assets/javascripts/modules/users/index.js @@ -0,0 +1,27 @@ +import UsersEditPage from './pages/edit'; +import UsersSignUpPage from './pages/sign-up'; +import UsersSignInPage from './pages/sign-in'; + +const USERS_EDIT_ROUTE = 'auth/registrations/edit'; +const USERS_SIGN_IN_ROUTE = 'auth/sessions/new'; +const USERS_SIGN_UP_ROUTE = 'auth/registrations/new'; + +$(() => { + const $body = $('body'); + const route = $body.data('route'); + + if (route === USERS_EDIT_ROUTE) { + // eslint-disable-next-line + new UsersEditPage($body); + } + + if (route === USERS_SIGN_UP_ROUTE) { + // eslint-disable-next-line + new UsersSignUpPage($body); + } + + if (route === USERS_SIGN_IN_ROUTE) { + // eslint-disable-next-line + new UsersSignInPage($body); + } +}); diff --git a/app/assets/javascripts/modules/users/pages/edit.js b/app/assets/javascripts/modules/users/pages/edit.js new file mode 100644 index 000000000..4923b3402 --- /dev/null +++ b/app/assets/javascripts/modules/users/pages/edit.js @@ -0,0 +1,27 @@ +import BaseComponent from '~/base/component'; + +import ProfileForm from '../components/profile-form'; +import PasswordForm from '../components/password-form'; +import ApplicationTokenPanel from '../components/application-token-panel'; + +const PROFILE_FORM = 'form.profile'; +const PASSWORD_FORM = 'form.password'; +const APP_TOKEN_PANEL = '.app-token-wrapper'; + +// UsersEditPage component responsible to instantiate +// the user's edit page components and handle interactions. +class UsersEditPage extends BaseComponent { + elements() { + this.$profileForm = this.$el.find(PROFILE_FORM); + this.$passwordForm = this.$el.find(PASSWORD_FORM); + this.$appTokenPanel = this.$el.find(APP_TOKEN_PANEL); + } + + mount() { + this.profileForm = new ProfileForm(this.$profileForm); + this.passwordForm = new PasswordForm(this.$passwordForm); + this.appTokenPanel = new ApplicationTokenPanel(this.$appTokenPanel); + } +} + +export default UsersEditPage; diff --git a/app/assets/javascripts/modules/users/pages/sign-in.js b/app/assets/javascripts/modules/users/pages/sign-in.js new file mode 100644 index 000000000..0167389a4 --- /dev/null +++ b/app/assets/javascripts/modules/users/pages/sign-in.js @@ -0,0 +1,13 @@ +import BaseComponent from '~/base/component'; + +import { fadeIn } from '~/utils/effects'; + +// UsersSignInPage component responsible to instantiate +// the user's sign in page components and handle interactions. +class UsersSignInPage extends BaseComponent { + mount() { + fadeIn(this.$el); + } +} + +export default UsersSignInPage; diff --git a/app/assets/javascripts/modules/users/pages/sign-up.js b/app/assets/javascripts/modules/users/pages/sign-up.js new file mode 100644 index 000000000..f981a5252 --- /dev/null +++ b/app/assets/javascripts/modules/users/pages/sign-up.js @@ -0,0 +1,13 @@ +import BaseComponent from '~/base/component'; + +import { fadeIn } from '~/utils/effects'; + +// UsersSignUpPage component responsible to instantiate +// the user's sign up page components and handle interactions. +class UsersSignUpPage extends BaseComponent { + mount() { + fadeIn(this.$el); + } +} + +export default UsersSignUpPage; diff --git a/app/assets/javascripts/namespaces.js b/app/assets/javascripts/namespaces.js index 035c9634f..b2de04237 100644 --- a/app/assets/javascripts/namespaces.js +++ b/app/assets/javascripts/namespaces.js @@ -1,8 +1,3 @@ -/* global layout_resizer, set_typeahead, open_close_icon */ - -//= require includes/set_typehead -//= require includes/open_close_icon - jQuery(function ($) { $('#edit_namespace').on('click', function (_event) { set_typeahead('/teams/typeahead/%QUERY'); diff --git a/app/assets/javascripts/repositories.js b/app/assets/javascripts/repositories.js index 9a2ec3b17..032ffa708 100644 --- a/app/assets/javascripts/repositories.js +++ b/app/assets/javascripts/repositories.js @@ -1,5 +1,3 @@ -/* global layout_resizer */ - jQuery(function ($) { // Shows and hides the comment form $('#write_comment_repository_btn').unbind('click').on('click', function (_e) { diff --git a/app/assets/javascripts/teams.js b/app/assets/javascripts/teams.js index de89c8ac1..9adc33d4e 100644 --- a/app/assets/javascripts/teams.js +++ b/app/assets/javascripts/teams.js @@ -1,7 +1,3 @@ -/* global layout_resizer, set_typeahead, open_close_icon */ - -//= require namespaces - jQuery(function ($) { $('#add_team_user_btn').on('click', function (_event) { var team_id; diff --git a/app/assets/javascripts/utils/effects.js b/app/assets/javascripts/utils/effects.js new file mode 100644 index 000000000..b9d86249e --- /dev/null +++ b/app/assets/javascripts/utils/effects.js @@ -0,0 +1,12 @@ +// after jquery was upgraded this effect was conflicting +// with lifeitup functions (probably layout_resizer) +// so setTimeout was the workaround I found to solve the error +export const fadeIn = function ($el) { + setTimeout(() => { + $el.hide().fadeIn(1000); + }, 0); +}; + +export default { + fadeIn, +}; diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 17bb4018e..e7199bbe1 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1,6 +1,18 @@ module ApplicationHelper include ActivitiesHelper + ACTION_ALIASES = { + "update" => "edit", + "create" => "new" + }.freeze + + def js_route + action_name = ACTION_ALIASES[controller.action_name] || controller.action_name + controller_name = controller.class.name.underscore.gsub("_controller", "") + + "#{controller_name}/#{action_name}" + end + # Render the user profile picture depending on the gravatar configuration. def user_image_tag(owner) email = owner.nil? ? nil : owner.email diff --git a/app/views/devise/registrations/edit.html.slim b/app/views/devise/registrations/edit.html.slim index 4a3688137..c9c26913a 100644 --- a/app/views/devise/registrations/edit.html.slim +++ b/app/views/devise/registrations/edit.html.slim @@ -27,44 +27,45 @@ .actions = f.submit('Update', class: 'btn btn-primary', disabled: true) - #add_application_token_form.collapse - = form_for :application_token, url: application_tokens_path, remote: true, html: {id: 'new-application-token-form', class: 'form-horizontal', role: 'form'} do |f| - .form-group - = f.label :application, {class: 'control-label col-md-2'} - .col-md-7 - = f.text_field(:application, class: 'form-control', required: true, placeholder: "Name") - .form-group - .col-md-offset-2.col-md-7 - = f.submit('Create', class: 'btn btn-primary') + .app-token-wrapper + #add_application_token_form.collapse + = form_for :application_token, url: application_tokens_path, remote: true, html: {id: 'new-application-token-form', class: 'form-horizontal', role: 'form'} do |f| + .form-group + = f.label :application, {class: 'control-label col-md-2'} + .col-md-7 + = f.text_field(:application, class: 'form-control', required: true, placeholder: "Name") + .form-group + .col-md-offset-2.col-md-7 + = f.submit('Create', class: 'btn btn-primary') - .panel.panel-default - .panel-heading - .row - .col-sm-6 - h5 - ' Application tokens - .col-sm-6.text-right - - if current_user.application_tokens.count >= User::APPLICATION_TOKENS_MAX - a#add_application_token_btn.btn.btn-xs.btn-link.js-toggle-button[disabled="disabled" role="button"] - i.fa.fa-plus-circle - | Create new token - - else - a#add_application_token_btn.btn.btn-xs.btn-link.js-toggle-button[role="button"] - i.fa.fa-plus-circle - | Create new token - .panel-body - .table-responsive - table.table.table-striped.table-hover - colgroup - col.col-90 - col.col-10 - thead - tr - th Application - th Remove - tbody#application_tokens - - current_user.application_tokens.each do |token| - = render partial: 'application_tokens/application_token', locals: {application_token: token} + .panel.panel-default + .panel-heading + .row + .col-sm-6 + h5 + ' Application tokens + .col-sm-6.text-right + - if current_user.application_tokens.count >= User::APPLICATION_TOKENS_MAX + a#add_application_token_btn.btn.btn-xs.btn-link.js-toggle-button[disabled="disabled" role="button"] + i.fa.fa-plus-circle + | Create new token + - else + a#add_application_token_btn.btn.btn-xs.btn-link.js-toggle-button[role="button"] + i.fa.fa-plus-circle + | Create new token + .panel-body + .table-responsive + table.table.table-striped.table-hover + colgroup + col.col-90 + col.col-10 + thead + tr + th Application + th Remove + tbody#application_tokens + - current_user.application_tokens.each do |token| + = render partial: 'application_tokens/application_token', locals: {application_token: token} - if current_user.email? - unless current_user.admin? && @admin_count == 1 diff --git a/app/views/layouts/application.html.slim b/app/views/layouts/application.html.slim index 745408668..8735faa30 100644 --- a/app/views/layouts/application.html.slim +++ b/app/views/layouts/application.html.slim @@ -29,7 +29,7 @@ html meta content="/favicon/browserconfig.xml" name="msapplication-config" meta content="#205683" name="theme-color" - body + body data-route="#{js_route}" header = render 'shared/header' .container-fluid diff --git a/app/views/layouts/authentication.html.slim b/app/views/layouts/authentication.html.slim index 53fdad230..7b5d607a7 100644 --- a/app/views/layouts/authentication.html.slim +++ b/app/views/layouts/authentication.html.slim @@ -28,6 +28,6 @@ html meta content="/favicon/mstile-144x144.png" name="msapplication-TileImage" meta content="/favicon/browserconfig.xml" name="msapplication-config" meta content="#205683" name="theme-color" - body.login + body.login data-route="#{js_route}" .container-fluid = yield diff --git a/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb index b851f2746..8f166b483 100644 --- a/spec/helpers/application_helper_spec.rb +++ b/spec/helpers/application_helper_spec.rb @@ -105,4 +105,25 @@ def time_tag(first, second, _args) expect(show_first_user_alert?).to be_falsey end end + + describe "#js_route" do + # controller.class === ActionView::TestCase::TestController + it "should return controller_name/action_name format" do + allow(controller).to receive(:action_name) { "action" } + + expect(js_route).to eq("action_view/test_case/test/action") + end + + it "should alias update as edit action name" do + allow(controller).to receive(:action_name) { "update" } + + expect(js_route).to eq("action_view/test_case/test/edit") + end + + it "should alias create as new action name" do + allow(controller).to receive(:action_name) { "create" } + + expect(js_route).to eq("action_view/test_case/test/new") + end + end end