UKHomeOfficeForms / hof

Bootstrap a HOF project
MIT License
15 stars 17 forks source link

HOF (Home Office Forms)

NPM_Publish Actions Status npm version Known Vulnerabilities

HOF (Home Office Forms) is a framework designed to assist developers in creating form-based workflows in a rapid, repeatable and secure way. It aims to reduce simple applications as much as possible to being configuration-only.

Server Settings

In your hof.settings.json file you can add getTerms: false and getCookies: false to turn off the default cookies, and terms and conditions information provided by the HOF framework. This is if you want to provide more specific material at the service level in regards to these subject matter otherwise the defaults should suffice.

Also you can set getAccessibility: true to get the default accessibility document for this framework if one is not provided at the service level. It is assumed there should have been an accessibility audit carried out for a service already hence why the default setting for this is set to false. But if a generic placeholder is needed to ensure the service is legally compliant then this can be set to true to provide the default one presented within the framework.

Resources

HOF documentation

https://ukhomeofficeforms.github.io/hof-guide/

Content Security Policy

Inline JavaScript from 18.0.0

From version 18.0.0, unsafe-inline has been removed from the content security policy by default. This means scripts must either be referenced using the src attribute, <script src='...'></script> or with a nonce value attribute. A nonce value is generated for every request. You can add this to your own templates' inline scripts as needed:

<script {{#nonce}}nonce="{{nonce}}"{{/nonce}}>
...
</script>

Built with HOF

HOF BUILD

Performs build workflow for hof apps in prod and development

Usage

Run a build by running hof-build from the command line in your project directory.

hof-build [task]

If no task is specified then all tasks will run.

It is recommended to alias hof-build to an npm script in your package.json.

Tasks

Note: For SASS compilation it's possible to additionally configure the following options via the hof.settings file (see the configuration section below)

Watch

You can additionally run a watch task to start a server instance, which will automatically restart based on changes to files. This will also re-perform the tasks above when relevant files change.

By default files inside node_modules directories and dotfiles will not trigger a restart. If you want to include these files then you can set --watch-node-modules and --watch-dotfiles flags respectively.

Local environment variables

You can load local environment variables from a file by passing an --env flag to hof-build watch and creating a .env file in your project root that defines your local variables as follows:

MY_LOCAL_ENVVAR=foo
MY_OTHER_ENVVAR=bar

Note: export is not required, and values should not be quoted.

To load variables from a file other than .env you should pass the location of the file as a value on the --env flag.

hof-build watch --env .envdev

Configuration

The default settings will match those for an app generated using hof-generator.

If a hof.settings.json file is found in the application root, then the build section of the settings file will be used to override the default configuration.

Alternatively you can define a path to a local config file by passing a --config option

hof-build --config /path/to/my/config.js

Any task can be disabled by setting its configuration to false (or any falsy value).

module.exports = {
  browserify: false,
};

Configuration options

Each task has a common configuration format with the following options:

Additionally the server instance created by watch can be configured by setting server config. Available options are:

Shared Translations

By default translations put in the commons directory in a HOF project, i.e. app/common/translations/src, are bundles together and shared with other translation files of the same name, e.g. fields.json, buttons.json etc. Any other files will have their own json file created in the default.json translation file of a sub application. E.g. in app/<sub_app>/translations/en/default.json.

To override this behaviour you can add the following to your hof.settings.json file or to the settings possible to hof on your server.js file

Hof.settings.json example

"build": {
  "translate": {
    "shared": "./apps/another_common_directory/translations/src"
  }
}

server.js example

const hof = require('hof');
const settings = { ...behaviours, ...routes };

settings.build = { translate: { shared: "./apps/another_common_directory/translations/src" } };

const app = hof(settings);

 HOF TRANSPILER

Home office forms transpiler is a tiny tool that can be used as part of a build or manually to convert multipart locales files into one default.json. This is used in our stack for translations of form applications.

Usage

hof-transpiler [source dir|glob] {OPTIONS}

       --shared, -s  A path or glob to a directory of shared translations

Example

Lets say you have a directory such as: translations/src/en

Which contains:

buttons.json
emails.json
errors.json
validation.json

If you run hof-transpiler against the directory hof-transpiler ./translations/src

It will iterate through src and for each directory it will create a new directory at the root level with a built default.json file translations/en/default.json

Which will look something like

{
  "buttons": {
    json blob from buttons.json
  },
  "emails": {
    json blob from emails.json
  },
  "errors": {
    json blob from errors.json
  },
  "validation": {
    json blob from validation.json
  }
}

This is used further down the hof stack for application translations.

Advanced example - duplicate keys between source folder and shared folder

Lets say you have a directory such as: translations/src/en

Which contains: buttons.json containing:

{
  "unusual-button": "Moo"
}

emails.json containing:

{
  "customer-email": "Hi how are you?"
}

And you also have a directory of shared translations such as: shared-translations/src/en

Which contains: buttons.json containing:

{
  "common-button": "Click me"
}

If you then run:

hof-transpiler translations/src --shared shared-translations/src

Then transpiled translations should appear in translations/en/default.json as follows:

{
  "buttons": {
    "unusual-button": "Moo",
    "common-button": "Click me"
  },
  "emails": {
    "customer-email": "Hi how are you?"
  }
}

Note how a deep merge is performed between the json, with key value pairs from "buttons" being included from both files.

Multiple shared sources

hof-transpiler supports multiple shared sources, extending them from left to right. This is useful if you have translations shared between applications, and additional shared translations between routes within an application.

If you have the following sources:

node_modules/hof-template-partials/translations/src/en/buttons.json

{
  "continue": "Continue",
  "skip": "Skip",
  "submit": "Submit",
  "abort": "Abort"
}

common/translations/src/en/buttons.json

{
  "skip": "Skip this step",
  "cancel": "Cancel"
}

my-application/translations/src/en/buttons.json

{
  "continue": "Go Forth!"
}

If you then run:

hof-transpiler my-application/translations/src --shared node_modules/hof-template-partials/translations/src --shared common/translations/src

my-application/translations/en/default.json

{
  "buttons": {
    "continue": "Go Forth!",
    "skip": "Skip this step",
    "submit": "Submit",
    "abort": "Abort",
    "cancel": "Cancel"
  }
}

HOF Controller

Implements a request pipeline for GET and POST of forms, with input cleaning/formatting and validation.

Usage

Basic usage:

var Form = require("./controller");

var form = new Form({
  template: "form",
  fields: {
    name: {
      validate: "required",
    },
  },
});

app.use("/", form.requestHandler());

This won't really be very useful though, since all it will do is render the "form" template on / and respond to GET and POST requests.

For real-world usage you will probably want to extend the Form class to create your own controllers.

var Form = require('./controller''),
    util = require('util');

var MyForm = function (options) {
    Form.call(this, options);
};

util.inherits(MyForm, Form);

module.exports = MyForm;

The Form class allows for a number of insertion points for extended functionality:

All of these methods take three arguments of the request, the response and a callback. In all cases the callback should be called with a first argument representing an error.

These methods are synchronous and take only the request and response obejct as arguments.

Validators

The library supports a number of validators.

By default the application of a validator is optional on empty strings. If you need to ensure a field is validated as being 9 characters long and exists then you need to use both an exactlength and a required validator.

Custom Validators

Custom validator functions can be passed in field config. These must be named functions and the name is used as the error.type for looking up validation error messages.

fields.js

{
    'field-1': {
        validate: ['required', function isTrue(val) {
            return val === true;
        }]
    }
}

steps config

Handles journey forking

Each step definition accepts a next property, the value of which is the next route in the journey. By default, when the form is successfully submitted, the next steps will load. However, there are times when it is necessary to fork from the current journey based on a users response to certain questions in a form. For such circumstances there exists the forks property.

In this example, when the submits the form, if the field called 'example-radio' has the value 'superman', the page at '/fork-page' will load, otherwise '/next-page' will be loaded.


'/my-page': {
    next: '/next-page',
    forks: [{
        target: '/fork-page',
        condition: {
            field: 'example-radio',
            value: 'superman'
        }
    }]
}

The condition property can also take a function. In the following example, if the field called 'name' is more than 30 characters in length, the page at '/fork-page' will be loaded.


'/my-page': {
    next: '/next-page',
    forks: [{
        target: '/fork-page',
        condition: function (req, res) {
            return req.form.values['name'].length > 30;
        }
    }]
}

Forks is an array and therefore each fork is interrogated in order from top to bottom. The last fork whose condition is met will assign its target to the next page variable.

In this example, if the last condition resolves to true - even if the others also resolve to true - then the page at '/fork-page-three' will be loaded. The last condition to be met is always the fork used to determine the next step.


'/my-page': {
    next: '/next-page',
    forks: [{
        target: '/fork-page-one',
        condition: function (req, res) {
            return req.form.values['name'].length > 30;
        }
    }, {
        target: '/fork-page-two',
        condition: {
            field: 'example-radio',
            value: 'superman'
        }
    }, {
        target: '/fork-page-three',
        condition: function (req, res) {
            return typeof req.form.values['email'] === 'undefined';
        }
    }]
}

Dynamic field options

If the options for a particular field are dependent on aspects of the user session, then these can be extended on a per-session basis using the configure method.

For example, for a dynamic address selection component:

MyForm.prototype.configure = function configure(req, res, next) {
  req.form.options.fields["address-select"].options =
    req.sessionModel.get("addresses");
  next();
};

The FormError class

FormError can be used as a façade to normalise different types of error one may receive / trigger, and to be subsequently returned from a controller. Its constructor takes a series of options. title and message have both getters and public methods to define default values.

let error = new ErrorClass(this.missingDoB, {
  key: this.missingDob,
  type: "required",
  redirect: "/missingData",
  title: "Something went wrong",
  message: "Please supply a valid date of birth",
});

hof-behaviour-session

HOF Behaviour for reading and writing to the session

Usage

With mixwith.js

const mix = require('mixwith').mix;
const Session = require('./controller/behaviour-session');
const BaseController = require('./controller');

class MyController extends mix(BaseController).with(Session) {
  ...
}

MyController now extends hof-form-controller and has hof-behaviour-session functionality mixed in.

Functionality

This mixin extends hof-form-controller by persisting the form data to the sessionModel - assuming the session-model middleware has been applied.

The following form controller methods are used:

behaviour-hooks

HOF Behaviour enabling lifecycle hooks for extending functionality in main form pipeline.

Usage

With mixwith.js

const mix = require('mixwith').mix;
const Hooks = require('./controller/behaviour-hooks');
const BaseController = require('./controller');

class MyController extends mix(BaseController).with(Hooks) {
  ...
}

MyController now extends hof-form-controller and has hof-behaviour-hooks functionality mixed in.

Functionality

The following hooks are currently supported, the methods are GET/POST pipeline methods from hof-form-controller:

GET

POST

In field config

fields.js

module.exports = {
  "field-1": {
    hooks: {
      "post-locals": (req, res, next) => {
        Object.assign(res.locals, {
          foo: "bar",
        });
        next();
      },
      "pre-process": (req, res, next) => {
        req.body["field-1"] = req.body["field-1"].toUpperCase();
        next();
      },
    },
  },
};

HOF Model

Simple model for interacting with http/rest apis.

Usage

const Model = require("./model");

Data Storage

Models can be used as basic data storage with set/get and change events.

Methods

set

Save a property to a model. Properties can be passed as a separate key/value arguments, or with multiple properties as an object.

const model = new Model();
model.set("key", "value");
model.set({
  firstname: "John",
  lastname: "Smith",
});

get

Retrieve a property from a model:

const val = model.get("key");
// val = 'value'

toJSON

Returns a map of all properties on a model:

const json = model.toJSON();
// json = { key: 'value' }

Events

change is emitted when a property on a model changes

const model = new Model();
model.on("change", (changedFields) => {
  // changedFields contains a map of the key/value pairs which have changed
  console.log(changedFields);
});

change:<key> is emitted when a particular property - with a key of <key> - on a model changes

const model = new Model();
model.on("change:name", (newValue, oldValue) => {
  // handler is passed the new value and the old value as arguents
});
model.set("name", "John Smith");

Referenced Fields

A field can be set to a reference to another field by setting it a value of $ref:<key> where <key> is the field to be reference. The field will then behave exactly like a normal field except that its value will always appear as the value of the referenced field.

const model = new Model();
model.set("home-address", "1 Main Street");
model.set("contact-address", "$ref:home-address");

model.get("contact-address"); // => '1 Main Street';
model.set("home-address", "2 Main Street");
model.get("contact-address"); // => '2 Main Street';

model.toJSON(); // => { home-address: '2 Main Street', 'contact-address': '2 Main Street' }

Change events will be fired on the referenced field if the underlying value changes.

const model = new Model();
model.set("home-address", "1 Main Street");
model.set("contact-address", "$ref:home-address");
model.on("change:contact-address", (value, oldValue) => {
  // this is fired when home-address property changes
});

model.set("home-address", "2 Main Street");

A field can be unreferenced by setting its value to any other value.

const model = new Model();
model.set("home-address", "1 Main Street");

// reference the field
model.set("contact-address", "$ref:home-address");

// unreference the field
model.set("contact-address", "1 Other Road");

API Client

Normally this would be used as an abstract class and extended with your own implementation.

Implementations would normally define at least a url method to define the target of API calls.

There are three methods for API interaction corresponding to GET, POST, and DELETE http methods. These methods all return a Promise.

Methods

fetch

const model = new Model();
model.fetch().then((data) => {
  console.log(data);
});

save

const model = new Model();
model.set({
  property: "properties are sent as JSON request body by default",
});
model.save().then((data) => {
  console.log(data);
});

The method can also be overwritten by passing options

const model = new Model();
model.set({
  property: "this will be sent as a PUT request",
});
model.save({ method: "PUT" }).then((data) => {
  console.log(data);
});

delete

const model = new Model();
model.delete().then((data) => {
  console.log(data);
});

Options

If no url method is defined then the model will use the options parameter and Node's url.format method to construct a URL.

const model = new Model();

// make a GET request to http://example.com:3000/foo/bar
model
  .fetch({
    protocol: "http",
    hostname: "example.com",
    port: 3000,
    path: "/foo/bar",
  })
  .then((data) => {
    console.log(data);
  });

Events

API requests will emit events as part of their lifecycle.

sync is emitted when an API request is sent

model.on("sync", function (settings) {});

success is emitted when an API request successfully completes

model.on("success", function (data, settings, statusCode, responseTime) {});

fail is emitted when an API request fails

model.on("fail", function (err, data, settings, statusCode, responseTime) {});

HOF Model APIs

name: NODE_TLS_REJECT_UNAUTHORIZED
value: "0"

which should NOT be used as it sets ignoring TLS at a global level which could present a MITM (Man-In-The-Middle) attack.

Usage: Example below, as per the converter docs (link above) it accepts html and responds with Buffered data in pdf format which can then be either written to a file or attached to a Gov Notify message:

const PDFModel = require('hof').apis.pdfConverter;

const pdfModel = new PDFModel();
pdfModel.set({ template: html });
const pdfData = await pdfModel.save();

HOF Middleware

A collection of commonly used HOF middleware, exports cookies, notFound, and errors on middleware

Arranging the middleware in your app

Cookies middleware should be placed before any other routes, this guarantees that any data gathered in the form will be saved to the session. The Not Found middleware should be placed after all routes and before the Error handler middleware. This arrangement ensures that if an error is thrown it will be caught.

Cookies

Usage

app.use(
  require("hof").middleware.cookies({
    "cookie-name": "my-application-cookie",
    "param-name": "my-query-param",
  })
);

This middleware must be declared before your other routes.

Options

The cookie-name can be the same as your session cookie. (The middleware will not overwrite it.) Defaults to hof-cookie-check.

The param-name should be chosen so that it does not clash with names you are using elsewhere. In almost all cases the default value of hof-cookie-check will suffice.

The error raised when cookies are not supported by the client can then be handled in you error handler by identifying it using its code property which will be set to NO_COOKIES.

You can also provide an array of healthcheck URLs with healthcheckUrls, should you not want to throw a Cookies required error when requesting the app with specific URLs. Kubernetes healthcheck URLs are provided as defaults if no overrides are supplied.

Not found (404)

Expects there to be a view called 404 in your configured /views directory

Usage

app.use(
  require("hof").middleware.notFound({
    logger: require("/logger"),
    translate: require("hof").i18n({
      path: path_to_translations / __lng__ / __ns__.json,
    }).translate,
  })
);

This middleware should be declared after your other routes but before your errorhandler.

Options

logger can be any object with a warn method.

translate can be the HOF i18n translate function

Errors

Usage

app.use(
  require("hof").middleware.errors({
    logger: require("/logger"),
    translate: require("hof").i18n({
      path: path_to_translations / __lng__ / __ns__.json,
    }).translate,
    debug: true,
  })
);

This middleware must be declared after your other routes.

Options

logger can be any object with an error method.

translate can be the HOF i18n translate function

debug set to true will present the stack trace in the form and return the err as the content of the template.

Note If debug === true translations will not be served, but the error handler default messages

Deep translate

deepTranslate middleware supports nested conditional translations in order to show different content in different scenarios. The middleware adds a translate function to req which is used in various points throughout the architecture. This middleware must be applied before any other middleware which rely on the req.translate function. Also when initializing the form wizard, or template mixins, if a translate function is provided, this will be used rather than the deepTranslate middleware.

Usage

const i18nFuture = require("hof").i18n;
const i18n = i18nFuture({
  path: path.resolve(__dirname, "./path/to/translations"),
});
app.use(
  require("hof").middleware.deepTranslate({
    translate: i18n.translate.bind(i18n),
  })
);

locales

"fields": {
    "field-name": {
        "label": {
            "dependent-field": {
                "value-1": {
                    "dependent-field-2": {
                        "value-1": "Label 1",
                        "value-2": "Label 2"
                    }
                },
                "value-2": "Label 3"
            },
            "default": "Fallback label"
        }
    }
}

Using the translation key fields.field-name.label will return different values in different situations depending on the values of named fields. In the above example the following are true:

HOF Components

 Date Component

A component for handling the rendering and processing of 3-input date fields used in HOF Applications.

Usage

In your fields config:

const dateComponent = require("hof").components.date;

module.exports = {
  "date-field": dateComponent("date-field", {
    validate: ["required", "before"],
  }),
};

The above example will create a new date component with the key 'date-field' and will apply the validators required and before (before today).

Configuration

The following optional configuration options are supported:

Labels

The three intermedate fields have fallback labels of Day, Month and Year, however custom labels can be used by including the translation at the following path:

fields.json

{
  "field-name": {
    "parts": {
      "day": {
        "label": "Custom Day Label"
      },
      "month": {
        "label": "Custom Month Label"
      },
      "year": {
        "label": "Custom Year Label"
      }
    }
  }
}

Summary Page Component

HOF behaviour for showing summary pages

The behaviour mixin will create a set of "locals" data which is compatible with the confirm view from hof-template-partials.

Usage

If no sections config is passed, then the mixin will create a section for each step that has fields, and a row within each section for each field on that step.

'/confirm': {
  behaviours: require('hof').components.summary,
  ...
}

Alternatively, sections can be defined manually as follows:

'/confirm': {
  behaviours: require('hof').components.summary,
  sections: {
    'museum-details': [
      'name',
      {
        field: 'exhibit-addresses',
        parse: (value) => value.map(a => a.address),
        step: '/exhibit-add-another-address'
      }
    ],
    'contact': [
      'contact-name',
      'contact-email',
      'contact-phone',
      {
        field: 'contact-address',
        step: '/contact-address'
      }
    ]
  },
  ...
}

Configuration

The sections configuration should be a map of arrays, where the entries in the array are the fields that should be shown within that section.

Field configuration

Fields can be defined as simple strings of the field key, in which case all default configuration will be used.

Alternatively, a field can be passed as an object with a field property defining the field key, and any additional properties as follows:

{
  field: 'location-addresses',
  step: '/location-add-another-address',
  multipleRowsFromAggregate: {
    labelCategory: 'address',
    valueCategory: 'address-category',
    // Optional: uses valueCategory name if not specified
    valueTranslation: 'location-address-category'
  }
}

The location-addresses field is one that the application has setup to aggregate and store all addresses labelled with the address field. Each address is a storage location for firearms, and so there is a sub-category which lists what firearms type is listed under each address (i.e. Full-bore, small-bore, muzzle-loading), and these are stored under the address-category field. Along with translations to them in the fields.json file living under the location-address-category translation header. By utilising these three values one can achieve the following output on the summary page.

Firearms Summary Page Example

This allows the creation of summary rows based on unknown dynamic user input, i.e. we can not predict in advance how many addresses a user wants to input, what the addresses are and how many categories the user wants to attach to each address. This allows you to easily list them this way.

Translations

The content for section headings and field labels will be loaded from translation files based on the keys.

Section headings

Translations for section headings are looked for in the following order:

Field labels

Translations for field labels are looked for in the following order:

Emailer Component

HOF behaviour to send emails

Usage

const EmailBehaviour = require('hof').components.emailer;

// configure email behaviour
const emailer = EmailBehaviour({
  transport: 'ses',
  transportOptions: {
    accessKeyId: '...',
    secretAccessKey: '...'
  },
  template: path.resolve(__dirname, './views/emails/confirm.html'),
  from: 'confirmation@homeoffice.gov.uk',
  recipient: 'customer-email',
  subject: 'Application Successful'
});

// in steps config
steps: {
  ...
  '/confirm': {
    behaviours: ['complete', emailer],
    next: '/confirmation',
    ...
  },
  ...
}

Options

In addition to the options passed to hof-emailer, the following options can be used:

recipient and subject options can also be defined as functions, which will be passed a copy of the session model and a translation function as arguments, and should return a string value.

// use a translated value for the email subject line
const emailer = EmailBehaviour({
  // ...
  subject: (model, translate) => translate("email.success.subject"),
});

HOF Emailer

An emailer service for HOF applications.

Installation

$ npm install hof-emailer --save

Usage

// first create an emailer instance
const Emailer = require("hof").components.email.emailer;
const emailer = new Emailer({
  from: "sender@example.com",
  transport: "smtp",
  transportOptions: {
    host: "my.smtp.host",
    port: 25,
  },
});

// then you can use your emailer to send emails
const to = "recipient@example.com";
const body = "This is the email body";
const subject = "Important email!";
emailer.send(to, body, subject).then(() => {
  console.log(`Email sent to ${to}!`);
});

Options