Adding custom autocomplete field type to Meteor/Blaze useraccounts.

After a while away from this framework, I recently went back to using Meteor. It has a lot of interesting features and I like how fast you can prototype with it, but for some really easy thing, I find myself stuck for too long.

The last thing that made me lose quite some time was the following problem : “How to easily add a city input to the registration form with autocomplete ?

The most obvious solution that came to my mind, and that internet suggests, is to override the entire registration form, but I find that it’s overkill and wanted an alternative approach. Also I didn’t want to use the Google autocomplete input as their API’s pricing is being more and more expensive as the time passes and I wanted something sustainable.

So after looking at the source code of the user accounts package, i decided to add a custom field type “autocomplete” wich woud allow me to use custom fields without having to override the form. And i created an autocomplete form based on the French API available on https://geo.api.gouv.fr/adresse. The code can be used with any geolocation API with a few minor changes. We won’t focus on the css here.

1. Setup the application and useraccounts

We will create a basic meteor application with the following commands than adds some packages for this example. If you already are familiar with useraccounts, you can skip to the next part.

meteor create meteor-blaze-autocomplete-registration-field
cd meteor-blaze-autocomplete-registration-field
meteor add kadira:flow-router
meteor add kadira:blaze-layout
meteor add accounts-password
meteor add useraccounts:unstyled
meteor add useraccounts:flow-routing
meteor add aldeed:template-extension
# Optional packages :
meteor add ddp-rate-limiter
meteor add underscore
meteor npm install --save bcrypt

As the project structure isn’t important here, we won’t go over the details of all the files, but you can see my folder structure below, and the full version in the git repository. I just followed the Meteor guide structure.

- client/
- head.html
- main.js # import imports/startup/both and imports/startup/client
- imports/
- api/
- startup/
- both/
- index.js
- client/
- index.js
- routes.js
- server/
- index.js
- ui/
- layout/
- app-body.html
- app-body.js
- pages/
- home/
- app-home.html
- app-home.js
- not-found/
- app-not-found.html
- app-not-found.js
- server
- main.js # import imports/startup/both and imports/startup/server

You can find the code up to this state on https://github.com/achiev-open/meteor-blaze-autocomplete-registration-field/tree/basic-folder-structure

Now we have a wonderfull app displaying “Home” that does nothing. Let’s add our authentication forms.

Create a new file /imports/startup/both/useraccounts-configuration.js. We’ll import it in the /imports/startup/both/index.js file.

import { AccountsTemplates } from 'meteor/useraccounts:core';

AccountsTemplates.configure({
defaultTemplate: 'Auth_page',
defaultLayout: 'App_body',
defaultContentRegion: 'main',
defaultLayoutRegions: {},
homeRoutePath: '/'
});

AccountsTemplates.configureRoute('signIn', {
name: 'signin',
path: '/signin',
});

AccountsTemplates.configureRoute('signUp', {
name: 'join',
path: '/join',
});

Update app-body.html to show the navigation links

<template name="App_body">
<div id="container">
<header>
<a href="/">Accueil</a>

{{#if currentUser}}
<a class="js-logout">Deconnexion</a>
{{else}}
<a href="{{pathFor 'signin'}}">Connexion</a>
<a href="{{pathFor 'join'}}">Inscription</a>
{{/if}}
</header>

{{#if Template.subscriptionsReady}}
{{> Template.dynamic template=main}}
{{else}}
{{> App_loading}}
{{/if}}
</div>
</template>

and the app-body.js for the logout button

import { Meteor } from 'meteor/meteor';
import { Template } from 'meteor/templating';
import { FlowRouter } from 'meteor/kadira:flow-router';

import "./app-body.html";

Template.App_body.events({
'click .js-logout'(event, instance) {
Meteor.logout();
FlowRouter.go('App.home');
}
});

Our authentication views currently shows nothing. We want to create the Auth_page template. Create /imports/ui/accounts/accounts-templates.html and /imports/ui/accounts/accounts-templates.js. Don’t forget to import them. Below is the html content.

<template name="Auth_page">
<div class="page auth">
<nav>
<div class="nav-group">
<a href="#" class="js-menu nav-item">
<span class="icon-list-unordered"></span>
</a>
</div>
</nav>

<div class="content-scrollable">
<div class="wrapper-auth">
{{> atForm}}
</div>
</div>
</div>
</template>

(Optional step) We’ll add some security rules. Create a file /imports/api/startup/server/security.js.

import { Meteor } from 'meteor/meteor';
import { DDPRateLimiter } from 'meteor/ddp-rate-limiter';
import { _ } from 'meteor/underscore';

Meteor.users.deny({
insert() { return true; },
update() { return true; },
remove() { return true; },
});

const AUTH_METHODS = [
'login',
'logout',
'logoutOtherClients',
'getNewToken',
'removeOtherTokens',
'configureLoginService',
'changePassword',
'forgotPassword',
'resetPassword',
'verifyEmail',
'createUser',
'ATRemoveService',
'ATCreateUserServer',
'ATResendVerificationEmail',
];

DDPRateLimiter.addRule({
name(name) {
return _.contains(AUTH_METHODS, name);
},
connectionId() { return true; },
}, 2, 5000);

We now have a functionnal basic registration system, time to go to the interesting part.

You can see the code up to this step at https://github.com/achiev-open/meteor-blaze-autocomplete-registration-field/tree/functional-auth-system

2. Adding the new property and the matching field

This is not required, but I like having a schema to validate my user. Let’s install the necessary packages :

meteor npm install --save simple-schema
meteor add aldeed:collection2

Let’s say we only want to keep the basic user’s data and add a required “address” field. Create /imports/api/users/users.js and import it in server startup. In our case we won’t use the username, so the email must be set.

import { Meteor } from 'meteor/meteor';
import { SimpleSchema } from 'meteor/aldeed:simple-schema';

Meteor.users.schema = new SimpleSchema({
// Required fields for the account pacakage
emails: {
type: Array,
optional: false
},
"emails.$": {
type: Object
},
"emails.$.address": {
type: String,
regEx: SimpleSchema.RegEx.Email
},
"emails.$.verified": {
type: Boolean
},
createdAt: {
type: Date
},
services: {
type: Object,
optional: true,
blackbox: true
},
heartbeat: {
type: Date,
optional: true
},
// Custom field
address: {
type: String,
optional: false
},
});

Meteor.users.attachSchema(Meteor.users.schema);

If you try to register now, you should have an error “Address is required in users insert”. Let’s see how to add this field to our form as a simple text input first. In /imports/api/startup/useraccounts-configuration.js, add :

AccountsTemplates.addField({
_id: "address",
placeholder: "Your full address",
type: "text",
required: true
});

The field is here but it’s not added to our user, we still have to add some server code. Create /imports/startup/server/accounts.js

import {Accounts} from 'meteor/accounts-base';

Accounts.onCreateUser((options, user) => {
user.address = options.profile.address;
return user;
});

That’s it, your users can now fill in their address in the registration form. Our next step is to turn this field into a custom field type so we can work on it as a reusable component.

The code for this step is available at https://github.com/achiev-open/meteor-blaze-autocomplete-registration-field/tree/add-user-address

3. Configure a custom field type

We can see in the source code of meteor-useraccounts/core a few important information about how the forms are build. We’ll also look at meteor-useraccounts/unstyled for the templates.

We are going to create a new input type “autocomplete”. Our first step will be to add it to the allowed input’s type list, and give this type to our field. Update useraccounts-configuration.js (after the imports) to add :

import { AccountsTemplates } from 'meteor/useraccounts:core';AccountsTemplates.INPUT_TYPES.push("autocomplete");AccountsTemplates.configure(...);

(...)

AccountsTemplates.addField({
_id: "address",
placeholder: "Your full address",
type: "autocomplete",
required: true
});

Right now, the autocomplete type doesn’t throw an error but nothing has changed in our form. As we can see in the source code, the default template for an unknown type is the text input template.

We’ll override this method to return our custom template for autocomplete type. First we’ll create the custom template based on the text input template. In accounts-template.html, add :

<template name="atAutocompleteInput">
<div class="at-input {{#if isValidating}}validating{{/if}} {{#if hasError}}has-error{{/if}} {{#if hasSuccess}}has-success{{/if}} {{#if feedback}}has-feedback{{/if}}">
{{#if showLabels}}
<label for="at-field-{{_id}}">
{{displayName}} {{#unless required}}{{optionalText}}{{/unless}} <small>(Our custom template)</small>
</label>
{{/if}}
<input type="text" id="at-field-{{_id}}" name="at-field-{{_id}}" placeholder="{{placeholder}}" autocapitalize="none" autocorrect="off">
{{#if hasIcon}}
<span class="{{iconClass}}"></span>
{{/if}}
{{#if hasError}}
<span>{{errorText}}</span>
{{/if}}
</div>
</template>

Then we’ll update accounts-template.js to render it and link the necessary helpers and events :

import "./accounts-template.html";
import {Template} from "meteor/templating";
import { AccountsTemplates } from 'meteor/useraccounts:core';

Template.atInput.helpers({
templateName: function() {
if (this.template)
return this.template;
if (this.type === "checkbox")
return "atCheckboxInput";
if (this.type === "select")
return "atSelectInput";
if (this.type === "radio")
return "atRadioInput";
if (this.type === "hidden")
return "atHiddenInput";
if (this.type === "autocomplete")
return "atAutocompleteInput";
return "atTextInput";
}
});

Template.atAutocompleteInput.helpers(AccountsTemplates.atInputHelpers);
Template.atAutocompleteInput.events(AccountsTemplates.atInputEvents);

You now have a custom input template, you can update it to fit your needs. The code for this step is available at https://github.com/achiev-open/meteor-blaze-autocomplete-registration-field/tree/add-custom-field-type

4. Add our autocomplete input

In my case, i had a few new issues as i wanted to include my autocomplete-input component inside the atAutocompleteInput template. If you try to use another template as your input, you’ll see a few errors related to the events handling.

I decided to keep my input component as general as possible, using an hidden input and some js to link the data to our form. I’m using some new packages here.

meteor add fourseven:scss
mateor add reactive-var
meteor add http

Let’s add the component files ( inside imports/ui/components/autocomplete-input ). Nothing special here, the css is kept to a minimum and I adapted the code to the API I’m using, this is easily adaptable to any other API. I’m also using jQuery to send the value to the parent and let it handle the data as it wants.

  • autocomplete-input.scss
.autocomplete {
position: relative;

&-suggestions {
position: absolute;
top: 22px;
background-color: white;
width: 100%;
display: flex;
flex-direction: column;
max-width: 500px;

> div {
padding: 5px 10px;
cursor: pointer;
border-bottom: 1px solid lightgrey;
}
}
}
  • autocomplete-input.html
<template name="AutocompleteInput">
<div class="autocomplete">
<input type="text"
placeholder="{{placeholder}}"
autocapitalize="none"
autocorrect="off">

{{#if suggestions}}
<div class="autocomplete-suggestions">
{{#each suggestion in suggestions }}
<div class="autocomplete-suggestion" data-suggestion-id="{{@index}}">
{{suggestion.properties.name}}, {{suggestion.properties.context}}
</div>
{{/each}}
</div>
{{/if}}
</div>
</template>
  • autocomplete-input.js
import {Template} from 'meteor/templating';
import { ReactiveVar } from 'meteor/reactive-var'

import "./autocomplete-input.html";
import { HTTP } from 'meteor/http'

const apiUrl = "https://api-adresse.data.gouv.fr/search";

Template.AutocompleteInput.onCreated(function() {
this.suggestions = new ReactiveVar();
this.locationData = new ReactiveVar({});
});

Template.AutocompleteInput.helpers({
suggestions: function() {
return Template.instance().suggestions.get();
}
});


Template.AutocompleteInput.events({
'click .autocomplete-suggestion'(event, t) {
let selectedItem = t.suggestions.get()[+event.currentTarget.dataset.suggestionId];
t.locationData.set(selectedItem);
if (this.parentId) {
$("#" + this.parentId).trigger("onSelected", t.locationData.get())
}
t.$("input").val(selectedItem.properties.name + ", " + selectedItem.properties.context);
t.suggestions.set(null);
},
'keyup input'(event, t) {
let input = event.target.value;
let currentData = t.locationData.get();
if (currentData && currentData.properties) {
t.locationData.set({});
if (this.parentId) {
$("#" + this.parentId).trigger("onSelected", null);
}
}

if (input && input.length > 2) {
let query = apiUrl + "?limit=5&q=" + input;
if (this.typeFilter) {
query += "&type=" + this.typeFilter;
}
HTTP.call("GET", query, {
headers: {
'Accept': 'application/json'
}
}, (error, response) => {
const data = JSON.parse(response.content).features;
t.suggestions.set(data);
})
} else {
t.suggestions.set(null);
}
},
});

We now want to use this inside our custom field template. Update our atAutocompleteInput in accounts-template.html

<template name="atAutocompleteInput">
<div class="at-input {{#if isValidating}}validating{{/if}} {{#if hasError}}has-error{{/if}} {{#if hasSuccess}}has-success{{/if}} {{#if feedback}}has-feedback{{/if}}">
{{#if showLabels}}
<label for="at-field-{{_id}}">
{{displayName}} {{#unless required}}{{optionalText}}{{/unless}} <small>(Our custom template)</small>
</label>
{{/if}}

{{> AutocompleteInput placeholder=placeholder parentId=(concat 'at-field-' _id)}}
<input type="hidden" id="at-field-{{_id}}" name="at-field-{{_id}}" placeholder="{{placeholder}}" autocapitalize="none" autocorrect="off">

{{#if hasIcon}}
<span class="{{iconClass}}"></span>
{{/if}}
{{#if hasError}}
<span>{{errorText}}</span>
{{/if}}
</div>
</template>

And the helper concat in accounts-template.js

Template.atAutocompleteInput.helpers({
'concat': function() {
return Array.prototype.slice.call(arguments, 0, -1).join('');
}
});

Our new input is available in the registration form but we have a few errors in the console and the data is not handled. Let’s fix that.

We can see in the code source that some events are handled to update and validate the data. In our case, we don’t want those to happen, we want to update our data only when the autocomplete component emit something.

Let’s override the templates events, we’ll change our accounts-template.js file so it looks like this :

import {Template} from "meteor/templating";
import { AccountsTemplates } from 'meteor/useraccounts:core';

import "../components/autocomplete-input/autocomplete-input.js";
import "./accounts-template.html";

Template.atInput.helpers({
templateName: function() {
if (this.template)
return this.template;
if (this.type === "checkbox")
return "atCheckboxInput";
if (this.type === "select")
return "atSelectInput";
if (this.type === "radio")
return "atRadioInput";
if (this.type === "hidden")
return "atHiddenInput";
if (this.type === "autocomplete")
return "atAutocompleteInput";
return "atTextInput";
}
});

Template.atAutocompleteInput.helpers(AccountsTemplates.atInputHelpers);

Template.atAutocompleteInput.helpers({
'concat': function() {
return Array.prototype.slice.call(arguments, 0, -1).join('');
}
});

Template.atAutocompleteInput.events({
'focusin input'(event, template) {
event.stopImmediatePropagation();
},
'focusout input, change select'(event, t) {
event.stopImmediatePropagation();
},
'onSelected'(event, template, data) {
let field = Template.currentData();
field.clearStatus();
field.setValue(template, data ? data.properties.name : "");
}
});

And that’s it ! Let me now what you think of this approach.

The full code is available at https://github.com/achiev-open/meteor-blaze-autocomplete-registration-field/tree/master

Software engineer at Achiev & Jounco