Merge pull request #4918 from techAPJ/user-fields-invite

FEATURE: add required user fields to invite accept form
This commit is contained in:
Arpit Jalan 2017-06-12 21:06:22 +05:30 committed by GitHub
commit 545c0ab056
14 changed files with 171 additions and 55 deletions

View File

@ -7,9 +7,10 @@ import InputValidation from 'discourse/models/input-validation';
import PasswordValidation from "discourse/mixins/password-validation"; import PasswordValidation from "discourse/mixins/password-validation";
import UsernameValidation from "discourse/mixins/username-validation"; import UsernameValidation from "discourse/mixins/username-validation";
import NameValidation from "discourse/mixins/name-validation"; import NameValidation from "discourse/mixins/name-validation";
import UserFieldsValidation from "discourse/mixins/user-fields-validation";
import { userPath } from 'discourse/lib/url'; import { userPath } from 'discourse/lib/url';
export default Ember.Controller.extend(ModalFunctionality, PasswordValidation, UsernameValidation, NameValidation, { export default Ember.Controller.extend(ModalFunctionality, PasswordValidation, UsernameValidation, NameValidation, UserFieldsValidation, {
login: Ember.inject.controller(), login: Ember.inject.controller(),
complete: false, complete: false,
@ -50,19 +51,10 @@ export default Ember.Controller.extend(ModalFunctionality, PasswordValidation, U
if (this.get('emailValidation.failed')) return true; if (this.get('emailValidation.failed')) return true;
if (this.get('usernameValidation.failed')) return true; if (this.get('usernameValidation.failed')) return true;
if (this.get('passwordValidation.failed')) return true; if (this.get('passwordValidation.failed')) return true;
if (this.get('userFieldsValidation.failed')) return true;
// Validate required fields
let userFields = this.get('userFields');
if (userFields) { userFields = userFields.filterBy('field.required'); }
if (!Ember.isEmpty(userFields)) {
const anyEmpty = userFields.any(function(uf) {
const val = uf.get('value');
return !val || Ember.isEmpty(val);
});
if (anyEmpty) { return true; }
}
return false; return false;
}.property('passwordRequired', 'nameValidation.failed', 'emailValidation.failed', 'usernameValidation.failed', 'passwordValidation.failed', 'formSubmitted', 'userFields.@each.value'), }.property('passwordRequired', 'nameValidation.failed', 'emailValidation.failed', 'usernameValidation.failed', 'passwordValidation.failed', 'userFieldsValidation.failed', 'formSubmitted'),
usernameRequired: Ember.computed.not('authOptions.omit_username'), usernameRequired: Ember.computed.not('authOptions.omit_username'),
@ -82,10 +74,6 @@ export default Ember.Controller.extend(ModalFunctionality, PasswordValidation, U
}); });
}.property(), }.property(),
nameInstructions: function() {
return I18n.t(Discourse.SiteSettings.full_name_required ? 'user.name.instructions_required' : 'user.name.instructions');
}.property(),
// Check the email address // Check the email address
emailValidation: function() { emailValidation: function() {
// If blank, fail without a reason // If blank, fail without a reason
@ -212,18 +200,6 @@ export default Ember.Controller.extend(ModalFunctionality, PasswordValidation, U
return self.flash(I18n.t('create_account.failed'), 'error'); return self.flash(I18n.t('create_account.failed'), 'error');
}); });
} }
}, }
_createUserFields: function() {
if (!this.site) { return; }
let userFields = this.site.get('user_fields');
if (userFields) {
userFields = _.sortBy(userFields, 'position').map(function(f) {
return Ember.Object.create({ value: null, field: f });
});
}
this.set('userFields', userFields);
}.on('init')
}); });

View File

@ -5,15 +5,17 @@ import { ajax } from 'discourse/lib/ajax';
import PasswordValidation from "discourse/mixins/password-validation"; import PasswordValidation from "discourse/mixins/password-validation";
import UsernameValidation from "discourse/mixins/username-validation"; import UsernameValidation from "discourse/mixins/username-validation";
import NameValidation from "discourse/mixins/name-validation"; import NameValidation from "discourse/mixins/name-validation";
import UserFieldsValidation from "discourse/mixins/user-fields-validation";
import { findAll as findLoginMethods } from 'discourse/models/login-method'; import { findAll as findLoginMethods } from 'discourse/models/login-method';
export default Ember.Controller.extend(PasswordValidation, UsernameValidation, NameValidation, { export default Ember.Controller.extend(PasswordValidation, UsernameValidation, NameValidation, UserFieldsValidation, {
invitedBy: Ember.computed.alias('model.invited_by'), invitedBy: Ember.computed.alias('model.invited_by'),
email: Ember.computed.alias('model.email'), email: Ember.computed.alias('model.email'),
accountUsername: Ember.computed.alias('model.username'), accountUsername: Ember.computed.alias('model.username'),
passwordRequired: Ember.computed.notEmpty('accountPassword'), passwordRequired: Ember.computed.notEmpty('accountPassword'),
successMessage: null, successMessage: null,
errorMessage: null, errorMessage: null,
userFields: null,
inviteImageUrl: getUrl('/images/envelope.svg'), inviteImageUrl: getUrl('/images/envelope.svg'),
@computed @computed
@ -21,11 +23,6 @@ export default Ember.Controller.extend(PasswordValidation, UsernameValidation, N
return I18n.t('invites.welcome_to', {site_name: this.siteSettings.title}); return I18n.t('invites.welcome_to', {site_name: this.siteSettings.title});
}, },
@computed
nameLabel() {
return I18n.t(this.siteSettings.full_name_required ? 'invites.name_label' : 'invites.name_label_optional');
},
@computed('email') @computed('email')
yourEmailMessage(email) { yourEmailMessage(email) {
return I18n.t('invites.your_email', {email: email}); return I18n.t('invites.your_email', {email: email});
@ -36,20 +33,30 @@ export default Ember.Controller.extend(PasswordValidation, UsernameValidation, N
return findLoginMethods(this.siteSettings, this.capabilities, this.site.isMobileDevice).length > 0; return findLoginMethods(this.siteSettings, this.capabilities, this.site.isMobileDevice).length > 0;
}, },
@computed('usernameValidation.failed', 'passwordValidation.failed', 'nameValidation.failed') @computed('usernameValidation.failed', 'passwordValidation.failed', 'nameValidation.failed', 'userFieldsValidation.failed')
submitDisabled(usernameFailed, passwordFailed, nameFailed) { submitDisabled(usernameFailed, passwordFailed, nameFailed, userFieldsFailed) {
return usernameFailed || passwordFailed || nameFailed; return usernameFailed || passwordFailed || nameFailed || userFieldsFailed;
}, },
actions: { actions: {
submit() { submit() {
const userFields = this.get('userFields');
let userCustomFields = {};
if (!Ember.isEmpty(userFields)) {
userFields.forEach(function(f) {
userCustomFields[f.get('field.id')] = f.get('value');
});
}
ajax({ ajax({
url: `/invites/show/${this.get('model.token')}.json`, url: `/invites/show/${this.get('model.token')}.json`,
type: 'PUT', type: 'PUT',
data: { data: {
username: this.get('accountUsername'), username: this.get('accountUsername'),
name: this.get('accountName'), name: this.get('accountName'),
password: this.get('accountPassword') password: this.get('accountPassword'),
userCustomFields
} }
}).then(result => { }).then(result => {
if (result.success) { if (result.success) {

View File

@ -3,6 +3,11 @@ import { default as computed } from 'ember-addons/ember-computed-decorators';
export default Ember.Mixin.create({ export default Ember.Mixin.create({
@computed()
nameInstructions() {
return I18n.t(this.siteSettings.full_name_required ? 'user.name.instructions_required' : 'user.name.instructions');
},
// Validate the name. // Validate the name.
@computed('accountName') @computed('accountName')
nameValidation() { nameValidation() {

View File

@ -0,0 +1,35 @@
import InputValidation from 'discourse/models/input-validation';
import { on, default as computed } from 'ember-addons/ember-computed-decorators';
export default Ember.Mixin.create({
@on('init')
_createUserFields() {
if (!this.site) { return; }
let userFields = this.site.get('user_fields');
if (userFields) {
userFields = _.sortBy(userFields, 'position').map(function(f) {
return Ember.Object.create({ value: null, field: f });
});
}
this.set('userFields', userFields);
},
// Validate required fields
@computed('userFields.@each.value')
userFieldsValidation() {
let userFields = this.get('userFields');
if (userFields) { userFields = userFields.filterBy('field.required'); }
if (!Ember.isEmpty(userFields)) {
const anyEmpty = userFields.any(uf => {
const val = uf.get('value');
return !val || Ember.isEmpty(val);
});
if (anyEmpty) {
return InputValidation.create({ failed: true });
}
}
return InputValidation.create({ ok: true });
}
});

View File

@ -1,3 +1,3 @@
<div class='controls'> <div class='controls'>
<label class="control-label">{{input checked=value type="checkbox"}} {{{field.description}}}</label> <label class="control-label checkbox-label">{{input checked=value type="checkbox"}} {{{field.description}}}</label>
</div> </div>

View File

@ -23,26 +23,36 @@
</p> </p>
<form> <form>
<label>{{i18n 'user.username.title'}}</label>
<div class="input username-input"> <div class="input username-input">
<label>{{i18n 'user.username.title'}}</label>
{{input value=accountUsername id="new-account-username" name="username" maxlength=maxUsernameLength autocomplete="off"}} {{input value=accountUsername id="new-account-username" name="username" maxlength=maxUsernameLength autocomplete="off"}}
&nbsp;{{input-tip validation=usernameValidation id="username-validation"}} &nbsp;{{input-tip validation=usernameValidation id="username-validation"}}
<div class="instructions">{{i18n 'user.username.instructions'}}</div>
</div> </div>
<label>{{nameLabel}}</label>
<div class="input name-input"> <div class="input name-input">
<label>{{i18n 'invites.name_label'}}</label>
{{input value=accountName id="new-account-name" name="name"}} {{input value=accountName id="new-account-name" name="name"}}
<div class="instructions">{{nameInstructions}}</div>
</div> </div>
<label>{{i18n 'invites.password_label'}}</label>
<div class="input password-input"> <div class="input password-input">
<label>{{i18n 'invites.password_label'}}</label>
{{password-field value=accountPassword type="password" id="new-account-password" capsLockOn=capsLockOn}} {{password-field value=accountPassword type="password" id="new-account-password" capsLockOn=capsLockOn}}
&nbsp;{{input-tip validation=passwordValidation}} &nbsp;{{input-tip validation=passwordValidation}}
<div class="instructions">
{{passwordInstructions}}
<div class="caps-lock-warning {{unless capsLockOn 'invisible'}}"><i class="fa fa-exclamation-triangle"></i> {{i18n 'login.caps_lock_warning'}}</div>
</div>
</div> </div>
<div class="instructions"> {{#if userFields}}
<div class="caps-lock-warning {{unless capsLockOn 'invisible'}}"><i class="fa fa-exclamation-triangle"></i> {{i18n 'login.caps_lock_warning'}}</div> <div class='user-fields'>
</div> {{#each userFields as |f|}}
{{user-field field=f.field value=f.value}}
{{/each}}
</div>
{{/if}}
<button class='btn btn-primary' {{action "submit"}} disabled={{submitDisabled}}>{{i18n 'invites.accept_invite'}}</button> <button class='btn btn-primary' {{action "submit"}} disabled={{submitDisabled}}>{{i18n 'invites.accept_invite'}}</button>

View File

@ -94,6 +94,13 @@ $input-width: 220px;
label { label {
font-weight: bold; font-weight: bold;
} }
.instructions {
color: dark-light-choose(scale-color($primary, $lightness: 50%), scale-color($secondary, $lightness: 50%));
margin: 0;
font-size: 0.929em;
font-weight: normal;
line-height: 18px;
}
} }
} }

View File

@ -97,7 +97,14 @@
} }
} }
form { form {
label, .input { .controls, .input {
margin-left: 20px;
margin-bottom: 10px;
},
input, label {
margin-bottom: 0;
},
.user-field .control-label:not(.checkbox-label) {
margin-left: 20px; margin-left: 20px;
} }
} }

View File

@ -30,12 +30,12 @@ class InvitesController < ApplicationController
def perform_accept_invitation def perform_accept_invitation
params.require(:id) params.require(:id)
params.permit(:username, :name, :password) params.permit(:username, :name, :password, :user_custom_fields)
invite = Invite.find_by(invite_key: params[:id]) invite = Invite.find_by(invite_key: params[:id])
if invite.present? if invite.present?
begin begin
user = invite.redeem(username: params[:username], name: params[:name], password: params[:password]) user = invite.redeem(username: params[:username], name: params[:name], password: params[:password], user_custom_fields: params[:user_custom_fields])
if user.present? if user.present?
log_on_user(user) log_on_user(user)
post_process_invite(user) post_process_invite(user)

View File

@ -52,8 +52,8 @@ class Invite < ActiveRecord::Base
invalidated_at.nil? invalidated_at.nil?
end end
def redeem(username: nil, name: nil, password: nil) def redeem(username: nil, name: nil, password: nil, user_custom_fields: nil)
InviteRedeemer.new(self, username, name, password).redeem unless expired? || destroyed? || !link_valid? InviteRedeemer.new(self, username, name, password, user_custom_fields).redeem unless expired? || destroyed? || !link_valid?
end end
def self.extend_permissions(topic, user, invited_by) def self.extend_permissions(topic, user, invited_by)

View File

@ -1,4 +1,4 @@
InviteRedeemer = Struct.new(:invite, :username, :name, :password) do InviteRedeemer = Struct.new(:invite, :username, :name, :password, :user_custom_fields) do
def redeem def redeem
Invite.transaction do Invite.transaction do
@ -18,7 +18,7 @@ InviteRedeemer = Struct.new(:invite, :username, :name, :password) do
end end
# extracted from User cause it is very specific to invites # extracted from User cause it is very specific to invites
def self.create_user_from_invite(invite, username, name, password=nil) def self.create_user_from_invite(invite, username, name, password=nil, user_custom_fields=nil)
user_exists = User.find_by_email(invite.email) user_exists = User.find_by_email(invite.email)
return user if user_exists return user if user_exists
@ -42,6 +42,18 @@ InviteRedeemer = Struct.new(:invite, :username, :name, :password) do
user.approved_at = Time.zone.now user.approved_at = Time.zone.now
end end
user_fields = UserField.all
if user_custom_fields.present? && user_fields.present?
field_params = user_custom_fields || {}
fields = user.custom_fields
user_fields.each do |f|
field_val = field_params[f.id.to_s]
fields["user_field_#{f.id}"] = field_val[0...UserField.max_length] unless field_val.blank?
end
user.custom_fields = fields
end
user.moderator = true if invite.moderator? && invite.invited_by.staff? user.moderator = true if invite.moderator? && invite.invited_by.staff?
user.save! user.save!
@ -76,7 +88,7 @@ InviteRedeemer = Struct.new(:invite, :username, :name, :password) do
def get_invited_user def get_invited_user
result = get_existing_user result = get_existing_user
result ||= InviteRedeemer.create_user_from_invite(invite, username, name, password) result ||= InviteRedeemer.create_user_from_invite(invite, username, name, password, user_custom_fields)
result.send_welcome_message = false result.send_welcome_message = false
result result
end end

View File

@ -1101,7 +1101,6 @@ en:
accept_invite: "Accept Invitation" accept_invite: "Accept Invitation"
success: "Your account has been created and you're now logged in." success: "Your account has been created and you're now logged in."
name_label: "Name" name_label: "Name"
name_label_optional: "Name (optional)"
password_label: "Set Password (optional)" password_label: "Set Password (optional)"
password_reset: password_reset:

View File

@ -93,5 +93,19 @@ describe InviteRedeemer do
expect(user.confirm_password?(password)).to eq(true) expect(user.confirm_password?(password)).to eq(true)
expect(user.approved).to eq(true) expect(user.approved).to eq(true)
end end
it "can set custom fields" do
required_field = Fabricate(:user_field)
optional_field= Fabricate(:user_field, required: false)
user_fields = {
required_field.id.to_s => 'value1',
optional_field.id.to_s => 'value2'
}
user = InviteRedeemer.new(invite, username, name, password, user_fields).redeem
expect(user).to be_present
expect(user.custom_fields["user_field_#{required_field.id}"]).to eq('value1')
expect(user.custom_fields["user_field_#{optional_field.id}"]).to eq('value2')
end
end end
end end

View File

@ -0,0 +1,44 @@
import { acceptance } from "helpers/qunit-helpers";
acceptance("Accept Invite - User Fields", {
site: {
user_fields: [{"id":34,"name":"I've read the terms of service","field_type":"confirm","required":true},
{"id":35,"name":"What is your pet's name?","field_type":"text","required":true},
{"id":36,"name":"What's your dad like?","field_type":"text","required":false}]
}
});
test("accept invite with user fields", () => {
visit("/invites/myvalidinvitetoken");
andThen(() => {
ok(exists(".invites-show"), "shows the accept invite page");
ok(exists('.user-field'), "it has at least one user field");
ok(exists('.invites-show .btn-primary:disabled'), 'submit is disabled');
});
fillIn("#new-account-name", 'John Doe');
fillIn("#new-account-username", 'validname');
fillIn("#new-account-password", 'secur3ty4Y0uAndMe');
andThen(() => {
ok(exists(".username-input .good"), "username is valid");
ok(exists('.invites-show .btn-primary:disabled'), 'submit is still disabled due to lack of user fields');
});
fillIn(".user-field input[type=text]:first", "Barky");
andThen(() => {
ok(exists('.invites-show .btn-primary:disabled'), 'submit is disabled because field is not checked');
});
click(".user-field input[type=checkbox]");
andThen(() => {
not(exists('.invites-show .btn-primary:disabled'), 'submit is enabled because field is checked');
});
click(".user-field input[type=checkbox]");
andThen(() => {
ok(exists('.invites-show .btn-primary:disabled'), 'unclicking the checkbox disables the submit');
});
});