GET /auth/name/<name> now serves possible free names

anonymous users are now warned if their new name is already chosen

User Settings is now without auto-save

Upon name conflict, a user will be suggested a new name which is then submitted if the user chooses to save after all.

Refactored conflicted name checking so it can be used in more places

Signup form now has an optional name field

Covered extra case where the debounced check happened too late. Support for submitting on enter.

Worked in scott's comments and got tests working again
This commit is contained in:
Ruben Vereecken 2014-07-10 20:50:16 +02:00
parent ae4a5eb460
commit e748417007
11 changed files with 212 additions and 25 deletions

View file

@ -19,8 +19,10 @@ module.exports = class CocoRouter extends Backbone.Router
# Direct links # Direct links
'test': go('TestView') 'test': go('TestView')
'test/*subpath': go('TestView') 'test/*subpath': go('TestView')
'demo': go('DemoView') 'demo': go('DemoView')
'demo/*subpath': go('DemoView') 'demo/*subpath': go('DemoView')
'play/ladder/:levelID': go('play/ladder/ladder_view') 'play/ladder/:levelID': go('play/ladder/ladder_view')
'play/ladder': go('play/ladder_home') 'play/ladder': go('play/ladder_home')

View file

@ -9,12 +9,12 @@ module.exports.formToObject = (el) ->
obj obj
module.exports.applyErrorsToForm = (el, errors) -> module.exports.applyErrorsToForm = (el, errors, warning=false) ->
errors = [errors] if not $.isArray(errors) errors = [errors] if not $.isArray(errors)
missingErrors = []
for error in errors for error in errors
if error.dataPath if error.dataPath
prop = error.dataPath[1..] prop = error.dataPath[1..]
console.log prop
message = error.message message = error.message
else else
@ -23,16 +23,28 @@ module.exports.applyErrorsToForm = (el, errors) ->
message = error.message if error.formatted message = error.message if error.formatted
prop = error.property prop = error.property
input = $("[name='#{prop}']", el) setErrorToProperty el, prop, message, warning
if not input.length
missingErrors.push(error) module.exports.setErrorToField = setErrorToField = (el, message, warning=false) ->
continue formGroup = el.closest('.form-group')
formGroup = input.closest('.form-group') unless formGroup.length
formGroup.addClass 'has-error' return console.error "#{el} did not contain a form group"
formGroup.append($("<span class='help-block error-help-block'>#{message}</span>"))
return missingErrors kind = if warning then 'warning' else 'error'
formGroup.addClass "has-#{kind}"
formGroup.append $("<span class='help-block #{kind}-help-block'>#{message}</span>")
module.exports.setErrorToProperty = setErrorToProperty = (el, property, message, warning=false) ->
input = $("[name='#{property}']", el)
unless input.length
return console.error "#{property} not found in #{el}"
setErrorToField input, message, warning
module.exports.clearFormAlerts = (el) -> module.exports.clearFormAlerts = (el) ->
$('.has-error', el).removeClass('has-error') $('.has-error', el).removeClass('has-error')
$('.has-warning', el).removeClass('has-warning')
$('.alert.alert-danger', el).remove() $('.alert.alert-danger', el).remove()
$('.alert.alert-warning', el).remove()
el.find('.help-block.error-help-block').remove() el.find('.help-block.error-help-block').remove()
el.find('.help-block.warning-help-block').remove()

View file

@ -69,6 +69,13 @@ module.exports = class User extends CocoModel
cache[idOrSlug] = user cache[idOrSlug] = user
user user
@getUnconflictedName: (name, done) ->
$.ajax "/auth/name/#{name}",
success: (data) -> done data.name
statusCode: 409: (data) ->
response = JSON.parse data.responseText
done response.name
getEnabledEmails: -> getEnabledEmails: ->
@migrateEmails() @migrateEmails()
emails = _.clone(@get('emails')) or {} emails = _.clone(@get('emails')) or {}

View file

@ -9,7 +9,7 @@ block content
else else
#save-button-container #save-button-container
button.btn#save-button.disabled.secret(data-i18n="account_settings.autosave") Changes Save Automatically button.btn#save-button.disabled(data-i18n="general.save" disabled="true") No Changes
ul.nav.nav-pills#settings-tabs ul.nav.nav-pills#settings-tabs
li li
@ -30,16 +30,19 @@ block content
#general-pane.tab-pane #general-pane.tab-pane
p p
.form .form
- var name = me.get('name') || '';
- var email = me.get('email');
- var admin = me.get('permissions').indexOf('admin') != -1;
.form-group .form-group
label.control-label(for="name", data-i18n="general.name") Name label.control-label(for="name", data-i18n="general.name") Name
input#name.form-control(name="name", type="text", value="#{me.get('name') || ''}") input#name.form-control(name="name", type="text", value="#{name}")
.form-group .form-group
label.control-label(for="email", data-i18n="general.email") Email label.control-label(for="email", data-i18n="general.email") Email
input#email.form-control(name="email", type="text", value="#{me.get('email')}") input#email.form-control(name="email", type="text", value="#{email}")
if !isProduction if !isProduction
.form-group.checkbox .form-group.checkbox
label(for="admin", data-i18n="account_settings.admin") Admin label(for="admin", data-i18n="account_settings.admin") Admin
input#admin(name="admin", type="checkbox", checked=me.get('permissions').indexOf('admin') != -1) input#admin(name="admin", type="checkbox", checked=admin)
#picture-pane.tab-pane #picture-pane.tab-pane

View file

@ -34,6 +34,12 @@ block modal-body-content
input#password.input-large.form-control(name="password", type="password", value=formValues.password) input#password.input-large.form-control(name="password", type="password", value=formValues.password)
if mode === 'signup' if mode === 'signup'
.form-group
label.control-label(for="name", data-i18n="general.name") Name
if me.get('name')
input#name.input-large.form-control(name="name", type="text", value="#{me.get('name')}")
else
input#name.input-large.form-control(name="name", type="text", value="", placeholder="Anoner")
.form-group.checkbox .form-group.checkbox
label.control-label(for="subscribe") label.control-label(for="subscribe")
input#subscribe(name="subscribe", type="checkbox", checked='checked') input#subscribe(name="subscribe", type="checkbox", checked='checked')

View file

@ -11,17 +11,73 @@ JobProfileView = require './job_profile_view'
module.exports = class SettingsView extends View module.exports = class SettingsView extends View
id: 'account-settings-view' id: 'account-settings-view'
template: template template: template
changedFields: [] # DOM input fields
events: events:
'click #save-button': 'save' 'click #save-button': 'save'
'change #settings-panes input': 'save' 'change #settings-panes input:checkbox': (e) -> @trigger 'checkboxToggled', e
'keyup #settings-panes input:text, #settings-panes input:password': (e) -> @trigger 'inputChanged', e
'keyup #name': 'onNameChange'
'click #toggle-all-button': 'toggleEmailSubscriptions' 'click #toggle-all-button': 'toggleEmailSubscriptions'
'keypress #settings-panes': 'onKeyPress'
constructor: (options) -> constructor: (options) ->
@save = _.debounce(@save, 200) @save = _.debounce(@save, 200)
@onNameChange = _.debounce @checkNameExists, 500
super options super options
return unless me return unless me
@listenTo(me, 'invalid', (errors) -> forms.applyErrorsToForm(@$el, me.validationError)) @listenTo(me, 'invalid', (errors) -> forms.applyErrorsToForm(@$el, me.validationError))
@on 'checkboxToggled', @onToggle
@on 'checkboxToggled', @onInputChanged
@on 'inputChanged', @onInputChanged
@on 'enterPressed', @onEnter
onInputChanged: (e) ->
return @enableSaveButton() unless e?.currentTarget
that = e.currentTarget
$that = $(that)
savedValue = $that.data 'saved-value'
currentValue = $that.val()
if savedValue isnt currentValue
@changedFields.push that unless that in @changedFields
@enableSaveButton()
else
_.pull @changedFields, that
@disableSaveButton() if _.isEmpty @changedFields
onToggle: (e) ->
$that = $(e.currentTarget)
$that.val $that[0].checked
onEnter: ->
@save()
onKeyPress: (e) ->
@trigger 'enterPressed', e if e.which is 13
enableSaveButton: ->
$('#save-button', @$el).removeClass 'disabled'
$('#save-button', @$el).removeClass 'btn-danger'
$('#save-button', @$el).removeAttr 'disabled'
$('#save-button', @$el).text 'Save'
disableSaveButton: ->
$('#save-button', @$el).addClass 'disabled'
$('#save-button', @$el).removeClass 'btn-danger'
$('#save-button', @$el).attr 'disabled', "true"
$('#save-button', @$el).text 'No Changes'
checkNameExists: =>
name = $('#name', @$el).val()
return if name is me.get 'name'
User.getUnconflictedName name, (newName) =>
forms.clearFormAlerts(@$el)
if name is newName
@suggestedName = undefined
else
@suggestedName = newName
forms.setErrorToProperty @$el, 'name', "That name is taken! How about #{newName}?", true
afterRender: -> afterRender: ->
super() super()
@ -50,6 +106,7 @@ module.exports = class SettingsView extends View
super() super()
if me.get('anonymous') if me.get('anonymous')
@openModalView new AuthModalView() @openModalView new AuthModalView()
@updateSavedValues()
chooseTab: (category) -> chooseTab: (category) ->
id = "##{category}-pane" id = "##{category}-pane"
@ -98,7 +155,7 @@ module.exports = class SettingsView extends View
@$el.find('.gravatar-fallback').toggle not me.get 'photoURL' @$el.find('.gravatar-fallback').toggle not me.get 'photoURL'
onPictureChanged: (e) => onPictureChanged: (e) =>
@trigger 'change' @trigger 'inputChanged', e
@$el.find('.gravatar-fallback').toggle not me.get 'photoURL' @$el.find('.gravatar-fallback').toggle not me.get 'photoURL'
save: (e) -> save: (e) ->
@ -122,7 +179,9 @@ module.exports = class SettingsView extends View
errors = JSON.parse(res.responseText) errors = JSON.parse(res.responseText)
forms.applyErrorsToForm(@$el, errors) forms.applyErrorsToForm(@$el, errors)
save.text($.i18n.t('account_settings.error_saving', defaultValue: 'Error Saving')).removeClass('btn-success').addClass('btn-danger', 500) save.text($.i18n.t('account_settings.error_saving', defaultValue: 'Error Saving')).removeClass('btn-success').addClass('btn-danger', 500)
res.success (model, response, options) -> res.success (model, response, options) =>
@changedFields = []
@updateSavedValues()
save.text($.i18n.t('account_settings.saved', defaultValue: 'Changes Saved')).removeClass('btn-success', 500) save.text($.i18n.t('account_settings.saved', defaultValue: 'Changes Saved')).removeClass('btn-success', 500)
grabData: -> grabData: ->
@ -142,6 +201,7 @@ module.exports = class SettingsView extends View
me.set('password', password1) me.set('password', password1)
grabOtherData: -> grabOtherData: ->
$('#name', @$el).val @suggestedName if @suggestedName
me.set 'name', $('#name', @$el).val() me.set 'name', $('#name', @$el).val()
me.set 'email', $('#email', @$el).val() me.set 'email', $('#email', @$el).val()
for emailName, enabled of @getSubscriptions() for emailName, enabled of @getSubscriptions()
@ -162,3 +222,9 @@ module.exports = class SettingsView extends View
if updated if updated
jobProfile.updated = (new Date()).toISOString() jobProfile.updated = (new Date()).toISOString()
me.set 'jobProfile', jobProfile me.set 'jobProfile', jobProfile
updateSavedValues: ->
$('#settings-panes input:text').each ->
$(@).data 'saved-value', $(@).val()
$('#settings-panes input:checkbox').each ->
$(@).data 'saved-value', JSON.stringify $(@)[0].checked

View file

@ -15,11 +15,16 @@ module.exports = class AuthModalView extends View
'click #switch-to-signup-button': 'onSignupInstead' 'click #switch-to-signup-button': 'onSignupInstead'
'click #signup-confirm-age': 'checkAge' 'click #signup-confirm-age': 'checkAge'
'submit': 'onSubmitForm' # handles both submit buttons 'submit': 'onSubmitForm' # handles both submit buttons
'keyup #name': 'onNameChange'
subscriptions: subscriptions:
'server-error': 'onServerError' 'server-error': 'onServerError'
'logging-in-with-facebook': 'onLoggingInWithFacebook' 'logging-in-with-facebook': 'onLoggingInWithFacebook'
constructor: (options) ->
@onNameChange = _.debounce @checkNameExists, 500
super options
getRenderData: -> getRenderData: ->
c = super() c = super()
c.showRequiredError = @options.showRequiredError c.showRequiredError = @options.showRequiredError
@ -31,6 +36,7 @@ module.exports = class AuthModalView extends View
c.mode = @mode c.mode = @mode
c.formValues = @previousFormInputs or {} c.formValues = @previousFormInputs or {}
c.onEmployersPage = Backbone.history.fragment is "employers" c.onEmployersPage = Backbone.history.fragment is "employers"
c.me = me
c c
afterInsert: -> afterInsert: ->
@ -64,6 +70,8 @@ module.exports = class AuthModalView extends View
userObject = forms.formToObject @$el userObject = forms.formToObject @$el
delete userObject.subscribe delete userObject.subscribe
delete userObject['confirm-age'] delete userObject['confirm-age']
delete userObject.name if userObject.name is ''
userObject.name = @suggestedName if @suggestedName
for key, val of me.attributes when key in ['preferredLanguage', 'testGroupNumber', 'dateCreated', 'wizardColor1', 'name', 'music', 'volume', 'emails'] for key, val of me.attributes when key in ['preferredLanguage', 'testGroupNumber', 'dateCreated', 'wizardColor1', 'name', 'music', 'volume', 'emails']
userObject[key] ?= val userObject[key] ?= val
subscribe = @$el.find('#signup-subscribe').prop('checked') subscribe = @$el.find('#signup-subscribe').prop('checked')
@ -82,3 +90,14 @@ module.exports = class AuthModalView extends View
onServerError: (e) -> # TODO: work error handling into a separate forms system onServerError: (e) -> # TODO: work error handling into a separate forms system
@disableModalInProgress(@$el) @disableModalInProgress(@$el)
checkNameExists: =>
name = $('#name', @$el).val()
return forms.clearFormAlerts(@$el) if name is ''
User.getUnconflictedName name, (newName) =>
forms.clearFormAlerts(@$el)
if name is newName
@suggestedName = undefined
else
@suggestedName = newName
forms.setErrorToProperty @$el, 'name', "That name is taken! How about #{newName}?", true

View file

@ -4,20 +4,22 @@ WizardSprite = require 'lib/surface/WizardSprite'
ThangType = require 'models/ThangType' ThangType = require 'models/ThangType'
{me} = require 'lib/auth' {me} = require 'lib/auth'
forms = require 'lib/forms' forms = require 'lib/forms'
User = require 'models/User'
module.exports = class WizardSettingsModal extends View module.exports = class WizardSettingsModal extends View
id: 'wizard-settings-modal' id: 'wizard-settings-modal'
template: template template: template
closesOnClickOutside: false closesOnClickOutside: false
events:
'keyup #wizard-settings-name': -> @trigger 'nameChanged'
'click #wizard-settings-done': 'onWizardSettingsDone'
constructor: (options) -> constructor: (options) ->
@onNameChange = _.debounce(@checkNameExists, 500) @onNameChange = _.debounce(@checkNameExists, 500)
@on 'nameChanged', @onNameChange
super options super options
events:
'keyup #wizard-settings-name': 'onNameChange'
'click #wizard-settings-done': 'onWizardSettingsDone'
afterRender: -> afterRender: ->
WizardSettingsView = require 'views/account/wizard_settings_view' WizardSettingsView = require 'views/account/wizard_settings_view'
view = new WizardSettingsView() view = new WizardSettingsView()
@ -27,10 +29,10 @@ module.exports = class WizardSettingsModal extends View
checkNameExists: => checkNameExists: =>
forms.clearFormAlerts(@$el) forms.clearFormAlerts(@$el)
name = $('#wizard-settings-name').val() name = $('#wizard-settings-name').val()
success = (id) => User.getUnconflictedName name, (newName) =>
forms.clearFormAlerts(@$el) forms.clearFormAlerts(@$el)
forms.applyErrorsToForm(@$el, {property: 'name', message: 'is already taken'}) if id and id isnt me.id if name isnt newName
$.ajax("/db/user/#{name}/nameToID", {success: success}) forms.setErrorToProperty @$el, 'name', 'This name is already taken so you won\'t be able to keep it.', true
onWizardSettingsDone: -> onWizardSettingsDone: ->
me.set('name', $('#wizard-settings-name').val()) me.set('name', $('#wizard-settings-name').val())

View file

@ -157,13 +157,27 @@ module.exports.setup = (app) ->
res.send msg + '<p><a href="/account/settings">Account settings</a></p>' res.send msg + '<p><a href="/account/settings">Account settings</a></p>'
res.end() res.end()
app.get '/auth/name*', (req, res) ->
parts = req.path.split '/'
originalName = decodeURI parts[3]
return errors.badInput res, 'No name provided.' unless parts.length > 3 and originalName? and originalName isnt ''
return errors.notFound res if parts.length isnt 4
User.unconflictName originalName, (err, name) ->
return errors.serverError res, err if err
response = name: name
if originalName is name
res.send 200, response
else
errors.conflict res, response
module.exports.loginUser = loginUser = (req, res, user, send=true, next=null) -> module.exports.loginUser = loginUser = (req, res, user, send=true, next=null) ->
user.save((err) -> user.save((err) ->
return errors.serverError res, err if err? return errors.serverError res, err if err?
req.logIn(user, (err) -> req.logIn(user, (err) ->
return errors.serverError res, err if err? return errors.serverError res, err if err?
return res.send user if send return res.send(user) and res.end() if send
next() if next next() if next
) )
) )

File diff suppressed because one or more lines are too long

View file

@ -152,3 +152,27 @@ describe '/auth/unsubscribe', ->
expect(user.get('emails').recruitNotes.enabled).toBe(false) expect(user.get('emails').recruitNotes.enabled).toBe(false)
expect(user.isEmailSubscriptionEnabled('generalNews')).toBeTruthy() expect(user.isEmailSubscriptionEnabled('generalNews')).toBeTruthy()
done() done()
describe '/auth/name', ->
url = '/auth/name'
it 'must provide a name to check with', (done) ->
request.get {url: getURL(url + '/'), json: {}}, (err, response) ->
expect(err).toBeNull()
expect(response.statusCode).toBe 422
done()
it 'can GET a non-conflicting name', (done) ->
request.get {url: getURL(url + '/Gandalf'), json: {}}, (err, response) ->
expect(err).toBeNull()
expect(response.statusCode).toBe 200
expect(response.body.name).toBe 'Gandalf'
done()
it 'can GET a new name in case of conflict', (done) ->
request.get {url: getURL(url + '/joe'), json: {}}, (err, response) ->
expect(err).toBeNull()
expect(response.statusCode).toBe 409
expect(response.body.name).not.toBe 'joe'
expect(response.body.name.length).toBe 4 # 'joe' and a random number
done()