diff --git a/eq-author-api/.cfignore b/eq-author-api/.cfignore
new file mode 100644
index 0000000000..6b8710a711
--- /dev/null
+++ b/eq-author-api/.cfignore
@@ -0,0 +1 @@
+.git
diff --git a/eq-author-api/.codecov.yml b/eq-author-api/.codecov.yml
new file mode 100644
index 0000000000..db3177a76b
--- /dev/null
+++ b/eq-author-api/.codecov.yml
@@ -0,0 +1,9 @@
+coverage:
+ status:
+ project:
+ default:
+ target: auto
+ threshold: null
+ base: auto
+
+comment: off
diff --git a/eq-author-api/.dockerignore b/eq-author-api/.dockerignore
new file mode 100644
index 0000000000..c6b40c6025
--- /dev/null
+++ b/eq-author-api/.dockerignore
@@ -0,0 +1,4 @@
+.git
+Dockerfile
+docker-compose.yml
+node_modules/
\ No newline at end of file
diff --git a/eq-author-api/.eslintignore b/eq-author-api/.eslintignore
new file mode 100644
index 0000000000..404abb2212
--- /dev/null
+++ b/eq-author-api/.eslintignore
@@ -0,0 +1 @@
+coverage/
diff --git a/eq-author-api/.eslintrc b/eq-author-api/.eslintrc
new file mode 100644
index 0000000000..b5b3c6ed19
--- /dev/null
+++ b/eq-author-api/.eslintrc
@@ -0,0 +1,11 @@
+{
+ "extends": ["eslint-config-eq-author"],
+ "env": {
+ "node": true,
+ "es6": true
+ },
+ "parserOptions": {
+ "ecmaVersion": 2018,
+ "sourceType": "script"
+ }
+}
\ No newline at end of file
diff --git a/eq-author-api/.github/ISSUE_TEMPLATE.md b/eq-author-api/.github/ISSUE_TEMPLATE.md
new file mode 100644
index 0000000000..ad4bd0111c
--- /dev/null
+++ b/eq-author-api/.github/ISSUE_TEMPLATE.md
@@ -0,0 +1,9 @@
+### Expected behaviour
+
+### Actual behaviour
+
+### Steps to reproduce the behaviour
+
+### Technical information
+
+### Screenshot
diff --git a/eq-author-api/.github/PULL_REQUEST_TEMPLATE.md b/eq-author-api/.github/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 0000000000..d76e63e17a
--- /dev/null
+++ b/eq-author-api/.github/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,5 @@
+### What is the context of this PR?
+Describe what you have changed and why, link to other PRs or Issues as appropriate.
+
+### How to review
+Describe the steps required to test the changes (include screenshots if appropriate).
diff --git a/eq-author-api/.gitignore b/eq-author-api/.gitignore
new file mode 100644
index 0000000000..591667d582
--- /dev/null
+++ b/eq-author-api/.gitignore
@@ -0,0 +1,69 @@
+
+# Created by https://www.gitignore.io/api/node
+
+### Node ###
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# Runtime data
+pids
+*.pid
+*.seed
+*.pid.lock
+
+# Directory for instrumented libs generated by jscoverage/JSCover
+lib-cov
+
+# Coverage directory used by tools like istanbul
+coverage
+
+# nyc test coverage
+.nyc_output
+
+# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
+.grunt
+
+# Bower dependency directory (https://bower.io/)
+bower_components
+
+# node-waf configuration
+.lock-wscript
+
+# Compiled binary addons (http://nodejs.org/api/addons.html)
+build/Release
+
+# Dependency directories
+node_modules/
+jspm_packages/
+
+# Typescript v1 declaration files
+typings/
+
+# Optional npm cache directory
+.npm
+
+# Optional eslint cache
+.eslintcache
+
+# Optional REPL history
+.node_repl_history
+
+# Output of 'npm pack'
+*.tgz
+
+# Yarn Integrity file
+.yarn-integrity
+
+# dotenv environment variables file
+.env
+
+
+# End of https://www.gitignore.io/api/node
+
+node_modules/
+
+.vscode
\ No newline at end of file
diff --git a/eq-author-api/.nvmrc b/eq-author-api/.nvmrc
new file mode 100644
index 0000000000..9a037142aa
--- /dev/null
+++ b/eq-author-api/.nvmrc
@@ -0,0 +1 @@
+10
\ No newline at end of file
diff --git a/eq-author-api/.prettierignore b/eq-author-api/.prettierignore
new file mode 100644
index 0000000000..2ff8622f17
--- /dev/null
+++ b/eq-author-api/.prettierignore
@@ -0,0 +1 @@
+package.json
\ No newline at end of file
diff --git a/eq-author-api/.travis.yml b/eq-author-api/.travis.yml
new file mode 100644
index 0000000000..326c842b6a
--- /dev/null
+++ b/eq-author-api/.travis.yml
@@ -0,0 +1,30 @@
+language: node_js
+node_js:
+ - "10"
+
+
+install:
+ - yarn install --frozen-lockfile
+ - yarn add codecov
+cache:
+ yarn: true
+ directories:
+ - "node_modules"
+
+script:
+ - yarn lint -- --max-warnings=0
+ - yarn test
+ - docker login -u $DOCKER_USERNAME -p $DOCKER_PASSWORD
+ - export TAG=`if [ "$TRAVIS_PULL_REQUEST_BRANCH" == "" ]; then echo "latest"; else echo $TRAVIS_PULL_REQUEST_BRANCH; fi`
+ - export TAG=${TAG//\//-}
+ - docker build -t onsdigital/eq-author-api:$TAG --build-arg APPLICATION_VERSION=$(git rev-parse HEAD) -f Dockerfile .
+ - echo "Pushing with tag [$TAG]"
+ - docker push onsdigital/eq-author-api:$TAG
+
+after_success:
+ - codecov
+
+branches:
+ only:
+ - master
+ - /^greenkeeper/.*$/
diff --git a/eq-author-api/Dockerfile b/eq-author-api/Dockerfile
new file mode 100644
index 0000000000..47666fe5ba
--- /dev/null
+++ b/eq-author-api/Dockerfile
@@ -0,0 +1,24 @@
+FROM node:10-alpine
+
+RUN apk add --no-cache python3 && \
+ python3 -m ensurepip && \
+ rm -r /usr/lib/python*/ensurepip && \
+ pip3 install --upgrade pip setuptools && \
+ if [ ! -e /usr/bin/pip ]; then ln -s pip3 /usr/bin/pip ; fi && \
+ if [ ! -e /usr/bin/python ]; then ln -sf /usr/bin/python3 /usr/bin/python; fi && \
+ rm -r /root/.cache && \
+ pip install --upgrade awscli==1.16.27
+
+EXPOSE 4000
+ENV PORT=4000
+WORKDIR /app
+
+ARG APPLICATION_VERSION
+ENV EQ_AUTHOR_API_VERSION $APPLICATION_VERSION
+ENV AWS_DEFAULT_REGION eu-west-1
+ENV NODE_ENV production
+
+ENTRYPOINT ["./docker-entrypoint.sh"]
+
+COPY . /app
+RUN yarn install
\ No newline at end of file
diff --git a/eq-author-api/README.md b/eq-author-api/README.md
new file mode 100644
index 0000000000..0b4e4fd2c7
--- /dev/null
+++ b/eq-author-api/README.md
@@ -0,0 +1,360 @@
+# eq-author-api
+
+[](https://greenkeeper.io/)
+
+A GraphQL based API for the [eq-author](https://github.com/ONSdigital/eq-author)
+application.
+
+## Installation
+
+### Configuration
+
+Environment variables can be used to configure various aspects of the API.
+In most cases sensible defaults have been selected.
+
+> **Tip**
+>
+> If you decide to run the Author API directly using `yarn` you will need to
+> ensure that a suitable database instance is running and configure the
+> associated database environment variables appropriately.
+>
+> Running using `docker-compose` will ensure that a suitable postgres instance
+> is started. So there is no need to configure the environment variables.
+
+## Environment Variables
+
+| Name | Description | Required |
+| --- | --- | --- |
+| `RUNNER_SESSION_URL` | Authentication URL for survey runner | Yes |
+| `PUBLISHER_URL` | URL that produces valid survey runner JSON | Yes |
+| `DB_CONNECTION_URI` | Connection string for database | Yes |
+| `SECRETS_S3_BUCKET` | Name of S3 bucket where secrets are stored | No |
+| `KEYS_FILE` | Name of the keys file to use inside the bucket | No |
+| `EQ_AUTHOR_API_VERSION` | The current Author API version. This is what gets reported on the /status endpoint | No |
+| `PORT` | The port which express listens on (defaults to `4000`). | No |
+| `NODE_ENV` | Sets the environment the code is running in | No |
+
+### Run using Docker
+
+To build and run the Author GraphQL API inside a docker container, ensure that
+Docker is installed for your platform, navigate to the project directory, then run:
+
+Build the docker image (1st time run):
+```
+docker-compose build
+```
+
+```
+docker-compose up
+```
+
+Once the containers are running you should be able to navigate to http://localhost:4000/graphiql and begin exploring the eQ Author GraphQL API.
+
+Changes to the application should hot reload via `nodemon`.
+
+### Querying pages
+
+There is no concrete `Page` type in the GraphQL schema. Instead we use a `Page` interface, which other types implement e.g. `QuestionPage` and `InterstitialPage`.
+
+To query all pages, and request different fields depending on the type, use [inline fragments](http://graphql.org/learn/queries/#inline-fragments):
+
+```gql
+query {
+ getQuestionnaire(id: 1) {
+ questionnaire {
+ sections {
+ pages {
+ id,
+
+ # inline fragment for `QuestionPage` type
+ ... on QuestionPage {
+ guidance,
+ answers {
+ id,
+ label
+ }
+ },
+
+ # For purposes of example only. `InterstitialPage` doesn't exist yet
+ ... on InterstitialPage { # doesn't exist yet
+ someField
+ }
+
+ }
+ }
+ }
+ }
+}
+```
+
+### Testing through GraphiQL
+
+There are [queries](tests/fixtures/queries.gql) and [example data](tests/fixtures/data.json) in the [fixtures folder](tests/fixtures). These can be used with graphiql to manually build up a questionnaire.
+
+### DB migrations
+
+First start app using Docker.
+
+#### Create migration
+
+```
+yarn knex -- migrate:make name_of_migration
+```
+
+Where `name_of_migration` is the name you wish to use. e.g. `create_questionnaires_table`
+
+#### Apply migrations
+
+```
+docker-compose exec web yarn knex -- migrate:latest
+```
+
+#### Rollback migrations
+
+```
+docker-compose exec web yarn knex -- migrate:rollback
+```
+
+## Tests
+
+`yarn test` will start a single run of unit and integration tests.
+
+`yarn test --watch` will start unit and integration tests in watch mode.
+
+## Debugging (with VS Code)
+
+### Debugging app
+
+Follow [this guide](https://github.com/docker/labs/blob/83514855aff21eaed3925d1fd28091b23de0e147/developer-tools/nodejs-debugging/VSCode-README.md) to enable debugging through VS Code.
+
+Use this config for VS Code, rather than what is detailed in the guide. This will attach *to the running docker container*:
+
+```json
+{
+ "version": "0.2.0",
+ "configurations": [
+ {
+ "name": "Attach to Container",
+ "type": "node",
+ "request": "attach",
+ "port": 5858,
+ "address": "localhost",
+ "restart": true,
+ "sourceMaps": false,
+ "localRoot": "${workspaceRoot}",
+ "remoteRoot": "/app",
+ "protocol": "inspector"
+ }
+ ]
+}
+```
+
+### Debugging tests
+
+Add the following to your `launch.json` configuration:
+
+```json
+{
+ "name": "Attach by Process ID",
+ "type": "node",
+ "request": "attach",
+ "processId": "${command:PickProcess}"
+}
+```
+
+Then start your tests [as described above](#tests). You can now start a debugging session, and pick the jest process to attach to.
+
+## Importing questionnaires
+There is a dev only endpoint exposed in the dev environment to be able to import questionnaires from other environments.
+
+## How to use it:
+1. Run the following query against the environment to retrieve the questionnaire. You need to provide the id as well. (You could use https://github.com/skevy/graphiql-app)
+```graphql
+fragment answerFragment on Answer {
+ id
+ type
+ label
+ description
+ guidance
+ properties
+ qCode
+ ...on BasicAnswer{
+ validation{
+ ...on NumberValidation{
+ minValue{
+ id
+ inclusive
+ enabled
+ custom
+ }
+ maxValue{
+ id
+ inclusive
+ enabled
+ custom
+ entityType
+ previousAnswer {
+ id
+ }
+ }
+ }
+ ...on DateValidation{
+ earliestDate{
+ id
+ enabled
+ custom
+ offset {
+ value
+ unit
+ }
+ relativePosition
+ }
+ latestDate{
+ id
+ enabled
+ custom
+ offset {
+ value
+ unit
+ }
+ relativePosition
+ }
+ }
+ }
+ }
+ ...on CompositeAnswer{
+ childAnswers{
+ id
+ label
+ }
+ }
+}
+
+fragment optionFragment on Option {
+ id
+ label
+ description
+ value
+ qCode
+}
+
+fragment destinationFragment on RoutingDestination {
+ ... on LogicalDestination {
+ __typename
+ logicalDestination
+ }
+ ... on AbsoluteDestination {
+ __typename
+ absoluteDestination {
+ ... on QuestionPage {
+ id
+ __typename
+ }
+ ... on Section {
+ id
+ __typename
+ }
+ }
+ }
+}
+
+fragment metadataFragment on Metadata {
+ id
+ key
+ type
+}
+
+query GetQuestionnaire($questionnaireId: ID!) {
+ questionnaire(id: $questionnaireId) {
+ id
+ title
+ description
+ theme
+ legalBasis
+ navigation
+ surveyId
+ summary
+ metadata {
+ ...metadataFragment
+ }
+ sections {
+ id
+ alias
+ title
+ description
+ pages {
+ ... on QuestionPage {
+ id
+ alias
+ title
+ description
+ guidance
+ pageType
+ routingRuleSet {
+ id
+ else {
+ ...destinationFragment
+ }
+ routingRules {
+ id
+ operation
+ goto {
+ ...destinationFragment
+ }
+ conditions {
+ id
+ comparator
+ answer {
+ id
+ type
+ ... on MultipleChoiceAnswer {
+ options {
+ id
+ label
+ }
+ other {
+ option {
+ id
+ label
+ }
+ }
+ }
+ }
+ routingValue {
+ ... on IDArrayValue {
+ value
+ }
+ ... on NumberValue {
+ numberValue
+ }
+ }
+ }
+ }
+ }
+ answers {
+ ...answerFragment
+ ... on MultipleChoiceAnswer {
+ options {
+ ...optionFragment
+ }
+ mutuallyExclusiveOption {
+ ...optionFragment
+ }
+ other {
+ option {
+ ...optionFragment
+ }
+ answer {
+ ...answerFragment
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+```
+1.`POST` the result to `/import`. (You could use https://www.getpostman.com/)
+1. The questionnaire should be there.
diff --git a/eq-author-api/app.js b/eq-author-api/app.js
new file mode 100644
index 0000000000..8c1ef63634
--- /dev/null
+++ b/eq-author-api/app.js
@@ -0,0 +1,55 @@
+/* eslint-disable no-console */
+const express = require("express");
+const { graphqlExpress, graphiqlExpress } = require("graphql-server-express");
+const { addErrorLoggingToSchema } = require("graphql-tools");
+const repositories = require("./repositories");
+const colors = require("colors");
+const cors = require("cors");
+const bodyParser = require("body-parser");
+const schema = require("./schema");
+const pinoMiddleware = require("express-pino-logger");
+const { PORT } = require("./config/settings");
+const createLogger = require("./utils/createLogger");
+const status = require("./middleware/status");
+const { getLaunchUrl } = require("./middleware/launch");
+const importAction = require("./middleware/import");
+
+const app = express();
+const pino = pinoMiddleware();
+
+const logger = createLogger(pino.logger);
+addErrorLoggingToSchema(schema, logger);
+
+const context = { repositories };
+
+app.use(
+ "/graphql",
+ pino,
+ cors(),
+ bodyParser.json(),
+ graphqlExpress({
+ schema,
+ context,
+ formatError: logger.log
+ })
+);
+
+app.get("/status", status);
+
+app.get("/launch/:questionnaireId", getLaunchUrl(context));
+if (process.env.NODE_ENV === "development") {
+ app.post("/import", bodyParser.json({ limit: "50mb" }), importAction);
+}
+
+if (process.env.NODE_ENV === "development") {
+ app.use(
+ "/graphiql",
+ pino,
+ cors(),
+ graphiqlExpress({ endpointURL: "/graphql" })
+ );
+}
+
+app.listen(PORT, "0.0.0.0", () => {
+ console.log(colors.green("Listening on port"), PORT);
+});
diff --git a/eq-author-api/config/knexfile.js b/eq-author-api/config/knexfile.js
new file mode 100644
index 0000000000..9bc2928483
--- /dev/null
+++ b/eq-author-api/config/knexfile.js
@@ -0,0 +1,35 @@
+module.exports = {
+ development: {
+ client: "postgresql",
+ connection: process.env.DB_CONNECTION_URI
+ },
+
+ test: {
+ client: "postgresql",
+ connection: process.env.DB_CONNECTION_URI
+ },
+
+ staging: {
+ client: "postgresql",
+ connection: process.env.DB_CONNECTION_URI,
+ pool: {
+ min: 2,
+ max: 10
+ },
+ migrations: {
+ tableName: "knex_migrations"
+ }
+ },
+
+ production: {
+ client: "postgresql",
+ connection: process.env.DB_CONNECTION_URI,
+ pool: {
+ min: 2,
+ max: 10
+ },
+ migrations: {
+ tableName: "knex_migrations"
+ }
+ }
+};
diff --git a/eq-author-api/config/settings.js b/eq-author-api/config/settings.js
new file mode 100644
index 0000000000..0053f3b2e1
--- /dev/null
+++ b/eq-author-api/config/settings.js
@@ -0,0 +1,5 @@
+require("dotenv").config();
+
+const { PORT = 4000 } = process.env;
+
+module.exports = { PORT };
diff --git a/eq-author-api/constants/validation-entity-types.js b/eq-author-api/constants/validation-entity-types.js
new file mode 100644
index 0000000000..08e5c1ef52
--- /dev/null
+++ b/eq-author-api/constants/validation-entity-types.js
@@ -0,0 +1,11 @@
+const CUSTOM = "Custom";
+const PREVIOUS_ANSWER = "PreviousAnswer";
+const METADATA = "Metadata";
+const NOW = "Now";
+
+module.exports = {
+ CUSTOM,
+ PREVIOUS_ANSWER,
+ METADATA,
+ NOW
+};
diff --git a/eq-author-api/db/Answer.js b/eq-author-api/db/Answer.js
new file mode 100644
index 0000000000..93d3f48183
--- /dev/null
+++ b/eq-author-api/db/Answer.js
@@ -0,0 +1,35 @@
+const db = require("./");
+
+function Answer() {
+ return db("Answers");
+}
+
+module.exports.findAll = function findAll() {
+ return Answer().select();
+};
+
+module.exports.findById = function findById(id) {
+ return Answer()
+ .where("id", parseInt(id, 10))
+ .first();
+};
+
+module.exports.update = function update(id, updates) {
+ return Answer()
+ .where("id", parseInt(id, 10))
+ .update(updates)
+ .returning("*");
+};
+
+module.exports.create = function create(answer) {
+ return Answer()
+ .insert(answer)
+ .returning("*");
+};
+
+module.exports.destroy = function destroy(id) {
+ return Answer()
+ .where("id", parseInt(id, 10))
+ .delete()
+ .returning("*");
+};
diff --git a/eq-author-api/db/Metadata.js b/eq-author-api/db/Metadata.js
new file mode 100644
index 0000000000..d8545f9745
--- /dev/null
+++ b/eq-author-api/db/Metadata.js
@@ -0,0 +1,35 @@
+const db = require("./");
+
+function Metadata() {
+ return db("Metadata");
+}
+
+module.exports.findAll = function findAll() {
+ return Metadata().select();
+};
+
+module.exports.findById = function findById(id) {
+ return Metadata()
+ .where("id", parseInt(id, 10))
+ .first();
+};
+
+module.exports.update = function update(id, updates) {
+ return Metadata()
+ .where("id", parseInt(id, 10))
+ .update(updates)
+ .returning("*");
+};
+
+module.exports.create = function create(obj) {
+ return Metadata()
+ .insert(obj)
+ .returning("*");
+};
+
+module.exports.destroy = function destroy(id) {
+ return Metadata()
+ .where("id", parseInt(id, 10))
+ .delete()
+ .returning("*");
+};
diff --git a/eq-author-api/db/Option.js b/eq-author-api/db/Option.js
new file mode 100644
index 0000000000..7a3fe41fca
--- /dev/null
+++ b/eq-author-api/db/Option.js
@@ -0,0 +1,35 @@
+const db = require("./");
+
+function Option() {
+ return db("Options");
+}
+
+module.exports.findAll = function findAll() {
+ return Option().select();
+};
+
+module.exports.findById = function findById(id) {
+ return Option()
+ .where("id", parseInt(id, 10))
+ .first();
+};
+
+module.exports.update = function update(id, updates) {
+ return Option()
+ .where("id", parseInt(id, 10))
+ .update(updates)
+ .returning("*");
+};
+
+module.exports.create = function create(answer) {
+ return Option()
+ .insert(answer)
+ .returning("*");
+};
+
+module.exports.destroy = function destroy(id) {
+ return Option()
+ .where("id", parseInt(id, 10))
+ .delete()
+ .returning("*");
+};
diff --git a/eq-author-api/db/Page.js b/eq-author-api/db/Page.js
new file mode 100644
index 0000000000..c5219874c0
--- /dev/null
+++ b/eq-author-api/db/Page.js
@@ -0,0 +1,35 @@
+const knex = require("./");
+
+function Page(db = knex) {
+ return db("Pages");
+}
+
+module.exports.findAll = function findAll() {
+ return Page().select();
+};
+
+module.exports.findById = function findById(id) {
+ return Page()
+ .where("id", parseInt(id, 10))
+ .first();
+};
+
+module.exports.update = function update(id, updates, db) {
+ return Page(db)
+ .where({ id: parseInt(id, 10) })
+ .update(updates)
+ .returning("*");
+};
+
+module.exports.create = function create(obj) {
+ return Page()
+ .insert(obj)
+ .returning("*");
+};
+
+module.exports.destroy = function destroy(id) {
+ return Page()
+ .where({ id: parseInt(id, 10) })
+ .delete()
+ .returning("*");
+};
diff --git a/eq-author-api/db/QuestionPage.js b/eq-author-api/db/QuestionPage.js
new file mode 100644
index 0000000000..7598ca2032
--- /dev/null
+++ b/eq-author-api/db/QuestionPage.js
@@ -0,0 +1,41 @@
+const knex = require("./index");
+
+function Question(db = knex) {
+ return db("Pages");
+}
+
+function restrictType(where = {}) {
+ return Object.assign({}, where, { pageType: "QuestionPage" });
+}
+
+module.exports.findAll = function findAll() {
+ return Question()
+ .where(restrictType())
+ .select();
+};
+
+module.exports.findById = function findById(id) {
+ return Question()
+ .where(restrictType({ id: parseInt(id, 10) }))
+ .first();
+};
+
+module.exports.update = function update(id, updates) {
+ return Question()
+ .where(restrictType({ id: parseInt(id, 10) }))
+ .update(updates)
+ .returning("*");
+};
+
+module.exports.create = function create(obj, db) {
+ return Question(db)
+ .insert(restrictType(obj))
+ .returning("*");
+};
+
+module.exports.destroy = function destroy(id) {
+ return Question()
+ .where(restrictType({ id: parseInt(id, 10) }))
+ .delete()
+ .returning("*");
+};
diff --git a/eq-author-api/db/Questionnaire.js b/eq-author-api/db/Questionnaire.js
new file mode 100644
index 0000000000..4e975175fb
--- /dev/null
+++ b/eq-author-api/db/Questionnaire.js
@@ -0,0 +1,35 @@
+const db = require("./");
+
+function Questionnaire() {
+ return db("Questionnaires");
+}
+
+module.exports.findAll = function findAll() {
+ return Questionnaire().select();
+};
+
+module.exports.findById = function findById(id) {
+ return Questionnaire()
+ .where("id", parseInt(id, 10))
+ .first();
+};
+
+module.exports.update = function update(id, updates) {
+ return Questionnaire()
+ .where("id", parseInt(id, 10))
+ .update(updates)
+ .returning("*");
+};
+
+module.exports.create = function create(obj) {
+ return Questionnaire()
+ .insert(obj)
+ .returning("*");
+};
+
+module.exports.destroy = function destroy(id) {
+ return Questionnaire()
+ .where("id", parseInt(id, 10))
+ .delete()
+ .returning("*");
+};
diff --git a/eq-author-api/db/Routing.js b/eq-author-api/db/Routing.js
new file mode 100644
index 0000000000..4beae1475d
--- /dev/null
+++ b/eq-author-api/db/Routing.js
@@ -0,0 +1,79 @@
+const db = require("./");
+const { parseInt } = require("lodash");
+const RoutingRuleSets = "Routing_RuleSets";
+const RoutingRules = "Routing_Rules";
+const RoutingConditions = "Routing_Conditions";
+const RoutingConditionValues = "Routing_ConditionValues";
+const RoutingDestinations = "Routing_Destinations";
+
+const table = (tableName, knex = db) => knex(tableName);
+const select = (tableName, knex = db) => table(tableName, knex).select();
+const selectById = (tableName, id, knex = db) =>
+ select(tableName, knex)
+ .where("id", parseInt(id))
+ .first();
+const insert = (tableName, record, knex = db) =>
+ table(tableName, knex)
+ .insert(record)
+ .returning("*");
+const update = (tableName, id, record, knex = db) =>
+ table(tableName, knex)
+ .where("id", parseInt(id))
+ .update(record)
+ .returning("*");
+const deleteById = (tableName, id, knex = db) =>
+ table(tableName, knex)
+ .where({ id })
+ .del()
+ .returning("*");
+
+const findAllRoutingRuleSets = () => select(RoutingRuleSets);
+const findAllRoutingRules = () => select(RoutingRules);
+const findAllRoutingConditions = () => select(RoutingConditions);
+const findAllRoutingConditionValues = () => select(RoutingConditionValues);
+
+const findRoutingRuleSetsById = id => selectById(RoutingRuleSets, id);
+const findRoutingRulesById = id => selectById(RoutingRules, id);
+const findRoutingConditionValuesById = id =>
+ selectById(RoutingConditionValues, id);
+const findRoutingDestinationById = id => selectById(RoutingDestinations, id);
+
+const createRoutingRuleSet = values => insert(RoutingRuleSets, values);
+const createRoutingRule = values => insert(RoutingRules, values);
+const createRoutingCondition = values => insert(RoutingConditions, values);
+const createRoutingConditionValue = values =>
+ insert(RoutingConditionValues, values);
+
+const updateRoutingRuleSet = (id, values) =>
+ update(RoutingRuleSets, id, values);
+const updateRoutingRule = (id, values) => update(RoutingRules, id, values);
+const updateRoutingCondition = (id, values) =>
+ update(RoutingConditions, id, values);
+const updateRoutingConditionValue = (id, values) =>
+ update(RoutingConditionValues, id, values);
+
+const updateRoutingDestination = (id, values) =>
+ update(RoutingDestinations, id, values);
+
+const deleteRoutingCondition = id => deleteById(RoutingConditions, id);
+
+Object.assign(module.exports, {
+ findAllRoutingRuleSets,
+ findAllRoutingRules,
+ findAllRoutingConditions,
+ findAllRoutingConditionValues,
+ findRoutingRuleSetsById,
+ findRoutingRulesById,
+ findRoutingConditionValuesById,
+ createRoutingRuleSet,
+ createRoutingRule,
+ createRoutingCondition,
+ createRoutingConditionValue,
+ updateRoutingRuleSet,
+ updateRoutingRule,
+ updateRoutingCondition,
+ updateRoutingConditionValue,
+ deleteRoutingCondition,
+ findRoutingDestinationById,
+ updateRoutingDestination
+});
diff --git a/eq-author-api/db/Section.js b/eq-author-api/db/Section.js
new file mode 100644
index 0000000000..08087e8cb8
--- /dev/null
+++ b/eq-author-api/db/Section.js
@@ -0,0 +1,35 @@
+const db = require("./");
+
+function Section() {
+ return db("Sections");
+}
+
+module.exports.findAll = function findAll() {
+ return Section().select();
+};
+
+module.exports.findById = function findById(id) {
+ return Section()
+ .where("id", parseInt(id, 10))
+ .first();
+};
+
+module.exports.update = function update(id, updates) {
+ return Section()
+ .where({ id: parseInt(id, 10) })
+ .update(updates)
+ .returning("*");
+};
+
+module.exports.create = function create(obj) {
+ return Section()
+ .insert(obj)
+ .returning("*");
+};
+
+module.exports.destroy = function destroy(id) {
+ return Section()
+ .where({ id: parseInt(id, 10) })
+ .delete()
+ .returning("*");
+};
diff --git a/eq-author-api/db/Validation.js b/eq-author-api/db/Validation.js
new file mode 100644
index 0000000000..b67eba58cd
--- /dev/null
+++ b/eq-author-api/db/Validation.js
@@ -0,0 +1,42 @@
+const db = require("./");
+
+function Validation() {
+ return db("Validation_AnswerRules");
+}
+
+module.exports.findAll = function findAll() {
+ return Validation().select();
+};
+
+module.exports.find = function find(where) {
+ return Validation()
+ .where(where)
+ .first();
+};
+
+module.exports.findField = function findById(where = {}, field) {
+ return Validation()
+ .select(field)
+ .where(where)
+ .first();
+};
+
+module.exports.update = function update(id, updates) {
+ return Validation()
+ .where({ id: parseInt(id, 10) })
+ .update(updates)
+ .returning("*");
+};
+
+module.exports.create = function create(obj) {
+ return Validation()
+ .insert(obj)
+ .returning("*");
+};
+
+module.exports.destroy = function destroy(id) {
+ return Validation()
+ .where({ id: parseInt(id, 10) })
+ .delete()
+ .returning("*");
+};
diff --git a/eq-author-api/db/index.js b/eq-author-api/db/index.js
new file mode 100644
index 0000000000..4ffb6feaa1
--- /dev/null
+++ b/eq-author-api/db/index.js
@@ -0,0 +1,4 @@
+var environment = process.env.NODE_ENV || "development";
+var config = require("../config/knexfile.js")[environment];
+
+module.exports = require("knex")(config);
\ No newline at end of file
diff --git a/eq-author-api/docker-compose.yml b/eq-author-api/docker-compose.yml
new file mode 100644
index 0000000000..cdc60e2406
--- /dev/null
+++ b/eq-author-api/docker-compose.yml
@@ -0,0 +1,31 @@
+version: "3"
+services:
+ web:
+ build:
+ context: .
+ depends_on:
+ - db
+ links:
+ - db
+ volumes:
+ - .:/app
+ #- ./node_modules/eq-author-graphql-schema:/app/node_modules/eq-author-graphql-schema
+ ports:
+ - 4000:4000
+ - 5858:5858 # open port for debugging
+ environment:
+ - DB_CONNECTION_URI=postgres://postgres:mysecretpassword@db:5432/postgres
+ - NODE_ENV=development
+ - RUNNER_SESSION_URL=http://docker.for.mac.localhost:5000/session?token=
+ - PUBLISHER_URL=http://docker.for.mac.localhost:9000/publish/
+ entrypoint:
+ - yarn
+ - start:dev
+ db:
+ image: postgres:9.4-alpine
+ environment:
+ - POSTGRES_USER=postgres
+ - POSTGRES_PASSWORD=mysecretpassword
+ - POSTGRES_DB=postgres
+ ports:
+ - 5432:5432
diff --git a/eq-author-api/docker-entrypoint.sh b/eq-author-api/docker-entrypoint.sh
new file mode 100755
index 0000000000..e907beff9f
--- /dev/null
+++ b/eq-author-api/docker-entrypoint.sh
@@ -0,0 +1,8 @@
+#!/usr/bin/env sh
+
+if [ -n "$SECRETS_S3_BUCKET" ]; then
+ echo "Load Secrets from S3 Bucket [$SECRETS_S3_BUCKET]"
+ aws s3 sync s3://$SECRETS_S3_BUCKET/ /secrets
+fi
+
+yarn start
\ No newline at end of file
diff --git a/eq-author-api/keys.yml b/eq-author-api/keys.yml
new file mode 100644
index 0000000000..4e8e0811a4
--- /dev/null
+++ b/eq-author-api/keys.yml
@@ -0,0 +1,54 @@
+keys:
+ 709eb42cfee5570058ce0711f730bfbb7d4c8ade:
+ platform: sdc
+ purpose: authentication
+ service: user
+ type: private
+ use: signing
+ value: |-
+ -----BEGIN RSA PRIVATE KEY-----
+ MIIEogIBAAKCAQEAvZzMraB96Wd1zfHS3vW3z//Nkqz+9HfwViNje2Y5L6m3K/7r
+ aA0kUsWD1f6X7/LIJfkCEctCEj9q19+cX30h0pi6IOu92MlIwdH/L6CTuzYnG4PA
+ CKT8FZonLw0NYBqh8p4vWS8xtNHNjTWua/FFTlxdtYnEb9HbUZkg7dXAtnikozlE
+ /ZZSponq7K00h3Uh9goxQIavcK1QI8pw5V+T8V8Ue7k98W8LpbYQWm7FPOZayu1E
+ oJWUZefdOlYAdeVbDS4tjrVF+3za+VX3q73zJEfyLEM0zKrkQQ796gfYpkzDYwJv
+ kiW7fb2Yh1teNHpFR5tozzMwUxkREl/TQ4U1kwIDAQABAoIBAHXiS1pTIpT/Dr24
+ b/rQV7RIfF2JkoUZIGHdZJcuqbUZVdlThrXNHd0cEWf0/i9fCNKa6o93iB9iMCIA
+ Uu8HFAUjkOyww/pIwiRGU9ofglltRIkVs0lskZE4os3c1oj+Zds6P4O6FLQvkBUP
+ 394aRZV/VX9tJKTEmw8zHcbgEw0eBpiY/EMELcSmZYk7lhB80Y+idTrZcHoV4AZo
+ DhQwyF0R63mMphuOV4PwaCdCYZKgd/tr2uUHglLpYbQag3iEzoDfxdFcxnRkBdOi
+ a/wcNo0JRlMsxXmtJ+HrZar+6ObUx5SgLGz7dQnKvP/ZgenTk0yyohwikh2b2KOS
+ M3M2oUkCgYEA9+olFPDZxtM1fwmlXcymBtokbiki/BJQGJ1/5RMqvdsSeq8icl/i
+ Qk5AoNbWEcsAxeBftb1IfnxJsRthRyp0NX5HOSsBFiIfdSF225nmBpktwPjJmvZZ
+ G2MQCVqw9Y40Cia0LZnRo8417ahSfVf8/IoggnAwkswJ3fkktt/FlW8CgYEAw8vi
+ 7hWxehiUaZO4RO7GuV47q4wPZ/nQvcimyjJuXBkC/gQay+TcA7CdXQTgxI2scMIk
+ UPas36mle1vbAp+GfWcNxDxhmSnQvUke4/wHF6sNZ3BwKoTRqJqFcFUHm+2uo6A4
+ HCBtXM83Z1nDYkHUrfng99U+zgGDz2XKPko9OB0CgYAtVVOSkLhB8z1FDa5/iHyT
+ pDAlNMCA95hN5/8LFIYsUXL/nCbgY0gsd8K5po9ekZCCnpTh1sr61h9jk24mZUz6
+ uyyq94IrWfIGqSfi4DF/42LKdrPm8kU5DNRR4ZOaU3aQpKMt84KyQXL7ElyDLyPD
+ yj5Hm9xF+6mSPYzJJAItYQKBgHzUZXbzf7ZfK2fwVSAlt68BJDvnzP62Z95Hqgbp
+ hjDThXPbvBXYcGkt1fYzIPZPeOxe6nZv/qGOcEGou4X9nOogpMdC09qprTqw/q/N
+ w9vUI3SaW/jPuzeqZH7Mx1Ajhh8uC/fquK7eMe2Dbi0b2XOeB08atrLyhk3ZEMsL
+ 2+IFAoGAUbmo0idyszcarBPPsiEFQY2y1yzHMajs8OkjUzOVLzdiMkr36LF4ojgw
+ UCM9sT0g1i+eTfTcuOEr3dAxcXld8Ffs6INSIplvRMWH1m7wgXMRpPCy74OuxlDQ
+ xwPp/1IVvrMqVgnyS9ezAeE0p9u8zUdZdwHz1UAggwbtHR6IbIA=
+ -----END RSA PRIVATE KEY-----
+ version: rrm
+ e19091072f920cbf3ca9f436ceba309e7d814a62:
+ platform: sdc
+ purpose: authentication
+ service: user
+ type: public
+ use: encryption
+ value: |
+ -----BEGIN PUBLIC KEY-----
+ MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAt8LZnIhuOdL/BC029GOa
+ JkVUAqgp2PcmbFr2Qwhf/514DUUQ9sKJ1rvwvbmmW2zE8JRtdY3ey0RXGtMn5UZH
+ s8NReHzMxvsmHN4VuaGEnFmPwO821Tkvg0LpKsLkotcw793FD/fut44N2lhpTSW2
+ Sc82uG0p9A+Kud8HCIaWaluosghk9rbMGYDzZQk8cA91GtKJRmIOED4PorB/dexD
+ f37qhuWNQgzyNyTti1DTDUIWyzQQJp926vLbkOip6Fc2R13hOFNETe68Rrw/h3hX
+ EFS17uPFZHsxvm9PFXX9KZMS25ohqbNh97I94LL4o4wybl6LaE6lJEHiD6docD0B
+ 6wIDAQAB
+ -----END PUBLIC KEY-----
+
+ version: sr
diff --git a/eq-author-api/middleware/import.js b/eq-author-api/middleware/import.js
new file mode 100644
index 0000000000..b69eb15d3f
--- /dev/null
+++ b/eq-author-api/middleware/import.js
@@ -0,0 +1,18 @@
+const buildQuestionnaire = require("../tests/utils/buildTestQuestionnaire");
+
+module.exports = async (req, res) => {
+ const data = (req.body.data || req.body).questionnaire || req.body;
+ try {
+ await buildQuestionnaire(data);
+ res.json({
+ status: "OK"
+ });
+ } catch (e) {
+ // eslint-disable-next-line no-console
+ console.error(e);
+ res.status(500);
+ res.json({
+ error: e
+ });
+ }
+};
diff --git a/eq-author-api/middleware/launch.js b/eq-author-api/middleware/launch.js
new file mode 100644
index 0000000000..b3b7083248
--- /dev/null
+++ b/eq-author-api/middleware/launch.js
@@ -0,0 +1,52 @@
+const { generateToken } = require("../utils/jwtHelper");
+const { assign, isNil, isEmpty } = require("lodash");
+const { sanitiseMetadata } = require("../utils/sanitiseMetadata");
+
+const buildClaims = metadata => {
+ const result = {
+ claims: {},
+ errors: []
+ };
+
+ metadata.map(({ key, value, id, type }) => {
+ if (isNil(key) || key.trim() === "") {
+ result.errors.push(id);
+ }
+
+ return assign(result.claims, {
+ [key]:
+ type === "Date" ? new Date(value).toISOString().split("T")[0] : value
+ });
+ });
+
+ return result;
+};
+
+module.exports.buildClaims = buildClaims;
+
+module.exports.getLaunchUrl = ctx => async (req, res, next) => {
+ const questionnaireId = req.params.questionnaireId;
+
+ const result = await ctx.repositories.Metadata.findAll({
+ questionnaireId
+ });
+
+ const { errors, claims: metadataValues } = buildClaims(result);
+
+ if (!isEmpty(errors)) {
+ next(
+ new Error(
+ `You have empty metadata keys, check your metadata and try again.`
+ )
+ );
+ } else {
+ const sanitisedMetadata = await sanitiseMetadata(
+ metadataValues,
+ questionnaireId
+ );
+
+ const jwt = await generateToken(sanitisedMetadata);
+
+ res.redirect(`${process.env.RUNNER_SESSION_URL}${jwt}`);
+ }
+};
diff --git a/eq-author-api/middleware/launch.test.js b/eq-author-api/middleware/launch.test.js
new file mode 100644
index 0000000000..94e36d898b
--- /dev/null
+++ b/eq-author-api/middleware/launch.test.js
@@ -0,0 +1,115 @@
+const { buildClaims, getLaunchUrl } = require("./launch");
+const mockRepository = require("../tests/utils/mockRepository");
+
+let repositories;
+const QUESTIONNAIRE_ID = 1;
+
+describe("launcher middleware", () => {
+ let res;
+ let req = { params: { questionnaireId: QUESTIONNAIRE_ID } };
+ res = {
+ redirect: jest.fn()
+ };
+ let next = jest.fn();
+
+ repositories = {
+ Metadata: mockRepository({
+ findAll: [{ id: 1, key: "hello", type: "Text", value: "hello" }]
+ })
+ };
+
+ it("should find all metadata when the endpoint is hit", async () => {
+ const ctx = { repositories };
+ await getLaunchUrl(ctx)(req, res, next);
+
+ expect(repositories.Metadata.findAll).toHaveBeenCalledWith({
+ questionnaireId: QUESTIONNAIRE_ID
+ });
+ });
+
+ it("should call a redirect with a jwt", async () => {
+ const ctx = { repositories };
+ await getLaunchUrl(ctx)(req, res, next);
+
+ expect(res.redirect).toHaveBeenCalled();
+ });
+
+ it("should call next with an error if a metadata is missing a key", async () => {
+ const ctx = {
+ repositories: {
+ Metadata: mockRepository({
+ findAll: [{ id: 1, type: "Text", value: "hello" }]
+ })
+ }
+ };
+ await getLaunchUrl(ctx)(req, res, next);
+
+ expect(next).toHaveBeenCalledWith(expect.any(Error));
+ });
+
+ it("should call next with an error if metadata key is null", async () => {
+ const ctx = {
+ repositories: {
+ Metadata: mockRepository({
+ findAll: [{ id: 1, key: null, type: "Text", value: "hello" }]
+ })
+ }
+ };
+ await getLaunchUrl(ctx)(req, res, next);
+
+ expect(next).toHaveBeenCalledWith(expect.any(Error));
+ });
+
+ it("should call next with an error if metadata key is an empty string", async () => {
+ const ctx = {
+ repositories: {
+ Metadata: mockRepository({
+ findAll: [{ id: 1, key: "", type: "Text", value: "hello" }]
+ })
+ }
+ };
+ await getLaunchUrl(ctx)(req, res, next);
+
+ expect(next).toHaveBeenCalledWith(expect.any(Error));
+ });
+
+ it("should call next with an error if metadata key is a string of whitespaces", async () => {
+ const ctx = {
+ repositories: {
+ Metadata: mockRepository({
+ findAll: [{ id: 1, key: " ", type: "Text", value: "hello" }]
+ })
+ }
+ };
+ await getLaunchUrl(ctx)(req, res, next);
+
+ expect(next).toHaveBeenCalledWith(expect.any(Error));
+ });
+
+ it("should convert date values to ISO string dates when building claims", () => {
+ expect(
+ buildClaims([{ id: 1, key: "hello", type: "Date", value: "01/01/2018" }])
+ ).toMatchObject({
+ claims: {
+ hello: "2018-01-01"
+ }
+ });
+ });
+
+ it("should convert date values to ISO string even if ISO format already", () => {
+ expect(
+ buildClaims([
+ {
+ id: 1,
+ key: "hello",
+ type: "Date",
+ value: "2018-01-01T00:00:00+00:00"
+ }
+ ])
+ ).toMatchObject({
+ claims: {
+ hello: "2018-01-01"
+ }
+ });
+ });
+});
diff --git a/eq-author-api/middleware/status.js b/eq-author-api/middleware/status.js
new file mode 100644
index 0000000000..6a72fae1db
--- /dev/null
+++ b/eq-author-api/middleware/status.js
@@ -0,0 +1,6 @@
+module.exports = (req, res) => {
+ res.json({
+ status: "OK",
+ version: process.env.EQ_AUTHOR_API_VERSION
+ });
+};
diff --git a/eq-author-api/middleware/status.test.js b/eq-author-api/middleware/status.test.js
new file mode 100644
index 0000000000..e7f765e525
--- /dev/null
+++ b/eq-author-api/middleware/status.test.js
@@ -0,0 +1,39 @@
+const status = require("./status");
+
+describe("status middleware", () => {
+ let req;
+ let res;
+ let previousEnv;
+
+ beforeEach(() => {
+ req = jest.fn();
+ res = {
+ json: jest.fn()
+ };
+
+ previousEnv = process.env;
+ });
+
+ it("should return OK status", () => {
+ status(req, res);
+ expect(res.json).toHaveBeenCalledWith(
+ expect.objectContaining({
+ status: "OK"
+ })
+ );
+ });
+
+ it("should include git commit if passed in env var", () => {
+ process.env.EQ_AUTHOR_API_VERSION = "some-long-git-commit-id";
+ status(req, res);
+ expect(res.json).toHaveBeenCalledWith(
+ expect.objectContaining({
+ version: process.env.EQ_AUTHOR_API_VERSION
+ })
+ );
+ });
+
+ afterEach(() => {
+ process.env = previousEnv;
+ });
+});
diff --git a/eq-author-api/migrations/20170620163533_create_questionnaires_table.js b/eq-author-api/migrations/20170620163533_create_questionnaires_table.js
new file mode 100644
index 0000000000..ead9f68686
--- /dev/null
+++ b/eq-author-api/migrations/20170620163533_create_questionnaires_table.js
@@ -0,0 +1,22 @@
+
+exports.up = function(knex) {
+ return knex.schema.createTable("Questionnaires", function(table) {
+ table.increments();
+ table.timestamp("created_at").notNullable().defaultTo(knex.fn.now());
+ table.timestamp("updated_at").notNullable().defaultTo(knex.fn.now());
+
+ table.text("title").notNullable();
+ table.string("surveyId").notNullable();
+ table.text("description");
+ table.string("theme").notNullable();
+ table.enum("legalBasis", [
+ "Voluntary",
+ "StatisticsOfTradeAct"
+ ]).notNullable();
+ table.bool("navigation").notNullable().defaultTo(false);
+ });
+};
+
+exports.down = function(knex) {
+ return knex.schema.dropTable("Questionnaires");
+};
diff --git a/eq-author-api/migrations/20170620163534_create_groups_table.js b/eq-author-api/migrations/20170620163534_create_groups_table.js
new file mode 100644
index 0000000000..483c83d3a4
--- /dev/null
+++ b/eq-author-api/migrations/20170620163534_create_groups_table.js
@@ -0,0 +1,22 @@
+
+exports.up = function(knex) {
+ return knex.schema.createTable("Groups", function(table) {
+ table.increments();
+ table.timestamp("created_at").notNullable().defaultTo(knex.fn.now());
+ table.timestamp("updated_at").notNullable().defaultTo(knex.fn.now());
+
+ table.text("title").notNullable();
+ table.text("description");
+
+ table.integer("QuestionnaireId")
+ .unsigned()
+ .index()
+ .references("id")
+ .inTable("Questionnaires")
+ .onDelete("CASCADE");
+ });
+};
+
+exports.down = function(knex) {
+ return knex.schema.dropTable("Groups");
+};
diff --git a/eq-author-api/migrations/20170620165046_create_pages_table.js b/eq-author-api/migrations/20170620165046_create_pages_table.js
new file mode 100644
index 0000000000..deda6701ca
--- /dev/null
+++ b/eq-author-api/migrations/20170620165046_create_pages_table.js
@@ -0,0 +1,37 @@
+
+exports.up = function(knex) {
+ return knex.schema.createTable("Pages", function(table) {
+ table.increments();
+ table.timestamp("created_at").notNullable().defaultTo(knex.fn.now());
+ table.timestamp("updated_at").notNullable().defaultTo(knex.fn.now());
+
+ table.text("title").notNullable();
+ table.text("description");
+
+ table.enum("pageType", [
+ "QuestionPage",
+ "InterstitialPage"
+ ]).notNullable();
+
+ // temporarily subsume the Questions table
+ table.text("guidance");
+ table.enum("type", [
+ "General",
+ "DateRange",
+ "RepeatingAnswer",
+ "Relationship"
+ ]).notNullable();
+ table.boolean("mandatory").notNullable().defaultsTo(false);
+
+ table.integer("GroupId")
+ .unsigned()
+ .index()
+ .references("id")
+ .inTable("Groups")
+ .onDelete("CASCADE");
+ });
+};
+
+exports.down = function(knex) {
+ return knex.schema.dropTable("Pages");
+};
diff --git a/eq-author-api/migrations/20170620170041_create_answers_table.js b/eq-author-api/migrations/20170620170041_create_answers_table.js
new file mode 100644
index 0000000000..e32b1e53c0
--- /dev/null
+++ b/eq-author-api/migrations/20170620170041_create_answers_table.js
@@ -0,0 +1,38 @@
+
+exports.up = function(knex) {
+ return knex.schema.createTable("Answers", function(table) {
+ table.increments();
+ table.timestamp("created_at").notNullable().defaultTo(knex.fn.now());
+ table.timestamp("updated_at").notNullable().defaultTo(knex.fn.now());
+
+ table.text("description");
+ table.text("guidance");
+ table.string("qCode");
+ table.text("label");
+ table.enum("type", [
+ "Checkbox",
+ "Currency",
+ "Date",
+ "MonthYearDate",
+ "Integer",
+ "Percentage",
+ "PositiveInteger",
+ "Radio",
+ "TextArea",
+ "TextField",
+ "Relationship"
+ ]).notNullable();
+ table.boolean("mandatory").notNullable().defaultsTo(false);
+
+ table.integer("QuestionPageId")
+ .unsigned()
+ .index()
+ .references("id")
+ .inTable("Pages")
+ .onDelete("CASCADE");
+ });
+};
+
+exports.down = function(knex) {
+ return knex.schema.dropTable("Answers");
+};
diff --git a/eq-author-api/migrations/20170712135414_rename_groups_to_sections.js b/eq-author-api/migrations/20170712135414_rename_groups_to_sections.js
new file mode 100644
index 0000000000..da373cd13d
--- /dev/null
+++ b/eq-author-api/migrations/20170712135414_rename_groups_to_sections.js
@@ -0,0 +1,31 @@
+exports.up = function(knex) {
+ return knex.schema
+ .table("Pages", t => {
+ t.dropForeign("GroupId");
+ })
+ .then(() => {
+ return knex.schema.renameTable("Groups", "Sections");
+ })
+ .then(() => {
+ return knex.schema.table("Pages", t => {
+ t.renameColumn("GroupId", "SectionId");
+ t.foreign("SectionId").references("Sections.id").onDelete("CASCADE");
+ });
+ });
+};
+
+exports.down = function(knex) {
+ return knex.schema
+ .table("Pages", t => {
+ t.dropForeign("SectionId");
+ })
+ .then(() => {
+ return knex.schema.renameTable("Sections", "Groups");
+ })
+ .then(() => {
+ return knex.schema.table("Pages", t => {
+ t.renameColumn("SectionId", "GroupId");
+ t.foreign("GroupId").references("Groups.id").onDelete("CASCADE");
+ });
+ });
+};
diff --git a/eq-author-api/migrations/20170810155613_create_options_table.js b/eq-author-api/migrations/20170810155613_create_options_table.js
new file mode 100644
index 0000000000..2d6c84b3c2
--- /dev/null
+++ b/eq-author-api/migrations/20170810155613_create_options_table.js
@@ -0,0 +1,25 @@
+exports.up = function(knex) {
+ return knex.schema.createTable("Options", function(table) {
+ table.increments();
+ table.timestamp("created_at").notNullable().defaultTo(knex.fn.now());
+ table.timestamp("updated_at").notNullable().defaultTo(knex.fn.now());
+
+ table.text("label");
+ table.text("description");
+ table.text("value");
+ table.string("qCode");
+ table.integer("childAnswerId");
+
+ table
+ .integer("AnswerId")
+ .unsigned()
+ .index()
+ .references("id")
+ .inTable("Answers")
+ .onDelete("CASCADE");
+ });
+};
+
+exports.down = function(knex) {
+ return knex.schema.dropTable("Options");
+};
diff --git a/eq-author-api/migrations/20170908115223_remove_mandatory_from_question.js b/eq-author-api/migrations/20170908115223_remove_mandatory_from_question.js
new file mode 100644
index 0000000000..dfb94a2579
--- /dev/null
+++ b/eq-author-api/migrations/20170908115223_remove_mandatory_from_question.js
@@ -0,0 +1,11 @@
+exports.up = function(knex) {
+ return knex.schema.table("Pages", function(table) {
+ table.dropColumn("mandatory");
+ });
+};
+
+exports.down = function(knex) {
+ return knex.schema.table("Pages", function(table) {
+ table.boolean("mandatory").notNullable().defaultsTo(false);
+ });
+};
diff --git a/eq-author-api/migrations/20170911160138_remove_type_from_questionpage.js b/eq-author-api/migrations/20170911160138_remove_type_from_questionpage.js
new file mode 100644
index 0000000000..f69d70a911
--- /dev/null
+++ b/eq-author-api/migrations/20170911160138_remove_type_from_questionpage.js
@@ -0,0 +1,14 @@
+exports.up = function(knex) {
+ return knex.schema.table("Pages", function(table) {
+ table.dropColumn("type");
+ });
+};
+
+exports.down = function(knex) {
+ return knex.schema.table("Pages", function(table) {
+ table
+ .string("type")
+ .notNullable()
+ .defaultsTo("General");
+ });
+};
diff --git a/eq-author-api/migrations/20170925095226_change_integer_types_to_number.js b/eq-author-api/migrations/20170925095226_change_integer_types_to_number.js
new file mode 100644
index 0000000000..a3b4d12db2
--- /dev/null
+++ b/eq-author-api/migrations/20170925095226_change_integer_types_to_number.js
@@ -0,0 +1,36 @@
+exports.up = function(knex) {
+ return knex.schema.raw(`
+ ALTER TABLE "Answers"
+ DROP CONSTRAINT "Answers_type_check",
+ ADD CONSTRAINT "Answers_type_check"
+ CHECK (type IN ('Checkbox',
+ 'Currency',
+ 'Date',
+ 'MonthYearDate',
+ 'Number',
+ 'Percentage',
+ 'Radio',
+ 'TextArea',
+ 'TextField',
+ 'Relationship'))
+ `);
+};
+
+exports.down = function(knex) {
+ return knex.schema.raw(`
+ ALTER TABLE "Answers"
+ DROP CONSTRAINT "Answers_type_check",
+ ADD CONSTRAINT "Answers_type_check"
+ CHECK (type IN ('Checkbox',
+ 'Currency',
+ 'Date',
+ 'MonthYearDate',
+ 'Integer',
+ 'Percentage',
+ 'PositiveInteger',
+ 'Radio',
+ 'TextArea',
+ 'TextField',
+ 'Relationship'))
+ `);
+};
diff --git a/eq-author-api/migrations/20171023153048_add_date_range_answer_type.js b/eq-author-api/migrations/20171023153048_add_date_range_answer_type.js
new file mode 100644
index 0000000000..dabef52b8d
--- /dev/null
+++ b/eq-author-api/migrations/20171023153048_add_date_range_answer_type.js
@@ -0,0 +1,36 @@
+exports.up = function(knex) {
+ return knex.schema.raw(`
+ ALTER TABLE "Answers"
+ DROP CONSTRAINT "Answers_type_check",
+ ADD CONSTRAINT "Answers_type_check"
+ CHECK (type IN ('Checkbox',
+ 'Currency',
+ 'Date',
+ 'DateRange',
+ 'MonthYearDate',
+ 'Number',
+ 'Percentage',
+ 'Radio',
+ 'TextArea',
+ 'TextField',
+ 'Relationship'))
+ `);
+};
+
+exports.down = function(knex) {
+ return knex.schema.raw(`
+ ALTER TABLE "Answers"
+ DROP CONSTRAINT "Answers_type_check",
+ ADD CONSTRAINT "Answers_type_check"
+ CHECK (type IN ('Checkbox',
+ 'Currency',
+ 'Date',
+ 'MonthYearDate',
+ 'Number',
+ 'Percentage',
+ 'Radio',
+ 'TextArea',
+ 'TextField',
+ 'Relationship'))
+ `);
+};
diff --git a/eq-author-api/migrations/20171024153850_add_soft_delete.js b/eq-author-api/migrations/20171024153850_add_soft_delete.js
new file mode 100644
index 0000000000..d5fc3f366e
--- /dev/null
+++ b/eq-author-api/migrations/20171024153850_add_soft_delete.js
@@ -0,0 +1,29 @@
+const IS_DELETED_COLUMN_NAME = "isDeleted";
+
+const TABLES_TO_CHANGE = [
+ "Questionnaires",
+ "Sections",
+ "Pages",
+ "Answers",
+ "Options"
+];
+
+const addIsDeletedColumn = table =>
+ table
+ .boolean(IS_DELETED_COLUMN_NAME)
+ .notNull()
+ .defaultTo(false);
+
+const dropIsDeletedColumn = table => table.dropColumn(IS_DELETED_COLUMN_NAME);
+
+exports.up = function(knex) {
+ return Promise.all(
+ TABLES_TO_CHANGE.map(t => knex.schema.table(t, addIsDeletedColumn))
+ );
+};
+
+exports.down = function(knex) {
+ return Promise.all(
+ TABLES_TO_CHANGE.map(t => knex.schema.table(t, dropIsDeletedColumn))
+ );
+};
diff --git a/eq-author-api/migrations/20171116131851_add_summary_column.js b/eq-author-api/migrations/20171116131851_add_summary_column.js
new file mode 100644
index 0000000000..3b8e03201c
--- /dev/null
+++ b/eq-author-api/migrations/20171116131851_add_summary_column.js
@@ -0,0 +1,14 @@
+const QUESTIONNAIRES_TABLE = "Questionnaires";
+const SUMMARY_COLUMN = "summary";
+
+exports.up = function(knex) {
+ return knex.schema.table(QUESTIONNAIRES_TABLE, table => {
+ table.bool(SUMMARY_COLUMN).defaultsTo(false);
+ });
+};
+
+exports.down = function(knex) {
+ return knex.schema.table(QUESTIONNAIRES_TABLE, table => {
+ table.dropColumn(SUMMARY_COLUMN);
+ });
+};
diff --git a/eq-author-api/migrations/20171211100317_add_created_by_to_questionnaires.js b/eq-author-api/migrations/20171211100317_add_created_by_to_questionnaires.js
new file mode 100644
index 0000000000..f69d450adc
--- /dev/null
+++ b/eq-author-api/migrations/20171211100317_add_created_by_to_questionnaires.js
@@ -0,0 +1,17 @@
+const TABLE = "Questionnaires";
+const COLUMN = "createdBy";
+
+exports.up = function(knex) {
+ return knex.schema.table(TABLE, table => {
+ table
+ .string(COLUMN)
+ .defaultsTo("")
+ .notNullable();
+ });
+};
+
+exports.down = function(knex) {
+ return knex.schema.table(TABLE, table => {
+ table.dropColumn(COLUMN);
+ });
+};
diff --git a/eq-author-api/migrations/20180209121814_add_index_to_page_section_id.js b/eq-author-api/migrations/20180209121814_add_index_to_page_section_id.js
new file mode 100644
index 0000000000..f921660bed
--- /dev/null
+++ b/eq-author-api/migrations/20180209121814_add_index_to_page_section_id.js
@@ -0,0 +1,11 @@
+exports.up = function(knex) {
+ return knex.schema.table("Pages", table => {
+ table.index(["SectionId"]);
+ });
+};
+
+exports.down = function(knex) {
+ return knex.schema.table("Pages", table => {
+ table.dropIndex(["SectionId"]);
+ });
+};
diff --git a/eq-author-api/migrations/20180209121829_add_order_to_page_id.js b/eq-author-api/migrations/20180209121829_add_order_to_page_id.js
new file mode 100644
index 0000000000..1a69de5120
--- /dev/null
+++ b/eq-author-api/migrations/20180209121829_add_order_to_page_id.js
@@ -0,0 +1,14 @@
+exports.up = function(knex) {
+ return knex.schema.table("Pages", table => {
+ table
+ .integer("order")
+ .defaultsTo(0)
+ .notNullable();
+ });
+};
+
+exports.down = function(knex) {
+ return knex.schema.table("Pages", table => {
+ table.dropColumn("order");
+ });
+};
diff --git a/eq-author-api/migrations/20180209165440_seed_page_order_column.js b/eq-author-api/migrations/20180209165440_seed_page_order_column.js
new file mode 100644
index 0000000000..b173e96439
--- /dev/null
+++ b/eq-author-api/migrations/20180209165440_seed_page_order_column.js
@@ -0,0 +1,47 @@
+exports.up = function(knex) {
+ /**
+ * Imagine you have a table like this:
+ *
+ * id SectionId
+ * ---- -----------
+ * 1 1
+ * 2 1
+ * 3 1
+ * 4 2
+ * 5 2
+ * 6 2
+ *
+ *
+ * This query generates a `ROW_NUMBER` (aliased as `position`), partitioned by `SectionId`, and ordered by `id`:
+ *
+ * id SectionId position
+ * ---- ----------- ----------
+ * 1 1 1
+ * 2 1 2
+ * 3 1 3
+ * 4 2 1
+ * 5 2 2
+ * 6 2 3
+ *
+ *
+ * The `position` value is multiplied by 1000 to gives spaced values for the `order` column.
+ * Each row is then updated with it's corresponding `order` value.
+ *
+ */
+ return knex.raw(`
+ UPDATE "Pages"
+ SET "order" = p.position
+ FROM (
+ SELECT id, ROW_NUMBER () OVER (
+ PARTITION BY "SectionId"
+ ORDER BY id
+ ) * 1000 as "position"
+ FROM "Pages"
+ ) p
+ WHERE "Pages".id = p.id;
+ `);
+};
+
+exports.down = function(knex) {
+ return knex("Pages").update({ order: 0 });
+};
diff --git a/eq-author-api/migrations/20180305123115_drop_child_answer_id.js b/eq-author-api/migrations/20180305123115_drop_child_answer_id.js
new file mode 100644
index 0000000000..d7c6087ec9
--- /dev/null
+++ b/eq-author-api/migrations/20180305123115_drop_child_answer_id.js
@@ -0,0 +1,13 @@
+const CHILD_ANSWER_ID_COL_NAME = "childAnswerId";
+
+exports.up = async function(knex) {
+ await knex.schema.table("Options", function(table) {
+ table.dropColumn(CHILD_ANSWER_ID_COL_NAME);
+ });
+};
+
+exports.down = async function(knex) {
+ await knex.schema.table("Options", function(table) {
+ table.integer(CHILD_ANSWER_ID_COL_NAME);
+ });
+};
diff --git a/eq-author-api/migrations/20180307162417_add_other_answer.js b/eq-author-api/migrations/20180307162417_add_other_answer.js
new file mode 100644
index 0000000000..91d4ff2ece
--- /dev/null
+++ b/eq-author-api/migrations/20180307162417_add_other_answer.js
@@ -0,0 +1,18 @@
+const OTHER_ANSWER_ID_COL_NAME = "parentAnswerId";
+
+exports.up = async function(knex) {
+ await knex.schema.table("Answers", function(table) {
+ table.integer(OTHER_ANSWER_ID_COL_NAME).unsigned();
+ table
+ .foreign(OTHER_ANSWER_ID_COL_NAME)
+ .references("Answers.id")
+ .onDelete("CASCADE");
+ });
+};
+
+exports.down = async function(knex) {
+ await knex.schema.table("Answers", function(table) {
+ table.dropForeign(OTHER_ANSWER_ID_COL_NAME);
+ table.dropColumn(OTHER_ANSWER_ID_COL_NAME);
+ });
+};
diff --git a/eq-author-api/migrations/20180322114835_add_second_label_to_answer.js b/eq-author-api/migrations/20180322114835_add_second_label_to_answer.js
new file mode 100644
index 0000000000..3d0340bdf8
--- /dev/null
+++ b/eq-author-api/migrations/20180322114835_add_second_label_to_answer.js
@@ -0,0 +1,11 @@
+exports.up = knex => {
+ return knex.schema.table("Answers", table => {
+ table.string("secondaryLabel");
+ });
+};
+
+exports.down = knex => {
+ return knex.schema.table("Answers", table =>
+ table.dropColumn("secondaryLabel")
+ );
+};
diff --git a/eq-author-api/migrations/20180328094207_add_default_labels_to_pre-existing_DateRanges.js b/eq-author-api/migrations/20180328094207_add_default_labels_to_pre-existing_DateRanges.js
new file mode 100644
index 0000000000..ff2bbfb189
--- /dev/null
+++ b/eq-author-api/migrations/20180328094207_add_default_labels_to_pre-existing_DateRanges.js
@@ -0,0 +1,11 @@
+exports.up = function(knex) {
+ return knex("Answers")
+ .where({ type: "DateRange" })
+ .update({ label: "Period from", secondaryLabel: "Period to" });
+};
+
+exports.down = function(knex) {
+ return knex("Answers")
+ .where({ type: "DateRange" })
+ .update({ label: null, secondaryLabel: null });
+};
diff --git a/eq-author-api/migrations/20180406083151_add_other_answer_id_to_options.js b/eq-author-api/migrations/20180406083151_add_other_answer_id_to_options.js
new file mode 100644
index 0000000000..41638a53a3
--- /dev/null
+++ b/eq-author-api/migrations/20180406083151_add_other_answer_id_to_options.js
@@ -0,0 +1,16 @@
+exports.up = async function(knex) {
+ await knex.schema.table("Options", table => {
+ table.integer("otherAnswerId").unsigned();
+ table
+ .foreign("otherAnswerId")
+ .references("Answers.id")
+ .onDelete("CASCADE");
+ });
+};
+
+exports.down = async function(knex) {
+ await knex.schema.table("Options", table => {
+ table.dropForeign("otherAnswerId");
+ table.dropColumn("otherAnswerId");
+ });
+};
diff --git a/eq-author-api/migrations/20180430155015_creating_routing_databases.js b/eq-author-api/migrations/20180430155015_creating_routing_databases.js
new file mode 100644
index 0000000000..74381bcb64
--- /dev/null
+++ b/eq-author-api/migrations/20180430155015_creating_routing_databases.js
@@ -0,0 +1,140 @@
+const createRoutingRuleSetTable = async knex => {
+ return knex.schema.createTable("Routing_RuleSets", function(table) {
+ table.increments();
+
+ table
+ .integer("QuestionPageId")
+ .unsigned()
+ .references("id")
+ .inTable("Pages")
+ .onDelete("CASCADE");
+
+ table
+ .integer("RoutingDestinationId")
+ .unsigned()
+ .references("id")
+ .inTable("Routing_Destinations")
+ .onDelete("CASCADE");
+
+ table
+ .boolean("isDeleted")
+ .notNull()
+ .defaultTo(false);
+ });
+};
+
+const createRoutingRuleTable = async knex => {
+ return knex.schema.createTable("Routing_Rules", function(table) {
+ table.increments();
+
+ table.enum("operation", ["And", "Or"]).notNullable();
+
+ table
+ .integer("RoutingRuleSetId")
+ .unsigned()
+ .references("id")
+ .inTable("Routing_RuleSets")
+ .onDelete("CASCADE");
+
+ table
+ .integer("RoutingDestinationId")
+ .unsigned()
+ .references("id")
+ .inTable("Routing_Destinations")
+ .onDelete("CASCADE");
+
+ table
+ .boolean("isDeleted")
+ .notNull()
+ .defaultTo(false);
+ });
+};
+
+const createRoutingConditionTable = async knex => {
+ return knex.schema.createTable("Routing_Conditions", function(table) {
+ table.increments();
+
+ table.enum("comparator", ["Equal", "NotEqual"]).notNullable();
+
+ table
+ .integer("RoutingRuleId")
+ .unsigned()
+ .references("id")
+ .inTable("Routing_Rules")
+ .onDelete("CASCADE");
+
+ table
+ .integer("QuestionPageId")
+ .unsigned()
+ .references("id")
+ .inTable("Pages")
+ .onDelete("CASCADE");
+
+ table
+ .integer("AnswerId")
+ .unsigned()
+ .references("id")
+ .inTable("Answers")
+ .onDelete("CASCADE");
+ });
+};
+
+const createRoutingConditionValuesTable = async knex => {
+ return knex.schema.createTable("Routing_ConditionValues", function(table) {
+ table.increments();
+
+ table
+ .integer("OptionId")
+ .unsigned()
+ .references("id")
+ .inTable("Options")
+ .onDelete("CASCADE");
+
+ table
+ .integer("ConditionId")
+ .unsigned()
+ .references("id")
+ .inTable("Routing_Conditions")
+ .onDelete("CASCADE");
+ });
+};
+
+const createRoutingDestinationsTable = async knex => {
+ return knex.schema.createTable("Routing_Destinations", table => {
+ table.increments();
+
+ table
+ .enum("logicalDestination", ["NextPage", "EndOfQuestionnaire"])
+ .defaultTo("NextPage");
+
+ table
+ .integer("PageId")
+ .unsigned()
+ .references("id")
+ .inTable("Pages")
+ .onDelete("CASCADE");
+
+ table
+ .integer("SectionId")
+ .unsigned()
+ .references("id")
+ .inTable("Sections")
+ .onDelete("CASCADE");
+ });
+};
+
+exports.up = async function(knex) {
+ await createRoutingDestinationsTable(knex);
+ await createRoutingRuleSetTable(knex);
+ await createRoutingRuleTable(knex);
+ await createRoutingConditionTable(knex);
+ await createRoutingConditionValuesTable(knex);
+};
+
+exports.down = async function(knex) {
+ await knex.schema.dropTable("Routing_ConditionValues");
+ await knex.schema.dropTable("Routing_Conditions");
+ await knex.schema.dropTable("Routing_Rules");
+ await knex.schema.dropTable("Routing_RuleSets");
+ await knex.schema.dropTable("Routing_Destinations");
+};
diff --git a/eq-author-api/migrations/20180501142343_add_view_table_for_pages.js b/eq-author-api/migrations/20180501142343_add_view_table_for_pages.js
new file mode 100644
index 0000000000..d5c88a81bc
--- /dev/null
+++ b/eq-author-api/migrations/20180501142343_add_view_table_for_pages.js
@@ -0,0 +1,16 @@
+exports.up = function(knex) {
+ return knex.raw(`
+ CREATE OR REPLACE VIEW "PagesView" AS (
+ SELECT *, ROW_NUMBER () OVER (
+ PARTITION BY "SectionId"
+ ORDER BY "order" ASC
+ ) - 1 AS "position"
+ FROM "Pages"
+ WHERE "isDeleted" = false
+ )
+ `);
+};
+
+exports.down = function(knex) {
+ return knex.raw(`DROP VIEW "PagesView"`);
+};
diff --git a/eq-author-api/migrations/20180621140416_add_properties_to_answers.js b/eq-author-api/migrations/20180621140416_add_properties_to_answers.js
new file mode 100644
index 0000000000..143081bf71
--- /dev/null
+++ b/eq-author-api/migrations/20180621140416_add_properties_to_answers.js
@@ -0,0 +1,14 @@
+exports.up = function(knex) {
+ return knex.schema.table("Answers", table => {
+ table
+ .jsonb("properties")
+ .defaultsTo("{}")
+ .notNullable();
+ });
+};
+
+exports.down = function(knex) {
+ return knex.schema.table("Answers", table => {
+ table.dropColumn("properties");
+ });
+};
diff --git a/eq-author-api/migrations/20180622154505_migrate_mandatory_field_data_to_properties.js b/eq-author-api/migrations/20180622154505_migrate_mandatory_field_data_to_properties.js
new file mode 100644
index 0000000000..62300dfe3b
--- /dev/null
+++ b/eq-author-api/migrations/20180622154505_migrate_mandatory_field_data_to_properties.js
@@ -0,0 +1,15 @@
+exports.up = function(knex) {
+ return knex.schema.raw(`
+ UPDATE "Answers"
+ SET properties = json_build_object(
+ 'required', mandatory
+ )::jsonb;
+ `);
+};
+
+exports.down = function(knex) {
+ return knex.schema.raw(`
+ UPDATE "Answers"
+ SET mandatory = (properties->>'required')::boolean
+ `);
+};
diff --git a/eq-author-api/migrations/20180622160307_remove_mandatory_from_answers.js b/eq-author-api/migrations/20180622160307_remove_mandatory_from_answers.js
new file mode 100644
index 0000000000..95d6781498
--- /dev/null
+++ b/eq-author-api/migrations/20180622160307_remove_mandatory_from_answers.js
@@ -0,0 +1,14 @@
+exports.up = function(knex) {
+ return knex.schema.table("Answers", function(table) {
+ table.dropColumn("mandatory");
+ });
+};
+
+exports.down = function(knex) {
+ return knex.schema.table("Answers", function(table) {
+ table
+ .boolean("mandatory")
+ .notNullable()
+ .defaultsTo(false);
+ });
+};
diff --git a/eq-author-api/migrations/20180629130216_add_order_to_sections.js b/eq-author-api/migrations/20180629130216_add_order_to_sections.js
new file mode 100644
index 0000000000..caa6f7ddee
--- /dev/null
+++ b/eq-author-api/migrations/20180629130216_add_order_to_sections.js
@@ -0,0 +1,14 @@
+exports.up = function(knex) {
+ return knex.schema.table("Sections", table => {
+ table
+ .integer("order")
+ .defaultsTo(0)
+ .notNullable();
+ });
+};
+
+exports.down = function(knex) {
+ return knex.schema.table("Sections", table => {
+ table.dropColumn("order");
+ });
+};
diff --git a/eq-author-api/migrations/20180629130217_seed_section_order_column.js b/eq-author-api/migrations/20180629130217_seed_section_order_column.js
new file mode 100644
index 0000000000..6c3c980140
--- /dev/null
+++ b/eq-author-api/migrations/20180629130217_seed_section_order_column.js
@@ -0,0 +1,18 @@
+exports.up = function(knex) {
+ return knex.raw(`
+ UPDATE "Sections"
+ SET "order" = s.position
+ FROM (
+ SELECT id, ROW_NUMBER () OVER (
+ PARTITION BY "QuestionnaireId"
+ ORDER BY id
+ ) * 1000 as "position"
+ FROM "Sections"
+ ) s
+ WHERE "Sections".id = s.id
+ `);
+};
+
+exports.down = function(knex) {
+ return knex("Sections").update({ order: 0 });
+};
diff --git a/eq-author-api/migrations/20180706134843_add_decimals_data_to_properties.js b/eq-author-api/migrations/20180706134843_add_decimals_data_to_properties.js
new file mode 100644
index 0000000000..d21e3b2ebd
--- /dev/null
+++ b/eq-author-api/migrations/20180706134843_add_decimals_data_to_properties.js
@@ -0,0 +1,20 @@
+exports.up = function(knex) {
+ return knex.schema.raw(`
+ UPDATE "Answers"
+ SET properties = json_build_object(
+ 'required', (properties->>'required')::boolean,
+ 'decimals', 0
+ )::jsonb
+ WHERE type IN ('Number', 'Currency');
+ `);
+};
+
+exports.down = function(knex) {
+ return knex.schema.raw(`
+ UPDATE "Answers"
+ SET properties = json_build_object(
+ 'required', (properties->>'required')::boolean
+ )::jsonb
+ WHERE type IN ('Number', 'Currency');
+ `);
+};
diff --git a/eq-author-api/migrations/20180711133028_add_view_table_for_sections.js b/eq-author-api/migrations/20180711133028_add_view_table_for_sections.js
new file mode 100644
index 0000000000..6c21e1a443
--- /dev/null
+++ b/eq-author-api/migrations/20180711133028_add_view_table_for_sections.js
@@ -0,0 +1,16 @@
+exports.up = function(knex) {
+ return knex.raw(`
+ CREATE OR REPLACE VIEW "SectionsView" AS (
+ SELECT *, ROW_NUMBER () OVER (
+ PARTITION BY "QuestionnaireId"
+ ORDER BY "order" ASC
+ ) - 1 AS "position"
+ FROM "Sections"
+ WHERE "isDeleted" = false
+ )
+ `);
+};
+
+exports.down = function(knex) {
+ return knex.raw(`DROP VIEW "SectionsView"`);
+};
diff --git a/eq-author-api/migrations/20180720085104_add_view_for_composite_answers.js b/eq-author-api/migrations/20180720085104_add_view_for_composite_answers.js
new file mode 100644
index 0000000000..13dfb3b0e4
--- /dev/null
+++ b/eq-author-api/migrations/20180720085104_add_view_for_composite_answers.js
@@ -0,0 +1,24 @@
+exports.up = function(knex) {
+ return knex.raw(`
+ CREATE OR REPLACE VIEW "CompositeAnswerView" AS (
+ select id::text, label,
+ "type", "QuestionPageId", "isDeleted", description, guidance,"qCode",
+ "parentAnswerId", created_at, updated_at
+ from "Answers" where "type" not in ('DateRange')
+ union
+ select concat(id, 'from') as id, label,
+ "type", "QuestionPageId", "isDeleted", description, guidance,"qCode",
+ "parentAnswerId", created_at, updated_at
+ from "Answers" where "type" in ('DateRange')
+ union
+ select concat(id, 'to') as id, "secondaryLabel" as label,
+ "type", "QuestionPageId", "isDeleted", description, guidance, "qCode",
+ "parentAnswerId", created_at, updated_at
+ from "Answers" where "type" in ('DateRange')
+ )
+ `);
+};
+
+exports.down = function(knex) {
+ return knex.raw(`DROP VIEW "CompositeAnswerView"`);
+};
diff --git a/eq-author-api/migrations/20180730084336_add_tables_for_validations.js b/eq-author-api/migrations/20180730084336_add_tables_for_validations.js
new file mode 100644
index 0000000000..1b50d041c8
--- /dev/null
+++ b/eq-author-api/migrations/20180730084336_add_tables_for_validations.js
@@ -0,0 +1,34 @@
+const createAnswerValidationRulesTable = async knex => {
+ return knex.schema.createTable("Validation_AnswerRules", function(table) {
+ table.increments();
+
+ table
+ .integer("AnswerId")
+ .unsigned()
+ .references("id")
+ .inTable("Answers")
+ .onDelete("CASCADE");
+
+ table.enum("validationType", ["minValue", "maxValue"]).notNullable();
+
+ table
+ .bool("enabled")
+ .defaultsTo(false)
+ .notNull();
+
+ table
+ .jsonb("config")
+ .defaultsTo("{}")
+ .notNull();
+
+ table.jsonb("custom");
+ });
+};
+
+exports.up = async function(knex) {
+ await createAnswerValidationRulesTable(knex);
+};
+
+exports.down = async function(knex) {
+ await knex.schema.dropTable("Validation_AnswerRules");
+};
diff --git a/eq-author-api/migrations/20180809154832_add_date_format_data_to_properties.js b/eq-author-api/migrations/20180809154832_add_date_format_data_to_properties.js
new file mode 100644
index 0000000000..6f05809200
--- /dev/null
+++ b/eq-author-api/migrations/20180809154832_add_date_format_data_to_properties.js
@@ -0,0 +1,20 @@
+exports.up = function(knex) {
+ return knex.schema.raw(`
+ UPDATE "Answers"
+ SET properties = json_build_object(
+ 'required', (properties->>'required')::boolean,
+ 'format', 'dd/mm/yyyy'
+ )::jsonb
+ WHERE type IN ('Date');
+ `);
+};
+
+exports.down = function(knex) {
+ return knex.schema.raw(`
+ UPDATE "Answers"
+ SET properties = json_build_object(
+ 'required', (properties->>'required')::boolean
+ )::jsonb
+ WHERE type IN ('Date');
+ `);
+};
diff --git a/eq-author-api/migrations/20180822122408_add_min_max_validation_to_existing_number_currency_answers.js b/eq-author-api/migrations/20180822122408_add_min_max_validation_to_existing_number_currency_answers.js
new file mode 100644
index 0000000000..56541d1f66
--- /dev/null
+++ b/eq-author-api/migrations/20180822122408_add_min_max_validation_to_existing_number_currency_answers.js
@@ -0,0 +1,29 @@
+exports.up = async function(knex) {
+ const ids = await knex
+ .select("Answers.id")
+ .from("Answers")
+ .where(function() {
+ this.where({ type: "Currency" }).orWhere({ type: "Number" });
+ })
+ .leftOuterJoin("Validation_AnswerRules", function() {
+ this.on("Validation_AnswerRules.AnswerId", "=", "Answers.id");
+ })
+ .whereNull("Validation_AnswerRules.id");
+
+ return ids.map(({ id }) => {
+ return ["minValue", "maxValue"].map(validationType => {
+ return knex("Validation_AnswerRules").insert({
+ AnswerId: id,
+ validationType,
+ config: { inclusive: false }
+ });
+ });
+ });
+};
+
+exports.down = async function() {
+ /*
+ This migration will not have a rollback mechanism as due to the addition of specific data
+ it is not possible to re-identity that data to delete it in a rollback action.
+ */
+};
diff --git a/eq-author-api/migrations/20180823133327_add_mutually_exclusive_option_bool.js b/eq-author-api/migrations/20180823133327_add_mutually_exclusive_option_bool.js
new file mode 100644
index 0000000000..c2df4a38e5
--- /dev/null
+++ b/eq-author-api/migrations/20180823133327_add_mutually_exclusive_option_bool.js
@@ -0,0 +1,11 @@
+exports.up = knex => {
+ return knex.schema.table("Options", table => {
+ table.bool("mutuallyExclusive").defaultsTo(false);
+ });
+};
+
+exports.down = knex => {
+ return knex.schema.table("Options", table =>
+ table.dropColumn("mutuallyExclusive")
+ );
+};
diff --git a/eq-author-api/migrations/20180824093112_add_min_max_validation_to_existing_answers_v2.js b/eq-author-api/migrations/20180824093112_add_min_max_validation_to_existing_answers_v2.js
new file mode 100644
index 0000000000..f8d2191041
--- /dev/null
+++ b/eq-author-api/migrations/20180824093112_add_min_max_validation_to_existing_answers_v2.js
@@ -0,0 +1,31 @@
+const { flatMap } = require("lodash");
+
+exports.up = async function(knex) {
+ await knex("Validation_AnswerRules").del();
+
+ const ids = await knex
+ .select("Answers.id")
+ .from("Answers")
+ .where(function() {
+ this.where({ type: "Currency" }).orWhere({ type: "Number" });
+ });
+
+ const result = flatMap(ids, ({ id }) => {
+ return ["minValue", "maxValue"].map(validationType => {
+ return knex("Validation_AnswerRules").insert({
+ AnswerId: id,
+ validationType,
+ config: { inclusive: false }
+ });
+ });
+ });
+
+ return Promise.all(result);
+};
+
+exports.down = async function() {
+ /*
+ This migration will not have a rollback mechanism as due to the addition of specific data
+ it is not possible to re-identity that data to delete it in a rollback action.
+ */
+};
diff --git a/eq-author-api/migrations/20180828155216_create_questionnaire_metadata_table.js b/eq-author-api/migrations/20180828155216_create_questionnaire_metadata_table.js
new file mode 100644
index 0000000000..4ca241f10b
--- /dev/null
+++ b/eq-author-api/migrations/20180828155216_create_questionnaire_metadata_table.js
@@ -0,0 +1,27 @@
+exports.up = function(knex) {
+ return knex.schema.createTable("Metadata", function(table) {
+ table.increments();
+ table.string("key");
+ table.string("alias");
+ table
+ .enum("type", ["Date", "Text", "Region", "Language"])
+ .notNullable()
+ .defaultTo("Text");
+ table.string("value");
+ table
+ .bool("isDeleted")
+ .notNull()
+ .defaultTo(false);
+ table
+ .integer("QuestionnaireId")
+ .unsigned()
+ .index()
+ .references("id")
+ .inTable("Questionnaires")
+ .onDelete("CASCADE");
+ });
+};
+
+exports.down = function(knex) {
+ return knex.schema.dropTable("Metadata");
+};
diff --git a/eq-author-api/migrations/20180903142820_renaming_fk_columns_in_db.js b/eq-author-api/migrations/20180903142820_renaming_fk_columns_in_db.js
new file mode 100644
index 0000000000..baaa8a6f24
--- /dev/null
+++ b/eq-author-api/migrations/20180903142820_renaming_fk_columns_in_db.js
@@ -0,0 +1,154 @@
+exports.up = function(knex) {
+ let promiseArray = [];
+
+ promiseArray.push(
+ knex.schema.table("Answers", t => {
+ t.renameColumn("QuestionPageId", "questionPageId");
+ })
+ );
+
+ promiseArray.push(
+ knex.schema.table("Options", t => {
+ t.renameColumn("AnswerId", "answerId");
+ })
+ );
+
+ promiseArray.push(
+ knex.schema.table("Pages", t => {
+ t.renameColumn("SectionId", "sectionId");
+ })
+ );
+
+ promiseArray.push(
+ knex.schema.table("Routing_ConditionValues", t => {
+ t.renameColumn("OptionId", "optionId");
+ t.renameColumn("ConditionId", "conditionId");
+ })
+ );
+
+ promiseArray.push(
+ knex.schema.table("Routing_Conditions", t => {
+ t.renameColumn("RoutingRuleId", "routingRuleId");
+ t.renameColumn("QuestionPageId", "questionPageId");
+ t.renameColumn("AnswerId", "answerId");
+ })
+ );
+
+ promiseArray.push(
+ knex.schema.table("Routing_Destinations", t => {
+ t.renameColumn("PageId", "pageId");
+ t.renameColumn("SectionId", "sectionId");
+ })
+ );
+
+ promiseArray.push(
+ knex.schema.table("Routing_RuleSets", t => {
+ t.renameColumn("QuestionPageId", "questionPageId");
+ t.renameColumn("RoutingDestinationId", "routingDestinationId");
+ })
+ );
+
+ promiseArray.push(
+ knex.schema.table("Routing_Rules", t => {
+ t.renameColumn("RoutingRuleSetId", "routingRuleSetId");
+ t.renameColumn("RoutingDestinationId", "routingDestinationId");
+ })
+ );
+
+ promiseArray.push(
+ knex.schema.table("Sections", t => {
+ t.renameColumn("QuestionnaireId", "questionnaireId");
+ })
+ );
+
+ promiseArray.push(
+ knex.schema.table("Validation_AnswerRules", t => {
+ t.renameColumn("AnswerId", "answerId");
+ })
+ );
+
+ promiseArray.push(
+ knex.schema.table("Metadata", t => {
+ t.renameColumn("QuestionnaireId", "questionnaireId");
+ })
+ );
+
+ return Promise.all(promiseArray);
+};
+
+exports.down = function(knex) {
+ let promiseArray = [];
+
+ promiseArray.push(
+ knex.schema.table("Answers", t => {
+ t.renameColumn("questionPageId", "QuestionPageId");
+ })
+ );
+
+ promiseArray.push(
+ knex.schema.table("Options", t => {
+ t.renameColumn("answerId", "AnswerId");
+ })
+ );
+
+ promiseArray.push(
+ knex.schema.table("Pages", t => {
+ t.renameColumn("sectionId", "SectionId");
+ })
+ );
+
+ promiseArray.push(
+ knex.schema.table("Routing_ConditionValues", t => {
+ t.renameColumn("optionId", "OptionId");
+ t.renameColumn("conditionId", "ConditionId");
+ })
+ );
+
+ promiseArray.push(
+ knex.schema.table("Routing_Conditions", t => {
+ t.renameColumn("routingRuleId", "RoutingRuleId");
+ t.renameColumn("questionPageId", "QuestionPageId");
+ t.renameColumn("answerId", "AnswerId");
+ })
+ );
+
+ promiseArray.push(
+ knex.schema.table("Routing_Destinations", t => {
+ t.renameColumn("pageId", "PageId");
+ t.renameColumn("sectionId", "SectionId");
+ })
+ );
+
+ promiseArray.push(
+ knex.schema.table("Routing_RuleSets", t => {
+ t.renameColumn("questionPageId", "QuestionPageId");
+ t.renameColumn("routingDestinationId", "RoutingDestinationId");
+ })
+ );
+
+ promiseArray.push(
+ knex.schema.table("Routing_Rules", t => {
+ t.renameColumn("routingRuleSetId", "RoutingRuleSetId");
+ t.renameColumn("routingDestinationId", "RoutingDestinationId");
+ })
+ );
+
+ promiseArray.push(
+ knex.schema.table("Sections", t => {
+ t.renameColumn("questionnaireId", "QuestionnaireId");
+ })
+ );
+
+ promiseArray.push(
+ knex.schema.table("Validation_AnswerRules", t => {
+ t.renameColumn("answerId", "AnswerId");
+ })
+ );
+
+ promiseArray.push(
+ knex.schema.table("Metadata", t => {
+ t.renameColumn("questionnaireId", "QuestionnaireId");
+ })
+ );
+ return Promise.all(promiseArray);
+};
diff --git a/eq-author-api/migrations/20180907153112_rename_created_at_columns.js b/eq-author-api/migrations/20180907153112_rename_created_at_columns.js
new file mode 100644
index 0000000000..f018906c67
--- /dev/null
+++ b/eq-author-api/migrations/20180907153112_rename_created_at_columns.js
@@ -0,0 +1,81 @@
+exports.up = function(knex) {
+ let promiseArray = [];
+
+ promiseArray.push(
+ knex.schema.table("Answers", t => {
+ t.renameColumn("created_at", "createdAt");
+ t.renameColumn("updated_at", "updatedAt");
+ })
+ );
+
+ promiseArray.push(
+ knex.schema.table("Options", t => {
+ t.renameColumn("created_at", "createdAt");
+ t.renameColumn("updated_at", "updatedAt");
+ })
+ );
+
+ promiseArray.push(
+ knex.schema.table("Pages", t => {
+ t.renameColumn("created_at", "createdAt");
+ t.renameColumn("updated_at", "updatedAt");
+ })
+ );
+
+ promiseArray.push(
+ knex.schema.table("Questionnaires", t => {
+ t.renameColumn("created_at", "createdAt");
+ t.renameColumn("updated_at", "updatedAt");
+ })
+ );
+
+ promiseArray.push(
+ knex.schema.table("Sections", t => {
+ t.renameColumn("created_at", "createdAt");
+ t.renameColumn("updated_at", "updatedAt");
+ })
+ );
+
+ return Promise.all(promiseArray);
+};
+
+exports.down = function(knex) {
+ let promiseArray = [];
+
+ promiseArray.push(
+ knex.schema.table("Answers", t => {
+ t.renameColumn("createdAt", "created_at");
+ t.renameColumn("updatedAt", "updated_at");
+ })
+ );
+
+ promiseArray.push(
+ knex.schema.table("Options", t => {
+ t.renameColumn("createdAt", "created_at");
+ t.renameColumn("updatedAt", "updated_at");
+ })
+ );
+
+ promiseArray.push(
+ knex.schema.table("Pages", t => {
+ t.renameColumn("createdAt", "created_at");
+ t.renameColumn("updatedAt", "updated_at");
+ })
+ );
+
+ promiseArray.push(
+ knex.schema.table("Questionnaires", t => {
+ t.renameColumn("createdAt", "created_at");
+ t.renameColumn("updatedAt", "updated_at");
+ })
+ );
+
+ promiseArray.push(
+ knex.schema.table("Sections", t => {
+ t.renameColumn("createdAt", "created_at");
+ t.renameColumn("updatedAt", "updated_at");
+ })
+ );
+
+ return Promise.all(promiseArray);
+};
diff --git a/eq-author-api/migrations/20180907154321_recreate_views_with_new_naming_conventions.js b/eq-author-api/migrations/20180907154321_recreate_views_with_new_naming_conventions.js
new file mode 100644
index 0000000000..2cccce9985
--- /dev/null
+++ b/eq-author-api/migrations/20180907154321_recreate_views_with_new_naming_conventions.js
@@ -0,0 +1,66 @@
+exports.up = async function(knex, Promise) {
+ await knex.raw(`DROP VIEW "PagesView"`);
+
+ await knex.raw(`DROP VIEW "SectionsView"`);
+
+ await knex.raw(`DROP VIEW "CompositeAnswerView"`);
+
+ let promiseArray = [];
+
+ promiseArray.push(
+ knex.raw(`
+ CREATE OR REPLACE VIEW "PagesView" AS (
+ SELECT *, ROW_NUMBER () OVER (
+ PARTITION BY "sectionId"
+ ORDER BY "order" ASC
+ ) - 1 AS "position"
+ FROM "Pages"
+ WHERE "isDeleted" = false
+ )
+ `)
+ );
+
+ promiseArray.push(
+ knex.raw(`
+ CREATE OR REPLACE VIEW "SectionsView" AS (
+ SELECT *, ROW_NUMBER () OVER (
+ PARTITION BY "questionnaireId"
+ ORDER BY "order" ASC
+ ) - 1 AS "position"
+ FROM "Sections"
+ WHERE "isDeleted" = false
+ )
+ `)
+ );
+
+ promiseArray.push(
+ knex.raw(`
+ CREATE OR REPLACE VIEW "CompositeAnswerView" AS (
+ select id::text, label,
+ "type", "questionPageId", "isDeleted", description, guidance,"qCode",
+ "parentAnswerId", "createdAt", "updatedAt"
+ from "Answers" where "type" not in ('DateRange')
+ union
+ select concat(id, 'from') as id, label,
+ "type", "questionPageId", "isDeleted", description, guidance,"qCode",
+ "parentAnswerId", "createdAt", "updatedAt"
+ from "Answers" where "type" in ('DateRange')
+ union
+ select concat(id, 'to') as id, "secondaryLabel" as label,
+ "type", "questionPageId", "isDeleted", description, guidance, "qCode",
+ "parentAnswerId", "createdAt", "updatedAt"
+ from "Answers" where "type" in ('DateRange')
+ )
+ `)
+ );
+
+ return Promise.all(promiseArray);
+};
+
+exports.down = async function(knex) {
+ await knex.raw(`DROP VIEW "PagesView"`);
+
+ await knex.raw(`DROP VIEW "SectionsView"`);
+
+ await knex.raw(`DROP VIEW "CompositeAnswerView"`);
+};
diff --git a/eq-author-api/migrations/20180907155542_add_earliest_date_validation.js b/eq-author-api/migrations/20180907155542_add_earliest_date_validation.js
new file mode 100644
index 0000000000..020374e429
--- /dev/null
+++ b/eq-author-api/migrations/20180907155542_add_earliest_date_validation.js
@@ -0,0 +1,43 @@
+const formatAlterTableEnumSql = (tableName, columnName, enums) => {
+ const constraintName = `${tableName}_${columnName}_check`;
+ return [
+ `ALTER TABLE "${tableName}" DROP CONSTRAINT IF EXISTS "${constraintName}";`,
+ `ALTER TABLE "${tableName}" ADD CONSTRAINT "${constraintName}" CHECK ("${columnName}" = ANY (ARRAY['${enums.join(
+ "'::text, '"
+ )}'::text]));`
+ ].join("\n");
+};
+
+exports.up = async function(knex) {
+ await knex.raw(
+ formatAlterTableEnumSql("Validation_AnswerRules", "validationType", [
+ "minValue",
+ "maxValue",
+ "earliestDate"
+ ])
+ );
+
+ const ids = await knex
+ .select("Answers.id")
+ .from("Answers")
+ .where({ type: "Date" });
+
+ const inserts = ids.map(({ id }) =>
+ knex("Validation_AnswerRules").insert({
+ AnswerId: id,
+ validationType: "earliestDate",
+ config: {
+ offset: {
+ value: 0,
+ unit: "Days"
+ },
+ relativePosition: "Before"
+ }
+ })
+ );
+ return Promise.resolve(inserts);
+};
+
+exports.down = function() {
+ return Promise.resolve();
+};
diff --git a/eq-author-api/migrations/20180914145815_add_latest_date_validation.js b/eq-author-api/migrations/20180914145815_add_latest_date_validation.js
new file mode 100644
index 0000000000..93479f6cc9
--- /dev/null
+++ b/eq-author-api/migrations/20180914145815_add_latest_date_validation.js
@@ -0,0 +1,44 @@
+const formatAlterTableEnumSql = (tableName, columnName, enums) => {
+ const constraintName = `${tableName}_${columnName}_check`;
+ return [
+ `ALTER TABLE "${tableName}" DROP CONSTRAINT IF EXISTS "${constraintName}";`,
+ `ALTER TABLE "${tableName}" ADD CONSTRAINT "${constraintName}" CHECK ("${columnName}" = ANY (ARRAY['${enums.join(
+ "'::text, '"
+ )}'::text]));`
+ ].join("\n");
+};
+
+exports.up = async function(knex) {
+ await knex.raw(
+ formatAlterTableEnumSql("Validation_AnswerRules", "validationType", [
+ "minValue",
+ "maxValue",
+ "earliestDate",
+ "latestDate"
+ ])
+ );
+
+ const ids = await knex
+ .select("Answers.id")
+ .from("Answers")
+ .where({ type: "Date" });
+
+ const inserts = ids.map(({ id }) =>
+ knex("Validation_AnswerRules").insert({
+ AnswerId: id,
+ validationType: "latestDate",
+ config: {
+ offset: {
+ value: 0,
+ unit: "Days"
+ },
+ relativePosition: "Before"
+ }
+ })
+ );
+ return Promise.resolve(inserts);
+};
+
+exports.down = function() {
+ return Promise.resolve();
+};
diff --git a/eq-author-api/migrations/20180918144616_add_alias_to_sections.js b/eq-author-api/migrations/20180918144616_add_alias_to_sections.js
new file mode 100644
index 0000000000..8ab7dc7020
--- /dev/null
+++ b/eq-author-api/migrations/20180918144616_add_alias_to_sections.js
@@ -0,0 +1,17 @@
+exports.up = async function(knex) {
+ await knex.schema.table("Sections", table => table.string("alias"));
+ await knex.raw(`DROP VIEW "SectionsView"`);
+ return knex.raw(`
+ CREATE OR REPLACE VIEW "SectionsView" AS (
+ SELECT *, ROW_NUMBER () OVER (
+ PARTITION BY "questionnaireId"
+ ORDER BY "order" ASC
+ ) - 1 AS "position"
+ FROM "Sections"
+ WHERE "isDeleted" = false
+ )`);
+};
+
+exports.down = async function() {
+ return Promise.resolve();
+};
diff --git a/eq-author-api/migrations/20180919111921_adding_date_validations_to_existing_answers.js b/eq-author-api/migrations/20180919111921_adding_date_validations_to_existing_answers.js
new file mode 100644
index 0000000000..b09a55b56e
--- /dev/null
+++ b/eq-author-api/migrations/20180919111921_adding_date_validations_to_existing_answers.js
@@ -0,0 +1,45 @@
+exports.up = async function(knex, Promise) {
+ const answersWithId = await knex
+ .select("Answers.id")
+ .from("Answers")
+ .where({ type: "Date" });
+
+ const ids = answersWithId.map(({ id }) => id);
+
+ await knex("Validation_AnswerRules")
+ .whereIn("answerId", ids)
+ .del();
+
+ const inserts = ids.map(id =>
+ knex("Validation_AnswerRules").insert([
+ {
+ answerId: id,
+ validationType: "earliestDate",
+ config: {
+ offset: {
+ value: 0,
+ unit: "Days"
+ },
+ relativePosition: "Before"
+ }
+ },
+ {
+ answerId: id,
+ validationType: "latestDate",
+ config: {
+ offset: {
+ value: 0,
+ unit: "Days"
+ },
+ relativePosition: "After"
+ }
+ }
+ ])
+ );
+
+ return Promise.all(inserts);
+};
+
+exports.down = function(knex, Promise) {
+ return Promise.resolve();
+};
diff --git a/eq-author-api/migrations/20180920120356_add_alias_to_questions.js b/eq-author-api/migrations/20180920120356_add_alias_to_questions.js
new file mode 100644
index 0000000000..ed45ae316a
--- /dev/null
+++ b/eq-author-api/migrations/20180920120356_add_alias_to_questions.js
@@ -0,0 +1,17 @@
+exports.up = async function(knex) {
+ await knex.schema.table("Pages", table => table.string("alias"));
+ await knex.raw(`DROP VIEW "PagesView"`);
+ return knex.raw(`
+ CREATE OR REPLACE VIEW "PagesView" AS (
+ SELECT *, ROW_NUMBER () OVER (
+ PARTITION BY "sectionId"
+ ORDER BY "order" ASC
+ ) - 1 AS "position"
+ FROM "Pages"
+ WHERE "isDeleted" = false
+ )`);
+};
+
+exports.down = async function() {
+ return Promise.resolve();
+};
diff --git a/eq-author-api/migrations/20181004135102_add_custom_values_to_routing_condition_values.js b/eq-author-api/migrations/20181004135102_add_custom_values_to_routing_condition_values.js
new file mode 100644
index 0000000000..722290e50b
--- /dev/null
+++ b/eq-author-api/migrations/20181004135102_add_custom_values_to_routing_condition_values.js
@@ -0,0 +1,20 @@
+const formatAlterTableEnumSql = require("../utils/migrateEnumChecks");
+const { noop } = require("lodash");
+
+exports.up = async function(knex) {
+ await knex.raw(
+ formatAlterTableEnumSql("Routing_Conditions", "comparator", [
+ "Equal",
+ "NotEqual",
+ "GreaterThan",
+ "LessThan",
+ "GreaterOrEqual",
+ "LessOrEqual"
+ ])
+ );
+ return knex.schema.table("Routing_ConditionValues", table => {
+ table.integer("customNumber").defaultsTo(null);
+ });
+};
+
+exports.down = noop;
diff --git a/eq-author-api/migrations/20181004142958_add_entity_type_to_validation_answer_rules.js b/eq-author-api/migrations/20181004142958_add_entity_type_to_validation_answer_rules.js
new file mode 100644
index 0000000000..d6ee4d598a
--- /dev/null
+++ b/eq-author-api/migrations/20181004142958_add_entity_type_to_validation_answer_rules.js
@@ -0,0 +1,17 @@
+const {
+ CUSTOM,
+ PREVIOUS_ANSWER
+} = require("../constants/validation-entity-types");
+
+exports.up = function(knex) {
+ return knex.schema.table("Validation_AnswerRules", table => {
+ table
+ .enum("entityType", [CUSTOM, PREVIOUS_ANSWER])
+ .defaultsTo(CUSTOM)
+ .notNullable();
+ });
+};
+
+exports.down = function() {
+ return Promise.resolve();
+};
diff --git a/eq-author-api/migrations/20181004143754_add_previous_answer_id_to_validation_answer_rules.js b/eq-author-api/migrations/20181004143754_add_previous_answer_id_to_validation_answer_rules.js
new file mode 100644
index 0000000000..3e0fc81f69
--- /dev/null
+++ b/eq-author-api/migrations/20181004143754_add_previous_answer_id_to_validation_answer_rules.js
@@ -0,0 +1,15 @@
+exports.up = function(knex) {
+ return knex.schema.table("Validation_AnswerRules", table => {
+ table
+ .integer("previousAnswerId")
+ .unsigned()
+ .index()
+ .references("id")
+ .inTable("Answers")
+ .onDelete("SET NULL");
+ });
+};
+
+exports.down = function() {
+ return Promise.resolve();
+};
diff --git a/eq-author-api/migrations/20181023144034_created_introduction_columns.js b/eq-author-api/migrations/20181023144034_created_introduction_columns.js
new file mode 100644
index 0000000000..5190a88cb4
--- /dev/null
+++ b/eq-author-api/migrations/20181023144034_created_introduction_columns.js
@@ -0,0 +1,21 @@
+const { noop } = require("lodash");
+
+exports.up = async function(knex) {
+ await knex.schema.table("Sections", table => {
+ table.string("introductionTitle");
+ table.string("introductionContent");
+ table.boolean("introductionEnabled").defaultsTo(false);
+ });
+ await knex.raw(`DROP VIEW "SectionsView"`);
+ return knex.raw(`
+ CREATE OR REPLACE VIEW "SectionsView" AS (
+ SELECT *, ROW_NUMBER () OVER (
+ PARTITION BY "questionnaireId"
+ ORDER BY "order" ASC
+ ) - 1 AS "position"
+ FROM "Sections"
+ WHERE "isDeleted" = false
+ )`);
+};
+
+exports.down = noop;
diff --git a/eq-author-api/migrations/20181024112053_update_validation_entity_type_enums.js b/eq-author-api/migrations/20181024112053_update_validation_entity_type_enums.js
new file mode 100644
index 0000000000..d7abf52ed6
--- /dev/null
+++ b/eq-author-api/migrations/20181024112053_update_validation_entity_type_enums.js
@@ -0,0 +1,19 @@
+const { noop } = require("lodash");
+const formatAlterTableEnumSql = require("../utils/migrateEnumChecks");
+const {
+ CUSTOM,
+ PREVIOUS_ANSWER,
+ METADATA
+} = require("../constants/validation-entity-types");
+
+exports.up = async function(knex) {
+ return knex.raw(
+ formatAlterTableEnumSql("Validation_AnswerRules", "entityType", [
+ CUSTOM,
+ PREVIOUS_ANSWER,
+ METADATA
+ ])
+ );
+};
+
+exports.down = noop;
diff --git a/eq-author-api/migrations/20181024112700_add_metadata_id_to_validation_answer_rules.js b/eq-author-api/migrations/20181024112700_add_metadata_id_to_validation_answer_rules.js
new file mode 100644
index 0000000000..902118b290
--- /dev/null
+++ b/eq-author-api/migrations/20181024112700_add_metadata_id_to_validation_answer_rules.js
@@ -0,0 +1,15 @@
+const { noop } = require("lodash");
+
+exports.up = async function(knex) {
+ return knex.schema.table("Validation_AnswerRules", table => {
+ table
+ .integer("metadataId")
+ .unsigned()
+ .index()
+ .references("id")
+ .inTable("Metadata")
+ .onDelete("SET NULL");
+ });
+};
+
+exports.down = noop;
diff --git a/eq-author-api/migrations/20181031140447_added_now_to_validation_enum.js b/eq-author-api/migrations/20181031140447_added_now_to_validation_enum.js
new file mode 100644
index 0000000000..f43f265a52
--- /dev/null
+++ b/eq-author-api/migrations/20181031140447_added_now_to_validation_enum.js
@@ -0,0 +1,21 @@
+const formatAlterTableEnumSql = require("../utils/migrateEnumChecks");
+const { noop } = require("lodash");
+const {
+ CUSTOM,
+ PREVIOUS_ANSWER,
+ METADATA,
+ NOW
+} = require("../constants/validation-entity-types");
+
+exports.up = function(knex) {
+ return knex.raw(
+ formatAlterTableEnumSql("Validation_AnswerRules", "entityType", [
+ CUSTOM,
+ PREVIOUS_ANSWER,
+ METADATA,
+ NOW
+ ])
+ );
+};
+
+exports.down = noop;
diff --git a/eq-author-api/package.json b/eq-author-api/package.json
new file mode 100644
index 0000000000..e00a8d1d71
--- /dev/null
+++ b/eq-author-api/package.json
@@ -0,0 +1,70 @@
+{
+ "name": "eq-author-api",
+ "version": "1.0.0",
+ "main": "app.js",
+ "license": "MIT",
+ "scripts": {
+ "start": "yarn knex -- migrate:latest && nodemon",
+ "start:dev": "yarn knex -- migrate:latest && nodemon -L --inspect=0.0.0.0:5858",
+ "lint": "eslint .",
+ "test": "NODE_ENV=test ./scripts/test.sh",
+ "knex": "knex --knexfile config/knexfile.js --cwd .",
+ "precommit": "lint-staged"
+ },
+ "lint-staged": {
+ "*.js": [
+ "prettier --write",
+ "git add"
+ ]
+ },
+ "dependencies": {
+ "body-parser": "^1.17.2",
+ "cheerio": "^1.0.0-rc.2",
+ "colors": "^1.1.2",
+ "cors": "^2.8.3",
+ "dotenv": "^6.0.0",
+ "eq-author-graphql-schema": "^0.40.0",
+ "express": "^4.15.3",
+ "express-pino-logger": "^4.0.0",
+ "graphql": "^14.0.2",
+ "graphql-iso-date": "^3.3.0",
+ "graphql-relay": "^0.5.2",
+ "graphql-server-express": "^1.3.2",
+ "graphql-tools": "^4.0.0",
+ "graphql-type-json": "^0.2.1",
+ "jest": "^23.0.0",
+ "js-yaml": "^3.12.0",
+ "json-web-key": "^0.4.0",
+ "jsrsasign": "^8.0.12",
+ "knex": "^0.15.2",
+ "lodash": "^4.17.4",
+ "node-jose": "^1.0.0",
+ "nodemon": "^1.11.0",
+ "pg": "^7.4.1",
+ "pg-hstore": "^2.3.2",
+ "wait-for-postgres": "^1.5.3"
+ },
+ "engines": {
+ "node": ">=7.10.0"
+ },
+ "jest": {
+ "testEnvironment": "node",
+ "coverageDirectory": "./coverage/",
+ "collectCoverage": true,
+ "collectCoverageFrom": [
+ "**/*.js",
+ "!config/**/*",
+ "!migrations/*",
+ "!coverage/**/*",
+ "!tests/**/*"
+ ]
+ },
+ "devDependencies": {
+ "eslint": "^5.6.1",
+ "eslint-config-eq-author": "^2.0.1",
+ "husky": "^1.0.1",
+ "lint-staged": "^7.0.5",
+ "prettier": "^1.5.3",
+ "sqlite3": "^4.0.0"
+ }
+}
diff --git a/eq-author-api/repositories/AnswerRepository.js b/eq-author-api/repositories/AnswerRepository.js
new file mode 100644
index 0000000000..e5c8ed6b87
--- /dev/null
+++ b/eq-author-api/repositories/AnswerRepository.js
@@ -0,0 +1,265 @@
+const {
+ flow,
+ head,
+ isBoolean,
+ isObject,
+ map,
+ omit,
+ assign,
+ values,
+ includes,
+ flatten
+} = require("lodash/fp");
+const { get, merge } = require("lodash");
+
+const db = require("../db");
+const Answer = require("../db/Answer");
+
+const childAnswerParser = require("../utils/childAnswerParser");
+const getDefaultAnswerProperties = require("../utils/defaultAnswerProperties");
+const { answerTypeMap } = require("../utils/defaultAnswerValidations");
+
+const OptionRepository = require("./OptionRepository");
+
+const {
+ createDefaultValidationsForAnswer
+} = require("./strategies/validationStrategy");
+const {
+ createOtherAnswerStrategy,
+ deleteOtherAnswerStrategy
+} = require("./strategies/multipleChoiceOtherAnswerStrategy");
+
+const { handleAnswerDeleted } = require("./strategies/routingStrategy");
+
+const handleDeprecatedMandatoryFieldFromDb = answer =>
+ isObject(answer)
+ ? merge({}, answer, { mandatory: get(answer, "properties.required") })
+ : answer;
+
+const handleDeprecatedMandatoryFieldToDb = answer =>
+ isBoolean(answer.mandatory)
+ ? merge({}, answer, { properties: { required: answer.mandatory } })
+ : answer;
+
+const fromDb = flow(handleDeprecatedMandatoryFieldFromDb);
+
+const toDb = flow(
+ handleDeprecatedMandatoryFieldToDb,
+ omit("mandatory")
+);
+
+const findAll = (where = {}, orderBy = "createdAt", direction = "asc") =>
+ Answer.findAll()
+ .where({ isDeleted: false, parentAnswerId: null })
+ .where(where)
+ .orderBy(orderBy, direction)
+ .then(map(fromDb));
+
+const getById = id =>
+ Answer.findById(id)
+ .where({ isDeleted: false })
+ .then(fromDb);
+
+const insert = (
+ {
+ description,
+ guidance,
+ label,
+ secondaryLabel,
+ qCode,
+ type,
+ mandatory,
+ properties,
+ questionPageId
+ },
+ trx = db
+) =>
+ trx("Answers")
+ .insert(
+ toDb({
+ description,
+ guidance,
+ label,
+ secondaryLabel,
+ qCode,
+ type,
+ mandatory,
+ properties,
+ questionPageId
+ })
+ )
+ .returning("*")
+ .then(head)
+ .then(fromDb);
+
+const update = ({
+ id,
+ description,
+ guidance,
+ label,
+ secondaryLabel,
+ qCode,
+ type,
+ isDeleted,
+ parentAnswerId,
+ mandatory,
+ properties
+}) => {
+ if (childAnswerParser(id) === "secondary") {
+ secondaryLabel = label;
+ label = undefined;
+ }
+ return Answer.update(
+ id,
+ toDb({
+ description,
+ guidance,
+ label,
+ secondaryLabel,
+ qCode,
+ type,
+ isDeleted,
+ parentAnswerId,
+ mandatory,
+ properties
+ })
+ )
+ .then(head)
+ .then(fromDb);
+};
+
+const createAnswer = async answerConfig => {
+ return db.transaction(async trx => {
+ const defaultProperties = getDefaultAnswerProperties(answerConfig.type);
+ const input = merge({}, answerConfig, {
+ properties: defaultProperties
+ });
+ const answer = await insert(input, trx);
+
+ if (includes(answer.type, flatten(values(answerTypeMap)))) {
+ await createDefaultValidationsForAnswer(answer, trx);
+ }
+
+ if (answer.type === "Checkbox" || answer.type === "Radio") {
+ const defaultOptions = [];
+ const defaultOption = {
+ label: "",
+ description: "",
+ value: "",
+ qCode: "",
+ answerId: answer.id
+ };
+
+ defaultOptions.push(defaultOption);
+
+ if (answer.type === "Radio") {
+ defaultOptions.push(defaultOption);
+ }
+
+ const promises = defaultOptions.map(it =>
+ OptionRepository.insert(it, trx)
+ );
+
+ await Promise.all(promises);
+ }
+
+ return answer;
+ });
+};
+
+const deleteAnswer = async (trx, id) => {
+ const deletedAnswer = await trx("Answers")
+ .where({
+ id: parseInt(id)
+ })
+ .update({ isDeleted: true })
+ .returning("*")
+ .then(head)
+ .then(fromDb);
+
+ await handleAnswerDeleted(trx, id);
+
+ return deletedAnswer;
+};
+
+const remove = id => db.transaction(trx => deleteAnswer(trx, id));
+
+const undelete = id =>
+ Answer.update(id, { isDeleted: false })
+ .then(head)
+ .then(fromDb);
+
+const getOtherAnswer = (
+ id,
+ where = {},
+ orderBy = "createdAt",
+ direction = "asc"
+) =>
+ Answer.findAll()
+ .where({ isDeleted: false, parentAnswerId: id })
+ .where(where)
+ .orderBy(orderBy, direction)
+ .first()
+ .then(fromDb);
+
+const createOtherAnswer = ({ id }) => {
+ return db.transaction(trx =>
+ createOtherAnswerStrategy(trx, { id }).then(fromDb)
+ );
+};
+
+const deleteOtherAnswer = ({ id }) => {
+ return db.transaction(trx =>
+ deleteOtherAnswerStrategy(trx, { id }).then(fromDb)
+ );
+};
+
+const splitComposites = answer => {
+ const firstAnswer = omit("secondaryLabel", answer);
+ const secondAnswer = omit("label", answer);
+ return [
+ assign(firstAnswer, {
+ id: `${answer.id}from`
+ }),
+ assign(secondAnswer, {
+ id: `${answer.id}to`,
+ label: secondAnswer.secondaryLabel
+ })
+ ];
+};
+
+const lookupComposite = async (where = {}) => {
+ return db("CompositeAnswerView")
+ .select("*")
+ .where({ isDeleted: false, parentAnswerId: null })
+ .where(where)
+ .then(head)
+ .then(fromDb);
+};
+
+const getAnswers = async ids => {
+ return Promise.all(
+ ids.map(id => {
+ if (childAnswerParser(id)) {
+ return lookupComposite({ id });
+ } else {
+ return getById(id);
+ }
+ })
+ );
+};
+
+Object.assign(module.exports, {
+ findAll,
+ getById,
+ insert,
+ update,
+ remove,
+ undelete,
+ getOtherAnswer,
+ createOtherAnswer,
+ deleteOtherAnswer,
+ splitComposites,
+ getAnswers,
+ createAnswer
+});
diff --git a/eq-author-api/repositories/MetadataRepository.js b/eq-author-api/repositories/MetadataRepository.js
new file mode 100644
index 0000000000..c509a578ee
--- /dev/null
+++ b/eq-author-api/repositories/MetadataRepository.js
@@ -0,0 +1,50 @@
+const { head } = require("lodash/fp");
+const { find, merge } = require("lodash");
+
+const Metadata = require("../db/Metadata");
+
+const {
+ defaultTypeValueNames,
+ defaultTypeValues,
+ defaultValues
+} = require("../utils/defaultMetadata");
+
+module.exports.getById = function(id) {
+ return Metadata.findById(id).where({ isDeleted: false });
+};
+
+module.exports.findAll = function findAll(where = {}) {
+ return Metadata.findAll()
+ .where({ isDeleted: false })
+ .where(where);
+};
+
+module.exports.insert = function({ questionnaireId }) {
+ return Metadata.create({ questionnaireId }).then(head);
+};
+
+module.exports.update = function({ id, key, alias, type, ...values }) {
+ return this.getById(id).then(current => {
+ const update = {
+ id,
+ key,
+ alias,
+ type,
+ value: values[defaultTypeValueNames[type]]
+ };
+
+ if (current.type !== type && !update.value) {
+ update.value = defaultTypeValues[type];
+ }
+
+ if (current.key !== key) {
+ merge(update, find(defaultValues, { key }));
+ }
+
+ return Metadata.update(id, update).then(head);
+ });
+};
+
+module.exports.remove = function(id) {
+ return Metadata.update(id, { isDeleted: true }).then(head);
+};
diff --git a/eq-author-api/repositories/MetadataRepository.test.js b/eq-author-api/repositories/MetadataRepository.test.js
new file mode 100644
index 0000000000..3190d08b5b
--- /dev/null
+++ b/eq-author-api/repositories/MetadataRepository.test.js
@@ -0,0 +1,320 @@
+const { map, times, omit } = require("lodash");
+
+const db = require("../db");
+const QuestionnaireRepository = require("../repositories/QuestionnaireRepository");
+const MetadataRepository = require("../repositories/MetadataRepository");
+
+const buildQuestionnaire = (json = {}) => {
+ return Object.assign(
+ {
+ title: "Test questionnaire",
+ surveyId: "1",
+ theme: "default",
+ legalBasis: "Voluntary",
+ navigation: false,
+ createdBy: "foo"
+ },
+ json
+ );
+};
+
+const buildMetadata = (questionnaireId, json = {}) => {
+ return Object.assign(
+ {
+ type: "Text",
+ questionnaireId: questionnaireId
+ },
+ json
+ );
+};
+
+describe("MetadataRepository", () => {
+ let questionnaireId;
+
+ beforeAll(() => db.migrate.latest());
+ afterAll(() => db.destroy());
+ afterEach(() => db("Questionnaires").delete());
+ beforeEach(async () => {
+ const questionnaire = buildQuestionnaire({ title: "New questionnaire" });
+ ({ id: questionnaireId } = await QuestionnaireRepository.insert(
+ questionnaire
+ ));
+ });
+
+ it("should retrieve a single Metadata", async () => {
+ const metadata = buildMetadata(questionnaireId);
+ const { id } = await MetadataRepository.insert(metadata);
+ const result = await MetadataRepository.getById(id);
+
+ expect(result.id).toBe(id);
+ expect(result).toMatchObject({
+ type: "Text",
+ questionnaireId
+ });
+ });
+
+ it("should retrieve all Metadata", async () => {
+ const metadata = times(4, async () => {
+ const metadata = buildMetadata(questionnaireId);
+ const { id } = await MetadataRepository.insert(metadata);
+ return id;
+ });
+ const metadataIds = await Promise.all(metadata);
+ const results = map(await MetadataRepository.findAll(), "id");
+ expect(results).toEqual(expect.arrayContaining(metadataIds));
+ });
+
+ it("should create new Metadata", async () => {
+ const metadata = buildMetadata(questionnaireId);
+ const result = await MetadataRepository.insert(metadata);
+ expect(result).toMatchObject({
+ type: "Text",
+ questionnaireId
+ });
+ });
+
+ it("should update Metadata - text", async () => {
+ const metadata = buildMetadata(questionnaireId);
+ const { id } = await MetadataRepository.insert(metadata);
+
+ const updateValues = {
+ id,
+ key: "foo_bar",
+ alias: "Reporting Unit Reference",
+ type: "Text",
+ textValue: "10000000",
+ questionnaireId: questionnaireId
+ };
+
+ const result = await MetadataRepository.update(updateValues);
+
+ expect(result).toMatchObject(
+ omit({ ...updateValues, value: "10000000" }, ["textValue"])
+ );
+ });
+
+ it("should update Metadata - date", async () => {
+ const metadata = buildMetadata(questionnaireId);
+ const { id } = await MetadataRepository.insert(metadata);
+
+ const updateValues = {
+ id,
+ key: "foo_bar",
+ alias: "Reporting Unit Reference",
+ type: "Date",
+ dateValue: new Date("2018-09-04"),
+ questionnaireId: questionnaireId
+ };
+
+ const result = await MetadataRepository.update(updateValues);
+
+ expect(result).toMatchObject(
+ omit(
+ {
+ ...updateValues,
+ value: expect.stringContaining("2018-09-04")
+ },
+ ["dateValue"]
+ )
+ );
+ });
+
+ it("should update Metadata - region", async () => {
+ const metadata = buildMetadata(questionnaireId);
+ const { id } = await MetadataRepository.insert(metadata);
+
+ const updateValues = {
+ id,
+ key: "foo_bar",
+ alias: "Reporting Unit Reference",
+ type: "Region",
+ regionValue: "GB_ENG",
+ questionnaireId: questionnaireId
+ };
+
+ const result = await MetadataRepository.update(updateValues);
+
+ expect(result).toMatchObject(
+ omit({ ...updateValues, value: "GB_ENG" }, ["regionValue"])
+ );
+ });
+
+ it("should update Metadata - language", async () => {
+ const metadata = buildMetadata(questionnaireId);
+ const { id } = await MetadataRepository.insert(metadata);
+
+ const updateValues = {
+ id,
+ key: "foo_bar",
+ alias: "Reporting Unit Reference",
+ type: "Language",
+ languageValue: "en",
+ questionnaireId: questionnaireId
+ };
+
+ const result = await MetadataRepository.update(updateValues);
+
+ expect(result).toMatchObject(
+ omit({ ...updateValues, value: "en" }, ["languageValue"])
+ );
+ });
+
+ it("should update Metadata Language type with default value", async () => {
+ const metadata = buildMetadata(questionnaireId);
+ const { id } = await MetadataRepository.insert(metadata);
+
+ const updateValues = {
+ id,
+ key: "",
+ alias: "",
+ type: "Language",
+ languageValue: "",
+ questionnaireId: questionnaireId
+ };
+
+ const result = await MetadataRepository.update(updateValues);
+
+ expect(result).toMatchObject(
+ omit({ ...updateValues, value: "en" }, ["languageValue"])
+ );
+ });
+
+ it("should update Metadata Region type with default value", async () => {
+ const metadata = buildMetadata(questionnaireId);
+ const { id } = await MetadataRepository.insert(metadata);
+
+ const updateValues = {
+ id,
+ key: "",
+ alias: "",
+ type: "Region",
+ regionValue: "",
+ questionnaireId: questionnaireId
+ };
+
+ const result = await MetadataRepository.update(updateValues);
+
+ expect(result).toMatchObject(
+ omit({ ...updateValues, value: "GB_ENG" }, ["regionValue"])
+ );
+ });
+
+ it("should update Metadata type and use update value", async () => {
+ const metadata = buildMetadata(questionnaireId);
+ const { id } = await MetadataRepository.insert(metadata);
+
+ const updateValues = {
+ id,
+ key: "",
+ alias: "",
+ type: "Text",
+ textValue: "test",
+ questionnaireId: questionnaireId
+ };
+
+ await MetadataRepository.update(updateValues);
+
+ const result = await MetadataRepository.update({
+ ...updateValues,
+ type: "Region",
+ regionValue: "GB_ENG"
+ });
+
+ expect(result).toMatchObject(
+ omit(
+ {
+ ...updateValues,
+ type: "Region",
+ value: "GB_ENG"
+ },
+ ["regionValue", "textValue"]
+ )
+ );
+ });
+
+ it("should update Metadata value with default values", async () => {
+ const metadata = buildMetadata(questionnaireId);
+ const { id } = await MetadataRepository.insert(metadata);
+
+ const updateValues = {
+ id,
+ key: "ru_ref",
+ alias: "",
+ type: "Text",
+ languageValue: "",
+ questionnaireId: questionnaireId
+ };
+
+ const result = await MetadataRepository.update(updateValues);
+
+ expect(result).toMatchObject({
+ id,
+ key: "ru_ref",
+ alias: "Ru Ref",
+ type: "Text",
+ value: "12346789012A"
+ });
+ });
+
+ it("should update Metadata with new defaults when new key given", async () => {
+ const metadata = buildMetadata(questionnaireId);
+ const { id } = await MetadataRepository.insert(metadata);
+
+ const updateValues = {
+ id,
+ key: "ru_ref",
+ alias: "",
+ type: "Text",
+ languageValue: "",
+ questionnaireId: questionnaireId
+ };
+
+ await MetadataRepository.update(updateValues);
+ const result = await MetadataRepository.update({
+ ...updateValues,
+ key: "ru_name"
+ });
+
+ expect(result).toMatchObject({
+ id,
+ key: "ru_name",
+ alias: "Ru Name",
+ type: "Text",
+ value: "ESSENTIAL ENTERPRISE LTD."
+ });
+ });
+
+ it("should update Metadata with custom values when same key given", async () => {
+ const metadata = buildMetadata(questionnaireId);
+ const { id } = await MetadataRepository.insert(metadata);
+
+ const updateValues = {
+ id,
+ key: "tx_id",
+ alias: "",
+ type: "Text",
+ textValue: "",
+ questionnaireId: questionnaireId
+ };
+
+ await MetadataRepository.update(updateValues);
+ const result = await MetadataRepository.update({
+ ...updateValues,
+ key: "tx_id",
+ textValue: "foobar"
+ });
+
+ expect(result).toMatchObject({
+ id,
+ value: "foobar"
+ });
+ });
+
+ it("should remove Metadata", async () => {
+ const metadata = buildMetadata(questionnaireId);
+ const { id } = await MetadataRepository.insert(metadata);
+ await MetadataRepository.remove(id);
+ const result = await MetadataRepository.getById(id);
+ expect(result).toBeUndefined();
+ });
+});
diff --git a/eq-author-api/repositories/OptionRepository.js b/eq-author-api/repositories/OptionRepository.js
new file mode 100644
index 0000000000..f5b19ddc4a
--- /dev/null
+++ b/eq-author-api/repositories/OptionRepository.js
@@ -0,0 +1,95 @@
+const { head, isNil } = require("lodash/fp");
+const Option = require("../db/Option");
+const db = require("../db");
+const { handleOptionDeleted } = require("./strategies/routingStrategy");
+
+const findExclusiveOptionByAnswerId = answerId =>
+ Option.findAll()
+ .where({
+ isDeleted: false,
+ otherAnswerId: null,
+ mutuallyExclusive: true,
+ answerId
+ })
+ .then(head);
+
+const checkForExistingExclusive = async answerId => {
+ const existingExclusive = await findExclusiveOptionByAnswerId(answerId);
+ if (!isNil(existingExclusive)) {
+ throw new Error("There is already an exclusive checkbox on this answer.");
+ }
+};
+
+const findAll = (where = {}, orderBy = "id", direction = "asc") =>
+ Option.findAll()
+ .where({ isDeleted: false, otherAnswerId: null })
+ .where(where)
+ .orderBy(orderBy, direction);
+
+const getById = id => Option.findById(id).where({ isDeleted: false });
+
+const insert = async (
+ { label, description, value, qCode, answerId, mutuallyExclusive = false },
+ trx = db
+) => {
+ if (mutuallyExclusive) {
+ await checkForExistingExclusive(answerId);
+ }
+ return trx("Options")
+ .insert({
+ label,
+ description,
+ value,
+ qCode,
+ answerId,
+ mutuallyExclusive
+ })
+ .returning("*")
+ .then(head);
+};
+
+const update = ({ id, label, description, value, qCode, isDeleted }) =>
+ Option.update(id, {
+ label,
+ description,
+ value,
+ qCode,
+ isDeleted
+ }).then(head);
+
+const deleteOption = async (trx, id) => {
+ const deletedOption = await trx("Options")
+ .where({
+ id: parseInt(id)
+ })
+ .update({
+ isDeleted: true
+ })
+ .returning("*")
+ .then(head);
+
+ await handleOptionDeleted(trx, id);
+
+ return deletedOption;
+};
+
+const remove = id => db.transaction(trx => deleteOption(trx, id));
+
+const undelete = id => Option.update(id, { isDeleted: false }).then(head);
+
+const getOtherOption = (answerId, orderBy = "createdAt", direction = "asc") =>
+ Option.findAll()
+ .where({ isDeleted: false, otherAnswerId: answerId })
+ .orderBy(orderBy, direction)
+ .first();
+
+Object.assign(module.exports, {
+ findAll,
+ findExclusiveOptionByAnswerId,
+ getById,
+ insert,
+ update,
+ remove,
+ undelete,
+ getOtherOption
+});
diff --git a/eq-author-api/repositories/PageRepository.js b/eq-author-api/repositories/PageRepository.js
new file mode 100644
index 0000000000..51a47dcbd9
--- /dev/null
+++ b/eq-author-api/repositories/PageRepository.js
@@ -0,0 +1,115 @@
+const addPrefix = require("../utils/addPrefix");
+const { duplicatePageStrategy } = require("./strategies/duplicateStrategy");
+const { head, get } = require("lodash/fp");
+const Page = require("../db/Page");
+const QuestionPageRepository = require("./QuestionPageRepository");
+const db = require("../db");
+const {
+ getOrUpdateOrderForPageInsert
+} = require("./strategies/spacedOrderStrategy");
+
+const {
+ getAvailableRoutingDestinations,
+ handlePageDeleted
+} = require("./strategies/routingStrategy");
+
+function getRepositoryForType({ pageType }) {
+ switch (pageType) {
+ case "QuestionPage":
+ return QuestionPageRepository;
+ default:
+ throw new TypeError(`Unknown pageType: '${pageType}'`);
+ }
+}
+
+function findAll(where = {}, orderBy = "position", direction = "asc") {
+ return db("PagesView")
+ .select("*")
+ .where(where)
+ .orderBy(orderBy, direction);
+}
+
+function getById(id) {
+ return db("PagesView")
+ .where("id", parseInt(id, 10))
+ .first();
+}
+
+function insert(args) {
+ const repository = getRepositoryForType(args);
+ const { sectionId, position } = args;
+
+ return db.transaction(trx => {
+ return getOrUpdateOrderForPageInsert(trx, sectionId, null, position)
+ .then(order => Object.assign(args, { order }))
+ .then(page => repository.insert(page, trx));
+ });
+}
+
+function update(args) {
+ const repository = getRepositoryForType(args);
+ return repository.update(args);
+}
+
+const deletePage = async (trx, id) => {
+ const deletedPage = await trx("Pages")
+ .where({ id: parseInt(id, 10) })
+ .update({ isDeleted: true })
+ .returning("*")
+ .then(head);
+
+ await handlePageDeleted(trx, id);
+
+ return deletedPage;
+};
+
+const remove = id => db.transaction(trx => deletePage(trx, id));
+
+function undelete(id) {
+ return Page.update(id, { isDeleted: false }).then(head);
+}
+
+function move({ id, sectionId, position }) {
+ return db.transaction(trx => {
+ return getOrUpdateOrderForPageInsert(trx, sectionId, id, position)
+ .then(order => Page.update(id, { sectionId, order }, trx))
+ .then(head)
+ .then(page => Object.assign(page, { position }));
+ });
+}
+
+function getPosition({ id }) {
+ return getById(id).then(get("position"));
+}
+
+function getRoutingDestinations(pageId) {
+ return db.transaction(trx => getAvailableRoutingDestinations(trx, pageId));
+}
+
+function duplicatePage(id, position) {
+ return db.transaction(async trx => {
+ const page = await trx
+ .select("*")
+ .from("Pages")
+ .where({ id })
+ .then(head);
+
+ return duplicatePageStrategy(trx, page, position, {
+ alias: addPrefix(page.alias),
+ title: addPrefix(page.title)
+ });
+ });
+}
+
+Object.assign(module.exports, {
+ findAll,
+ getById,
+ insert,
+ update,
+ remove,
+ undelete,
+ move,
+ getPosition,
+ getRoutingDestinations,
+ duplicatePage
+});
diff --git a/eq-author-api/repositories/PageRepository.test.js b/eq-author-api/repositories/PageRepository.test.js
new file mode 100644
index 0000000000..c880438dbf
--- /dev/null
+++ b/eq-author-api/repositories/PageRepository.test.js
@@ -0,0 +1,583 @@
+const db = require("../db");
+const QuestionnaireRepository = require("../repositories/QuestionnaireRepository");
+const SectionRepository = require("../repositories/SectionRepository");
+const PageRepository = require("../repositories/PageRepository");
+const { last, head, map, times, omit, parseInt } = require("lodash");
+
+const reverse = array => array.slice().reverse();
+
+const buildQuestionnaire = questionnaire => ({
+ title: "Test questionnaire",
+ surveyId: "1",
+ theme: "default",
+ legalBasis: "Voluntary",
+ navigation: false,
+ createdBy: "foo",
+ ...questionnaire
+});
+
+const buildSection = section => ({
+ title: "Test section",
+ description: "section description",
+ ...section
+});
+
+const buildPage = page => ({
+ alias: "Page alias",
+ title: "Test page",
+ description: "page description",
+ guidance: "page description",
+ pageType: "QuestionPage",
+ ...page
+});
+
+const setup = async () => {
+ const questionnaire = await QuestionnaireRepository.insert(
+ buildQuestionnaire()
+ );
+
+ const section = await SectionRepository.insert(
+ buildSection({
+ questionnaireId: questionnaire.id
+ })
+ );
+
+ return { questionnaire, section };
+};
+
+const eachP = (items, iter) =>
+ items.reduce(
+ (promise, item) => promise.then(() => iter(item)),
+ Promise.resolve()
+ );
+
+describe("PagesRepository", () => {
+ beforeAll(() => db.migrate.latest());
+ afterAll(() => db.destroy());
+ afterEach(() => db("Questionnaires").delete());
+
+ it("throws for unknown page types", () => {
+ const page = buildPage({ pageType: "NotARealPageType" });
+ expect(() => PageRepository.insert(page)).toThrow();
+ });
+
+ it("allows pages to be created", async () => {
+ const { section } = await setup();
+
+ const page = buildPage({ sectionId: section.id });
+
+ const result = await PageRepository.insert(page);
+
+ expect(result).toMatchObject(page);
+ expect(result.order).not.toBeNull();
+ });
+
+ it("allows pages to be updated", async () => {
+ const { section } = await setup();
+
+ const result = await PageRepository.insert(
+ buildPage({ sectionId: section.id })
+ );
+
+ await PageRepository.update({
+ id: result.id,
+ title: "updated title",
+ pageType: "QuestionPage"
+ });
+ const updated = await PageRepository.getById(result.id);
+
+ expect(updated.title).not.toEqual(result.title);
+ });
+
+ it("allow pages to be deleted", async () => {
+ const { section } = await setup();
+ const page = await PageRepository.insert(
+ buildPage({ sectionId: section.id })
+ );
+
+ await PageRepository.remove(page.id);
+ const result = await PageRepository.getById(page.id);
+
+ expect(result).toBeUndefined();
+ });
+
+ it("allows pages to be un-deleted", async () => {
+ const { section } = await setup();
+
+ const page = await PageRepository.insert(
+ buildPage({ sectionId: section.id })
+ );
+
+ await PageRepository.remove(page.id);
+ await PageRepository.undelete(page.id);
+
+ const result = await PageRepository.getById(page.id);
+ expect(result).toMatchObject(page);
+ });
+
+ describe("re-ordering", () => {
+ const createPages = (sectionId, numberOfPages) => {
+ const pages = times(numberOfPages, i =>
+ buildPage({
+ title: `Page ${i}`,
+ sectionId
+ })
+ );
+
+ return eachP(pages, PageRepository.insert).then(() =>
+ PageRepository.findAll({ sectionId })
+ );
+ };
+
+ it("should add pages in correct order", async () => {
+ const { section } = await setup();
+ const results = await createPages(section.id, 5);
+
+ expect(results).toHaveLength(5);
+
+ results.forEach((result, i) => {
+ expect(result).toMatchObject({ title: `Page ${i}` });
+ expect(result.position).toEqual(String(i));
+ });
+ });
+
+ it("can move pages backwards within same section", async () => {
+ const { section } = await setup();
+ const pages = await createPages(section.id, 5);
+
+ // reverse the list
+ await eachP(pages, page =>
+ PageRepository.move({
+ id: page.id,
+ sectionId: section.id,
+ position: 0
+ })
+ );
+
+ const updatePages = await PageRepository.findAll({
+ sectionId: section.id
+ });
+
+ expect(map(updatePages, "id")).toEqual(map(reverse(pages), "id"));
+ });
+
+ it("can move pages to the end of the current section", async () => {
+ const { section } = await setup();
+ const pages = await createPages(section.id, 5);
+
+ const middlePage = pages[3];
+
+ await PageRepository.move({
+ id: middlePage.id,
+ sectionId: section.id,
+ position: "5"
+ });
+
+ const updatePages = await PageRepository.findAll({
+ sectionId: section.id
+ });
+
+ expect(last(updatePages).id).toEqual(middlePage.id);
+ });
+
+ it("can move pages forwards in the current section", async () => {
+ const { section } = await setup();
+ const pages = await createPages(section.id, 5);
+
+ const firstPage = pages[0];
+
+ await PageRepository.move({
+ id: firstPage.id,
+ sectionId: section.id,
+ position: "3"
+ });
+
+ const updatePages = await PageRepository.findAll({
+ sectionId: section.id
+ });
+
+ expect(updatePages[3].id).toEqual(firstPage.id);
+ });
+
+ it("gracefully handles position values greater than number of pages", async () => {
+ const { section } = await setup();
+ const results = await createPages(section.id, 5);
+
+ await PageRepository.move({
+ id: head(results).id,
+ sectionId: section.id,
+ position: results.length * 2
+ });
+
+ const updatedResults = await PageRepository.findAll({
+ sectionId: section.id
+ });
+
+ expect(last(updatedResults).id).toBe(head(results).id);
+ });
+
+ it("gracefully handles position values less than zero", async () => {
+ const { section } = await setup();
+ const results = await createPages(section.id, 5);
+
+ await PageRepository.move({
+ id: last(results).id,
+ sectionId: section.id,
+ position: -100
+ });
+
+ const updatedResults = await PageRepository.findAll({
+ sectionId: section.id
+ });
+
+ expect(head(updatedResults).id).toBe(last(results).id);
+ });
+
+ it("can move pages between sections", async () => {
+ const {
+ section,
+ questionnaire: { id: questionnaireId }
+ } = await setup();
+
+ const section2 = await SectionRepository.insert(
+ buildSection({ title: "Section 2", questionnaireId })
+ );
+ const results = await createPages(section.id, 3);
+
+ await PageRepository.move({
+ id: head(results).id,
+ sectionId: section2.id,
+ position: 0
+ });
+
+ const updatedResults = await PageRepository.findAll({
+ sectionId: section2.id
+ });
+
+ expect(head(updatedResults)).toMatchObject({ id: head(results).id });
+ });
+
+ it("correctly re-orders pages as they're moved between sections", async () => {
+ const {
+ section,
+ questionnaire: { id: questionnaireId }
+ } = await setup();
+
+ const section2 = await SectionRepository.insert(
+ buildSection({ title: "Section 2", questionnaireId })
+ );
+ const results = await createPages(section.id, 3);
+
+ await PageRepository.move({
+ id: results[0].id,
+ sectionId: section2.id,
+ position: 0
+ });
+ await PageRepository.move({
+ id: results[1].id,
+ sectionId: section2.id,
+ position: 0
+ });
+
+ const updatedResults = await PageRepository.findAll({
+ sectionId: section2.id
+ });
+
+ expect(map(updatedResults, "id")).toEqual(
+ map([results[1], results[0]], "id")
+ );
+ });
+
+ it("reorders pages correctly even when there are deleted pages in a section", async () => {
+ const { section } = await setup();
+ const pages = await createPages(section.id, 3);
+
+ await PageRepository.remove(pages[1].id);
+ const newPage = await PageRepository.insert(
+ buildPage({ title: "newest page", sectionId: section.id })
+ );
+
+ await PageRepository.move({
+ id: newPage.id,
+ sectionId: section.id,
+ position: 0
+ });
+
+ const updatedResults = await PageRepository.findAll({
+ sectionId: section.id
+ });
+
+ expect(updatedResults).not.toContainEqual(
+ expect.objectContaining({ id: pages[1].id })
+ );
+
+ expect(map(updatedResults, "id")).toEqual(
+ map([newPage, pages[0], pages[2]], "id")
+ );
+ });
+
+ it("returns deleted pages to correct position when un-deleted, even after moves ", async () => {
+ const { section } = await setup();
+ const pages = await createPages(section.id, 5);
+
+ await PageRepository.remove(pages[3].id);
+
+ await PageRepository.move({
+ id: pages[4].id,
+ sectionId: section.id,
+ position: 2
+ });
+
+ await PageRepository.undelete(pages[3].id);
+
+ const updatedResults = await PageRepository.findAll({
+ sectionId: section.id
+ });
+
+ expect(map(updatedResults, "id")).toEqual(
+ map([pages[0], pages[1], pages[4], pages[2], pages[3]], "id")
+ );
+ });
+
+ it("allow insertion at specific position", async () => {
+ const { section } = await setup();
+
+ const page1 = await PageRepository.insert(
+ buildPage({ sectionId: section.id, position: 0 })
+ );
+ const page2 = await PageRepository.insert(
+ buildPage({ sectionId: section.id, position: 0 })
+ );
+ const page3 = await PageRepository.insert(
+ buildPage({ sectionId: section.id, position: 0 })
+ );
+ const page4 = await PageRepository.insert(
+ buildPage({ sectionId: section.id, position: 0 })
+ );
+ const page5 = await PageRepository.insert(
+ buildPage({ sectionId: section.id, position: 0 })
+ );
+
+ const updatedResults = await PageRepository.findAll({
+ sectionId: section.id
+ });
+
+ expect(map(updatedResults, "id")).toEqual(
+ map([page5, page4, page3, page2, page1], "id")
+ );
+ });
+
+ it("allows insertion at middle of list", async () => {
+ const { section } = await setup();
+
+ const page1 = await PageRepository.insert(
+ buildPage({ sectionId: section.id, position: 0 })
+ );
+ const page2 = await PageRepository.insert(
+ buildPage({ sectionId: section.id, position: 1 })
+ );
+ const page3 = await PageRepository.insert(
+ buildPage({ sectionId: section.id, position: 2 })
+ );
+
+ await PageRepository.move({
+ id: page3.id,
+ sectionId: section.id,
+ position: 1
+ });
+
+ const updatedResults = await PageRepository.findAll({
+ sectionId: section.id
+ });
+
+ expect(map(updatedResults, "id")).toEqual(
+ map([page1, page3, page2], "id")
+ );
+ });
+
+ it("makes space when `order` values converge", async () => {
+ const { section } = await setup();
+
+ const page1 = await PageRepository.insert(
+ buildPage({ sectionId: section.id, position: 0 })
+ );
+ const page2 = await PageRepository.insert(
+ buildPage({ sectionId: section.id, position: 1 })
+ );
+
+ // by moving more than 10 times the `order` values will converge, since (1000 / 2^12) < 1
+ const plan = times(12, i => (i % 2 === 0 ? page1 : page2));
+
+ await eachP(plan, page =>
+ PageRepository.move({
+ id: page.id,
+ sectionId: section.id,
+ position: 0
+ })
+ );
+
+ const updatedResults = await PageRepository.findAll({
+ sectionId: section.id
+ });
+
+ expect(map(updatedResults, "id")).toEqual(map([page2, page1], "id"));
+ });
+
+ it("correctly inserts at end of list", async () => {
+ const { section } = await setup();
+
+ const pages = await createPages(section.id, 3);
+
+ await eachP(reverse(pages), page =>
+ PageRepository.move({
+ id: page.id,
+ sectionId: section.id,
+ position: 2
+ })
+ );
+
+ const updatedResults = await PageRepository.findAll({
+ sectionId: section.id
+ });
+
+ expect(map(updatedResults, "id")).toEqual(map(reverse(pages), "id"));
+ });
+
+ it("handles moving single page to its own position", async () => {
+ const { section } = await setup();
+ const pages = await createPages(section.id, 3);
+
+ await PageRepository.move({
+ id: pages[1].id,
+ sectionId: section.id,
+ position: 1
+ });
+
+ const updatedResults = await PageRepository.findAll({
+ sectionId: section.id
+ });
+
+ expect(map(updatedResults, "id")).toEqual(map(pages, "id"));
+ });
+
+ it("handles moving only page in a section", async () => {
+ const { section } = await setup();
+ const pages = await createPages(section.id, 1);
+
+ await PageRepository.move({
+ id: pages[0].id,
+ sectionId: section.id,
+ position: 1000
+ });
+
+ const updatedResults = await PageRepository.findAll({
+ sectionId: section.id
+ });
+
+ expect(map(updatedResults, "id")).toEqual(map(pages, "id"));
+ });
+
+ it("can get position for a single page", async () => {
+ const { section } = await setup();
+ const pages = await createPages(section.id, 5);
+
+ const position = await PageRepository.getPosition({ id: pages[2].id });
+
+ expect(position).toBe(pages[2].position);
+ });
+ });
+
+ describe("Duplicating a page", () => {
+ it("copies the necessary data into the duplicate from the original", async () => {
+ const { section } = await setup();
+
+ const page = buildPage({ sectionId: section.id });
+ const result = await PageRepository.insert(page);
+
+ const duplicatePage = await PageRepository.duplicatePage(result.id, 1);
+
+ const fieldsToOmit = [
+ "id",
+ "order",
+ "alias",
+ "title",
+ "createdAt",
+ "updatedAt"
+ ];
+
+ expect(omit(result, fieldsToOmit)).toMatchObject(
+ omit(duplicatePage, fieldsToOmit)
+ );
+ });
+
+ it("prepends 'Copy of' to the question alias of the copy when duplicating a page", async () => {
+ const { section } = await setup();
+
+ const page = buildPage({ sectionId: section.id });
+ const result = await PageRepository.insert(page);
+
+ const duplicatePage = await PageRepository.duplicatePage(result.id, 1);
+
+ const startsWithCopyOf = duplicatePage.alias.startsWith("Copy of");
+
+ expect(startsWithCopyOf).toBe(true);
+ });
+
+ it("prepends 'Copy of' to the question title of the copy when duplicating a page", async () => {
+ const { section } = await setup();
+
+ const page = buildPage({ sectionId: section.id });
+ const result = await PageRepository.insert(page);
+
+ const duplicatePage = await PageRepository.duplicatePage(result.id, 1);
+
+ const startsWithCopyOf = duplicatePage.title.startsWith("Copy of");
+
+ expect(startsWithCopyOf).toBe(true);
+ });
+
+ it("is able to insert the copied page below the original", async () => {
+ const { section } = await setup();
+
+ const pageOne = await PageRepository.insert(
+ buildPage({ sectionId: section.id })
+ );
+ const positionOfPageOne = await PageRepository.getPosition(pageOne);
+
+ const pageTwo = await PageRepository.duplicatePage(
+ pageOne.id,
+ parseInt(positionOfPageOne) + 1
+ );
+
+ const positionOfPageTwo = await PageRepository.getPosition(pageTwo);
+
+ expect(parseInt(positionOfPageOne) + 1).toEqual(
+ parseInt(positionOfPageTwo)
+ );
+ });
+
+ it("maintains the order of other questions within the section after duplicating a page", async () => {
+ const { section } = await setup();
+
+ const pageOne = await PageRepository.insert(
+ buildPage({ sectionId: section.id })
+ );
+ const pageTwo = await PageRepository.insert(
+ buildPage({ sectionId: section.id })
+ );
+
+ const pageThree = await PageRepository.duplicatePage(pageOne.id, 1);
+
+ const positionOfPageOne = await PageRepository.getPosition(pageOne);
+ const positionOfPageTwo = await PageRepository.getPosition(pageTwo);
+ const positionOfPageThree = await PageRepository.getPosition(pageThree);
+
+ expect(parseInt(positionOfPageOne) + 1).toEqual(
+ parseInt(positionOfPageThree)
+ );
+ expect(parseInt(positionOfPageTwo) - 1).toEqual(
+ parseInt(positionOfPageThree)
+ );
+ });
+ });
+});
diff --git a/eq-author-api/repositories/QuestionPageRepository.js b/eq-author-api/repositories/QuestionPageRepository.js
new file mode 100644
index 0000000000..8ff2dd0522
--- /dev/null
+++ b/eq-author-api/repositories/QuestionPageRepository.js
@@ -0,0 +1,61 @@
+const { head } = require("lodash/fp");
+const QuestionPage = require("../db/QuestionPage");
+
+const knex = require("../db");
+
+module.exports.findAll = function findAll(
+ where = {},
+ orderBy = "createdAt",
+ direction = "asc"
+) {
+ return QuestionPage.findAll()
+ .where({ isDeleted: false })
+ .where(where)
+ .orderBy(orderBy, direction);
+};
+
+module.exports.getById = function getById(id) {
+ return QuestionPage.findById(id).where({ isDeleted: false });
+};
+
+module.exports.insert = function insert(
+ { title, alias, description, guidance, sectionId, order },
+ db = knex
+) {
+ return QuestionPage.create(
+ {
+ title,
+ alias,
+ description,
+ guidance,
+ sectionId,
+ order
+ },
+ db
+ ).then(head);
+};
+
+module.exports.update = function update({
+ id,
+ title,
+ alias,
+ description,
+ guidance,
+ isDeleted
+}) {
+ return QuestionPage.update(id, {
+ title,
+ alias,
+ description,
+ guidance,
+ isDeleted
+ }).then(head);
+};
+
+module.exports.remove = function remove(id) {
+ return QuestionPage.update(id, { isDeleted: true }).then(head);
+};
+
+module.exports.undelete = function(id) {
+ return QuestionPage.update(id, { isDeleted: false }).then(head);
+};
diff --git a/eq-author-api/repositories/QuestionnaireRepository.js b/eq-author-api/repositories/QuestionnaireRepository.js
new file mode 100644
index 0000000000..937e800e5f
--- /dev/null
+++ b/eq-author-api/repositories/QuestionnaireRepository.js
@@ -0,0 +1,91 @@
+const { head } = require("lodash/fp");
+
+const Questionnaire = require("../db/Questionnaire");
+const db = require("../db");
+const addPrefix = require("../utils/addPrefix");
+
+const {
+ duplicateQuestionnaireStrategy
+} = require("./strategies/duplicateStrategy");
+
+module.exports.getById = function(id) {
+ return Questionnaire.findById(id).where({ isDeleted: false });
+};
+
+module.exports.findAll = function findAll(
+ where = {},
+ orderBy = "createdAt",
+ direction = "desc"
+) {
+ return Questionnaire.findAll()
+ .where({ isDeleted: false })
+ .where(where)
+ .orderBy(orderBy, direction);
+};
+
+module.exports.insert = function({
+ title,
+ description,
+ theme,
+ legalBasis,
+ navigation,
+ surveyId,
+ summary,
+ createdBy
+}) {
+ return Questionnaire.create({
+ title,
+ description,
+ theme,
+ legalBasis,
+ navigation,
+ surveyId,
+ summary,
+ createdBy
+ }).then(head);
+};
+
+module.exports.update = function({
+ id,
+ title,
+ description,
+ theme,
+ legalBasis,
+ navigation,
+ surveyId,
+ isDeleted,
+ summary
+}) {
+ return Questionnaire.update(id, {
+ title,
+ surveyId,
+ description,
+ theme,
+ legalBasis,
+ navigation,
+ isDeleted,
+ summary
+ }).then(head);
+};
+
+module.exports.remove = function(id) {
+ return Questionnaire.update(id, { isDeleted: true }).then(head);
+};
+
+module.exports.undelete = function(id) {
+ return Questionnaire.update(id, { isDeleted: false }).then(head);
+};
+
+module.exports.duplicate = (id, createdBy) => {
+ return db.transaction(async trx => {
+ const questionnaire = await trx
+ .select("*")
+ .from("Questionnaires")
+ .where({ id })
+ .then(head);
+ return duplicateQuestionnaireStrategy(trx, questionnaire, {
+ title: addPrefix(questionnaire.title),
+ createdBy
+ });
+ });
+};
diff --git a/eq-author-api/repositories/QuestionnaireRepository.test.js b/eq-author-api/repositories/QuestionnaireRepository.test.js
new file mode 100644
index 0000000000..17bba83887
--- /dev/null
+++ b/eq-author-api/repositories/QuestionnaireRepository.test.js
@@ -0,0 +1,97 @@
+const fp = require("lodash/fp");
+
+const knex = require("../db");
+const QuestionnaireRepository = require("../repositories/QuestionnaireRepository");
+
+const buildQuestionnaire = (json = {}) => {
+ return Object.assign(
+ {
+ title: "Test questionnaire",
+ surveyId: "1",
+ theme: "default",
+ legalBasis: "Voluntary",
+ navigation: false,
+ createdBy: "foo"
+ },
+ json
+ );
+};
+
+describe("QuestionnaireRepository", () => {
+ beforeAll(() => knex.migrate.latest());
+ afterAll(() => knex.destroy());
+ afterEach(() => knex("Questionnaires").delete());
+
+ it("should create new Questionnaire", async () => {
+ const questionnaire = buildQuestionnaire({
+ title: "creating a new questionnaire "
+ });
+ const result = await QuestionnaireRepository.insert(questionnaire);
+
+ expect(result).toMatchObject(questionnaire);
+ });
+
+ it("should retrieve a single questionnaire", async () => {
+ const questionnaire = buildQuestionnaire({ title: "foo bar" });
+
+ const { id } = await QuestionnaireRepository.insert(questionnaire);
+
+ const result = await QuestionnaireRepository.getById(id);
+
+ expect(result.id).toBe(id);
+ expect(result).toMatchObject(questionnaire);
+ });
+
+ it("should retrieve all questionnaires sorted by date desc", async () => {
+ const { id: id1 } = await QuestionnaireRepository.insert(
+ buildQuestionnaire()
+ );
+ const { id: id2 } = await QuestionnaireRepository.insert(
+ buildQuestionnaire({ surveyId: 2 })
+ );
+
+ const results = await QuestionnaireRepository.findAll();
+
+ expect(results).toEqual([
+ expect.objectContaining({ id: id2, isDeleted: false }),
+ expect.objectContaining({ id: id1, isDeleted: false })
+ ]);
+ });
+
+ it("should remove questionnaire", async () => {
+ const { id } = await QuestionnaireRepository.insert(buildQuestionnaire());
+
+ await QuestionnaireRepository.remove(id);
+ const result = await QuestionnaireRepository.getById(id);
+
+ expect(result).toBeUndefined();
+ });
+
+ it("should update questionnaires", async () => {
+ const { id } = await QuestionnaireRepository.insert(buildQuestionnaire());
+ const result = await QuestionnaireRepository.update({
+ id,
+ surveyId: "456"
+ });
+
+ expect(result).toMatchObject({ surveyId: "456" });
+ });
+
+ it("should duplicate a questionnaire", async () => {
+ const { id, ...questionnaire } = await QuestionnaireRepository.insert(
+ buildQuestionnaire({ createdBy: "Foo Bar" })
+ );
+ const duplicatedQuestionnaire = await QuestionnaireRepository.duplicate(
+ id,
+ "Test Person"
+ );
+
+ const filterUnwanted = fp.omit(["id", "createdAt", "updatedAt"]);
+
+ expect(filterUnwanted(duplicatedQuestionnaire)).toMatchObject({
+ ...filterUnwanted(questionnaire),
+ title: `Copy of ${questionnaire.title}`,
+ createdBy: "Test Person"
+ });
+ });
+});
diff --git a/eq-author-api/repositories/RoutingRepository.js b/eq-author-api/repositories/RoutingRepository.js
new file mode 100644
index 0000000000..b2122e968e
--- /dev/null
+++ b/eq-author-api/repositories/RoutingRepository.js
@@ -0,0 +1,255 @@
+const { head } = require("lodash/fp");
+const { get, isNil, parseInt } = require("lodash");
+const db = require("../db");
+const Answer = require("../repositories/AnswerRepository");
+
+const {
+ updateRoutingConditionStrategy,
+ toggleConditionOptionStrategy,
+ getAvailableRoutingDestinations,
+ checkRoutingDestinations,
+ createRoutingRuleSetStrategy,
+ createRoutingRuleStrategy,
+ createRoutingConditionStrategy
+} = require("./strategies/routingStrategy");
+
+const Routing = require("../db/Routing");
+const PageRepository = require("./PageRepository");
+const SectionRepository = require("./SectionRepository");
+
+const getRoutingDestinations = async pageId => {
+ const logicalDestinations = [
+ {
+ logicalDestination: "NextPage"
+ },
+ {
+ logicalDestination: "EndOfQuestionnaire"
+ }
+ ];
+ const absoluteDestinations = await db.transaction(trx =>
+ getAvailableRoutingDestinations(trx, pageId)
+ );
+ return {
+ logicalDestinations,
+ ...absoluteDestinations
+ };
+};
+
+const checkValidDestination = async (questionPageId, destination) => {
+ if (isNil(destination)) {
+ throw new Error(`Invalid destination specified for routing rule`);
+ }
+
+ const { logicalDestination, absoluteDestination } = destination;
+ if (!isNil(logicalDestination) && !isNil(absoluteDestination)) {
+ throw new Error("Routing destination cannot be both logical and absolute");
+ }
+
+ const availableRoutingDestinations = await getRoutingDestinations(
+ questionPageId
+ );
+ await checkRoutingDestinations(availableRoutingDestinations, destination);
+};
+
+function findRoutingRuleSetByQuestionPageId(where = {}) {
+ return Routing.findAllRoutingRuleSets()
+ .where({ isDeleted: false })
+ .where(where)
+ .first();
+}
+
+function findAllRoutingRules(where = {}) {
+ return Routing.findAllRoutingRules()
+ .where({ isDeleted: false })
+ .where(where);
+}
+
+function getRoutingRuleById(id) {
+ return Routing.findAllRoutingRules()
+ .where({ id, isDeleted: false })
+ .first();
+}
+
+function getRoutingRuleSetById(id) {
+ return Routing.findAllRoutingRuleSets()
+ .where({ id: parseInt(id), isDeleted: false })
+ .then(head);
+}
+
+function findAllRoutingConditions(where = {}) {
+ return Routing.findAllRoutingConditions()
+ .where(where)
+ .orderBy("id");
+}
+
+function findAllRoutingConditionValues(where = {}) {
+ return Routing.findAllRoutingConditionValues().where(where);
+}
+
+function createRoutingRuleSet({ questionPageId }) {
+ return db.transaction(trx =>
+ createRoutingRuleSetStrategy(trx, questionPageId)
+ );
+}
+
+const deleteRoutingRuleSet = ({ id }) =>
+ Routing.updateRoutingRuleSet(id, {
+ isDeleted: true
+ }).then(head);
+
+async function createRoutingRule(createRoutingRuleInput) {
+ return db.transaction(trx =>
+ createRoutingRuleStrategy(trx, createRoutingRuleInput)
+ );
+}
+
+function createRoutingCondition(routingCondition) {
+ return db.transaction(trx =>
+ createRoutingConditionStrategy(trx, routingCondition)
+ );
+}
+
+const toggleConditionOption = async ({ conditionId, optionId, checked }) =>
+ db.transaction(trx =>
+ toggleConditionOptionStrategy(trx, {
+ conditionId,
+ optionId,
+ checked
+ })
+ );
+
+const createConditionValue = async ({ conditionId }) =>
+ Routing.createRoutingConditionValue({
+ conditionId,
+ customNumber: null
+ }).then(head);
+
+const updateConditionValue = async ({ id, customNumber }) =>
+ Routing.updateRoutingConditionValue(id, { customNumber }).then(head);
+
+const updateDestination = async (id, destination) => {
+ const { logicalDestination, absoluteDestination } = destination;
+
+ const updatedFields = {
+ logicalDestination: get(logicalDestination, "destinationType", null),
+ pageId: null,
+ sectionId: null
+ };
+
+ if (!isNil(absoluteDestination)) {
+ const key =
+ absoluteDestination.destinationType === "QuestionPage"
+ ? "pageId"
+ : "sectionId";
+
+ updatedFields[key] = parseInt(absoluteDestination.destinationId);
+ }
+
+ return Routing.updateRoutingDestination(id, updatedFields).then(head);
+};
+
+async function updateRoutingRuleSet({ id, else: destination }) {
+ const routingRuleSet = await getRoutingRuleSetById(id);
+
+ const { questionPageId, routingDestinationId } = routingRuleSet;
+ await checkValidDestination(questionPageId, destination);
+
+ await updateDestination(routingDestinationId, destination);
+
+ return routingRuleSet;
+}
+
+async function getAnswerTypeByConditionId(id) {
+ const { answerId } = await findAllRoutingConditions({ id }).then(head);
+ if (isNil(answerId)) {
+ return null;
+ } else {
+ const { type } = await Answer.getById(answerId);
+ return type;
+ }
+}
+
+async function updateRoutingRule({ id, goto: destination }) {
+ const routingRule = await getRoutingRuleById(id);
+ const routingRuleSet = await getRoutingRuleSetById(
+ routingRule.routingRuleSetId
+ );
+
+ const { routingDestinationId } = routingRule;
+
+ await checkValidDestination(routingRuleSet.questionPageId, destination);
+ await updateDestination(routingDestinationId, destination);
+
+ // No need to update rule since there is not mechanism to change the operation yet
+ return routingRule;
+}
+
+function updateRoutingCondition({ id, questionPageId, answerId, comparator }) {
+ return db.transaction(trx =>
+ updateRoutingConditionStrategy(trx, {
+ id,
+ questionPageId,
+ answerId,
+ comparator
+ })
+ );
+}
+
+function removeRoutingRule({ id }) {
+ return Routing.updateRoutingRule(id, { isDeleted: true }).then(head);
+}
+
+function removeRoutingCondition({ id }) {
+ return Routing.deleteRoutingCondition(id).then(() => null);
+}
+
+function undeleteRoutingRule(id) {
+ return Routing.updateRoutingRule(id, { isDeleted: false }).then(head);
+}
+
+const getRoutingDestination = async routingDestinationId => {
+ const destination = await Routing.findRoutingDestinationById(
+ routingDestinationId
+ );
+
+ if (!destination) {
+ return null;
+ }
+
+ if (destination.sectionId) {
+ const section = await SectionRepository.getById(destination.sectionId);
+ return section ? { absoluteDestination: section } : null;
+ }
+
+ if (destination.pageId) {
+ const page = await PageRepository.getById(destination.pageId);
+ return page ? { absoluteDestination: page } : null;
+ }
+
+ if (destination.logicalDestination) {
+ return { logicalDestination: destination.logicalDestination };
+ }
+};
+
+Object.assign(module.exports, {
+ undeleteRoutingRule,
+ removeRoutingCondition,
+ removeRoutingRule,
+ updateRoutingCondition,
+ updateRoutingRule,
+ findRoutingRuleSetByQuestionPageId,
+ findAllRoutingRules,
+ findAllRoutingConditions,
+ findAllRoutingConditionValues,
+ createRoutingRuleSet,
+ createRoutingRule,
+ createRoutingCondition,
+ toggleConditionOption,
+ createConditionValue,
+ updateConditionValue,
+ updateRoutingRuleSet,
+ getRoutingDestinations,
+ deleteRoutingRuleSet,
+ getRoutingDestination,
+ getAnswerTypeByConditionId
+});
diff --git a/eq-author-api/repositories/SectionRepository.js b/eq-author-api/repositories/SectionRepository.js
new file mode 100644
index 0000000000..de375c8f49
--- /dev/null
+++ b/eq-author-api/repositories/SectionRepository.js
@@ -0,0 +1,122 @@
+const { get, head, pick } = require("lodash/fp");
+const Section = require("../db/Section");
+const db = require("../db");
+const {
+ getOrUpdateOrderForSectionInsert
+} = require("./strategies/spacedOrderStrategy");
+const addPrefix = require("../utils/addPrefix");
+const { duplicateSectionStrategy } = require("./strategies/duplicateStrategy");
+
+module.exports.findAll = function findAll(
+ where = {},
+ orderBy = "position",
+ direction = "asc"
+) {
+ return db("SectionsView")
+ .select("*")
+ .where(where)
+ .orderBy(orderBy, direction);
+};
+
+module.exports.getById = function getById(id) {
+ return db("SectionsView")
+ .where("id", parseInt(id, 10))
+ .first();
+};
+
+module.exports.insert = function insert(args) {
+ const { questionnaireId, position } = args;
+ return db.transaction(trx => {
+ return getOrUpdateOrderForSectionInsert(
+ trx,
+ questionnaireId,
+ null,
+ position
+ )
+ .then(order => Object.assign(args, { order }))
+ .then(
+ pick([
+ "title",
+ "alias",
+ "questionnaireId",
+ "order",
+ "introductionContent",
+ "introductionEnabled",
+ "introductionTitle"
+ ])
+ )
+ .then(section => Section.create(section, trx))
+ .then(head);
+ });
+};
+
+module.exports.update = function update({
+ id,
+ title,
+ alias,
+ isDeleted,
+ introductionContent,
+ introductionEnabled,
+ introductionTitle
+}) {
+ return Section.update(id, {
+ title,
+ alias,
+ isDeleted,
+ introductionContent,
+ introductionEnabled,
+ introductionTitle
+ }).then(head);
+};
+
+module.exports.remove = function remove(id) {
+ return Section.update(id, { isDeleted: true }).then(head);
+};
+
+module.exports.undelete = function(id) {
+ return Section.update(id, { isDeleted: false }).then(head);
+};
+
+module.exports.move = function({ id, questionnaireId, position }) {
+ return db.transaction(trx => {
+ return getOrUpdateOrderForSectionInsert(trx, questionnaireId, id, position)
+ .then(order => Section.update(id, { questionnaireId, order }, trx))
+ .then(head)
+
+ .then(section => Object.assign(section, { position }));
+ });
+};
+
+module.exports.getPosition = function({ id }) {
+ return this.getById(id)
+ .then(get("position"))
+ .then(position => {
+ if (position) {
+ return parseInt(position, 10);
+ }
+ throw new Error(`No position found for section with id: ${id}`);
+ });
+};
+
+module.exports.getSectionCount = function getSectionCount(questionnaireId) {
+ return db("SectionsView")
+ .count()
+ .where({ questionnaireId })
+ .then(head)
+ .then(get("count"));
+};
+
+module.exports.duplicateSection = function duplicateSection(id, position) {
+ return db.transaction(async trx => {
+ const section = await trx
+ .select("*")
+ .from("Sections")
+ .where({ id })
+ .first();
+
+ return duplicateSectionStrategy(trx, section, position, {
+ alias: addPrefix(section.alias),
+ title: addPrefix(section.title)
+ });
+ });
+};
diff --git a/eq-author-api/repositories/SectionRepository.test.js b/eq-author-api/repositories/SectionRepository.test.js
new file mode 100644
index 0000000000..68c32ea041
--- /dev/null
+++ b/eq-author-api/repositories/SectionRepository.test.js
@@ -0,0 +1,422 @@
+const db = require("../db");
+const QuestionnaireRepository = require("../repositories/QuestionnaireRepository");
+const SectionRepository = require("../repositories/SectionRepository");
+const { last, head, map, toString, times } = require("lodash");
+
+const reverse = array => array.slice().reverse();
+
+const buildQuestionnaire = questionnaire => ({
+ title: "Test questionnaire",
+ surveyId: "1",
+ theme: "default",
+ legalBasis: "Voluntary",
+ navigation: false,
+ createdBy: "foo",
+ ...questionnaire
+});
+
+const buildSection = section => ({
+ title: "Test section",
+ alias: "Test alias",
+ introductionTitle: null,
+ introductionContent: null,
+ introductionEnabled: false,
+ ...section
+});
+
+const setup = async () => {
+ const questionnaire = await QuestionnaireRepository.insert(
+ buildQuestionnaire()
+ );
+
+ return { questionnaire };
+};
+
+const eachP = (items, iter) =>
+ items.reduce(
+ (promise, item) => promise.then(() => iter(item)),
+ Promise.resolve()
+ );
+
+describe("SectionRepository", () => {
+ beforeAll(() => db.migrate.latest());
+ afterAll(() => db.destroy());
+ afterEach(() => db("Questionnaires").delete());
+
+ it("allows sections to be created", async () => {
+ const {
+ questionnaire: { id: questionnaireId }
+ } = await setup();
+
+ const section = buildSection({ questionnaireId: questionnaireId });
+ const result = await SectionRepository.insert(section);
+
+ expect(result).toMatchObject(section);
+ expect(result.order).not.toBeNull();
+ });
+
+ it("allows sections to be updated", async () => {
+ const {
+ questionnaire: { id: questionnaireId }
+ } = await setup();
+
+ const section = await SectionRepository.insert(
+ buildSection({ questionnaireId: questionnaireId })
+ );
+
+ const update = {
+ id: section.id,
+ title: "updated title",
+ alias: "updated alias",
+ introductionTitle: "updated intro title",
+ introductionContent: "updated intro content",
+ introductionEnabled: true
+ };
+
+ await SectionRepository.update(update);
+ const result = await SectionRepository.getById(section.id);
+
+ expect(result).toMatchObject(update);
+ });
+
+ it("allow sections to be deleted", async () => {
+ const {
+ questionnaire: { id: questionnaireId }
+ } = await setup();
+ const section = await SectionRepository.insert(
+ buildSection({ questionnaireId: questionnaireId })
+ );
+
+ await SectionRepository.remove(section.id);
+ const result = await SectionRepository.getById(section.id);
+
+ expect(result).toBeUndefined();
+ });
+
+ it("allows sections to be un-deleted", async () => {
+ const {
+ questionnaire: { id: questionnaireId }
+ } = await setup();
+
+ const section = await SectionRepository.insert(
+ buildSection({ questionnaireId: questionnaireId })
+ );
+
+ await SectionRepository.remove(section.id);
+ await SectionRepository.undelete(section.id);
+
+ const result = await SectionRepository.getById(section.id);
+ expect(result).toMatchObject(section);
+ });
+
+ it("can get section position", async () => {
+ const {
+ questionnaire: { id: questionnaireId }
+ } = await setup();
+
+ const result = await SectionRepository.insert(
+ buildSection({ questionnaireId: questionnaireId })
+ );
+
+ const position = await SectionRepository.getPosition(result);
+ expect(position).toEqual(0);
+ });
+
+ it("throws if asked for the position of an invalid id", async () => {
+ let error = undefined;
+ try {
+ await SectionRepository.getPosition({ id: 999900 });
+ } catch (e) {
+ error = e;
+ }
+ expect(error).not.toBeUndefined();
+ });
+
+ it("can get section count ", async () => {
+ const {
+ questionnaire: { id: questionnaireId }
+ } = await setup();
+
+ await SectionRepository.insert(buildSection({ questionnaireId }));
+
+ const count = await SectionRepository.getSectionCount(questionnaireId);
+ expect(count).toEqual("1");
+ });
+
+ describe("re-ordering", () => {
+ const createSections = (questionnaireId, numberOfPages) => {
+ const sections = times(numberOfPages, i =>
+ buildSection({ title: `Section ${i}`, questionnaireId })
+ );
+
+ return eachP(sections, SectionRepository.insert).then(() =>
+ SectionRepository.findAll({ questionnaireId })
+ );
+ };
+
+ it("should add sections in correct order", async () => {
+ const {
+ questionnaire: { id: questionnaireId }
+ } = await setup();
+
+ const results = await createSections(questionnaireId, 5);
+
+ expect(results).toHaveLength(5);
+
+ results.forEach((result, i) => {
+ expect(result).toMatchObject({ title: `Section ${i}` });
+ expect(result.position).toEqual(toString(i));
+ });
+ });
+
+ it("can move sections within a questionnaire", async () => {
+ const {
+ questionnaire: { id: questionnaireId }
+ } = await setup();
+
+ const sections = await createSections(questionnaireId, 5);
+
+ // reverse the list
+ await eachP(sections, ({ id, questionnaireId }) =>
+ SectionRepository.move({
+ id: id,
+ questionnaireId: questionnaireId,
+ position: 0
+ })
+ );
+
+ const updatedSections = await SectionRepository.findAll({
+ questionnaireId
+ });
+
+ expect(map(updatedSections, "id")).toEqual(map(reverse(sections), "id"));
+ });
+
+ it("can move sections forwards", async () => {
+ const {
+ questionnaire: { id: questionnaireId }
+ } = await setup();
+
+ const sections = await createSections(questionnaireId, 5);
+
+ const firstSection = sections[0];
+
+ await SectionRepository.move({
+ id: firstSection.id,
+ questionnaireId: questionnaireId,
+ position: "3"
+ });
+
+ const updatedSections = await SectionRepository.findAll({
+ questionnaireId
+ });
+
+ expect(updatedSections[3].id).toEqual(firstSection.id);
+ });
+
+ it("gracefully handles position values greater than number of pages", async () => {
+ const {
+ questionnaire: { id: questionnaireId }
+ } = await setup();
+
+ const results = await createSections(questionnaireId, 5);
+
+ await SectionRepository.move({
+ id: head(results).id,
+ questionnaireId: questionnaireId,
+ position: 10
+ });
+
+ const updatedResults = await SectionRepository.findAll({
+ questionnaireId: questionnaireId
+ });
+
+ expect(last(updatedResults).id).toBe(head(results).id);
+ });
+
+ it("gracefully handles position values less than zero", async () => {
+ const {
+ questionnaire: { id: questionnaireId }
+ } = await setup();
+
+ const results = await createSections(questionnaireId, 5);
+
+ await SectionRepository.move({
+ id: last(results).id,
+ questionnaireId: questionnaireId,
+ position: -100
+ });
+
+ const updatedResults = await SectionRepository.findAll({
+ questionnaireId: questionnaireId
+ });
+
+ expect(head(updatedResults).id).toBe(last(results).id);
+ });
+
+ it("reorders sections correctly even when there are deleted sections", async () => {
+ const {
+ questionnaire: { id: questionnaireId }
+ } = await setup();
+
+ const sections = await createSections(questionnaireId, 3);
+
+ await SectionRepository.remove(sections[1].id);
+
+ const newSection = await SectionRepository.insert(
+ buildSection({ title: "new section", questionnaireId: questionnaireId })
+ );
+
+ await SectionRepository.move({
+ id: newSection.id,
+ questionnaireId: questionnaireId,
+ position: 0
+ });
+
+ const updatedResults = await SectionRepository.findAll({
+ questionnaireId: questionnaireId
+ });
+
+ expect(updatedResults).not.toContainEqual(
+ expect.objectContaining({ id: sections[1].id })
+ );
+
+ expect(map(updatedResults, "id")).toEqual(
+ map([newSection, sections[0], sections[2]], "id")
+ );
+ });
+
+ it("returns deleted section to correct position when un-deleted ", async () => {
+ const {
+ questionnaire: { id: questionnaireId }
+ } = await setup();
+
+ const sections = await createSections(questionnaireId, 5);
+
+ await SectionRepository.remove(sections[3].id);
+
+ await SectionRepository.move({
+ id: sections[4].id,
+ questionnaireId: questionnaireId,
+ position: 2
+ });
+
+ await SectionRepository.undelete(sections[3].id);
+
+ const updatedResults = await SectionRepository.findAll({
+ questionnaireId: questionnaireId
+ });
+
+ expect(map(updatedResults, "id")).toEqual(
+ map(
+ [sections[0], sections[1], sections[4], sections[2], sections[3]],
+ "id"
+ )
+ );
+ });
+
+ it("allow insertion at specific position", async () => {
+ const {
+ questionnaire: { id: questionnaireId }
+ } = await setup();
+
+ const section1 = await SectionRepository.insert(
+ buildSection({ questionnaireId: questionnaireId, position: 0 })
+ );
+ const section2 = await SectionRepository.insert(
+ buildSection({ questionnaireId: questionnaireId, position: 0 })
+ );
+ const section3 = await SectionRepository.insert(
+ buildSection({ questionnaireId: questionnaireId, position: 0 })
+ );
+ const section4 = await SectionRepository.insert(
+ buildSection({ questionnaireId: questionnaireId, position: 0 })
+ );
+ const section5 = await SectionRepository.insert(
+ buildSection({ questionnaireId: questionnaireId, position: 0 })
+ );
+
+ const updatedResults = await SectionRepository.findAll({
+ questionnaireId: questionnaireId
+ });
+
+ expect(map(updatedResults, "id")).toEqual(
+ map([section5, section4, section3, section2, section1], "id")
+ );
+ });
+
+ it("allows insertion at middle of list", async () => {
+ const {
+ questionnaire: { id: questionnaireId }
+ } = await setup();
+
+ const section1 = await SectionRepository.insert(
+ buildSection({ questionnaireId: questionnaireId, position: 0 })
+ );
+ const section2 = await SectionRepository.insert(
+ buildSection({ questionnaireId: questionnaireId, position: 1 })
+ );
+ const section3 = await SectionRepository.insert(
+ buildSection({ questionnaireId: questionnaireId, position: 2 })
+ );
+
+ await SectionRepository.move({
+ id: section3.id,
+ questionnaireId: questionnaireId,
+ position: 1
+ });
+
+ const updatedResults = await SectionRepository.findAll({
+ questionnaireId: questionnaireId
+ });
+
+ expect(map(updatedResults, "id")).toEqual(
+ map([section1, section3, section2], "id")
+ );
+ });
+
+ it("correctly inserts at end of list", async () => {
+ const {
+ questionnaire: { id: questionnaireId }
+ } = await setup();
+
+ const sections = await createSections(questionnaireId, 3);
+
+ await eachP(reverse(sections), section =>
+ SectionRepository.move({
+ id: section.id,
+ questionnaireId: questionnaireId,
+ position: 2
+ })
+ );
+
+ const updatedResults = await SectionRepository.findAll({
+ questionnaireId: questionnaireId
+ });
+
+ expect(map(updatedResults, "id")).toEqual(map(reverse(sections), "id"));
+ });
+ });
+
+ describe("Duplication", () => {
+ it("should duplicate a section", async () => {
+ const {
+ questionnaire: { id: questionnaireId }
+ } = await setup();
+
+ const section = await SectionRepository.insert(
+ buildSection({ questionnaireId })
+ );
+ const positionOfSection = await SectionRepository.getPosition(section);
+
+ const duplicateSection = await SectionRepository.duplicateSection(
+ section.id,
+ positionOfSection
+ );
+ expect(duplicateSection).toMatchObject({
+ title: `Copy of ${section.title}`,
+ alias: `Copy of ${section.alias}`
+ });
+ });
+ });
+});
diff --git a/eq-author-api/repositories/ValidationRepository.js b/eq-author-api/repositories/ValidationRepository.js
new file mode 100644
index 0000000000..ae0264e5c0
--- /dev/null
+++ b/eq-author-api/repositories/ValidationRepository.js
@@ -0,0 +1,40 @@
+const Validation = require("../db/Validation");
+const { head, flow, keys, remove, first } = require("lodash/fp");
+
+const toggleValidationRule = ({ id, enabled }) => {
+ return Validation.update(id, { enabled }).then(head);
+};
+
+const findByAnswerIdAndValidationType = ({ id }, validationType) => {
+ return Validation.find({ answerId: id, validationType });
+};
+
+const getInputType = flow(
+ keys,
+ remove(key => key === "id"),
+ first
+);
+
+const updateValidationRule = input => {
+ const {
+ custom,
+ entityType,
+ previousAnswer: previousAnswerId,
+ metadata: metadataId,
+ ...config
+ } = input[getInputType(input)];
+
+ return Validation.update(input.id, {
+ custom: JSON.stringify(custom),
+ config: JSON.stringify(config),
+ entityType,
+ previousAnswerId,
+ metadataId
+ }).then(head);
+};
+
+Object.assign(module.exports, {
+ toggleValidationRule,
+ findByAnswerIdAndValidationType,
+ updateValidationRule
+});
diff --git a/eq-author-api/repositories/index.js b/eq-author-api/repositories/index.js
new file mode 100644
index 0000000000..129d10900b
--- /dev/null
+++ b/eq-author-api/repositories/index.js
@@ -0,0 +1,11 @@
+module.exports = {
+ Questionnaire: require("./QuestionnaireRepository"),
+ Section: require("./SectionRepository"),
+ Page: require("./PageRepository"),
+ QuestionPage: require("./QuestionPageRepository"),
+ Answer: require("./AnswerRepository"),
+ Option: require("./OptionRepository"),
+ Routing: require("./RoutingRepository"),
+ Validation: require("./ValidationRepository"),
+ Metadata: require("./MetadataRepository")
+};
diff --git a/eq-author-api/repositories/strategies/duplicateStrategy.test.js b/eq-author-api/repositories/strategies/duplicateStrategy.test.js
new file mode 100644
index 0000000000..ec17cb25fa
--- /dev/null
+++ b/eq-author-api/repositories/strategies/duplicateStrategy.test.js
@@ -0,0 +1,1297 @@
+const { flow, map, omit } = require("lodash/fp");
+
+const db = require("../../db");
+const {
+ duplicatePageStrategy,
+ duplicateSectionStrategy,
+ duplicateQuestionnaireStrategy
+} = require("./duplicateStrategy");
+const SectionRepository = require("../SectionRepository");
+const PageRepository = require("../PageRepository");
+const AnswerRepository = require("../AnswerRepository");
+const OptionRepository = require("../OptionRepository");
+const ValidationRepository = require("../ValidationRepository");
+const MetadataRepository = require("../MetadataRepository");
+const RoutingRepository = require("../RoutingRepository");
+
+const buildTestQuestionnaire = require("../../tests/utils/buildTestQuestionnaire");
+
+const sanitize = omit([
+ "id",
+ "createdAt",
+ "updatedAt",
+ "answerId",
+ "otherAnswerId",
+ "parentAnswerId",
+ "questionPageId",
+ "sectionId",
+ "position",
+ "questionnaireId",
+ "routingDestinationId",
+ "routingRuleSetId",
+ "conditionId"
+]);
+const removeChildren = omit([
+ "answers",
+ "validation",
+ "options",
+ "sections",
+ "otherAnswer",
+ "pages",
+ "metadata",
+ "ruleSet",
+ "routingValue",
+ "conditions",
+ "goto"
+]);
+
+const sanitizeAllProperties = obj =>
+ Object.keys(obj).reduce(
+ (struct, key) => ({
+ ...struct,
+ [key]: sanitize(obj[key])
+ }),
+ {}
+ );
+
+const sanitizeParent = flow(
+ removeChildren,
+ sanitize
+);
+
+describe("Duplicate strategy tests", () => {
+ beforeAll(() => db.migrate.latest());
+ afterAll(() => db.destroy());
+ afterEach(async () => {
+ await db.transaction(async trx => {
+ await trx.table("Questionnaires").delete();
+ });
+ });
+
+ describe("Page", () => {
+ it("will duplicate a page", async () => {
+ const questionnaire = await buildTestQuestionnaire({
+ sections: [
+ {
+ pages: [
+ {
+ title: "My page"
+ }
+ ]
+ }
+ ]
+ });
+
+ const page = questionnaire.sections[0].pages[0];
+
+ const duplicatePage = await db.transaction(trx =>
+ duplicatePageStrategy(trx, removeChildren(page))
+ );
+ expect(sanitize(duplicatePage)).toMatchObject({
+ ...sanitize(removeChildren(page)),
+ order: page.order + 1000
+ });
+ });
+
+ it("will not duplicate deleted answers", async () => {
+ const questionnaire = await buildTestQuestionnaire({
+ sections: [
+ {
+ pages: [
+ {
+ title: "MyPage",
+ answers: [
+ {
+ label: "Is deleted",
+ isDeleted: true
+ },
+ {
+ label: "Is not deleted",
+ isDeleted: false
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ });
+
+ const page = questionnaire.sections[0].pages[0];
+
+ const duplicatePage = await db.transaction(trx =>
+ duplicatePageStrategy(trx, removeChildren(page))
+ );
+
+ const duplicateAnswers = await AnswerRepository.findAll({
+ questionPageId: duplicatePage.id
+ });
+
+ expect(duplicateAnswers).toHaveLength(1);
+ expect(duplicateAnswers.map(sanitize)).toMatchObject(
+ page.answers
+ .filter(a => !a.isDeleted)
+ .map(a => sanitize(removeChildren(a)))
+ );
+ });
+
+ it("will duplicate piping references in a page", async () => {
+ const questionnaire = await buildTestQuestionnaire({
+ metadata: [{ key: "foo", id: "m1" }],
+ sections: [
+ {
+ pages: [
+ {
+ title:
+ 'MyPage {{foo}} ',
+ answers: []
+ }
+ ]
+ }
+ ]
+ });
+
+ const page = questionnaire.sections[0].pages[0];
+
+ const duplicatePage = await db.transaction(trx =>
+ duplicatePageStrategy(trx, removeChildren(page))
+ );
+
+ expect(duplicatePage.title).toEqual(
+ `MyPage {{foo}} `
+ );
+ });
+
+ it("will duplicate routing from the page - checkbox answers", async () => {
+ const questionnaire = await buildTestQuestionnaire({
+ sections: [
+ {
+ pages: [
+ {
+ id: "page1",
+ answers: [
+ {
+ id: "answer1",
+ type: "Radio",
+ options: [
+ {
+ id: "yes",
+ label: "Yes"
+ },
+ {
+ id: "no",
+ label: "No"
+ }
+ ]
+ }
+ ],
+ routingRuleSet: {
+ else: {
+ logicalDestination: "EndOfQuestionnaire"
+ },
+ routingRules: [
+ {
+ goto: {
+ absoluteDestination: {
+ id: "section2",
+ __typename: "Section"
+ }
+ },
+ conditions: [
+ {
+ answer: { id: "answer1" },
+ routingValue: {
+ value: ["yes"]
+ }
+ }
+ ]
+ }
+ ]
+ }
+ }
+ ]
+ },
+ {
+ id: "section2",
+ pages: [{}]
+ }
+ ]
+ });
+
+ const page = questionnaire.sections[0].pages[0];
+
+ const duplicatePage = await db.transaction(trx =>
+ duplicatePageStrategy(trx, removeChildren(page))
+ );
+
+ const duplicateRuleSet = await RoutingRepository.findRoutingRuleSetByQuestionPageId(
+ { questionPageId: duplicatePage.id }
+ );
+ const duplicateRuleSetDestination = await RoutingRepository.getRoutingDestination(
+ duplicateRuleSet.routingDestinationId
+ );
+ expect(duplicateRuleSetDestination.logicalDestination).toEqual(
+ "EndOfQuestionnaire"
+ );
+
+ const duplicateRules = await RoutingRepository.findAllRoutingRules({
+ routingRuleSetId: duplicateRuleSet.id
+ });
+ const duplicateRuleDestination = await RoutingRepository.getRoutingDestination(
+ duplicateRules[0].routingDestinationId
+ );
+ expect(duplicateRuleDestination.absoluteDestination.id).toEqual(
+ questionnaire.sections[1].id
+ );
+ expect(duplicateRules[0]).toMatchObject({
+ ...sanitizeParent(page.ruleSet.rules[0])
+ });
+
+ const duplicateConditions = await RoutingRepository.findAllRoutingConditions(
+ {
+ routingRuleId: duplicateRules[0].id
+ }
+ );
+ const duplicateAnswers = await AnswerRepository.findAll({
+ questionPageId: duplicatePage.id
+ });
+ expect(duplicateConditions[0]).toMatchObject({
+ ...sanitizeParent(page.ruleSet.rules[0].conditions[0]),
+ routingRuleId: duplicateRules[0].id,
+ answerId: duplicateAnswers[0].id
+ });
+
+ const duplicateValues = await RoutingRepository.findAllRoutingConditionValues(
+ {
+ conditionId: duplicateConditions[0].id
+ }
+ );
+ const duplicateOptions = await OptionRepository.findAll({
+ answerId: duplicateAnswers[0].id
+ });
+
+ expect(duplicateValues).toMatchObject([
+ {
+ ...sanitize(
+ page.ruleSet.rules[0].conditions[0].routingValue.value[0]
+ ),
+ optionId: duplicateOptions[0].id
+ }
+ ]);
+ });
+
+ it("will duplicate routing based on a number", async () => {
+ const questionnaire = await buildTestQuestionnaire({
+ sections: [
+ {
+ pages: [
+ {
+ id: "page1",
+ answers: [
+ {
+ id: "answer1",
+ type: "Number"
+ }
+ ],
+ routingRuleSet: {
+ else: {
+ logicalDestination: "EndOfQuestionnaire"
+ },
+ routingRules: [
+ {
+ goto: {
+ absoluteDestination: {
+ __typename: "Section",
+ id: "section2"
+ }
+ },
+ conditions: [
+ {
+ comparator: "Equal",
+ answer: { id: "answer1" },
+ routingValue: {
+ numberValue: 2
+ }
+ }
+ ]
+ }
+ ]
+ }
+ }
+ ]
+ },
+ {
+ id: "section2",
+ pages: [{}]
+ }
+ ]
+ });
+
+ const page = questionnaire.sections[0].pages[0];
+
+ const duplicatePage = await db.transaction(trx =>
+ duplicatePageStrategy(trx, removeChildren(page))
+ );
+
+ const duplicateRuleSet = await RoutingRepository.findRoutingRuleSetByQuestionPageId(
+ { questionPageId: duplicatePage.id }
+ );
+ const duplicateRules = await RoutingRepository.findAllRoutingRules({
+ routingRuleSetId: duplicateRuleSet.id
+ });
+ const duplicateConditions = await RoutingRepository.findAllRoutingConditions(
+ {
+ routingRuleId: duplicateRules[0].id
+ }
+ );
+
+ expect(duplicateConditions[0].comparator).toEqual("Equal");
+
+ const duplicateConditionValues = await RoutingRepository.findAllRoutingConditionValues(
+ {
+ conditionId: duplicateConditions[0].id
+ }
+ );
+
+ expect(duplicateConditionValues[0]).toMatchObject({
+ customNumber: 2
+ });
+ });
+
+ describe("Answer", () => {
+ it("will duplicate an answer with an option", async () => {
+ const questionnaire = await buildTestQuestionnaire({
+ sections: [
+ {
+ pages: [
+ {
+ answers: [
+ {
+ type: "Radio",
+ options: [{}]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ });
+
+ const page = questionnaire.sections[0].pages[0];
+ const option = page.answers[0].options[0];
+
+ const duplicatePage = await db.transaction(trx =>
+ duplicatePageStrategy(trx, removeChildren(page))
+ );
+
+ const duplicateAnswers = await AnswerRepository.findAll({
+ questionPageId: duplicatePage.id
+ });
+ const duplicateOptions = await OptionRepository.findAll({
+ answerId: duplicateAnswers[0].id
+ });
+
+ expect(sanitize(duplicateOptions[0])).toMatchObject(sanitize(option));
+ });
+
+ it("will ensure the option order is maintained", async () => {
+ const questionnaire = await buildTestQuestionnaire({
+ sections: [
+ {
+ pages: [
+ {
+ answers: [
+ {
+ type: "Radio",
+ options: [
+ { label: "1" },
+ { label: "2" },
+ { label: "3" },
+ { label: "4" }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ });
+
+ const page = questionnaire.sections[0].pages[0];
+
+ const duplicatePage = await db.transaction(trx =>
+ duplicatePageStrategy(trx, removeChildren(page))
+ );
+
+ const duplicateAnswers = await AnswerRepository.findAll({
+ questionPageId: duplicatePage.id
+ });
+ const duplicateOptions = await OptionRepository.findAll({
+ answerId: duplicateAnswers[0].id
+ });
+
+ const optionLabels = map(o => o.label);
+
+ expect(optionLabels(duplicateOptions)).toMatchObject([
+ "1",
+ "2",
+ "3",
+ "4"
+ ]);
+ });
+
+ it("will duplicate an answer with an other option", async () => {
+ const questionnaire = await buildTestQuestionnaire({
+ sections: [
+ {
+ pages: [
+ {
+ answers: [
+ {
+ type: "Radio",
+ options: [{ label: "1" }, { label: "2" }],
+ other: {
+ answer: {
+ label: "Other answer label"
+ },
+ option: {
+ label: "Other option label"
+ }
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ });
+
+ const page = questionnaire.sections[0].pages[0];
+
+ const answer = page.answers[0];
+ const otherAnswer = answer.otherAnswer;
+ const otherOption = otherAnswer.options[0];
+
+ const duplicatePage = await db.transaction(trx =>
+ duplicatePageStrategy(trx, removeChildren(page))
+ );
+ const dupAnswers = await AnswerRepository.findAll({
+ questionPageId: duplicatePage.id
+ });
+ const duplicateAnswer = dupAnswers[0];
+ const duplicateOtherAnswer = await AnswerRepository.getOtherAnswer(
+ duplicateAnswer.id
+ );
+ const duplicateOtherOption = await OptionRepository.getOtherOption(
+ duplicateOtherAnswer.id
+ );
+
+ expect(sanitize(duplicateAnswer)).toMatchObject(sanitizeParent(answer));
+ expect(duplicateOtherAnswer.parentAnswerId).toEqual(duplicateAnswer.id);
+ expect(sanitize(duplicateOtherAnswer)).toMatchObject({
+ ...sanitizeParent(otherAnswer),
+ label: "Other answer label"
+ });
+ expect(sanitize(duplicateOtherOption)).toMatchObject({
+ ...sanitize(otherOption),
+ label: "Other option label"
+ });
+ });
+
+ it("will duplicate a mutually exclusive option", async () => {
+ const questionnaire = await buildTestQuestionnaire({
+ sections: [
+ {
+ pages: [
+ {
+ answers: [
+ {
+ type: "Radio",
+ options: [{ label: "1" }, { label: "2" }],
+ mutuallyExclusiveOption: {
+ label: "3"
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ });
+
+ const page = questionnaire.sections[0].pages[0];
+ const duplicatePage = await db.transaction(trx =>
+ duplicatePageStrategy(trx, removeChildren(page))
+ );
+ const dupAnswers = await AnswerRepository.findAll({
+ questionPageId: duplicatePage.id
+ });
+ const duplicateAnswer = dupAnswers[0];
+ const duplicateMutuallyExclusiveOption = await OptionRepository.findExclusiveOptionByAnswerId(
+ duplicateAnswer.id
+ );
+
+ expect(sanitize(duplicateMutuallyExclusiveOption)).toMatchObject(
+ sanitize(page.answers[0].mutuallyExclusiveOption)
+ );
+ });
+
+ it("will duplicate the validations for a number answer", async () => {
+ const questionnaire = await buildTestQuestionnaire({
+ sections: [
+ {
+ pages: [
+ {
+ answers: [
+ {
+ type: "Number",
+ validation: {
+ minValue: {
+ enabled: true,
+ custom: 5,
+ inclusive: true
+ }
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ });
+
+ const page = questionnaire.sections[0].pages[0];
+
+ const answer = page.answers[0];
+
+ const duplicatePage = await db.transaction(trx =>
+ duplicatePageStrategy(trx, removeChildren(page))
+ );
+
+ const dupAnswers = await AnswerRepository.findAll({
+ questionPageId: duplicatePage.id
+ });
+
+ const duplicatedAnswer = dupAnswers[0];
+
+ const duplicatedValidations = {
+ minValue: await ValidationRepository.findByAnswerIdAndValidationType(
+ duplicatedAnswer,
+ "minValue"
+ ),
+ maxValue: await ValidationRepository.findByAnswerIdAndValidationType(
+ duplicatedAnswer,
+ "maxValue"
+ )
+ };
+
+ expect(sanitizeAllProperties(duplicatedValidations)).toMatchObject(
+ sanitizeAllProperties(answer.validation)
+ );
+ });
+
+ it("will duplicate the validations for date answer", async () => {
+ const questionnaire = await buildTestQuestionnaire({
+ sections: [
+ {
+ pages: [
+ {
+ answers: [
+ {
+ type: "Date",
+ validation: {
+ earliestDate: {
+ enabled: true,
+ offset: { unit: "Days", value: 0 },
+ relativePosition: "Before",
+ custom: "2018-10-10T00:00:00.000Z",
+ entityType: "Custom",
+ previousAnswerId: null,
+ metadataId: null
+ }
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ });
+
+ const page = questionnaire.sections[0].pages[0];
+
+ const answer = page.answers[0];
+
+ const duplicatePage = await db.transaction(trx =>
+ duplicatePageStrategy(trx, removeChildren(page))
+ );
+
+ const dupAnswers = await AnswerRepository.findAll({
+ questionPageId: duplicatePage.id
+ });
+
+ const duplicatedAnswer = dupAnswers[0];
+
+ const duplicatedValidations = {
+ earliestDate: await ValidationRepository.findByAnswerIdAndValidationType(
+ duplicatedAnswer,
+ "earliestDate"
+ ),
+ latestDate: await ValidationRepository.findByAnswerIdAndValidationType(
+ duplicatedAnswer,
+ "latestDate"
+ )
+ };
+
+ expect(sanitizeAllProperties(duplicatedValidations)).toMatchObject(
+ sanitizeAllProperties(answer.validation)
+ );
+ expect(duplicatedValidations.earliestDate).toMatchObject({
+ entityType: "Custom",
+ custom: "2018-10-10T00:00:00.000Z"
+ });
+ });
+
+ it("will duplicate the validations associated with metadata", async () => {
+ const questionnaire = await buildTestQuestionnaire({
+ metadata: [{ id: "metadata1" }],
+ sections: [
+ {
+ pages: [
+ {
+ answers: [
+ {
+ type: "Date",
+ validation: {
+ earliestDate: {
+ enabled: true,
+ offset: { unit: "Days", value: 0 },
+ relativePosition: "Before",
+ custom: null,
+ entityType: "Metadata",
+ previousAnswer: null,
+ metadata: {
+ id: "metadata1"
+ }
+ }
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ });
+
+ const page = questionnaire.sections[0].pages[0];
+
+ const duplicatePage = await db.transaction(trx =>
+ duplicatePageStrategy(trx, removeChildren(page))
+ );
+
+ const dupAnswers = await AnswerRepository.findAll({
+ questionPageId: duplicatePage.id
+ });
+
+ const duplicatedAnswer = dupAnswers[0];
+
+ const duplicatedValidations = {
+ earliestDate: await ValidationRepository.findByAnswerIdAndValidationType(
+ duplicatedAnswer,
+ "earliestDate"
+ )
+ };
+
+ expect(duplicatedValidations.earliestDate).toMatchObject({
+ entityType: "Metadata",
+ metadataId: questionnaire.metadata[0].id
+ });
+ });
+
+ it("will duplicate the validations associated with previous answer", async () => {
+ const questionnaire = await buildTestQuestionnaire({
+ sections: [
+ {
+ pages: [
+ {
+ answers: [
+ {
+ id: "answer1",
+ type: "Date"
+ },
+ {
+ type: "Date",
+ validation: {
+ earliestDate: {
+ enabled: true,
+ offset: { unit: "Days", value: 0 },
+ relativePosition: "Before",
+ custom: null,
+ entityType: "PreviousAnswer",
+ previousAnswer: { id: "answer1" },
+ metadataId: null
+ }
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ });
+
+ const page = questionnaire.sections[0].pages[0];
+
+ const duplicatePage = await db.transaction(trx =>
+ duplicatePageStrategy(trx, removeChildren(page))
+ );
+
+ // Returns answers on page in reverse order if created at the same time
+ const dupAnswers = await AnswerRepository.findAll(
+ {
+ questionPageId: duplicatePage.id
+ },
+ "id",
+ "asc"
+ );
+
+ const duplicatedAnswer = dupAnswers[1];
+
+ const duplicatedValidations = {
+ earliestDate: await ValidationRepository.findByAnswerIdAndValidationType(
+ duplicatedAnswer,
+ "earliestDate"
+ )
+ };
+ expect(duplicatedValidations.earliestDate).toMatchObject({
+ entityType: "PreviousAnswer",
+ previousAnswerId: dupAnswers[0].id
+ });
+ });
+ });
+ });
+
+ describe("Section", () => {
+ it("will duplicate a section to the position specified", async () => {
+ const questionnaire = await buildTestQuestionnaire({
+ sections: [
+ {
+ title: "My section",
+ pages: [{}]
+ }
+ ]
+ });
+
+ const section = questionnaire.sections[0];
+
+ const duplicateSection = await db.transaction(trx => {
+ return duplicateSectionStrategy(trx, removeChildren(section), 1);
+ });
+
+ expect(sanitize(duplicateSection)).toMatchObject(sanitizeParent(section));
+
+ const position = await SectionRepository.getPosition(duplicateSection);
+ expect(position).toEqual(1);
+ });
+
+ it("will duplicate pages for a section but not deleted ones", async () => {
+ const questionnaire = await buildTestQuestionnaire({
+ sections: [
+ {
+ title: "My section",
+ pages: [
+ {
+ title: "Question 1",
+ isDeleted: false
+ },
+ {
+ title: "Question 2",
+ isDeleted: true
+ },
+ {
+ title: "Question 3",
+ isDeleted: false
+ }
+ ]
+ }
+ ]
+ });
+
+ const section = questionnaire.sections[0];
+
+ const duplicateSection = await db.transaction(trx =>
+ duplicateSectionStrategy(trx, removeChildren(section), 1)
+ );
+
+ const duplicatePages = await PageRepository.findAll({
+ sectionId: duplicateSection.id
+ });
+
+ expect(duplicatePages).toHaveLength(2);
+ expect(duplicatePages.map(sanitize)).toEqual(
+ section.pages
+ .filter(p => !p.isDeleted)
+ .map(p => sanitize(removeChildren(p)))
+ );
+ });
+
+ it("will update piping to references within the section", async () => {
+ const questionnaire = await buildTestQuestionnaire({
+ sections: [
+ {
+ title: "My section",
+ pages: [
+ {
+ title: "Question 1",
+ answers: [{ id: "a1", label: "Answer 1" }]
+ },
+ {
+ title:
+ 'Title {{Answer 1}} ',
+ description:
+ 'Description {{Answer 1}} ',
+ guidance:
+ 'Guidance {{Answer 1}} '
+ }
+ ]
+ }
+ ]
+ });
+
+ const section = questionnaire.sections[0];
+
+ const duplicateSection = await db.transaction(trx =>
+ duplicateSectionStrategy(trx, removeChildren(section), 1)
+ );
+
+ const duplicatePages = await PageRepository.findAll({
+ sectionId: duplicateSection.id
+ });
+
+ const duplicateFirstPageAnswers = await AnswerRepository.findAll({
+ questionPageId: duplicatePages[0].id
+ });
+ const newPipedAnswerId = duplicateFirstPageAnswers[0].id;
+
+ const secondPage = duplicatePages[1];
+
+ expect(secondPage.title).toEqual(
+ `Title {{Answer 1}} `
+ );
+ expect(secondPage.description).toEqual(
+ `Description {{Answer 1}} `
+ );
+ expect(secondPage.guidance).toEqual(
+ `Guidance {{Answer 1}} `
+ );
+ });
+
+ it("will not update piping with references outside of the secontion", async () => {
+ const questionnaire = await buildTestQuestionnaire({
+ metadata: [
+ {
+ id: "m1",
+ key: "foo",
+ type: "Text",
+ textValue: "Hello world"
+ }
+ ],
+ sections: [
+ {
+ title: "My section 1",
+ pages: [
+ {
+ title: "Question 1",
+ answers: [{ id: "s1q1a1", label: "S1Q1A1" }]
+ }
+ ]
+ },
+ {
+ title: "My section 2",
+ pages: [
+ {
+ title:
+ 'Question {{S1Q1A1}} ',
+ description:
+ 'Description {{foo}} '
+ }
+ ]
+ }
+ ]
+ });
+
+ const section = questionnaire.sections[1];
+
+ const duplicateSection = await db.transaction(trx =>
+ duplicateSectionStrategy(trx, removeChildren(section), 1)
+ );
+
+ const duplicatePages = await PageRepository.findAll({
+ sectionId: duplicateSection.id
+ });
+ const referencedAnswerId =
+ questionnaire.sections[0].pages[0].answers[0].id;
+
+ expect(duplicatePages[0].title).toEqual(
+ `Question {{S1Q1A1}} `
+ );
+ expect(duplicatePages[0].description).toEqual(
+ `Description {{foo}} `
+ );
+ });
+
+ it("will duplicate routing and keep references inside a section and update those outside", async () => {
+ const questionnaire = await buildTestQuestionnaire({
+ sections: [
+ {
+ pages: [
+ {
+ id: "page1",
+ answers: [
+ {
+ id: "answer1",
+ type: "Radio",
+ options: [
+ {
+ id: "yes",
+ label: "Yes"
+ },
+ {
+ id: "no",
+ label: "No"
+ }
+ ]
+ }
+ ],
+ routingRuleSet: {
+ else: {
+ absoluteDestination: {
+ id: "page2",
+ __typename: "QuestionPage"
+ }
+ },
+ routingRules: [
+ {
+ goto: {
+ absoluteDestination: {
+ id: "section2",
+ __typename: "Section"
+ }
+ },
+ conditions: [
+ {
+ answer: { id: "answer1" },
+ routingValue: {
+ value: ["yes"]
+ }
+ }
+ ]
+ }
+ ]
+ }
+ },
+ {
+ id: "page2"
+ }
+ ]
+ },
+ {
+ id: "section2",
+ pages: [{}]
+ }
+ ]
+ });
+
+ const section = questionnaire.sections[0];
+
+ const duplicateSection = await db.transaction(trx =>
+ duplicateSectionStrategy(trx, removeChildren(section), 1)
+ );
+
+ const duplicatePages = await PageRepository.findAll({
+ sectionId: duplicateSection.id
+ });
+
+ const duplicatedPage = duplicatePages[0];
+ const duplicatedPageRuleSet = await RoutingRepository.findRoutingRuleSetByQuestionPageId(
+ { questionPageId: duplicatedPage.id }
+ );
+ const duplicateRuleSetDestination = await RoutingRepository.getRoutingDestination(
+ duplicatedPageRuleSet.routingDestinationId
+ );
+ // Internal reference updated
+ expect(duplicateRuleSetDestination).toMatchObject({
+ absoluteDestination: {
+ id: duplicatePages[1].id
+ }
+ });
+
+ const duplicateRules = await RoutingRepository.findAllRoutingRules({
+ routingRuleSetId: duplicatedPageRuleSet.id
+ });
+ const duplicateRuleDestination = await RoutingRepository.getRoutingDestination(
+ duplicateRules[0].routingDestinationId
+ );
+ // External reference still pointing to section
+ expect(duplicateRuleDestination.absoluteDestination.id).toEqual(
+ questionnaire.sections[1].id
+ );
+ });
+ });
+
+ describe("Questionnaire", () => {
+ it("will duplicate a questionnaire", async () => {
+ const questionnaire = await buildTestQuestionnaire({
+ sections: [
+ {
+ pages: [{}]
+ }
+ ]
+ });
+
+ const duplicateQuestionnaire = await db.transaction(trx => {
+ return duplicateQuestionnaireStrategy(
+ trx,
+ removeChildren(questionnaire)
+ );
+ });
+
+ expect(sanitize(duplicateQuestionnaire)).toMatchObject(
+ sanitizeParent(questionnaire)
+ );
+ });
+
+ it("will duplicate child entities", async () => {
+ const questionnaire = await buildTestQuestionnaire({
+ sections: [
+ {
+ pages: [{}]
+ }
+ ]
+ });
+
+ const duplicateQuestionnaire = await db.transaction(trx =>
+ duplicateQuestionnaireStrategy(trx, removeChildren(questionnaire))
+ );
+
+ const duplicateSections = await SectionRepository.findAll({
+ questionnaireId: duplicateQuestionnaire.id
+ });
+ const duplicatePages = await PageRepository.findAll({
+ sectionId: duplicateSections[0].id
+ });
+
+ expect(sanitize(duplicateSections[0])).toMatchObject(
+ sanitizeParent(questionnaire.sections[0])
+ );
+ expect(sanitize(duplicatePages[0])).toMatchObject(
+ sanitizeParent(questionnaire.sections[0].pages[0])
+ );
+ });
+
+ it("will duplicate metadata", async () => {
+ const questionnaire = await buildTestQuestionnaire({
+ metadata: [{ key: "foo", type: "Text", textValue: "Hello world" }],
+ sections: [
+ {
+ pages: [{}]
+ }
+ ]
+ });
+
+ const duplicateQuestionnaire = await db.transaction(trx =>
+ duplicateQuestionnaireStrategy(trx, removeChildren(questionnaire))
+ );
+
+ const duplicateMetadata = await MetadataRepository.findAll({
+ questionnaireId: duplicateQuestionnaire.id
+ });
+
+ expect(duplicateMetadata.map(sanitize)).toMatchObject(
+ questionnaire.metadata.map(sanitize)
+ );
+ });
+
+ it("will update all piping references", async () => {
+ const questionnaire = await buildTestQuestionnaire({
+ metadata: [
+ {
+ id: "m1",
+ key: "foo",
+ type: "Text",
+ textValue: "Hello world"
+ }
+ ],
+ sections: [
+ {
+ title: "Section 1",
+ pages: [
+ {
+ title:
+ 'Page title {{foo}} ',
+ answers: [
+ {
+ id: "a1",
+ label: "Answer 1"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ title: "Section 2",
+ pages: [
+ {
+ guidance:
+ 'Section 2 title {{Answer 1}} '
+ }
+ ]
+ }
+ ]
+ });
+
+ const duplicateQuestionnaire = await db.transaction(trx =>
+ duplicateQuestionnaireStrategy(trx, removeChildren(questionnaire))
+ );
+
+ const duplicateMetadata = await MetadataRepository.findAll({
+ questionnaireId: duplicateQuestionnaire.id
+ });
+ const duplicateSections = await SectionRepository.findAll({
+ questionnaireId: duplicateQuestionnaire.id
+ });
+
+ const dupSection1 = duplicateSections[0];
+ const dupSection1Pages = await PageRepository.findAll({
+ sectionId: dupSection1.id
+ });
+ const dupSection1Page1 = dupSection1Pages[0];
+
+ expect(dupSection1Page1.title).toEqual(
+ `Page title {{foo}} `
+ );
+
+ const dupSection1Page1Answers = await AnswerRepository.findAll({
+ questionPageId: dupSection1Page1.id
+ });
+ const dupSection1Page1Answer1 = dupSection1Page1Answers[0];
+
+ const dupSection2 = duplicateSections[1];
+ const dupSection2Pages = await PageRepository.findAll({
+ sectionId: dupSection2.id
+ });
+ const dupSection2Page1 = dupSection2Pages[0];
+
+ expect(dupSection2Page1.guidance).toEqual(
+ `Section 2 title {{Answer 1}} `
+ );
+ });
+
+ it("will update all routing references", async () => {
+ const questionnaire = await buildTestQuestionnaire({
+ sections: [
+ {
+ pages: [
+ {
+ id: "page1",
+ answers: [
+ {
+ id: "answer1",
+ type: "Radio",
+ options: [
+ {
+ id: "yes",
+ label: "Yes"
+ },
+ {
+ id: "no",
+ label: "No"
+ }
+ ]
+ }
+ ],
+ routingRuleSet: {
+ else: {
+ absoluteDestination: {
+ id: "page2",
+ __typename: "QuestionPage"
+ }
+ },
+ routingRules: [
+ {
+ goto: {
+ absoluteDestination: {
+ id: "section2",
+ __typename: "Section"
+ }
+ },
+ conditions: [
+ {
+ answer: { id: "answer1" },
+ routingValue: {
+ value: ["yes"]
+ }
+ }
+ ]
+ }
+ ]
+ }
+ },
+ {
+ id: "page2"
+ }
+ ]
+ },
+ {
+ id: "section2",
+ pages: [{}]
+ }
+ ]
+ });
+
+ const duplicateQuestionnaire = await db.transaction(trx =>
+ duplicateQuestionnaireStrategy(trx, removeChildren(questionnaire))
+ );
+
+ const duplicateSections = await SectionRepository.findAll({
+ questionnaireId: duplicateQuestionnaire.id
+ });
+
+ const duplicatePages = await PageRepository.findAll({
+ sectionId: duplicateSections[0].id
+ });
+
+ const duplicatedPage = duplicatePages[0];
+ const duplicatedPageRuleSet = await RoutingRepository.findRoutingRuleSetByQuestionPageId(
+ { questionPageId: duplicatedPage.id }
+ );
+ const duplicateRuleSetDestination = await RoutingRepository.getRoutingDestination(
+ duplicatedPageRuleSet.routingDestinationId
+ );
+ // Internal reference updated
+ expect(duplicateRuleSetDestination).toMatchObject({
+ absoluteDestination: {
+ id: duplicatePages[1].id
+ }
+ });
+
+ const duplicateRules = await RoutingRepository.findAllRoutingRules({
+ routingRuleSetId: duplicatedPageRuleSet.id
+ });
+ const duplicateRuleDestination = await RoutingRepository.getRoutingDestination(
+ duplicateRules[0].routingDestinationId
+ );
+ // Internal reference updated
+ expect(duplicateRuleDestination.absoluteDestination.id).toEqual(
+ duplicateSections[1].id
+ );
+ });
+ });
+});
diff --git a/eq-author-api/repositories/strategies/duplicateStrategy/destinations.js b/eq-author-api/repositories/strategies/duplicateStrategy/destinations.js
new file mode 100644
index 0000000000..b79802bc11
--- /dev/null
+++ b/eq-author-api/repositories/strategies/duplicateStrategy/destinations.js
@@ -0,0 +1,80 @@
+const { flatten, omit, get } = require("lodash/fp");
+
+const DESTINATION_CONFIG = [
+ { table: "Routing_RuleSets", name: "routingRuleSets" },
+ { table: "Routing_Rules", name: "routingRules" }
+];
+
+const getDestinationEntities = async (trx, references, { table, name }) => {
+ const duplicatedEntities = references[name];
+ if (!duplicatedEntities) {
+ return [];
+ }
+
+ return trx
+ .select("id", "routingDestinationId")
+ .from(table)
+ .whereIn("id", Object.values(references[name]))
+ .andWhere("isDeleted", false);
+};
+
+const updateDestinationEntities = async (
+ trx,
+ destLookup,
+ entities,
+ { table }
+) => {
+ for (let i = 0; i < entities.length; ++i) {
+ const { id, routingDestinationId } = entities[i];
+ await trx
+ .table(table)
+ .where({ id })
+ .update({
+ routingDestinationId: destLookup[routingDestinationId]
+ });
+ }
+};
+
+const duplicateDestinations = async (trx, references) => {
+ const destinationEntities = await Promise.all(
+ DESTINATION_CONFIG.map(config =>
+ getDestinationEntities(trx, references, config)
+ )
+ );
+
+ const allIds = flatten(destinationEntities).map(get("routingDestinationId"));
+
+ const destinations = await trx
+ .select("*")
+ .from("Routing_Destinations")
+ .whereIn("id", allIds);
+
+ const updatedDestinations = destinations.map(dest => ({
+ ...omit("id")(dest),
+ pageId: references.pages[dest.pageId] || dest.pageId,
+ sectionId: references.sections[dest.sectionId] || dest.sectionId
+ }));
+
+ const newDestinations = await trx
+ .insert(updatedDestinations)
+ .into("Routing_Destinations")
+ .returning("id");
+
+ const oldDestIdToNewDestId = destinations.reduce(
+ (map, { id }, index) => ({ ...map, [id]: newDestinations[index] }),
+ {}
+ );
+
+ return Promise.all(
+ DESTINATION_CONFIG.map((config, idx) =>
+ updateDestinationEntities(
+ trx,
+ oldDestIdToNewDestId,
+ destinationEntities[idx],
+ config
+ )
+ )
+ );
+};
+
+module.exports = duplicateDestinations;
diff --git a/eq-author-api/repositories/strategies/duplicateStrategy/index.js b/eq-author-api/repositories/strategies/duplicateStrategy/index.js
new file mode 100644
index 0000000000..f3053f7801
--- /dev/null
+++ b/eq-author-api/repositories/strategies/duplicateStrategy/index.js
@@ -0,0 +1,253 @@
+const { head, isString } = require("lodash");
+
+const { selectData, duplicateRecord, duplicateTree } = require("./utils");
+const updatePiping = require("./piping");
+const duplicateDestinations = require("./destinations");
+
+const getDefaultReferenceStructure = () => ({
+ options: {},
+ answers: {},
+ pages: {},
+ sections: {},
+ metadata: {},
+ questionnaires: {}
+});
+
+const ENTITY_TREE = [
+ [
+ {
+ name: "sections",
+ table: "Sections",
+ links: [
+ {
+ column: "questionnaireId",
+ entityName: "questionnaires",
+ parent: true
+ }
+ ]
+ },
+ {
+ name: "metadata",
+ table: "Metadata",
+ links: [
+ {
+ column: "questionnaireId",
+ entityName: "questionnaires",
+ parent: true
+ }
+ ]
+ }
+ ],
+ {
+ name: "pages",
+ table: "Pages",
+ links: [{ column: "sectionId", entityName: "sections", parent: true }]
+ },
+ {
+ name: "answers",
+ table: "Answers",
+ links: [
+ {
+ column: "questionPageId",
+ entityName: "pages",
+ parent: true
+ }
+ ],
+ where: '"parentAnswerId" is null'
+ },
+ [
+ // Other answers
+ {
+ name: "answers",
+ table: "Answers",
+ links: [
+ {
+ column: "parentAnswerId",
+ entityName: "answers",
+ parent: true
+ }
+ ]
+ },
+ {
+ name: "validations",
+ table: "Validation_AnswerRules",
+ links: [
+ {
+ column: "answerId",
+ entityName: "answers",
+ parent: true
+ },
+ {
+ column: "previousAnswerId",
+ entityName: "answers"
+ },
+ {
+ column: "metadataId",
+ entityName: "metadata"
+ }
+ ],
+ transform: ({ custom, ...rest }) => ({
+ ...rest,
+ //Required as it's stored as JSONB
+ custom: isString(custom) ? `"${custom}"` : custom
+ }),
+ noIsDeleted: true
+ }
+ ],
+ {
+ name: "options",
+ table: "Options",
+ links: [
+ {
+ column: "answerId",
+ entityName: "answers",
+ parent: true
+ },
+ {
+ column: "otherAnswerId",
+ entityName: "answers",
+ parent: true
+ }
+ ]
+ },
+ {
+ name: "routingRuleSets",
+ table: "Routing_RuleSets",
+ links: [
+ {
+ column: "questionPageId",
+ entityName: "pages",
+ parent: true
+ }
+ ]
+ },
+ {
+ name: "routingRules",
+ table: "Routing_Rules",
+ links: [
+ {
+ column: "routingRuleSetId",
+ entityName: "routingRuleSets",
+ parent: true
+ }
+ ]
+ },
+ {
+ name: "routingConditions",
+ table: "Routing_Conditions",
+ links: [
+ {
+ column: "routingRuleId",
+ entityName: "routingRules",
+ parent: true
+ },
+ {
+ column: "questionPageId",
+ entityName: "pages"
+ },
+ {
+ column: "answerId",
+ entityName: "answers"
+ }
+ ],
+ noIsDeleted: true
+ },
+ {
+ name: "routingConditionValues",
+ table: "Routing_ConditionValues",
+ links: [
+ {
+ column: "conditionId",
+ entityName: "routingConditions",
+ parent: true
+ },
+ {
+ column: "optionId",
+ entityName: "options"
+ }
+ ],
+ noIsDeleted: true
+ }
+];
+
+const duplicatePageStrategy = async (
+ trx,
+ page,
+ position,
+ overrides = {},
+ references = getDefaultReferenceStructure()
+) => {
+ const duplicatePage = await duplicateRecord(
+ trx,
+ "Pages",
+ page,
+ {
+ ...overrides
+ },
+ position
+ );
+
+ references.pages[page.id] = duplicatePage.id;
+
+ await duplicateTree(trx, ENTITY_TREE, references);
+ await duplicateDestinations(trx, references);
+
+ return selectData(trx, "Pages", "*", { id: duplicatePage.id }).then(head);
+};
+
+const duplicateSectionStrategy = async (
+ trx,
+ section,
+ position,
+ overrides = {},
+ references = getDefaultReferenceStructure()
+) => {
+ const duplicateSection = await duplicateRecord(
+ trx,
+ "Sections",
+ section,
+ overrides,
+ position
+ );
+
+ references.sections[section.id] = duplicateSection.id;
+
+ await duplicateTree(trx, ENTITY_TREE, references);
+ await Promise.all([
+ duplicateDestinations(trx, references),
+ updatePiping(trx, references)
+ ]);
+
+ return duplicateSection;
+};
+
+const duplicateQuestionnaireStrategy = async (
+ trx,
+ questionnaire,
+ overrides = {}
+) => {
+ const duplicateQuestionnaire = await duplicateRecord(
+ trx,
+ "Questionnaires",
+ questionnaire,
+ overrides
+ );
+
+ const references = getDefaultReferenceStructure();
+
+ references.questionnaires[questionnaire.id] = duplicateQuestionnaire.id;
+
+ await duplicateTree(trx, ENTITY_TREE, references);
+ await Promise.all([
+ duplicateDestinations(trx, references),
+ updatePiping(trx, references)
+ ]);
+
+ return duplicateQuestionnaire;
+};
+
+Object.assign(module.exports, {
+ duplicatePageStrategy,
+ duplicateSectionStrategy,
+ duplicateQuestionnaireStrategy
+});
diff --git a/eq-author-api/repositories/strategies/duplicateStrategy/piping.js b/eq-author-api/repositories/strategies/duplicateStrategy/piping.js
new file mode 100644
index 0000000000..36a93bfe42
--- /dev/null
+++ b/eq-author-api/repositories/strategies/duplicateStrategy/piping.js
@@ -0,0 +1,89 @@
+const cheerio = require("cheerio");
+
+const PIPING_LOCATIONS = [
+ {
+ entityName: "pages",
+ table: "Pages",
+ fields: ["title", "description", "guidance"]
+ },
+ {
+ entityName: "sections",
+ table: "Sections",
+ fields: ["introductionTitle", "introductionContent"]
+ }
+];
+
+const updatePipingField = references => field => {
+ if (!field || field.indexOf(" {
+ const $el = $(el);
+ const pipeType = $el.data("piped");
+ const id = $el.data("id");
+
+ const newId = references[pipeType][id];
+ $el.attr("data-id", newId);
+
+ return $.html($el);
+ });
+
+ return $("body").html();
+};
+
+const updateEntityPiping = async (
+ trx,
+ references,
+ updateField,
+ { entityName, table, fields }
+) => {
+ const ids = Object.values(references[entityName] || {});
+ if (ids.length === 0) {
+ return;
+ }
+
+ const entities = await trx
+ .select("*")
+ .from(table)
+ .whereIn("id", ids)
+ .andWhere(builder => {
+ fields.forEach(field => {
+ builder.orWhere(field, "like", "%<%");
+ });
+ });
+
+ for (let i = 0; i < entities.length; ++i) {
+ const entity = entities[i];
+ const modifiedEntity = fields.reduce(
+ (obj, field) => ({
+ ...obj,
+ [field]: updateField(entity[field])
+ }),
+ {}
+ );
+ await trx
+ .table(table)
+ .update(modifiedEntity)
+ .where({ id: entity.id });
+ }
+};
+
+const updatePiping = async (trx, references) => {
+ const updatePipingFieldWithRef = updatePipingField(references);
+
+ return Promise.all(
+ PIPING_LOCATIONS.map(entityConfig =>
+ updateEntityPiping(
+ trx,
+ references,
+ updatePipingFieldWithRef,
+ entityConfig
+ )
+ )
+ );
+};
+
+module.exports = updatePiping;
diff --git a/eq-author-api/repositories/strategies/duplicateStrategy/piping.test.js b/eq-author-api/repositories/strategies/duplicateStrategy/piping.test.js
new file mode 100644
index 0000000000..44cfd7c9a8
--- /dev/null
+++ b/eq-author-api/repositories/strategies/duplicateStrategy/piping.test.js
@@ -0,0 +1,158 @@
+const updatePiping = require("./piping");
+
+describe("Update piping", () => {
+ let trx, builder;
+ beforeEach(() => {
+ builder = {
+ orWhere: jest.fn()
+ };
+ trx = {
+ select: jest.fn().mockReturnThis(),
+ from: jest.fn().mockReturnThis(),
+ andWhere: func => {
+ func(builder);
+ return trx;
+ },
+ whereIn: jest.fn().mockReturnThis(),
+ table: jest.fn().mockReturnThis(),
+ update: jest.fn().mockReturnThis(),
+ where: jest.fn().mockResolvedValue([])
+ };
+ });
+
+ it("should retrieve pages that have been updated and have piping", async () => {
+ const references = {
+ pages: {
+ "1": "new1",
+ "2": "new2"
+ }
+ };
+
+ await updatePiping(trx, references);
+
+ expect(trx.select).toHaveBeenCalledWith("*");
+ expect(trx.from).toHaveBeenCalledWith("Pages");
+ expect(trx.whereIn).toHaveBeenCalledWith("id", ["new1", "new2"]);
+ expect(builder.orWhere).toHaveBeenCalledWith("title", "like", "%<%");
+ expect(builder.orWhere).toHaveBeenCalledWith("description", "like", "%<%");
+ expect(builder.orWhere).toHaveBeenCalledWith("guidance", "like", "%<%");
+ });
+
+ it("should retrieve sections that have been updated and have piping", async () => {
+ const references = {
+ sections: {
+ "1": "new1",
+ "2": "new2"
+ }
+ };
+
+ await updatePiping(trx, references);
+
+ expect(trx.select).toHaveBeenCalledWith("*");
+ expect(trx.from).toHaveBeenCalledWith("Sections");
+ expect(trx.whereIn).toHaveBeenCalledWith("id", ["new1", "new2"]);
+ expect(builder.orWhere).toHaveBeenCalledWith(
+ "introductionTitle",
+ "like",
+ "%<%"
+ );
+ expect(builder.orWhere).toHaveBeenCalledWith(
+ "introductionContent",
+ "like",
+ "%<%"
+ );
+ });
+
+ it("should update the piping value and save it back to the database", async () => {
+ const references = {
+ pages: {
+ "1": "new1"
+ },
+ answers: {
+ a1: "newA1"
+ },
+ metadata: {
+ m1: "newM1"
+ }
+ };
+ trx.andWhere = async func => {
+ func(builder);
+ return [
+ {
+ id: "new1",
+ title:
+ 'Title {{Answer 1}} ',
+ guidance:
+ 'Guidance {{Metadata 1}} '
+ }
+ ];
+ };
+
+ await updatePiping(trx, references);
+
+ expect(trx.table).toHaveBeenCalledWith("Pages");
+ expect(trx.update).toHaveBeenCalledWith({
+ title:
+ 'Title {{Answer 1}} ',
+ guidance:
+ 'Guidance {{Metadata 1}} '
+ });
+ expect(trx.where).toHaveBeenCalledWith({
+ id: "new1"
+ });
+ });
+
+ it("should update the piping for sections and save it back to the database", async () => {
+ const references = {
+ sections: {
+ "1": "new1"
+ },
+ answers: {
+ a1: "newA1"
+ },
+ metadata: {
+ m1: "newM1"
+ }
+ };
+ trx.andWhere = async func => {
+ func(builder);
+ return [
+ {
+ id: "new1",
+ introductionTitle:
+ 'introTitle {{Answer 1}} ',
+ introductionContent:
+ 'introContent {{Metadata 1}} '
+ }
+ ];
+ };
+
+ await updatePiping(trx, references);
+
+ expect(trx.table).toHaveBeenCalledWith("Sections");
+ expect(trx.update).toHaveBeenCalledWith({
+ introductionTitle:
+ 'introTitle {{Answer 1}} ',
+ introductionContent:
+ 'introContent {{Metadata 1}} '
+ });
+ expect(trx.where).toHaveBeenCalledWith({
+ id: "new1"
+ });
+ });
+
+ it("should do nothing if there are no changed entities that could have piping", async () => {
+ const references = {
+ answers: {
+ a1: "newA1"
+ },
+ metadata: {
+ m1: "newM1"
+ }
+ };
+
+ await updatePiping(trx, references);
+
+ expect(trx.update).not.toHaveBeenCalled();
+ });
+});
diff --git a/eq-author-api/repositories/strategies/duplicateStrategy/utils.js b/eq-author-api/repositories/strategies/duplicateStrategy/utils.js
new file mode 100644
index 0000000000..6c7c79a92f
--- /dev/null
+++ b/eq-author-api/repositories/strategies/duplicateStrategy/utils.js
@@ -0,0 +1,158 @@
+const { omit, head, get, isNil, isFunction } = require("lodash");
+const {
+ getOrUpdateOrderForPageInsert,
+ getOrUpdateOrderForSectionInsert
+} = require("../spacedOrderStrategy");
+
+const insertData = async (
+ trx,
+ tableName,
+ data,
+ callback,
+ returning = "*",
+ position
+) => {
+ const returnedData = await trx
+ .table(tableName)
+ .insert(data)
+ .returning(returning)
+ .then(callback);
+
+ if (returnedData.order) {
+ let updateOrder = getOrUpdateOrderForPageInsert;
+ let parentId = returnedData.sectionId;
+ if (tableName === "Sections") {
+ updateOrder = getOrUpdateOrderForSectionInsert;
+ parentId = returnedData.questionnaireId;
+ }
+ const order = await updateOrder(trx, parentId, returnedData.id, position);
+
+ await trx
+ .table(tableName)
+ .update({ order })
+ .where({ id: returnedData.id });
+ }
+
+ return returnedData;
+};
+
+const selectData = (trx, tableName, columns, where, orderBy) => {
+ const queryP = trx
+ .select(columns)
+ .from(tableName)
+ .where(where);
+ if (orderBy) {
+ const { column, direction } = orderBy;
+ queryP.orderBy(column, direction);
+ }
+ return queryP;
+};
+
+const duplicateRecord = async (
+ trx,
+ tableName,
+ record,
+ overrides = {},
+ position
+) => {
+ const duplicatedRecord = omit(record, "id", "createdAt", "updatedAt");
+ const { parentRelation, ...other } = overrides;
+
+ if (!isNil(parentRelation)) {
+ duplicatedRecord[get(parentRelation, "columnName")] = get(
+ parentRelation,
+ "id"
+ );
+ }
+
+ const newRecord = { ...duplicatedRecord, ...other };
+
+ return insertData(trx, tableName, newRecord, head, "*", position);
+};
+
+const FIELDS_TO_NEVER_DUPLICATE = ["id", "createdAt", "updatedAt"];
+
+const duplicateTree = async (trx, tree, references) => {
+ if (tree.length === 0) {
+ return;
+ }
+
+ const [entityTypeToDuplicate, ...restOfTree] = tree;
+
+ if (Array.isArray(entityTypeToDuplicate)) {
+ await Promise.all(
+ entityTypeToDuplicate.map(type => duplicateTree(trx, [type], references))
+ );
+ return duplicateTree(trx, restOfTree, references);
+ }
+
+ const {
+ name,
+ links,
+ table,
+ where,
+ noIsDeleted,
+ transform
+ } = entityTypeToDuplicate;
+
+ const selectQuery = trx
+ .select("*")
+ .from(table)
+ .where(builder => {
+ const parentLinks = links.filter(l => l.parent);
+ parentLinks.forEach(({ column, entityName }) => {
+ const ids = Object.keys(references[entityName] || {});
+ builder.orWhereIn(column, ids);
+ });
+
+ if (where) {
+ builder.andWhereRaw(where);
+ }
+ if (!noIsDeleted) {
+ builder.andWhere({ isDeleted: false });
+ }
+ })
+ .orderBy("id");
+
+ const originalEntities = await selectQuery;
+
+ if (originalEntities.length === 0) {
+ return duplicateTree(trx, restOfTree, references);
+ }
+
+ const transformReferences = entity =>
+ links.reduce(
+ (e, { column, entityName }) => ({
+ ...e,
+ [column]: (references[entityName] || {})[e[column]] || e[column]
+ }),
+ omit(entity, FIELDS_TO_NEVER_DUPLICATE)
+ );
+
+ const transformedEntities = originalEntities.map(transformReferences);
+
+ const transformed = isFunction(transform)
+ ? transformedEntities.map(transform)
+ : transformedEntities;
+
+ const newEntities = await trx
+ .insert(transformed)
+ .into(table)
+ .returning("id");
+
+ references[name] = references[name] || {};
+
+ newEntities.forEach((newId, index) => {
+ const original = originalEntities[index];
+ references[name][original.id] = newId;
+ });
+
+ return duplicateTree(trx, restOfTree, references);
+};
+
+module.exports = {
+ insertData,
+ selectData,
+ duplicateRecord,
+ duplicateTree
+};
diff --git a/eq-author-api/repositories/strategies/duplicateStrategy/utils.test.js b/eq-author-api/repositories/strategies/duplicateStrategy/utils.test.js
new file mode 100644
index 0000000000..b8bedf8bf2
--- /dev/null
+++ b/eq-author-api/repositories/strategies/duplicateStrategy/utils.test.js
@@ -0,0 +1,317 @@
+const { duplicateTree } = require("./utils");
+
+describe("Duplicate utils", () => {
+ describe("duplicateTree", () => {
+ let trx, builder;
+ beforeEach(() => {
+ builder = {
+ andWhere: jest.fn(),
+ andWhereRaw: jest.fn(),
+ orWhereIn: jest.fn()
+ };
+ trx = {
+ select: jest.fn().mockReturnThis(),
+ from: jest.fn().mockReturnThis(),
+ orderBy: jest.fn().mockResolvedValue([]),
+ where: func => {
+ func(builder);
+ return trx;
+ },
+ insert: jest.fn().mockReturnThis(),
+ into: jest.fn().mockReturnThis(),
+ returning: jest.fn().mockResolvedValue([])
+ };
+ });
+
+ it("should select the entities linking to a parent", async () => {
+ const references = {
+ parents: {
+ 1: "newId1",
+ 2: "newId2"
+ }
+ };
+ const tree = [
+ {
+ name: "entity",
+ table: "entities",
+ links: [
+ {
+ column: "parentId",
+ entityName: "parents",
+ parent: true
+ }
+ ]
+ }
+ ];
+ await duplicateTree(trx, tree, references);
+ expect(trx.select).toHaveBeenCalledWith("*");
+ expect(trx.from).toHaveBeenCalledWith("entities");
+ expect(builder.orWhereIn).toHaveBeenCalledWith("parentId", ["1", "2"]);
+ expect(builder.andWhere).toHaveBeenCalledWith({ isDeleted: false });
+ });
+
+ it("should not filter by isDeleted when there is no deleted column", async () => {
+ const references = { parents: { p1: "newId1", p2: "newId2" } };
+ const tree = [
+ {
+ name: "entity",
+ table: "entities",
+ links: [{ column: "parentId", entityName: "parents", parent: true }],
+ noIsDeleted: true
+ }
+ ];
+
+ await duplicateTree(trx, tree, references);
+
+ expect(builder.andWhere).not.toHaveBeenCalled();
+ });
+
+ it("should filter by additional where clause when provided", async () => {
+ const references = { parents: { p1: "newId1", p2: "newId2" } };
+ const tree = [
+ {
+ name: "entity",
+ table: "entities",
+ links: [{ column: "parentId", entityName: "parents", parent: true }],
+ where: "1 = 1"
+ }
+ ];
+
+ await duplicateTree(trx, tree, references);
+
+ expect(builder.andWhereRaw).toHaveBeenCalledWith("1 = 1");
+ });
+
+ it("should insert the new values with parent references updated", async () => {
+ const references = { parents: { p1: "newId1", p2: "newId2" } };
+ const tree = [
+ {
+ name: "entity",
+ table: "entities",
+ links: [{ column: "parentId", entityName: "parents", parent: true }]
+ }
+ ];
+
+ const entities = [
+ { id: "e1", parentId: "p1" },
+ { id: "e2", parentId: "p2" }
+ ];
+ trx.orderBy = jest.fn().mockResolvedValue(entities);
+
+ await duplicateTree(trx, tree, references);
+
+ expect(trx.insert).toHaveBeenCalledWith([
+ { parentId: "newId1" },
+ { parentId: "newId2" }
+ ]);
+ expect(trx.into).toHaveBeenCalledWith("entities");
+ expect(trx.returning).toHaveBeenCalledWith("id");
+ });
+
+ it("should update references for non parent links", async () => {
+ const references = {
+ parents: { p1: "newId1", p2: "newId2" },
+ others: { o1: "newO1", o2: "newO2" }
+ };
+ const tree = [
+ {
+ name: "entity",
+ table: "entities",
+ links: [
+ { column: "parentId", entityName: "parents", parent: true },
+ { column: "otherId", entityName: "others" }
+ ]
+ }
+ ];
+
+ const entities = [
+ { id: "e1", parentId: "p1", otherId: "o2" },
+ { id: "e2", parentId: "p2", otherId: "o1" }
+ ];
+ trx.orderBy = jest.fn().mockResolvedValue(entities);
+
+ await duplicateTree(trx, tree, references);
+
+ expect(trx.insert).toHaveBeenCalledWith([
+ { otherId: "newO2", parentId: "newId1" },
+ { otherId: "newO1", parentId: "newId2" }
+ ]);
+ });
+
+ it("should not update references if no replacement is found", async () => {
+ const references = {
+ parents: { p1: "newId1", p2: "newId2" }
+ };
+ const tree = [
+ {
+ name: "entity",
+ table: "entities",
+ links: [
+ { column: "parentId", entityName: "parents", parent: true },
+ { column: "otherId", entityName: "others" }
+ ]
+ }
+ ];
+
+ const entities = [
+ { id: "e1", parentId: "p1", otherId: "o2" },
+ { id: "e2", parentId: "p2", otherId: "o1" }
+ ];
+ trx.orderBy = jest.fn().mockResolvedValue(entities);
+
+ await duplicateTree(trx, tree, references);
+
+ expect(trx.insert).toHaveBeenCalledWith([
+ { otherId: "o2", parentId: "newId1" },
+ { otherId: "o1", parentId: "newId2" }
+ ]);
+ });
+
+ it("should save the references mapping the old to new ids", async () => {
+ const references = { parents: { p1: "newId1", p2: "newId2" } };
+ const tree = [
+ {
+ name: "entity",
+ table: "entities",
+ links: [{ column: "parentId", entityName: "parents", parent: true }]
+ }
+ ];
+
+ const entities = [
+ { id: "e1", parentId: "p1" },
+ { id: "e2", parentId: "p2" }
+ ];
+ trx.orderBy = jest.fn().mockResolvedValue(entities);
+ trx.returning = jest.fn().mockResolvedValue(["newE1", "newE2"]);
+
+ await duplicateTree(trx, tree, references);
+
+ expect(references).toMatchObject({
+ entity: {
+ e1: "newE1",
+ e2: "newE2"
+ }
+ });
+ });
+
+ it("should drop id and timestamps to not duplicate them", async () => {
+ const references = { parents: { p1: "newId1", p2: "newId2" } };
+ const tree = [
+ {
+ name: "entity",
+ table: "entities",
+ links: [{ column: "parentId", entityName: "parents", parent: true }]
+ }
+ ];
+
+ const entities = [
+ {
+ id: "e1",
+ parentId: "p1",
+ other: "bar",
+ createdAt: "createdTime",
+ updatedAt: "updatedTime"
+ },
+ {
+ id: "e2",
+ parentId: "p2",
+ other: "foo",
+ createdAt: "createdTime",
+ updatedAt: "updatedTime"
+ }
+ ];
+ trx.orderBy = jest.fn().mockResolvedValue(entities);
+
+ await duplicateTree(trx, tree, references);
+
+ expect(trx.insert).toHaveBeenCalledWith([
+ { parentId: "newId1", other: "bar" },
+ { parentId: "newId2", other: "foo" }
+ ]);
+ });
+
+ it("should transform the entity with the transform provided before insertion", async () => {
+ const references = { parents: { p1: "newId1", p2: "newId2" } };
+ const tree = [
+ {
+ name: "entity",
+ table: "entities",
+ links: [{ column: "parentId", entityName: "parents", parent: true }],
+ transform: entity => ({
+ ...entity,
+ parentId: `${entity.parentId}foo`
+ })
+ }
+ ];
+
+ const entities = [
+ {
+ id: "e1",
+ parentId: "p1"
+ },
+ {
+ id: "e2",
+ parentId: "p2"
+ }
+ ];
+ trx.orderBy = jest.fn().mockResolvedValue(entities);
+
+ await duplicateTree(trx, tree, references);
+
+ expect(trx.insert).toHaveBeenCalledWith([
+ { parentId: "newId1foo" },
+ { parentId: "newId2foo" }
+ ]);
+ });
+
+ it("should select the child entities based on the parents it inserted and insert with the new references", async () => {
+ const references = { parents: { p1: "newId1", p2: "newId2" } };
+ const tree = [
+ {
+ name: "entity",
+ table: "entities",
+ links: [{ column: "parentId", entityName: "parents", parent: true }]
+ },
+ {
+ name: "child",
+ table: "children",
+ links: [{ column: "entityId", entityName: "entity", parent: true }]
+ }
+ ];
+
+ let orderCount = 0;
+ trx.orderBy = jest.fn().mockImplementation(() => {
+ let result;
+ if (orderCount === 0) {
+ result = [{ id: "e1", parentId: "p1" }, { id: "e2", parentId: "p2" }];
+ } else if (orderCount === 1) {
+ result = [{ id: "c1", entityId: "e1" }, { id: "c2", entityId: "e2" }];
+ }
+ orderCount++;
+ return Promise.resolve(result);
+ });
+
+ let returningCount = 0;
+ trx.returning = jest.fn().mockImplementation(() => {
+ let result;
+ if (returningCount === 0) {
+ result = ["newE1", "newE2"];
+ } else if (orderCount === 1) {
+ result = ["newC1", "newC2"];
+ }
+
+ return Promise.resolve(result);
+ });
+
+ await duplicateTree(trx, tree, references);
+ expect(builder.orWhereIn.mock.calls[1]).toEqual([
+ "entityId",
+ ["e1", "e2"]
+ ]);
+
+ expect(trx.insert.mock.calls[1]).toEqual([
+ [{ entityId: "newE1" }, { entityId: "newE2" }]
+ ]);
+ });
+ });
+});
diff --git a/eq-author-api/repositories/strategies/multipleChoiceOtherAnswerStrategy.js b/eq-author-api/repositories/strategies/multipleChoiceOtherAnswerStrategy.js
new file mode 100644
index 0000000000..8e20e61d3a
--- /dev/null
+++ b/eq-author-api/repositories/strategies/multipleChoiceOtherAnswerStrategy.js
@@ -0,0 +1,82 @@
+const { head, isNil } = require("lodash/fp");
+const getDefaultAnswerProperties = require("../../utils/defaultAnswerProperties");
+
+const findOtherAnswer = async (trx, parentAnswerId) =>
+ trx("Answers")
+ .where({ parentAnswerId, isDeleted: false })
+ .first();
+
+const createAnswer = async (trx, parentAnswerId, type) =>
+ trx("Answers")
+ .insert({
+ properties: getDefaultAnswerProperties(type),
+ description: "",
+ type,
+ parentAnswerId
+ })
+ .returning("*")
+ .then(head);
+
+const deleteAnswer = async (trx, { id }) =>
+ trx("Answers")
+ .where("id", id)
+ .update({
+ isDeleted: true
+ })
+ .returning("*")
+ .then(head);
+
+const createOption = async (trx, { id, parentAnswerId }) =>
+ trx("Options")
+ .insert({
+ answerId: parentAnswerId,
+ otherAnswerId: id
+ })
+ .returning("*")
+ .then(head);
+
+const deleteOption = async (trx, { id }) =>
+ trx("Options")
+ .where("otherAnswerId", id)
+ .update({
+ isDeleted: true
+ })
+ .returning("*")
+ .then(head);
+
+const createOtherAnswerStrategy = async (trx, { id }) => {
+ const existingOtherAnswer = await findOtherAnswer(trx, id);
+
+ if (!isNil(existingOtherAnswer)) {
+ throw new Error(
+ "Cannot add a second 'other' Answer. Delete the existing one first."
+ );
+ }
+
+ const answer = await createAnswer(trx, id, "TextField");
+ const option = await createOption(trx, answer);
+ return {
+ option,
+ answer
+ };
+};
+
+const deleteOtherAnswerStrategy = async (trx, { id }) => {
+ const otherAnswer = await findOtherAnswer(trx, id);
+
+ if (isNil(otherAnswer)) {
+ throw new Error(`Answer with id ${id} does not have an "other" answer.`);
+ }
+
+ const option = await deleteOption(trx, otherAnswer);
+ const answer = await deleteAnswer(trx, otherAnswer);
+ return {
+ option,
+ answer
+ };
+};
+
+module.exports = {
+ createOtherAnswerStrategy,
+ deleteOtherAnswerStrategy
+};
diff --git a/eq-author-api/repositories/strategies/routingStrategy.js b/eq-author-api/repositories/strategies/routingStrategy.js
new file mode 100644
index 0000000000..7c95ea0eff
--- /dev/null
+++ b/eq-author-api/repositories/strategies/routingStrategy.js
@@ -0,0 +1,387 @@
+const { head } = require("lodash/fp");
+const { parseInt, isNil, find, isEmpty, get, includes } = require("lodash");
+
+const updateAllRoutingConditions = (trx, where, values) =>
+ trx("Routing_Conditions")
+ .where(where)
+ .update(values)
+ .returning("*");
+
+const updateRoutingCondition = (
+ trx,
+ id,
+ questionPageId,
+ answerId,
+ comparator
+) =>
+ trx("Routing_Conditions")
+ .where({ id })
+ .update({
+ questionPageId,
+ answerId,
+ comparator
+ })
+ .returning("*")
+ .then(head);
+
+const getFirstAnswer = (trx, questionPageId) =>
+ trx("Answers")
+ .where({ questionPageId, isDeleted: false })
+ .orderBy("id")
+ .first();
+
+const findAnswerByIdAndQuestionPageId = (trx, id, questionPageId) =>
+ trx("Answers")
+ .where({ isDeleted: false })
+ .where({ id, questionPageId })
+ .then(head);
+
+const deleteRoutingConditionValues = (trx, where) =>
+ trx("Routing_ConditionValues")
+ .where(where)
+ .del();
+
+const getPageById = (trx, id) =>
+ trx("PagesView")
+ .where({ id })
+ .first();
+
+const getSectionById = (trx, id) =>
+ trx("Sections")
+ .where({ id, isDeleted: false })
+ .first();
+
+const getPageDestinations = (trx, { sectionId, order }) =>
+ trx("PagesView")
+ .where({ sectionId })
+ .where("order", ">", order);
+
+const getSectionDestinations = (trx, { order, questionnaireId }) =>
+ trx("SectionsView")
+ .select("*")
+ .where({ questionnaireId })
+ .where("order", ">", order);
+
+const findRoutingRuleSet = (trx, questionPageId) =>
+ trx("Routing_RuleSets")
+ .where({ questionPageId, isDeleted: false })
+ .first();
+
+const getRoutingRuleSetById = (trx, routingRuleSetId) => {
+ return trx("Routing_RuleSets")
+ .where({ id: routingRuleSetId, isDeleted: false })
+ .first();
+};
+
+const createSpecificConditionValue = async (trx, conditionId) =>
+ trx("Routing_ConditionValues")
+ .insert({
+ conditionId,
+ customNumber: null
+ })
+ .returning("*")
+ .then(head);
+
+const insertRoutingCondition = async (trx, routingCondition, answer) => {
+ const condition = await trx("Routing_Conditions")
+ .insert(routingCondition)
+ .returning("*")
+ .then(head);
+
+ if (!isNil(answer) && includes(["Currency", "Number"], answer.type)) {
+ await createSpecificConditionValue(trx, condition.id);
+ }
+
+ return condition;
+};
+
+const insertRoutingRule = (trx, routingRule) =>
+ trx("Routing_Rules")
+ .insert(routingRule)
+ .returning("*")
+ .then(head);
+
+const insertRoutingRuleSet = async (
+ trx,
+ { questionPageId, routingDestinationId }
+) =>
+ trx("Routing_RuleSets")
+ .insert({
+ questionPageId: parseInt(questionPageId),
+ routingDestinationId: parseInt(routingDestinationId)
+ })
+
+ .returning("*")
+ .then(head);
+
+const checkAnswerBelongsToPage = async (trx, answerId, questionPageId) => {
+ if (isNil(answerId)) {
+ return;
+ }
+
+ const answer = await findAnswerByIdAndQuestionPageId(
+ trx,
+ answerId,
+ questionPageId
+ );
+ if (!answer) {
+ throw new Error(
+ `Answer ${answerId} does not belong to ${questionPageId}. Choose a different answer.`
+ );
+ }
+};
+
+const getAnswerOrFirstAnswerOnPage = (trx, answerId, questionPageId) => {
+ if (!isNil(answerId)) {
+ return trx("Answers")
+ .select("*")
+ .where({ id: answerId })
+ .then(head);
+ }
+ return getFirstAnswer(trx, questionPageId);
+};
+
+const checkIfPageChange = async (trx, routingConditionId, newAnswerId) => {
+ const routingCondition = await trx("Routing_Conditions")
+ .select("*")
+ .where({ id: routingConditionId })
+ .then(head);
+
+ return routingCondition.answerId !== newAnswerId;
+};
+
+const updateRoutingConditionStrategy = async (
+ trx,
+ { id: routingConditionId, questionPageId, answerId, comparator }
+) => {
+ await checkAnswerBelongsToPage(trx, answerId, questionPageId);
+ const routingConditionAnswer = await getAnswerOrFirstAnswerOnPage(
+ trx,
+ answerId,
+ questionPageId
+ );
+
+ const hasPageChanged = await checkIfPageChange(
+ trx,
+ routingConditionId,
+ routingConditionAnswer.id
+ );
+
+ if (hasPageChanged) {
+ await deleteRoutingConditionValues(trx, {
+ conditionId: routingConditionId
+ });
+ comparator = "Equal";
+ if (
+ !isNil(routingConditionAnswer) &&
+ includes(["Currency", "Number"], routingConditionAnswer.type)
+ ) {
+ await createSpecificConditionValue(trx, routingConditionId);
+ }
+ }
+
+ return updateRoutingCondition(
+ trx,
+ routingConditionId,
+ questionPageId,
+ get(routingConditionAnswer, "id", null),
+ comparator
+ );
+};
+
+const toggleConditionOptionStrategy = async (
+ trx,
+ { conditionId, optionId, checked }
+) => {
+ const table = trx("Routing_ConditionValues");
+ const where = { optionId, conditionId };
+ const existing = await table.where(where);
+
+ if (!isEmpty(existing) && checked) {
+ throw new Error("A condition value already exists");
+ }
+
+ const query = checked
+ ? table.insert({ conditionId, optionId })
+ : table.where({ optionId }).del();
+
+ return query.returning("*").then(head);
+};
+
+async function getAvailableRoutingDestinations(trx, pageId) {
+ const page = await getPageById(trx, pageId);
+ const section = await getSectionById(trx, page.sectionId);
+ const questionPages = await getPageDestinations(trx, page);
+ const sections = await getSectionDestinations(trx, section);
+
+ return {
+ questionPages,
+ sections
+ };
+}
+
+const checkRoutingDestinations = async (
+ availableRoutingDestinations,
+ destination
+) => {
+ const { logicalDestinations } = availableRoutingDestinations;
+ const { logicalDestination, absoluteDestination } = destination;
+
+ if (!isNil(logicalDestination)) {
+ if (
+ !find(logicalDestinations, {
+ logicalDestination: logicalDestination.destinationType
+ })
+ ) {
+ throw new Error(
+ `Unable to route from this question ${
+ logicalDestination.destinationType
+ }`
+ );
+ }
+ }
+
+ if (!isNil(absoluteDestination)) {
+ const { destinationType, destinationId } = absoluteDestination;
+ const key =
+ destinationType === "QuestionPage" ? "questionPages" : "sections";
+ if (
+ !find(availableRoutingDestinations[key], { id: parseInt(destinationId) })
+ ) {
+ throw new Error(
+ `Unable to route from this question to ${destinationType} ${destinationId}`
+ );
+ }
+ }
+};
+
+async function createRoutingConditionStrategy(trx, routingCondition) {
+ const { questionPageId, answerId } = routingCondition;
+
+ let firstAnswerOnPage;
+ if (isNil(answerId)) {
+ firstAnswerOnPage = await getFirstAnswer(trx, questionPageId);
+ }
+
+ const targetAnswerId = get(firstAnswerOnPage, "id", answerId);
+
+ let targetAnswer;
+ if (!isNil(targetAnswerId)) {
+ targetAnswer = await trx("Answers")
+ .select()
+ .where({ id: targetAnswerId })
+ .first();
+ } else {
+ targetAnswer = null;
+ }
+
+ return insertRoutingCondition(
+ trx,
+ {
+ ...routingCondition,
+ answerId: targetAnswerId
+ },
+ targetAnswer
+ );
+}
+
+const createRoutingDestination = async trx =>
+ trx("Routing_Destinations")
+ .insert({})
+ .returning("*")
+ .then(head);
+
+async function createRoutingRuleStrategy(
+ trx,
+ { routingRuleSetId, operation = "And" }
+) {
+ const routingDestination = await createRoutingDestination(trx);
+
+ const routingRule = await insertRoutingRule(trx, {
+ operation,
+ routingRuleSetId,
+ routingDestinationId: routingDestination.id
+ });
+
+ const routingRuleSet = await getRoutingRuleSetById(
+ trx,
+ routingRule.routingRuleSetId
+ );
+
+ await createRoutingConditionStrategy(trx, {
+ comparator: "Equal",
+ routingRuleId: routingRule.id,
+ questionPageId: routingRuleSet.questionPageId
+ });
+
+ return routingRule;
+}
+
+async function createRoutingRuleSetStrategy(trx, questionPageId) {
+ const existingRuleSet = await findRoutingRuleSet(trx, questionPageId);
+ if (!isNil(existingRuleSet)) {
+ throw new Error(
+ `Cannot add a second RoutingRuleSet to question ${questionPageId}. Delete the existing one first.`
+ );
+ }
+
+ const routingDestination = await createRoutingDestination(trx);
+ const routingRuleSetDefaults = {
+ questionPageId,
+ routingDestinationId: routingDestination.id
+ };
+
+ const routingRuleSet = await insertRoutingRuleSet(
+ trx,
+ routingRuleSetDefaults
+ );
+
+ const routingRuleInput = {
+ routingRuleSetId: routingRuleSet.id
+ };
+
+ await createRoutingRuleStrategy(trx, routingRuleInput);
+ return routingRuleSet;
+}
+
+const handlePageDeleted = (trx, pageId) =>
+ updateAllRoutingConditions(
+ trx,
+ {
+ questionPageId: parseInt(pageId)
+ },
+ {
+ questionPageId: null,
+ answerId: null
+ }
+ );
+
+const handleAnswerDeleted = (trx, answerId) =>
+ updateAllRoutingConditions(
+ trx,
+ {
+ answerId: parseInt(answerId)
+ },
+ {
+ questionPageId: null,
+ answerId: null
+ }
+ );
+
+const handleOptionDeleted = (trx, optionId) =>
+ deleteRoutingConditionValues(trx, {
+ optionId: parseInt(optionId)
+ });
+
+Object.assign(module.exports, {
+ checkRoutingDestinations,
+ getAvailableRoutingDestinations,
+ toggleConditionOptionStrategy,
+ updateRoutingConditionStrategy,
+ createRoutingRuleSetStrategy,
+ createRoutingRuleStrategy,
+ createRoutingConditionStrategy,
+ handlePageDeleted,
+ handleAnswerDeleted,
+ handleOptionDeleted
+});
diff --git a/eq-author-api/repositories/strategies/routingStrategy.test.js b/eq-author-api/repositories/strategies/routingStrategy.test.js
new file mode 100644
index 0000000000..eb4162f00c
--- /dev/null
+++ b/eq-author-api/repositories/strategies/routingStrategy.test.js
@@ -0,0 +1,26 @@
+const { checkRoutingDestinations } = require("./routingStrategy");
+
+describe("repositories / strategies / routingStrategy", () => {
+ it("should error when trying to navigate to a logical destination that doesn't exist", () => {
+ const availableRoutingDestinations = {
+ logicalDestinations: [
+ {
+ logicalDestination: "NextPage"
+ },
+ {
+ logicalDestination: "Summary"
+ }
+ ]
+ };
+
+ const destination = {
+ logicalDestination: {
+ destinationType: "DoesNotExist"
+ }
+ };
+
+ expect(
+ checkRoutingDestinations(availableRoutingDestinations, destination)
+ ).rejects.toThrowError();
+ });
+});
diff --git a/eq-author-api/repositories/strategies/spacedOrderStrategy.js b/eq-author-api/repositories/strategies/spacedOrderStrategy.js
new file mode 100644
index 0000000000..a753913298
--- /dev/null
+++ b/eq-author-api/repositories/strategies/spacedOrderStrategy.js
@@ -0,0 +1,113 @@
+const {
+ isEmpty,
+ flow,
+ last,
+ clamp,
+ getOr,
+ isNil,
+ reject
+} = require("lodash/fp");
+
+const SPACING = 1000;
+
+const calculateMidPoint = (a, b) => Math.round((a + b) / 2);
+
+const valuesHaveConverged = (orderBefore, orderAfter) =>
+ Math.abs(orderAfter - orderBefore) < 2;
+
+const getMaxOrder = flow(
+ last,
+ getOr(0, "order")
+);
+
+const getPagesBySection = (trx, sectionId) =>
+ trx("PagesView")
+ .columns("id", "order")
+ .where({ sectionId })
+ .orderBy("order");
+
+const getSectionsByQuestionnaire = (trx, questionnaireId) =>
+ trx("SectionsView")
+ .columns("id", "order")
+ .where({ questionnaireId })
+ .orderBy("order");
+
+const makeSpaceForInsert = (trx, tableName, parentId, order) => {
+ const key = tableName === "Sections" ? "questionnaireId" : "sectionId";
+ return trx(tableName)
+ .where({ [key]: parentId })
+ .andWhere("order", ">", order)
+ .increment("order", SPACING);
+};
+
+const getOrUpdateOrderForInsert = async (
+ trx,
+ collection,
+ type,
+ parentId,
+ position
+) => {
+ const maxOrder = getMaxOrder(collection) + SPACING;
+
+ position = isNil(position)
+ ? collection.length
+ : clamp(0, collection.length, position);
+
+ if (isEmpty(collection)) {
+ return SPACING;
+ }
+ if (position === collection.length) {
+ return maxOrder;
+ }
+
+ collection.splice(position, 0, { id: "dummyElement" });
+
+ let left = getOr(0, "order", collection[position - 1]);
+ let right = getOr(maxOrder, "order", collection[position + 1]);
+
+ if (valuesHaveConverged(left, right)) {
+ await makeSpaceForInsert(trx, type, parentId, left);
+ right += SPACING;
+ }
+
+ return calculateMidPoint(left, right);
+};
+
+const getOrUpdateOrderForPageInsert = async (
+ trx,
+ sectionId,
+ movingPageId,
+ position
+) => {
+ const pages = reject(
+ { id: parseInt(movingPageId, 10) },
+ await getPagesBySection(trx, sectionId)
+ );
+
+ return getOrUpdateOrderForInsert(trx, pages, "Pages", sectionId, position);
+};
+
+const getOrUpdateOrderForSectionInsert = async (
+ trx,
+ questionnaireId,
+ id,
+ position
+) => {
+ const sections = reject(
+ { id: parseInt(id, 10) },
+ await getSectionsByQuestionnaire(trx, questionnaireId)
+ );
+
+ return getOrUpdateOrderForInsert(
+ trx,
+ sections,
+ "Sections",
+ questionnaireId,
+ position
+ );
+};
+
+module.exports = {
+ getOrUpdateOrderForPageInsert,
+ getOrUpdateOrderForSectionInsert
+};
diff --git a/eq-author-api/repositories/strategies/validationStrategy.js b/eq-author-api/repositories/strategies/validationStrategy.js
new file mode 100644
index 0000000000..f79ae107e9
--- /dev/null
+++ b/eq-author-api/repositories/strategies/validationStrategy.js
@@ -0,0 +1,33 @@
+const {
+ answerTypeMap,
+ validationRuleMap,
+ defaultValidationRuleConfigs,
+ defaultValidationEntityTypes
+} = require("../../utils/defaultAnswerValidations");
+const db = require("../../db");
+
+const { findKey, includes } = require("lodash");
+
+const getValidationEntity = type =>
+ findKey(answerTypeMap, field => includes(field, type));
+
+const createDefaultValidationsForAnswer = async ({ id, type }, trx = db) => {
+ const validationEntity = getValidationEntity(type);
+
+ const validationTypes = validationRuleMap[validationEntity];
+
+ const promises = validationTypes.map(validationType => {
+ return trx("Validation_AnswerRules").insert({
+ answerId: id,
+ validationType,
+ config: defaultValidationRuleConfigs[validationType],
+ entityType: defaultValidationEntityTypes[validationType].entityType
+ });
+ });
+ await Promise.all(promises);
+};
+
+Object.assign(module.exports, {
+ createDefaultValidationsForAnswer,
+ getValidationEntity
+});
diff --git a/eq-author-api/schema/index.js b/eq-author-api/schema/index.js
new file mode 100644
index 0000000000..c9de82ef0c
--- /dev/null
+++ b/eq-author-api/schema/index.js
@@ -0,0 +1,11 @@
+const graphqlTools = require("graphql-tools");
+const typeDefs = require("eq-author-graphql-schema");
+const resolvers = require("./resolvers");
+
+/**
+* This is where all the GraphQL schema types will be defined.
+*/
+module.exports = graphqlTools.makeExecutableSchema({
+ typeDefs,
+ resolvers
+});
diff --git a/eq-author-api/schema/resolvers.js b/eq-author-api/schema/resolvers.js
new file mode 100644
index 0000000000..d5a3210fcb
--- /dev/null
+++ b/eq-author-api/schema/resolvers.js
@@ -0,0 +1,526 @@
+const { GraphQLDate } = require("graphql-iso-date");
+const { includes, isNil } = require("lodash");
+const GraphQLJSON = require("graphql-type-json");
+const { getName } = require("../utils/getName");
+const formatRichText = require("../utils/formatRichText");
+const {
+ getValidationEntity
+} = require("../repositories/strategies/validationStrategy");
+
+const assertMultipleChoiceAnswer = answer => {
+ if (isNil(answer) || !includes(["Checkbox", "Radio"], answer.type)) {
+ throw new Error(
+ `Answer with id '${answer.id}' must be a Checkbox or Radio.`
+ );
+ }
+};
+
+const Resolvers = {
+ Query: {
+ questionnaires: (_, args, ctx) => ctx.repositories.Questionnaire.findAll(),
+ questionnaire: (root, { id }, ctx) =>
+ ctx.repositories.Questionnaire.getById(id),
+ section: (parent, { id }, ctx) => ctx.repositories.Section.getById(id),
+ page: (parent, { id }, ctx) => ctx.repositories.Page.getById(id),
+ questionPage: (_, { id }, ctx) => ctx.repositories.QuestionPage.getById(id),
+ answer: (root, { id }, ctx) => ctx.repositories.Answer.getById(id),
+ answers: async (root, { ids }, ctx) =>
+ ctx.repositories.Answer.getAnswers(ids),
+ option: (root, { id }, ctx) => ctx.repositories.Option.getById(id),
+ availableRoutingDestinations: (root, { pageId }, ctx) =>
+ ctx.repositories.Routing.getRoutingDestinations(pageId)
+ },
+
+ Mutation: {
+ createQuestionnaire: async (root, args, ctx) => {
+ const questionnaire = await ctx.repositories.Questionnaire.insert(
+ args.input
+ );
+ const section = {
+ title: "",
+ questionnaireId: questionnaire.id
+ };
+
+ await Resolvers.Mutation.createSection(root, { input: section }, ctx);
+ return questionnaire;
+ },
+ updateQuestionnaire: (_, args, ctx) =>
+ ctx.repositories.Questionnaire.update(args.input),
+ deleteQuestionnaire: (_, args, ctx) =>
+ ctx.repositories.Questionnaire.remove(args.input.id),
+ undeleteQuestionnaire: (_, args, ctx) =>
+ ctx.repositories.Questionnaire.undelete(args.input.id),
+ duplicateQuestionnaire: (_, args, ctx) =>
+ ctx.repositories.Questionnaire.duplicate(
+ args.input.id,
+ args.input.createdBy
+ ),
+
+ createSection: async (root, args, ctx) => {
+ const section = await ctx.repositories.Section.insert(args.input);
+ const page = {
+ pageType: "QuestionPage",
+ title: "",
+ description: "",
+ sectionId: section.id
+ };
+
+ await Resolvers.Mutation.createPage(root, { input: page }, ctx);
+ return section;
+ },
+ updateSection: (_, { input }, ctx) =>
+ ctx.repositories.Section.update(input),
+ deleteSection: (_, args, ctx) =>
+ ctx.repositories.Section.remove(args.input.id),
+ undeleteSection: (_, args, ctx) =>
+ ctx.repositories.Section.undelete(args.input.id),
+ moveSection: (_, args, ctx) => ctx.repositories.Section.move(args.input),
+ duplicateSection: (_, args, ctx) =>
+ ctx.repositories.Section.duplicateSection(
+ args.input.id,
+ args.input.position
+ ),
+
+ createPage: (root, args, ctx) => ctx.repositories.Page.insert(args.input),
+
+ updatePage: (_, args, ctx) => ctx.repositories.Page.update(args.input),
+ deletePage: (_, args, ctx) => ctx.repositories.Page.remove(args.input.id),
+ undeletePage: (_, args, ctx) =>
+ ctx.repositories.Page.undelete(args.input.id),
+ movePage: (_, args, ctx) => ctx.repositories.Page.move(args.input),
+ duplicatePage: (_, args, ctx) =>
+ ctx.repositories.Page.duplicatePage(args.input.id, args.input.position),
+
+ createQuestionPage: (root, args, ctx) =>
+ ctx.repositories.Page.insert(
+ Object.assign({}, args.input, { pageType: "QuestionPage" })
+ ),
+ updateQuestionPage: (_, args, ctx) =>
+ ctx.repositories.QuestionPage.update(args.input),
+ deleteQuestionPage: (_, args, ctx) =>
+ ctx.repositories.QuestionPage.remove(args.input.id),
+ undeleteQuestionPage: (_, args, ctx) =>
+ ctx.repositories.QuestionPage.undelete(args.input.id),
+
+ createAnswer: (root, args, ctx) =>
+ ctx.repositories.Answer.createAnswer(args.input),
+ updateAnswer: (_, args, ctx) => ctx.repositories.Answer.update(args.input),
+ deleteAnswer: (_, args, ctx) =>
+ ctx.repositories.Answer.remove(args.input.id),
+ undeleteAnswer: (_, args, ctx) =>
+ ctx.repositories.Answer.undelete(args.input.id),
+
+ createOption: (root, args, ctx) =>
+ ctx.repositories.Option.insert(args.input),
+ createMutuallyExclusiveOption: (root, { input }, ctx) =>
+ ctx.repositories.Option.insert({ mutuallyExclusive: true, ...input }),
+ updateOption: (_, args, ctx) => ctx.repositories.Option.update(args.input),
+ deleteOption: (_, args, ctx) =>
+ ctx.repositories.Option.remove(args.input.id),
+ undeleteOption: (_, args, ctx) =>
+ ctx.repositories.Option.undelete(args.input.id),
+ createOther: async (root, args, ctx) => {
+ const parentAnswer = await ctx.repositories.Answer.getById(
+ args.input.parentAnswerId
+ );
+ assertMultipleChoiceAnswer(parentAnswer);
+ return ctx.repositories.Answer.createOtherAnswer(parentAnswer);
+ },
+ deleteOther: async (_, args, ctx) => {
+ const parentAnswer = await ctx.repositories.Answer.getById(
+ args.input.parentAnswerId
+ );
+ assertMultipleChoiceAnswer(parentAnswer);
+ return ctx.repositories.Answer.deleteOtherAnswer(parentAnswer);
+ },
+ createRoutingRuleSet: async (root, args, ctx) =>
+ ctx.repositories.Routing.createRoutingRuleSet(args.input),
+ updateRoutingRuleSet: (_, args, ctx) =>
+ ctx.repositories.Routing.updateRoutingRuleSet(args.input),
+ deleteRoutingRuleSet: (_, args, ctx) =>
+ ctx.repositories.Routing.deleteRoutingRuleSet(args.input),
+ resetRoutingRuleSetElse: (_, args, ctx) => {
+ return ctx.repositories.Routing.updateRoutingRuleSet(args.input);
+ },
+ createRoutingRule: async (_, args, ctx) =>
+ ctx.repositories.Routing.createRoutingRule(args.input),
+ updateRoutingRule: (_, args, ctx) =>
+ ctx.repositories.Routing.updateRoutingRule(args.input),
+ deleteRoutingRule: (_, args, ctx) =>
+ ctx.repositories.Routing.removeRoutingRule(args.input),
+ undeleteRoutingRule: (_, args, ctx) =>
+ ctx.repositories.Routing.undeleteRoutingRule(args.input),
+ createRoutingCondition: (_, args, ctx) =>
+ ctx.repositories.Routing.createRoutingCondition(args.input),
+ updateRoutingCondition: (_, args, ctx) =>
+ ctx.repositories.Routing.updateRoutingCondition(args.input),
+ deleteRoutingCondition: (_, args, ctx) =>
+ ctx.repositories.Routing.removeRoutingCondition(args.input),
+ toggleConditionOption: async (_, args, ctx) =>
+ ctx.repositories.Routing.toggleConditionOption(args.input),
+ createConditionValue: async (_, args, ctx) =>
+ ctx.repositories.Routing.createConditionValue(args.input),
+ updateConditionValue: async (_, args, ctx) =>
+ ctx.repositories.Routing.updateConditionValue(args.input),
+ toggleValidationRule: (_, args, ctx) =>
+ ctx.repositories.Validation.toggleValidationRule(args.input),
+ updateValidationRule: (_, args, ctx) =>
+ ctx.repositories.Validation.updateValidationRule(args.input),
+ createMetadata: (root, args, ctx) =>
+ ctx.repositories.Metadata.insert(args.input),
+ updateMetadata: (_, args, ctx) =>
+ ctx.repositories.Metadata.update(args.input),
+ deleteMetadata: (_, args, ctx) =>
+ ctx.repositories.Metadata.remove(args.input.id)
+ },
+
+ Questionnaire: {
+ sections: (questionnaire, args, ctx) =>
+ ctx.repositories.Section.findAll({ questionnaireId: questionnaire.id }),
+ createdBy: questionnaire => ({ name: questionnaire.createdBy }),
+ questionnaireInfo: ({ id }) => id,
+ metadata: (questionnaire, args, ctx) =>
+ ctx.repositories.Metadata.findAll({ questionnaireId: questionnaire.id })
+ },
+
+ QuestionnaireInfo: {
+ totalSectionCount: (questionnaireId, args, ctx) =>
+ ctx.repositories.Section.getSectionCount(questionnaireId)
+ },
+
+ Section: {
+ pages: (section, args, ctx) =>
+ ctx.repositories.Page.findAll({ sectionId: section.id }),
+ questionnaire: (section, args, ctx) =>
+ ctx.repositories.Questionnaire.getById(section.questionnaireId),
+ displayName: section => getName(section, "Section"),
+ title: (page, args) => formatRichText(page.title, args.format),
+ position: ({ position, id }, args, ctx) => {
+ if (position !== undefined) {
+ return position;
+ }
+ return ctx.repositories.Section.getPosition({ id });
+ }
+ },
+ Page: {
+ __resolveType: ({ pageType }) => pageType,
+ position: ({ position, id }, args, ctx) => {
+ if (position !== undefined) {
+ return position;
+ }
+
+ return ctx.repositories.Page.getPosition({ id });
+ }
+ },
+
+ QuestionPage: {
+ answers: ({ id }, args, ctx) =>
+ ctx.repositories.Answer.findAll({
+ questionPageId: id
+ }),
+ section: ({ sectionId }, args, ctx) => {
+ return ctx.repositories.Section.getById(sectionId);
+ },
+ position: (page, args, ctx) => Resolvers.Page.position(page, args, ctx),
+ routingRuleSet: ({ id: questionPageId }, args, ctx) =>
+ ctx.repositories.Routing.findRoutingRuleSetByQuestionPageId({
+ questionPageId
+ }),
+ displayName: page => getName(page, "QuestionPage"),
+ title: (page, args) => formatRichText(page.title, args.format)
+ },
+
+ RoutingRuleSet: {
+ routingRules: ({ id }, args, ctx) => {
+ return ctx.repositories.Routing.findAllRoutingRules({
+ routingRuleSetId: id
+ });
+ },
+ questionPage: ({ questionPageId }, args, ctx) => {
+ return ctx.repositories.Page.getById(questionPageId);
+ },
+ else: ({ routingDestinationId }, args, ctx) =>
+ ctx.repositories.Routing.getRoutingDestination(routingDestinationId)
+ },
+
+ RoutingRule: {
+ conditions: ({ id }, args, ctx) => {
+ return ctx.repositories.Routing.findAllRoutingConditions({
+ routingRuleId: id
+ });
+ },
+ goto: (routingRule, args, ctx) =>
+ ctx.repositories.Routing.getRoutingDestination(
+ routingRule.routingDestinationId
+ )
+ },
+
+ RoutingCondition: {
+ routingValue: ({ id, answerId }) => {
+ return { conditionId: id, answerId };
+ },
+ questionPage: ({ questionPageId }, args, ctx) => {
+ return isNil(questionPageId)
+ ? null
+ : ctx.repositories.Page.getById(questionPageId);
+ },
+ answer: ({ answerId }, args, ctx) => {
+ return isNil(answerId) ? null : ctx.repositories.Answer.getById(answerId);
+ }
+ },
+
+ RoutingConditionValue: {
+ __resolveType: async ({ conditionId }, ctx) => {
+ const answerType = await ctx.repositories.Routing.getAnswerTypeByConditionId(
+ conditionId,
+ ctx
+ );
+ if (includes(["Currency", "Number"], answerType)) {
+ return "NumberValue";
+ } else {
+ return "IDArrayValue";
+ }
+ }
+ },
+
+ IDArrayValue: {
+ value: async ({ conditionId }, args, ctx) => {
+ const conditionValues = await ctx.repositories.Routing.findAllRoutingConditionValues(
+ {
+ conditionId
+ }
+ );
+ return conditionValues.map(conditionValue => conditionValue.optionId);
+ }
+ },
+
+ NumberValue: {
+ id: async ({ conditionId }, args, ctx) => {
+ const conditionValues = await ctx.repositories.Routing.findAllRoutingConditionValues(
+ { conditionId }
+ );
+ return conditionValues[0].id;
+ },
+ numberValue: async ({ conditionId }, args, ctx) => {
+ const conditionValues = await ctx.repositories.Routing.findAllRoutingConditionValues(
+ { conditionId }
+ );
+ return conditionValues[0].customNumber;
+ }
+ },
+
+ RoutingDestination: {
+ __resolveType: ({ logicalDestination }) => {
+ return isNil(logicalDestination)
+ ? "AbsoluteDestination"
+ : "LogicalDestination";
+ }
+ },
+
+ AbsoluteDestinations: {
+ __resolveType: ({ pageType }) => {
+ if (pageType) {
+ return "QuestionPage";
+ } else {
+ return "Section";
+ }
+ }
+ },
+
+ LogicalDestination: {
+ id: destination => destination.logicalDestination
+ },
+
+ Answer: {
+ __resolveType: ({ type }) => {
+ if (includes(["Checkbox", "Radio"], type)) {
+ return "MultipleChoiceAnswer";
+ } else if (includes(["DateRange"], type)) {
+ return "CompositeAnswer";
+ } else {
+ return "BasicAnswer";
+ }
+ }
+ },
+
+ BasicAnswer: {
+ page: (answer, args, ctx) =>
+ ctx.repositories.QuestionPage.getById(answer.questionPageId),
+ validation: answer =>
+ ["number", "date"].includes(getValidationEntity(answer.type))
+ ? answer
+ : null,
+ displayName: answer => getName(answer, "BasicAnswer")
+ },
+
+ CompositeAnswer: {
+ childAnswers: (answer, args, ctx) =>
+ ctx.repositories.Answer.splitComposites(answer),
+ page: (answer, args, ctx) =>
+ ctx.repositories.QuestionPage.getById(answer.questionPageId),
+ displayName: answer => getName(answer, "CompositeAnswer")
+ },
+
+ MultipleChoiceAnswer: {
+ page: (answer, args, ctx) =>
+ ctx.repositories.QuestionPage.getById(answer.questionPageId),
+ options: (answer, args, ctx) =>
+ ctx.repositories.Option.findAll({
+ answerId: answer.id,
+ mutuallyExclusive: false
+ }),
+ mutuallyExclusiveOption: (answer, args, ctx) =>
+ ctx.repositories.Option.findExclusiveOptionByAnswerId(answer.id),
+ other: async ({ id }, args, ctx) => {
+ const answer = await ctx.repositories.Answer.getOtherAnswer(id);
+
+ if (isNil(answer)) {
+ return null;
+ }
+
+ const option = await ctx.repositories.Option.getOtherOption(answer.id);
+
+ if (isNil(option)) {
+ return null;
+ }
+
+ return {
+ answer,
+ option
+ };
+ },
+ displayName: answer => getName(answer, "MultipleChoiceAnswer")
+ },
+
+ Option: {
+ answer: ({ answerId }, args, ctx) =>
+ ctx.repositories.Answer.getById(answerId),
+ displayName: option => getName(option, "Option")
+ },
+
+ ValidationType: {
+ __resolveType: answer => {
+ const validationEntity = getValidationEntity(answer.type);
+
+ switch (validationEntity) {
+ case "number":
+ return "NumberValidation";
+ case "date":
+ return "DateValidation";
+
+ default:
+ throw new TypeError(
+ `Validation is not supported on '${answer.type}' answers`
+ );
+ }
+ }
+ },
+
+ ValidationRule: {
+ __resolveType: ({ validationType }) => {
+ switch (validationType) {
+ case "maxValue":
+ return "MaxValueValidationRule";
+ case "minValue":
+ return "MinValueValidationRule";
+ case "earliestDate":
+ return "EarliestDateValidationRule";
+ case "latestDate":
+ return "LatestDateValidationRule";
+
+ default:
+ throw new TypeError(
+ `Validation is not supported on '${validationType}' answers`
+ );
+ }
+ }
+ },
+
+ NumberValidation: {
+ minValue: (answer, args, ctx) =>
+ ctx.repositories.Validation.findByAnswerIdAndValidationType(
+ answer,
+ "minValue"
+ ),
+ maxValue: (answer, args, ctx) =>
+ ctx.repositories.Validation.findByAnswerIdAndValidationType(
+ answer,
+ "maxValue"
+ )
+ },
+
+ DateValidation: {
+ earliestDate: (answer, args, ctx) =>
+ ctx.repositories.Validation.findByAnswerIdAndValidationType(
+ answer,
+ "earliestDate"
+ ),
+ latestDate: (answer, args, ctx) =>
+ ctx.repositories.Validation.findByAnswerIdAndValidationType(
+ answer,
+ "latestDate"
+ )
+ },
+
+ MinValueValidationRule: {
+ enabled: ({ enabled }) => enabled,
+ inclusive: ({ config }) => config.inclusive,
+ custom: ({ custom }) => custom
+ },
+
+ MaxValueValidationRule: {
+ enabled: ({ enabled }) => enabled,
+ inclusive: ({ config }) => config.inclusive,
+ custom: ({ custom }) => custom,
+ entityType: ({ entityType }) => entityType,
+ previousAnswer: ({ previousAnswerId }, args, ctx) =>
+ isNil(previousAnswerId)
+ ? null
+ : ctx.repositories.Answer.getById(previousAnswerId)
+ },
+
+ EarliestDateValidationRule: {
+ custom: ({ custom }) => (custom ? new Date(custom) : null),
+ offset: ({ config: { offset } }) => offset,
+ relativePosition: ({ config: { relativePosition } }) => relativePosition,
+ entityType: ({ entityType }) => entityType,
+ previousAnswer: ({ previousAnswerId }, args, ctx) =>
+ isNil(previousAnswerId)
+ ? null
+ : ctx.repositories.Answer.getById(previousAnswerId),
+ metadata: ({ metadataId }, args, ctx) =>
+ isNil(metadataId) ? null : ctx.repositories.Metadata.getById(metadataId)
+ },
+
+ LatestDateValidationRule: {
+ custom: ({ custom }) => (custom ? new Date(custom) : null),
+ offset: ({ config: { offset } }) => offset,
+ relativePosition: ({ config: { relativePosition } }) => relativePosition,
+ entityType: ({ entityType }) => entityType,
+ previousAnswer: ({ previousAnswerId }, args, ctx) =>
+ isNil(previousAnswerId)
+ ? null
+ : ctx.repositories.Answer.getById(previousAnswerId),
+ metadata: ({ metadataId }, args, ctx) =>
+ isNil(metadataId) ? null : ctx.repositories.Metadata.getById(metadataId)
+ },
+
+ Metadata: {
+ textValue: ({ type, value }) => (type === "Text" ? value : null),
+ languageValue: ({ type, value }) => (type === "Language" ? value : null),
+ regionValue: ({ type, value }) => (type === "Region" ? value : null),
+ dateValue: ({ type, value }) => {
+ if (type !== "Date" || !value) {
+ return null;
+ }
+ return new Date(value);
+ },
+ displayName: metadata => getName(metadata, "Metadata")
+ },
+
+ Date: GraphQLDate,
+
+ JSON: GraphQLJSON
+};
+
+module.exports = Resolvers;
diff --git a/eq-author-api/schema/resolvers.test.js b/eq-author-api/schema/resolvers.test.js
new file mode 100644
index 0000000000..387dd64d7a
--- /dev/null
+++ b/eq-author-api/schema/resolvers.test.js
@@ -0,0 +1,1695 @@
+const { first, get } = require("lodash");
+const repositories = require("../repositories");
+const db = require("../db");
+const executeQuery = require("../tests/utils/executeQuery");
+const {
+ createQuestionnaireMutation,
+ getPipableAnswersQuery,
+ createAnswerMutation,
+ createOtherMutation,
+ deleteOtherMutation,
+ getAnswerQuery,
+ getPageQuery,
+ getAnswersQuery,
+ updateAnswerMutation,
+ createRoutingRuleSet,
+ updateRoutingRule,
+ toggleConditionOption,
+ getEntireRoutingStructure,
+ updateConditionValue,
+ getBasicRoutingQuery,
+ updateCondition,
+ createSectionMutation,
+ createQuestionPageMutation,
+ getAvailableRoutingDestinations,
+ updateRoutingRuleSet,
+ deleteRoutingRuleSet,
+ createRoutingRule,
+ deleteRoutingRule,
+ createRoutingCondition,
+ deleteRoutingCondition,
+ deletePageMutation,
+ deleteAnswerMutation,
+ deleteOptionMutation,
+ moveSectionMutation,
+ createExclusiveMutation
+} = require("../tests/utils/graphql");
+
+const ctx = { repositories };
+
+const createNewQuestionnaire = async () => {
+ const input = {
+ title: "Test Questionnaire",
+ description: "Questionnaire created by integration test.",
+ theme: "default",
+ legalBasis: "Voluntary",
+ navigation: false,
+ surveyId: "001",
+ summary: true,
+ createdBy: "Integration test"
+ };
+
+ const result = await executeQuery(
+ createQuestionnaireMutation,
+ { input },
+ ctx
+ );
+ return result.data.createQuestionnaire;
+};
+
+const createSection = async questionnaireId =>
+ executeQuery(
+ createSectionMutation,
+ {
+ input: {
+ title: "Foo",
+ questionnaireId: questionnaireId
+ }
+ },
+ ctx
+ );
+
+const createNewAnswer = async ({ id: pageId }, type) => {
+ const input = {
+ description: "",
+ guidance: "",
+ label: `${type} answer`,
+ qCode: null,
+ type: `${type}`,
+ questionPageId: pageId
+ };
+
+ const result = await executeQuery(createAnswerMutation, { input }, ctx);
+ return result.data.createAnswer;
+};
+
+const createNewOtherMutation = async answer =>
+ executeQuery(
+ createOtherMutation,
+ { input: { parentAnswerId: answer.id } },
+ ctx
+ );
+
+const createOther = async answer => {
+ const result = await createNewOtherMutation(answer);
+ return result.data.createOther;
+};
+
+const createExclusive = async answer => {
+ const result = await executeQuery(
+ createExclusiveMutation,
+ { input: { answerId: answer.id } },
+ ctx
+ );
+ return result;
+};
+
+const deleteOther = async answer =>
+ executeQuery(
+ deleteOtherMutation,
+ {
+ input: {
+ parentAnswerId: answer.id
+ }
+ },
+ ctx
+ );
+
+const deleteQuestionPage = async input =>
+ executeQuery(
+ deletePageMutation,
+ {
+ input
+ },
+ ctx
+ );
+
+const updateAnswer = async args =>
+ executeQuery(
+ updateAnswerMutation,
+ {
+ input: args
+ },
+ ctx
+ );
+
+const deleteAnswer = async ({ id }) =>
+ executeQuery(
+ deleteAnswerMutation,
+ {
+ input: {
+ id
+ }
+ },
+ ctx
+ );
+
+const deleteOption = async input =>
+ executeQuery(
+ deleteOptionMutation,
+ {
+ input
+ },
+ ctx
+ );
+
+const createThenDeleteOther = async (page, type) => {
+ const answer = await createNewAnswer(page, type);
+ await createOther(answer);
+ await deleteOther(answer);
+
+ return answer;
+};
+
+const createNewRoutingRuleSet = async questionPageId => {
+ return executeQuery(
+ createRoutingRuleSet,
+ {
+ input: {
+ questionPageId
+ }
+ },
+ ctx
+ );
+};
+
+const deleteRoutingRuleSetMutation = async id => {
+ return executeQuery(
+ deleteRoutingRuleSet,
+ {
+ input: {
+ id
+ }
+ },
+ ctx
+ );
+};
+
+const createNewRoutingRule = async input => {
+ return executeQuery(
+ createRoutingRule,
+ {
+ input
+ },
+ ctx
+ );
+};
+
+const createNewRoutingCondition = async input => {
+ return executeQuery(
+ createRoutingCondition,
+ {
+ input
+ },
+ ctx
+ );
+};
+
+const updateRoutingRuleSetMutation = async input =>
+ executeQuery(
+ updateRoutingRuleSet,
+ {
+ input
+ },
+ ctx
+ );
+
+const updateRoutingRuleMutation = (destinationType, destinationId, id) =>
+ executeQuery(
+ updateRoutingRule,
+ {
+ input: {
+ id,
+ operation: "And",
+ goto: {
+ absoluteDestination: {
+ destinationType,
+ destinationId
+ }
+ }
+ }
+ },
+ ctx
+ );
+
+const deleteRoutingRuleMutation = async input =>
+ executeQuery(
+ deleteRoutingRule,
+ {
+ input
+ },
+ ctx
+ );
+
+const deleteRoutingConditionMutation = async input =>
+ executeQuery(
+ deleteRoutingCondition,
+ {
+ input
+ },
+ ctx
+ );
+
+const updateConditionValueMutation = async ({ id, customNumber }) =>
+ executeQuery(updateConditionValue, { input: { id, customNumber } }, ctx);
+
+const toggleConditionOptionMutation = async (
+ conditionId,
+ checked,
+ optionId = null
+) =>
+ executeQuery(
+ toggleConditionOption,
+ {
+ input: {
+ conditionId,
+ optionId,
+ checked
+ }
+ },
+ ctx
+ );
+
+const changeRoutingConditionMutation = async input =>
+ executeQuery(
+ updateCondition,
+ {
+ input
+ },
+ ctx
+ );
+
+const changeRoutingCondition = async (firstPage, answer, conditionId) => {
+ const RoutingCondition = await changeRoutingConditionMutation({
+ id: conditionId,
+ questionPageId: firstPage.id,
+ answerId: answer.id
+ });
+ return RoutingCondition.data;
+};
+
+const createFullRoutingTree = async firstPage => {
+ await createNewRoutingRuleSet(firstPage.id);
+ const result = await executeQuery(
+ getBasicRoutingQuery,
+ { id: firstPage.id },
+ ctx
+ );
+ const routingConditionId = get(
+ result,
+ "data.page.routingRuleSet.routingRules[0].conditions[0].id"
+ );
+
+ const answer = await createNewAnswer(firstPage, "Checkbox");
+
+ const routingConditionValue = await toggleConditionOptionMutation(
+ routingConditionId,
+ true,
+ first(answer.options).id
+ ).then(res => res.data);
+
+ return { routingConditionId, answer, routingConditionValue };
+};
+
+const getFullRoutingTree = async firstPage =>
+ executeQuery(getEntireRoutingStructure, { id: firstPage.id }, ctx);
+
+const createQuestionPage = async id =>
+ executeQuery(
+ createQuestionPageMutation,
+ { input: { title: "Bar", sectionId: id } },
+ ctx
+ );
+
+const refreshAnswerDetails = async ({ id }) => {
+ const result = await executeQuery(getAnswerQuery, { id }, ctx);
+ return result.data.answer;
+};
+
+describe("resolvers", () => {
+ let questionnaire;
+ let sections;
+ let pages;
+ let firstPage;
+
+ beforeAll(() => db.migrate.latest());
+ afterAll(() => db.destroy());
+ afterEach(() => db("Questionnaires").delete());
+
+ beforeEach(async () => {
+ questionnaire = await createNewQuestionnaire();
+ sections = questionnaire.sections;
+ pages = first(sections).pages;
+ firstPage = first(pages);
+ });
+
+ it("should automatically add properties to an answer", async () => {
+ const numberAnswer = await createNewAnswer(firstPage, "Number");
+ const result = await executeQuery(
+ getAnswerQuery,
+ { id: numberAnswer.id },
+ ctx
+ );
+ expect(result.data.answer.properties).toMatchObject({
+ decimals: 0,
+ required: false
+ });
+ });
+
+ it("should split a date range answer into child answers on retrieval", async () => {
+ const dateRangeAnswer = await createNewAnswer(firstPage, "DateRange");
+
+ const result = await executeQuery(
+ getAnswerQuery,
+ { id: dateRangeAnswer.id },
+ ctx
+ );
+
+ const childAnswers = get(result, "data.answer.childAnswers");
+
+ expect(childAnswers).toEqual([
+ { id: `${dateRangeAnswer.id}from`, label: "DateRange answer" },
+ { id: `${dateRangeAnswer.id}to`, label: null }
+ ]);
+ });
+
+ it("should return a composite and basic answer in the correct shapes", async () => {
+ const dateRange = await createNewAnswer(firstPage, "DateRange");
+ const textField = await createNewAnswer(firstPage, "TextField");
+
+ const result = await executeQuery(
+ getPipableAnswersQuery,
+ { ids: [dateRange.id, textField.id] },
+ ctx
+ );
+
+ const answers = get(result, "data.answers");
+
+ expect(answers).toHaveLength(2);
+ expect(answers[0]).toHaveProperty("childAnswers");
+ expect(answers[1].childAnswers).toBeUndefined();
+ });
+
+ it("should re-combine a composite answer in the db", async () => {
+ const dateRangeAnswer = await createNewAnswer(firstPage, "DateRange");
+
+ await updateAnswer({
+ id: `${dateRangeAnswer.id}from`,
+ label: "This is the from"
+ });
+ await updateAnswer({
+ id: `${dateRangeAnswer.id}to`,
+ label: "This is the to"
+ });
+
+ const result = await executeQuery(
+ getAnswerQuery,
+ { id: dateRangeAnswer.id },
+ ctx
+ );
+
+ const childAnswers = get(result, "data.answer.childAnswers");
+
+ expect(childAnswers).toEqual([
+ { id: `${dateRangeAnswer.id}from`, label: "This is the from" },
+ { id: `${dateRangeAnswer.id}to`, label: "This is the to" }
+ ]);
+ });
+
+ it("should create other answer for Checkbox answers", async () => {
+ const checkboxAnswer = await createNewAnswer(firstPage, "Checkbox");
+ expect(checkboxAnswer.other).toBeNull();
+
+ const other = await createOther(checkboxAnswer);
+ expect(other.answer).toMatchObject({ type: "TextField", description: "" });
+
+ const updatedCheckboxAnswer = await refreshAnswerDetails(checkboxAnswer);
+ expect(updatedCheckboxAnswer.other).not.toBeNull();
+ expect(updatedCheckboxAnswer.other).toMatchObject(other);
+ });
+
+ it("can create exclusive option for checkbox answers", async () => {
+ const checkboxAnswer = await createNewAnswer(firstPage, "Checkbox");
+ const result = await createExclusive(checkboxAnswer);
+
+ expect(result).not.toHaveProperty("errors");
+ });
+
+ it("fails when trying to create a second exclusive option", async () => {
+ const checkboxAnswer = await createNewAnswer(firstPage, "Checkbox");
+ await createExclusive(checkboxAnswer);
+ const result = await createExclusive(checkboxAnswer);
+
+ expect(result.errors).toHaveLength(1);
+ });
+
+ it("should create other answer for Radio answers", async () => {
+ const radioAnswer = await createNewAnswer(firstPage, "Radio");
+ expect(radioAnswer.other).toBeNull();
+
+ const other = await createOther(radioAnswer);
+ expect(other.answer).toMatchObject({ type: "TextField" });
+
+ const updatedRadioAnswer = await refreshAnswerDetails(radioAnswer);
+ expect(updatedRadioAnswer.other).not.toBeNull();
+ expect(updatedRadioAnswer.other).toMatchObject(other);
+ });
+
+ it("should throw error when creating other answer for BasicAnswer", async () => {
+ const textAnswer = await createNewAnswer(firstPage, "TextField");
+ const result = await createNewOtherMutation(textAnswer);
+ expect(result).toHaveProperty("errors");
+ expect(result.errors).toHaveLength(1);
+ });
+
+ it("should throw error when deleting other answer for BasicAnswer", async () => {
+ const textAnswer = await createNewAnswer(firstPage, "TextField");
+ const result = await deleteOther(textAnswer);
+ expect(result).toHaveProperty("errors");
+ expect(result.errors).toHaveLength(1);
+ });
+
+ it("should return undefined when accessing other property on BasicAnswers", async () => {
+ const textAnswer = await createNewAnswer(firstPage, "TextField");
+ expect(textAnswer.other).toBeUndefined();
+ });
+
+ it("should delete other answer for Checkbox answers", async () => {
+ const parentAnswer = await createThenDeleteOther(firstPage, "Checkbox");
+ const checkboxAnswer = await refreshAnswerDetails(parentAnswer);
+
+ expect(checkboxAnswer.other).toBeNull();
+ });
+
+ it("should delete other answer for Radio answers", async () => {
+ const parentAnswer = await createThenDeleteOther(firstPage, "Radio");
+ const radioAnswer = await refreshAnswerDetails(parentAnswer);
+
+ expect(radioAnswer.other).toBeNull();
+ });
+
+ it("should not create a new other answer if one already exists", async () => {
+ const parentAnswer = await createNewAnswer(firstPage, "Checkbox");
+ const other = await createOther(parentAnswer);
+
+ await expect(createOther(parentAnswer)).resolves.toBeNull();
+
+ const updatedParent = await refreshAnswerDetails(parentAnswer);
+ expect(updatedParent.other).toMatchObject(other);
+ });
+
+ it("should filter out Other answers from regular answers", async () => {
+ const checkboxAnswer = await createNewAnswer(firstPage, "Checkbox");
+ await createOther(checkboxAnswer);
+
+ const textFieldAnswer = await createNewAnswer(firstPage, "TextField");
+
+ const result = await executeQuery(
+ getAnswersQuery,
+ { id: firstPage.id },
+ ctx
+ );
+ expect(result.data.page.answers).toHaveLength(2);
+ expect(result.data.page.answers).toContainEqual({
+ id: checkboxAnswer.id,
+ type: checkboxAnswer.type
+ });
+ expect(result.data.page.answers).toContainEqual({
+ id: textFieldAnswer.id,
+ type: textFieldAnswer.type
+ });
+ });
+
+ it("should create an other option while creating an other answer", async () => {
+ const checkboxAnswer = await createNewAnswer(firstPage, "Checkbox");
+ await createOther(checkboxAnswer);
+ const refreshedCheckbox = await refreshAnswerDetails(checkboxAnswer);
+ expect(refreshedCheckbox.other.option).not.toBeNull();
+ expect(refreshedCheckbox.other.option).toHaveProperty("id");
+ });
+
+ it("should not return 'other' option with regular checkbox/radio options", async () => {
+ const checkboxAnswer = await createNewAnswer(firstPage, "Checkbox");
+ await createOther(checkboxAnswer);
+ const refreshedCheckbox = await refreshAnswerDetails(checkboxAnswer);
+ expect(refreshedCheckbox.options).toHaveLength(1);
+ expect(refreshedCheckbox.options).not.toContainEqual({
+ id: refreshedCheckbox.other.option.id
+ });
+ });
+
+ it("should return an error when trying to delete a non-existent other Answer", async () => {
+ const checkboxAnswer = await createNewAnswer(firstPage, "Checkbox");
+ const result = await deleteOther(checkboxAnswer);
+ expect(result).toHaveProperty("errors");
+ expect(result.errors).toHaveLength(1);
+ });
+
+ it("should return an error when trying to create other answer when one already exists", async () => {
+ const checkboxAnswer = await createNewAnswer(firstPage, "Checkbox");
+ const firstAttempt = await createNewOtherMutation(checkboxAnswer);
+ const secondAttempt = await createNewOtherMutation(checkboxAnswer);
+
+ expect(firstAttempt).not.toHaveProperty("errors");
+
+ expect(secondAttempt).toHaveProperty("errors");
+ expect(secondAttempt.errors).toHaveLength(1);
+ });
+
+ it("should create a RoutingRule and RoutingCondition on RoutingRuleSet creation", async () => {
+ await createNewRoutingRuleSet(firstPage.id);
+ const result = await executeQuery(
+ getBasicRoutingQuery,
+ { id: firstPage.id },
+ ctx
+ );
+
+ expect(get(result, "data.page.routingRuleSet.routingRules")).toHaveLength(
+ 1
+ );
+
+ expect(
+ get(result, "data.page.routingRuleSet.routingRules[0].conditions")
+ ).toHaveLength(1);
+ });
+
+ it("should delete routing rule set", async () => {
+ const routingRuleSet = await createNewRoutingRuleSet(firstPage.id).then(
+ res => res.data.createRoutingRuleSet
+ );
+
+ const result = await deleteRoutingRuleSetMutation(routingRuleSet.id);
+
+ expect(result).toMatchObject({
+ data: {
+ deleteRoutingRuleSet: {
+ id: routingRuleSet.id
+ }
+ }
+ });
+
+ return expect(
+ executeQuery(getBasicRoutingQuery, { id: firstPage.id }, ctx)
+ ).resolves.toMatchObject({
+ data: {
+ page: {
+ id: firstPage.id,
+ routingRuleSet: null
+ }
+ }
+ });
+ });
+
+ it("should error if you make a second RoutingRuleSet on one question", async () => {
+ await createNewRoutingRuleSet(firstPage.id);
+ const secondAttempt = await createNewRoutingRuleSet(firstPage.id);
+
+ expect(secondAttempt.errors).toHaveLength(1);
+ });
+
+ it("should error if you try route the question to itself", async () => {
+ const routingRuleSet = await createNewRoutingRuleSet(firstPage.id);
+ const ruleId = get(
+ routingRuleSet,
+ "data.createRoutingRuleSet.routingRules[0].id"
+ );
+ const page = await executeQuery(getPageQuery, { id: firstPage.id }, ctx);
+ const result = await updateRoutingRuleMutation(
+ "QuestionPage",
+ page.id,
+ ruleId
+ );
+
+ expect(result.errors).toHaveLength(1);
+ });
+
+ it("should error if you route it to the beginning of its own section", async () => {
+ const routingRuleSet = await createNewRoutingRuleSet(firstPage.id);
+ const page = await executeQuery(getPageQuery, { id: firstPage.id }, ctx);
+ const result = await updateRoutingRuleSetMutation(
+ get(routingRuleSet, "data.createRoutingRuleSet.id"),
+ {
+ else: {
+ absoluteDestination: {
+ destinationType: "Section",
+ destinationId: get(page, "data.questionPage.section.id")
+ }
+ }
+ }
+ );
+ expect(result.errors).toHaveLength(1);
+ });
+
+ it("can create a Routing Condition and can toggle a optionValue on", async () => {
+ const routingTree = await createFullRoutingTree(firstPage);
+ let routingStructure = await getFullRoutingTree(firstPage);
+
+ let routingConditions = get(
+ routingStructure,
+ "data.questionPage.routingRuleSet.routingRules[0].conditions"
+ );
+
+ expect(routingConditions).toHaveLength(1);
+ expect(get(first(routingConditions), "routingValue.value[0]")).toEqual(
+ get(routingTree, "answer.options[0].id")
+ );
+
+ await toggleConditionOptionMutation(
+ routingTree.routingConditionId,
+ false,
+ get(routingTree, "answer.options[0].id")
+ );
+
+ routingStructure = await getFullRoutingTree(firstPage);
+
+ routingConditions = get(
+ routingStructure,
+ "data.questionPage.routingRuleSet.routingRules[0].conditions"
+ );
+
+ expect(get(first(routingConditions), "routingValue.value")).toHaveLength(0);
+ });
+
+ it("should delete routing condition value when answer is updated", async () => {
+ const routingTree = await createFullRoutingTree(firstPage);
+
+ await toggleConditionOptionMutation(
+ routingTree.routingConditionId,
+ true,
+ first(routingTree.answer.options).id
+ );
+
+ let routingStructure = await getFullRoutingTree(firstPage);
+
+ let routingCondition = get(
+ routingStructure,
+ "data.questionPage.routingRuleSet.routingRules[0].conditions[0]"
+ );
+
+ expect(get(routingCondition, "routingValue.value[0]")).toEqual(
+ first(routingTree.answer.options).id
+ );
+
+ await changeRoutingCondition(
+ firstPage,
+ routingTree.answer,
+ routingTree.routingConditionId
+ );
+
+ routingStructure = await getFullRoutingTree(firstPage);
+ routingCondition = get(
+ routingStructure,
+ "data.questionPage.routingRuleSet.routingRules[0].conditions[0]"
+ );
+
+ expect(get(routingCondition, "routingValue.value")).toHaveLength(0);
+ });
+
+ it("should return available routing destinations", async () => {
+ const secondSection = await createSection(questionnaire.id);
+ await createQuestionPage(get(sections, "[0].id"));
+ await createQuestionPage(get(sections, "[0].id"));
+ await createQuestionPage(secondSection.data.createSection.id);
+ const result = await executeQuery(
+ getAvailableRoutingDestinations,
+ {
+ id: firstPage.id
+ },
+ ctx
+ );
+
+ const destinations = get(result, "data.availableRoutingDestinations");
+
+ expect(get(destinations, "logicalDestinations")).toHaveLength(2);
+ expect(get(destinations, "questionPages")).toHaveLength(2);
+ expect(get(destinations, "sections")).toHaveLength(1);
+ });
+
+ it("should reorder section with correct position", async () => {
+ const sectionOne = sections[0];
+ const {
+ data: { createSection: sectionTwo }
+ } = await createSection(questionnaire.id);
+ const {
+ data: { createSection: sectionThree }
+ } = await createSection(questionnaire.id);
+
+ const getSectionsQuery = `
+ query GetQuestionnaire($id: ID!) {
+ questionnaire(id: $id) {
+ id
+ sections{
+ id
+ position
+ }
+ }
+ }
+ `;
+
+ await executeQuery(
+ moveSectionMutation,
+ {
+ input: {
+ id: sectionOne.id,
+ questionnaireId: questionnaire.id,
+ position: 3
+ }
+ },
+ ctx
+ );
+
+ const result = await executeQuery(
+ getSectionsQuery,
+ {
+ id: questionnaire.id
+ },
+ ctx
+ );
+
+ const expected = {
+ data: {
+ questionnaire: {
+ id: questionnaire.id,
+ sections: [
+ { id: sectionTwo.id, position: 0 },
+ { id: sectionThree.id, position: 1 },
+ { id: sectionOne.id, position: 2 }
+ ]
+ }
+ }
+ };
+
+ expect(result).toMatchObject(expected);
+ });
+
+ it("should get total number of sections", async () => {
+ const getTotalSectionCountQuery = `
+ query GetTotalSectionCount($id: ID!) {
+ questionnaire(id: $id) {
+ questionnaireInfo {
+ totalSectionCount
+ }
+ }
+ }
+ `;
+
+ const result = await executeQuery(
+ getTotalSectionCountQuery,
+ {
+ id: questionnaire.id
+ },
+ ctx
+ );
+
+ const expected = {
+ data: {
+ questionnaire: {
+ questionnaireInfo: {
+ totalSectionCount: 1
+ }
+ }
+ }
+ };
+ expect(result).toMatchObject(expected);
+ });
+
+ it("should get metadata for questionnaire", async () => {
+ const createMetadata = `
+ mutation CreateMetadata($input: CreateMetadataInput!) {
+ createMetadata(input: $input) {
+ ...Metadata
+ __typename
+ }
+ }
+
+ fragment Metadata on Metadata {
+ id
+ key
+ alias
+ type
+ textValue
+ languageValue
+ regionValue
+ dateValue
+ __typename
+ }
+ `;
+
+ const {
+ data: { createMetadata: metadata }
+ } = await executeQuery(
+ createMetadata,
+ {
+ input: { questionnaireId: questionnaire.id }
+ },
+ ctx
+ );
+
+ const getQuestionnaireWithMetadata = `
+ query GetQuestionnaireWithMetadata($id: ID!) {
+ questionnaire(id: $id) {
+ id
+ metadata {
+ ...Metadata
+ __typename
+ }
+ __typename
+ }
+ }
+
+ fragment Metadata on Metadata {
+ id
+ key
+ alias
+ type
+ textValue
+ languageValue
+ regionValue
+ dateValue
+ __typename
+ }
+ `;
+
+ const result = await executeQuery(
+ getQuestionnaireWithMetadata,
+ {
+ id: questionnaire.id
+ },
+ ctx
+ );
+
+ const expected = {
+ data: {
+ questionnaire: {
+ id: questionnaire.id,
+ metadata: [metadata],
+ __typename: "Questionnaire"
+ }
+ }
+ };
+ expect(result).toMatchObject(expected);
+ });
+
+ it("should resolve displayName for section", async () => {
+ const getSectionWithDisplayName = `
+ query GetSection($id: ID!) {
+ section(id: $id) {
+ id
+ displayName
+ }
+ }
+ `;
+ const {
+ data: { section }
+ } = await executeQuery(
+ getSectionWithDisplayName,
+ {
+ id: first(sections).id
+ },
+ ctx
+ );
+
+ expect(section).toHaveProperty("displayName");
+ });
+
+ it("should resolve displayName for question page", async () => {
+ const getQuestionPageWithDisplayName = `
+ query GetQuestionPage($id: ID!) {
+ questionPage(id: $id) {
+ id
+ displayName
+ }
+ }
+ `;
+ const {
+ data: { questionPage }
+ } = await executeQuery(
+ getQuestionPageWithDisplayName,
+ {
+ id: firstPage.id
+ },
+ ctx
+ );
+
+ expect(questionPage).toHaveProperty("displayName");
+ });
+
+ it("should resolve displayName for basic answer", async () => {
+ const { id } = await createNewAnswer(firstPage, "Number");
+
+ const getBasicAnswerWithDisplayName = `
+ query GetAnswer($id: ID!) {
+ answer(id: $id) {
+ id
+ displayName
+ }
+ }
+ `;
+
+ const {
+ data: { answer }
+ } = await executeQuery(
+ getBasicAnswerWithDisplayName,
+ {
+ id: id
+ },
+ ctx
+ );
+
+ expect(answer).toHaveProperty("displayName");
+ });
+
+ it("should resolve displayName for composite answer", async () => {
+ const { id } = await createNewAnswer(firstPage, "DateRange");
+
+ const getBasicAnswerWithDisplayName = `
+ query GetAnswer($id: ID!) {
+ answer(id: $id) {
+ id
+ displayName
+ }
+ }
+ `;
+
+ const {
+ data: { answer }
+ } = await executeQuery(
+ getBasicAnswerWithDisplayName,
+ {
+ id: id
+ },
+ ctx
+ );
+
+ expect(answer).toHaveProperty("displayName");
+ });
+
+ it("should resolve displayName for multiple choice answer", async () => {
+ const { id } = await createNewAnswer(firstPage, "Radio");
+
+ const getBasicAnswerWithDisplayName = `
+ query GetAnswer($id: ID!) {
+ answer(id: $id) {
+ id
+ displayName
+ ... on MultipleChoiceAnswer {
+ options {
+ id
+ displayName
+ }
+ }
+ }
+ }
+ `;
+
+ const {
+ data: { answer }
+ } = await executeQuery(
+ getBasicAnswerWithDisplayName,
+ {
+ id: id
+ },
+ ctx
+ );
+
+ expect(answer).toHaveProperty("displayName");
+ expect(first(answer.options)).toHaveProperty("displayName");
+ });
+
+ describe("routing", () => {
+ const mutate = async input => createNewRoutingRule(input);
+ const newRoutingRule = async input =>
+ mutate(input).then(res => res.data.createRoutingRule);
+ const addPage = async sectionId =>
+ createQuestionPage(sectionId).then(res => res.data.createQuestionPage);
+ const addSection = async questionnaireId =>
+ createSection(questionnaireId).then(res => res.data.createSection);
+ const getRoutingDataForPage = async page =>
+ getFullRoutingTree(page).then(res => res.data.questionPage);
+ const deleteRoutingRule = async input => deleteRoutingRuleMutation(input);
+
+ describe("routing rule sets", () => {
+ it("should default else to next page", async () => {
+ const routingRuleSet = await createNewRoutingRuleSet(firstPage.id);
+ expect(routingRuleSet).toMatchObject({
+ data: {
+ createRoutingRuleSet: {
+ else: {
+ logicalDestination: "NextPage"
+ }
+ }
+ }
+ });
+ });
+
+ it("should update else to end of questionnaire", async () => {
+ const routingRuleSet = await createNewRoutingRuleSet(firstPage.id);
+
+ const updated = await updateRoutingRuleSetMutation({
+ id: get(routingRuleSet, "data.createRoutingRuleSet.id"),
+ else: {
+ logicalDestination: {
+ destinationType: "EndOfQuestionnaire"
+ }
+ }
+ });
+
+ expect(updated).toMatchObject({
+ data: {
+ updateRoutingRuleSet: {
+ else: {
+ logicalDestination: "EndOfQuestionnaire"
+ }
+ }
+ }
+ });
+ });
+
+ it("should update else to question page", async () => {
+ const routingRuleSet = await createNewRoutingRuleSet(firstPage.id);
+ const section = first(sections);
+ const newPage = await addPage(section.id);
+
+ const updated = await updateRoutingRuleSetMutation({
+ id: get(routingRuleSet, "data.createRoutingRuleSet.id"),
+ else: {
+ absoluteDestination: {
+ destinationType: "QuestionPage",
+ destinationId: `${newPage.id}`
+ }
+ }
+ });
+
+ expect(updated).toMatchObject({
+ data: {
+ updateRoutingRuleSet: {
+ else: {
+ absoluteDestination: newPage
+ }
+ }
+ }
+ });
+ });
+
+ it("should update else to section", async () => {
+ const routingRuleSet = await createNewRoutingRuleSet(firstPage.id);
+ const newSection = await addSection(questionnaire.id);
+
+ const updated = await updateRoutingRuleSetMutation({
+ id: get(routingRuleSet, "data.createRoutingRuleSet.id"),
+ else: {
+ absoluteDestination: {
+ destinationType: "Section",
+ destinationId: `${newSection.id}`
+ }
+ }
+ });
+
+ expect(updated).toMatchObject({
+ data: {
+ updateRoutingRuleSet: {
+ else: {
+ absoluteDestination: newSection
+ }
+ }
+ }
+ });
+ });
+ });
+
+ describe("routing rules", () => {
+ let input;
+
+ beforeEach(async () => {
+ const routingRuleSet = await createNewRoutingRuleSet(firstPage.id).then(
+ result => result.data.createRoutingRuleSet
+ );
+ input = {
+ operation: "And",
+ routingRuleSetId: routingRuleSet.id
+ };
+ });
+
+ it("should default goto next page", async () => {
+ const routingRule = await newRoutingRule(input);
+
+ expect(routingRule).toMatchObject({
+ goto: {
+ logicalDestination: "NextPage"
+ }
+ });
+ });
+
+ it("should create a routing rule that routes to a question page", async () => {
+ const section = first(sections);
+ const newPage = await addPage(section.id);
+ const routingRule = await newRoutingRule(input);
+
+ const result = await updateRoutingRuleMutation(
+ "QuestionPage",
+ newPage.id,
+ routingRule.id
+ );
+
+ expect(result).toMatchObject({
+ data: {
+ updateRoutingRule: {
+ goto: {
+ absoluteDestination: {
+ id: newPage.id,
+ __typename: "QuestionPage"
+ }
+ }
+ }
+ }
+ });
+ });
+
+ it("create a routing rule that routes to another section", async () => {
+ const newSection = await addSection(questionnaire.id);
+ const routingRule = await newRoutingRule(input);
+
+ const result = await updateRoutingRuleMutation(
+ "Section",
+ newSection.id,
+ routingRule.id
+ );
+
+ expect(result).toMatchObject({
+ data: {
+ updateRoutingRule: {
+ goto: {
+ absoluteDestination: {
+ id: newSection.id,
+ __typename: "Section"
+ }
+ }
+ }
+ }
+ });
+ });
+
+ it("should create a condition for newly created routing rule", async () => {
+ const routingRule = await newRoutingRule(input);
+ expect(get(routingRule, "conditions")).toHaveLength(1);
+ });
+
+ it("should be possible to delete a routing rule", async () => {
+ const routingRule = await newRoutingRule(input);
+ const page = await getRoutingDataForPage(firstPage);
+ expect(page.routingRuleSet.routingRules).toHaveLength(2);
+
+ await deleteRoutingRule({
+ id: routingRule.id
+ });
+
+ const updated = await getRoutingDataForPage(firstPage);
+ expect(updated.routingRuleSet.routingRules).toHaveLength(1);
+ });
+ });
+
+ describe("routing conditions", () => {
+ let input;
+
+ const newCondition = input =>
+ createNewRoutingCondition(input).then(
+ res => res.data.createRoutingCondition
+ );
+
+ beforeEach(async () => {
+ const routingRuleSet = await createNewRoutingRuleSet(firstPage.id).then(
+ res => res.data.createRoutingRuleSet
+ );
+ input = {
+ comparator: "Equal",
+ questionPageId: firstPage.id,
+ routingRuleId: get(routingRuleSet, "routingRules[0].id")
+ };
+ });
+
+ it("should create a routing condition for question page", async () => {
+ const newRoutingCondition = await newCondition(input);
+ expect(newRoutingCondition).toMatchObject({
+ questionPage: {
+ id: firstPage.id
+ }
+ });
+ });
+
+ it("should set question page to null when page deleted", async () => {
+ /*
+ Given two pages A and B
+ And B has a routing condition that refers to A
+ When A is deleted
+ Then B's routing condition page should be null
+ */
+ const pageA = firstPage;
+ await createNewAnswer(pageA, "Radio");
+ const pageB = await createQuestionPage(first(sections).id).then(
+ res => res.data.createQuestionPage
+ );
+
+ await createNewRoutingRuleSet(pageB.id);
+ const routingInfo = await getFullRoutingTree(pageB);
+ const routingCondition = get(
+ routingInfo,
+ "data.questionPage.routingRuleSet.routingRules[0].conditions[0]"
+ );
+ await changeRoutingConditionMutation({
+ id: routingCondition.id,
+ questionPageId: pageA.id
+ });
+
+ await deleteQuestionPage(pageA);
+
+ const updatedRoutingInfo = await getFullRoutingTree(pageB);
+ const updatedRoutingCondition = get(
+ updatedRoutingInfo,
+ "data.questionPage.routingRuleSet.routingRules[0].conditions[0]"
+ );
+
+ expect(updatedRoutingCondition).toMatchObject({
+ questionPage: null,
+ answer: null
+ });
+ });
+
+ it("should set condition answer to null when answer is deleted", async () => {
+ /*
+ Given two pages A and B
+ And B has a routing condition that refers to an answer on page A
+ When the answer from page A is deleted
+ Then B's routing condition answer should be null
+ */
+ const pageA = firstPage;
+ const answer = await createNewAnswer(pageA, "Radio");
+
+ const pageB = await createQuestionPage(first(sections).id).then(
+ res => res.data.createQuestionPage
+ );
+
+ await createNewRoutingRuleSet(pageB.id);
+ const routingInfo = await getFullRoutingTree(pageB);
+ const routingCondition = get(
+ routingInfo,
+ "data.questionPage.routingRuleSet.routingRules[0].conditions[0]"
+ );
+ await changeRoutingConditionMutation({
+ id: routingCondition.id,
+ questionPageId: pageA.id,
+ answerId: answer.id
+ });
+
+ await deleteAnswer(answer);
+
+ const updatedRoutingInfo = await getFullRoutingTree(pageB);
+ const updatedRoutingCondition = get(
+ updatedRoutingInfo,
+ "data.questionPage.routingRuleSet.routingRules[0].conditions[0]"
+ );
+
+ expect(updatedRoutingCondition).toMatchObject({
+ questionPage: null,
+ answer: null
+ });
+ });
+
+ it("should remove routing condition values when option is deleted", async () => {
+ /*
+ Given a page with a routing condition that points to a checkbox answer
+ And one of the options is toggled on at the routing condition
+ When the option is deleted
+ Then the routing condition value for the deleted option should also be deleted
+ */
+ const answer = await createNewAnswer(firstPage, "Radio");
+ await createNewRoutingRuleSet(firstPage.id);
+
+ const options = get(answer, "options");
+
+ const routingInfo = await getFullRoutingTree(firstPage);
+ const routingCondition = get(
+ routingInfo,
+ "data.questionPage.routingRuleSet.routingRules[0].conditions[0]"
+ );
+
+ await changeRoutingConditionMutation({
+ id: routingCondition.id,
+ questionPageId: firstPage.id,
+ answerId: answer.id
+ });
+
+ await toggleConditionOptionMutation(
+ routingCondition.id,
+ true,
+ options[0].id
+ );
+
+ const beforeDeletion = await getFullRoutingTree(firstPage);
+ let conditionValues = get(
+ beforeDeletion,
+ "data.questionPage.routingRuleSet.routingRules[0].conditions[0].routingValue.value[0]"
+ );
+ expect(conditionValues).toEqual(options[0].id);
+
+ await deleteOption(options[0]);
+
+ const afterDeletion = await getFullRoutingTree(firstPage);
+ conditionValues = get(
+ afterDeletion,
+ "data.questionPage.routingRuleSet.routingRules[0].conditions[0].routingValue.value"
+ );
+ expect(conditionValues).toHaveLength(0);
+ });
+
+ it("should delete a routing condition for question page", async () => {
+ const newRoutingCondition = await newCondition(input);
+ const page = await getRoutingDataForPage(firstPage);
+ expect(
+ get(page, "routingRuleSet.routingRules[0].conditions")
+ ).toHaveLength(2);
+
+ await deleteRoutingConditionMutation({ id: newRoutingCondition.id });
+
+ const updated = await getRoutingDataForPage(firstPage);
+ expect(
+ get(updated, "routingRuleSet.routingRules[0].conditions")
+ ).toHaveLength(1);
+ });
+
+ it("should set condition to first answer in page if one exists", async () => {
+ const answer = await createNewAnswer(firstPage, "Checkbox");
+ const routingCondition = await newCondition(input);
+
+ expect(routingCondition).toMatchObject({
+ answer: {
+ id: answer.id
+ }
+ });
+ });
+
+ it("should always use first answer on page when creating a condition", async () => {
+ const answer1 = await createNewAnswer(firstPage, "Number");
+ await createNewAnswer(firstPage, "Checkbox");
+ const routingCondition = await newCondition(input);
+
+ expect(routingCondition).toMatchObject({
+ answer: {
+ id: answer1.id
+ }
+ });
+ });
+
+ it("should be possible to change the page for the condition", async () => {
+ const section = first(sections);
+ const page = await getRoutingDataForPage(firstPage);
+
+ const routingCondition = get(
+ page,
+ "routingRuleSet.routingRules[0].conditions[0]"
+ );
+ expect(routingCondition).toMatchObject({
+ questionPage: {
+ id: firstPage.id
+ }
+ });
+
+ const newPage = await addPage(section.id);
+
+ await createNewAnswer(newPage, "Radio");
+
+ await changeRoutingConditionMutation({
+ id: routingCondition.id,
+ questionPageId: newPage.id
+ });
+
+ const updated = await getRoutingDataForPage(firstPage);
+ expect(
+ get(updated, "routingRuleSet.routingRules[0].conditions[0]")
+ ).toMatchObject({
+ questionPage: {
+ id: newPage.id
+ }
+ });
+ });
+
+ it("should error if trying to update a condition with an invalid question/answer combo", async () => {
+ // Given page A with answer I
+ // And page B with answer J
+ // When I update a routing condition with question A answer J
+ // Then I should get an error
+
+ const section = first(sections);
+ const pageA = firstPage;
+ const pageB = await addPage(section.id);
+
+ const answerI = await createNewAnswer(pageA, "Checkbox");
+ const answerJ = await createNewAnswer(pageB, "Checkbox");
+
+ const routingInfo = await getRoutingDataForPage(pageA);
+ const routingCondition = get(
+ routingInfo,
+ "routingRuleSet.routingRules[0].conditions[0]"
+ );
+
+ const res1 = await changeRoutingConditionMutation({
+ id: routingCondition.id,
+ questionPageId: pageA.id,
+ answerId: answerJ.id
+ });
+
+ expect(res1.errors).toHaveLength(1);
+
+ const res2 = await changeRoutingConditionMutation({
+ id: routingCondition.id,
+ questionPageId: pageB.id,
+ answerId: answerI.id
+ });
+
+ expect(res2.errors).toHaveLength(1);
+ });
+
+ it("returns the conditions in a deterministic order", async () => {
+ const section = first(sections);
+
+ await createNewAnswer(firstPage, "Currency");
+
+ const pageB = await addPage(section.id);
+ await createNewAnswer(pageB, "Radio");
+
+ const pageC = await addPage(section.id);
+ await createNewAnswer(pageC, "Number");
+
+ const pageD = await addPage(section.id);
+ await createNewAnswer(pageD, "Radio");
+
+ const finalPageRoutingRuleSet = await createNewRoutingRuleSet(
+ pageD.id
+ ).then(res => res.data.createRoutingRuleSet);
+
+ const conditionInput = pageId => ({
+ comparator: "Equal",
+ questionPageId: pageId,
+ routingRuleId: get(finalPageRoutingRuleSet, "routingRules[0].id")
+ });
+
+ const routingInfo = await getRoutingDataForPage(pageD);
+ const routingCondition = get(
+ routingInfo,
+ "routingRuleSet.routingRules[0].conditions[0]"
+ );
+ await changeRoutingConditionMutation({
+ id: routingCondition.id,
+ questionPageId: pageD.id
+ });
+ await newCondition(conditionInput(pageC.id));
+ await newCondition(conditionInput(pageB.id));
+ await newCondition(conditionInput(firstPage.id));
+
+ const updatedRoutingInfo = await getFullRoutingTree(pageD);
+ const updatedRoutingCondition = get(
+ updatedRoutingInfo,
+ "data.questionPage.routingRuleSet.routingRules[0].conditions"
+ );
+
+ expect(updatedRoutingCondition[0].questionPage.id).toEqual(pageD.id);
+ expect(updatedRoutingCondition[1].questionPage.id).toEqual(pageC.id);
+ expect(updatedRoutingCondition[2].questionPage.id).toEqual(pageB.id);
+ expect(updatedRoutingCondition[3].questionPage.id).toEqual(
+ firstPage.id
+ );
+ });
+ });
+
+ describe("Numeric routing", () => {
+ it("should be able to create a routing rule for numeric answers", async () => {
+ const answer = await createNewAnswer(firstPage, "Currency");
+ await createNewRoutingRuleSet(firstPage.id);
+
+ const routingInfo = await getFullRoutingTree(firstPage);
+ const routingCondition = get(
+ routingInfo,
+ "data.questionPage.routingRuleSet.routingRules[0].conditions[0]"
+ );
+
+ expect(routingCondition).toMatchObject({
+ comparator: "Equal",
+ questionPage: { id: firstPage.id },
+ answer: { id: answer.id }
+ });
+ });
+
+ it("should create a condition value when routing answer is changed from multiple choice to number", async () => {
+ const section = first(sections);
+ await createNewAnswer(firstPage, "Currency");
+
+ const pageB = await addPage(section.id);
+ await createNewAnswer(pageB, "Radio");
+
+ await createNewRoutingRuleSet(pageB.id);
+
+ const routingInfo = await getFullRoutingTree(pageB);
+ const routingCondition = get(
+ routingInfo,
+ "data.questionPage.routingRuleSet.routingRules[0].conditions[0]"
+ );
+
+ await changeRoutingConditionMutation({
+ id: routingCondition.id,
+ questionPageId: firstPage.id
+ });
+
+ const updatedRoutingInfo = await getFullRoutingTree(pageB);
+ const updatedRoutingCondition = get(
+ updatedRoutingInfo,
+ "data.questionPage.routingRuleSet.routingRules[0].conditions[0].routingValue"
+ );
+
+ expect(updatedRoutingCondition).toHaveProperty("numberValue");
+ });
+
+ it("should be able to insert a value", async () => {
+ const answer = await createNewAnswer(firstPage, "Currency");
+ await createNewRoutingRuleSet(firstPage.id);
+
+ const routingInfo = await getFullRoutingTree(firstPage);
+ const routingCondition = get(
+ routingInfo,
+ "data.questionPage.routingRuleSet.routingRules[0].conditions[0]"
+ );
+
+ await updateConditionValueMutation({
+ id: routingCondition.routingValue.id,
+ customNumber: 8
+ });
+
+ const updatedRoutingInfo = await getFullRoutingTree(firstPage);
+ const updatedRoutingCondition = get(
+ updatedRoutingInfo,
+ "data.questionPage.routingRuleSet.routingRules[0].conditions[0]"
+ );
+
+ expect(updatedRoutingCondition).toMatchObject({
+ comparator: "Equal",
+ questionPage: { id: firstPage.id },
+ answer: { id: answer.id },
+ routingValue: { id: routingCondition.routingValue.id, numberValue: 8 }
+ });
+ });
+
+ it("should be able to change the numeric comparator", async () => {
+ const answer = await createNewAnswer(firstPage, "Currency");
+ await createNewRoutingRuleSet(firstPage.id);
+
+ const routingInfo = await getFullRoutingTree(firstPage);
+ const routingCondition = get(
+ routingInfo,
+ "data.questionPage.routingRuleSet.routingRules[0].conditions[0]"
+ );
+
+ await changeRoutingConditionMutation({
+ id: routingCondition.id,
+ comparator: "GreaterThan",
+ questionPageId: firstPage.id
+ });
+
+ const updatedRoutingInfo = await getFullRoutingTree(firstPage);
+ const updatedRoutingCondition = get(
+ updatedRoutingInfo,
+ "data.questionPage.routingRuleSet.routingRules[0].conditions[0]"
+ );
+
+ expect(updatedRoutingCondition).toMatchObject({
+ comparator: "GreaterThan",
+ questionPage: { id: firstPage.id },
+ answer: { id: answer.id }
+ });
+ });
+
+ it("should not create new condition values on comparator change", async () => {
+ await createNewAnswer(firstPage, "Currency");
+ await createNewRoutingRuleSet(firstPage.id);
+
+ const routingInfo = await getFullRoutingTree(firstPage);
+ const routingCondition = get(
+ routingInfo,
+ "data.questionPage.routingRuleSet.routingRules[0].conditions[0]"
+ );
+
+ await changeRoutingConditionMutation({
+ id: routingCondition.id,
+ comparator: "GreaterThan",
+ questionPageId: firstPage.id
+ });
+
+ await updateConditionValueMutation({
+ id: routingCondition.routingValue.id,
+ customNumber: 8
+ });
+
+ const updatedRoutingInfo = await getFullRoutingTree(firstPage);
+ const updatedRoutingConditionValue = get(
+ updatedRoutingInfo,
+ "data.questionPage.routingRuleSet.routingRules[0].conditions[0].routingValue"
+ );
+
+ expect(updatedRoutingConditionValue).toMatchObject({
+ id: routingCondition.routingValue.id,
+ numberValue: 8
+ });
+ });
+
+ it("doesn't wipe out the condition value on a comparator change", async () => {
+ await createNewAnswer(firstPage, "Currency");
+ await createNewRoutingRuleSet(firstPage.id);
+
+ const routingInfo = await getFullRoutingTree(firstPage);
+ const routingCondition = get(
+ routingInfo,
+ "data.questionPage.routingRuleSet.routingRules[0].conditions[0]"
+ );
+
+ await updateConditionValueMutation({
+ id: routingCondition.routingValue.id,
+ customNumber: 8
+ });
+
+ await changeRoutingConditionMutation({
+ id: routingCondition.id,
+ comparator: "GreaterThan",
+ questionPageId: firstPage.id
+ });
+
+ const updatedRoutingInfo = await getFullRoutingTree(firstPage);
+ const updatedRoutingConditionValue = get(
+ updatedRoutingInfo,
+ "data.questionPage.routingRuleSet.routingRules[0].conditions[0].routingValue"
+ );
+
+ expect(updatedRoutingConditionValue).toMatchObject({
+ id: routingCondition.routingValue.id,
+ numberValue: 8
+ });
+ });
+ });
+ });
+});
diff --git a/eq-author-api/schema/resolversTests/duplication.test.js b/eq-author-api/schema/resolversTests/duplication.test.js
new file mode 100644
index 0000000000..99988c8bc1
--- /dev/null
+++ b/eq-author-api/schema/resolversTests/duplication.test.js
@@ -0,0 +1,103 @@
+const executeQuery = require("../../tests/utils/executeQuery");
+
+describe("Duplication", () => {
+ beforeEach(async () => {});
+
+ it("should be able to duplicate a page", async () => {
+ const ctx = {
+ repositories: {
+ Page: {
+ duplicatePage: jest.fn(() => ({
+ id: 2,
+ title: "Duplicate page",
+ pageType: "QuestionPage"
+ }))
+ }
+ }
+ };
+ const query = `
+ mutation duplicatePage($input: DuplicatePageInput!) {
+ duplicatePage(input: $input) {
+ id
+ }
+ }
+ `;
+ const result = await executeQuery(
+ query,
+ { input: { id: "1", position: 1 } },
+ ctx
+ );
+ expect(result.errors).toBeUndefined();
+ expect(ctx.repositories.Page.duplicatePage).toHaveBeenCalledWith("1", 1);
+ expect(result.data).toMatchObject({
+ duplicatePage: { id: "2" }
+ });
+ });
+
+ it("should be able to duplicate a section", async () => {
+ const ctx = {
+ repositories: {
+ Section: {
+ duplicateSection: jest.fn(() => ({
+ id: 2,
+ title: "Duplicate Section"
+ }))
+ }
+ }
+ };
+ const query = `
+ mutation duplicateSection($input: DuplicateSectionInput!) {
+ duplicateSection(input: $input) {
+ id
+ }
+ }
+ `;
+ const result = await executeQuery(
+ query,
+ { input: { id: "1", position: 1 } },
+ ctx
+ );
+ expect(result.errors).toBeUndefined();
+ expect(ctx.repositories.Section.duplicateSection).toHaveBeenCalledWith(
+ "1",
+ 1
+ );
+ expect(result.data).toMatchObject({
+ duplicateSection: { id: "2" }
+ });
+ });
+
+ it("should be able to duplicate a questionnaire", async () => {
+ const ctx = {
+ repositories: {
+ Questionnaire: {
+ duplicate: jest.fn(() => ({
+ id: 2,
+ title: "Duplicate Questionnaire"
+ }))
+ }
+ }
+ };
+ const query = `
+ mutation duplicateQuestionnaire($input: DuplicateQuestionnaireInput!) {
+ duplicateQuestionnaire(input: $input) {
+ id
+ }
+ }
+ `;
+
+ const result = await executeQuery(
+ query,
+ { input: { id: "1", createdBy: "foo" } },
+ ctx
+ );
+ expect(result.errors).toBeUndefined();
+ expect(ctx.repositories.Questionnaire.duplicate).toHaveBeenCalledWith(
+ "1",
+ "foo"
+ );
+ expect(result.data).toMatchObject({
+ duplicateQuestionnaire: { id: "2" }
+ });
+ });
+});
diff --git a/eq-author-api/schema/resolversTests/properties.test.js b/eq-author-api/schema/resolversTests/properties.test.js
new file mode 100644
index 0000000000..d02964d26f
--- /dev/null
+++ b/eq-author-api/schema/resolversTests/properties.test.js
@@ -0,0 +1,132 @@
+const { first } = require("lodash");
+const repositories = require("../../repositories");
+const db = require("../../db");
+const executeQuery = require("../../tests/utils/executeQuery");
+const {
+ createQuestionnaireMutation,
+ createAnswerMutation,
+ getAnswerQuery,
+ updateAnswerMutation
+} = require("../../tests/utils/graphql");
+
+const ctx = { repositories };
+
+const createNewQuestionnaire = async () => {
+ const input = {
+ title: "Test Questionnaire",
+ description: "Questionnaire created by integration test.",
+ theme: "default",
+ legalBasis: "Voluntary",
+ navigation: false,
+ surveyId: "001",
+ summary: true,
+ createdBy: "Integration test"
+ };
+
+ const result = await executeQuery(
+ createQuestionnaireMutation,
+ { input },
+ ctx
+ );
+ return result.data.createQuestionnaire;
+};
+
+const createNewAnswer = async ({ id: pageId }, type) => {
+ const input = {
+ description: "",
+ guidance: "",
+ label: `${type} answer`,
+ qCode: null,
+ type: `${type}`,
+ questionPageId: pageId
+ };
+
+ const result = await executeQuery(createAnswerMutation, { input }, ctx);
+ return result.data.createAnswer;
+};
+
+const queryAnswer = async id => {
+ const result = await executeQuery(
+ getAnswerQuery,
+ {
+ id
+ },
+ ctx
+ );
+ return result.data.answer;
+};
+
+const updateAnswer = async input => {
+ const result = await executeQuery(
+ updateAnswerMutation,
+ {
+ input
+ },
+ ctx
+ );
+
+ return result.data;
+};
+
+describe("resolvers", () => {
+ let questionnaire;
+ let sections;
+ let pages;
+ let firstPage;
+
+ beforeAll(() => db.migrate.latest());
+ afterAll(() => db.destroy());
+ afterEach(() => db("Questionnaires").delete());
+
+ beforeEach(async () => {
+ questionnaire = await createNewQuestionnaire();
+ sections = questionnaire.sections;
+ pages = first(sections).pages;
+ firstPage = first(pages);
+ });
+
+ it("should set correct default properties for currency answer", async () => {
+ const { id } = await createNewAnswer(firstPage, "Currency");
+ const { properties } = await queryAnswer(id);
+ expect(properties).toMatchObject({ decimals: 0, required: false });
+ });
+
+ it("should set correct default properties for number answer", async () => {
+ const { id } = await createNewAnswer(firstPage, "Number");
+ const { properties } = await queryAnswer(id);
+ expect(properties).toMatchObject({ decimals: 0, required: false });
+ });
+
+ it("should set correct default properties for date answer", async () => {
+ const { id } = await createNewAnswer(firstPage, "Date");
+ const { properties } = await queryAnswer(id);
+ expect(properties).toMatchObject({ format: "dd/mm/yyyy", required: false });
+ });
+
+ it("should allow required property to be updated", async () => {
+ const { id } = await createNewAnswer(firstPage, "Currency");
+ const properties = { required: true };
+ await updateAnswer({ id, properties });
+ const { properties: updatedProperties } = await queryAnswer(id);
+
+ expect(updatedProperties).toEqual(expect.objectContaining(properties));
+ });
+
+ it("should allow decimals property to be updated", async () => {
+ const { id } = await createNewAnswer(firstPage, "Currency");
+ const properties = { decimals: 5 };
+ await updateAnswer({ id, properties });
+ const { properties: updatedProperties } = await queryAnswer(id);
+
+ expect(updatedProperties).toEqual(expect.objectContaining(properties));
+ });
+
+ it("should allow format property to be updated", async () => {
+ const { id } = await createNewAnswer(firstPage, "Date");
+ const properties = { format: "mm/yy" };
+ await updateAnswer({ id, properties });
+ const { properties: updatedProperties } = await queryAnswer(id);
+
+ expect(updatedProperties).toEqual(expect.objectContaining(properties));
+ });
+});
diff --git a/eq-author-api/schema/resolversTests/validation.test.js b/eq-author-api/schema/resolversTests/validation.test.js
new file mode 100644
index 0000000000..f9e6c74121
--- /dev/null
+++ b/eq-author-api/schema/resolversTests/validation.test.js
@@ -0,0 +1,513 @@
+const { first } = require("lodash");
+const repositories = require("../../repositories");
+const db = require("../../db");
+const executeQuery = require("../../tests/utils/executeQuery");
+const {
+ createQuestionnaireMutation,
+ createAnswerMutation,
+ getAnswerValidations,
+ toggleAnswerValidation,
+ updateAnswerValidation,
+ createMetadataMutation
+} = require("../../tests/utils/graphql");
+
+const {
+ CUSTOM,
+ PREVIOUS_ANSWER,
+ METADATA,
+ NOW
+} = require("../../constants/validation-entity-types");
+
+const ctx = { repositories };
+
+const createNewQuestionnaire = async () => {
+ const input = {
+ title: "Test Questionnaire",
+ description: "Questionnaire created by integration test.",
+ theme: "default",
+ legalBasis: "Voluntary",
+ navigation: false,
+ surveyId: "001",
+ summary: true,
+ createdBy: "Integration test"
+ };
+
+ const result = await executeQuery(
+ createQuestionnaireMutation,
+ { input },
+ ctx
+ );
+ return result.data.createQuestionnaire;
+};
+
+const createNewAnswer = async ({ id: pageId }, type) => {
+ const input = {
+ description: "",
+ guidance: "",
+ label: `${type} answer`,
+ qCode: null,
+ type: `${type}`,
+ questionPageId: pageId
+ };
+
+ const result = await executeQuery(createAnswerMutation, { input }, ctx);
+ if (result.errors) {
+ throw new Error(result.errors[0]);
+ }
+ return result.data.createAnswer;
+};
+
+const createMetadata = async questionnaireId => {
+ const input = {
+ questionnaireId
+ };
+
+ const result = await executeQuery(createMetadataMutation, { input }, ctx);
+
+ if (result.errors) {
+ throw new Error(result.errors[0]);
+ }
+ return result.data.createMetadata;
+};
+
+const queryAnswerValidations = async id => {
+ const result = await executeQuery(
+ getAnswerValidations,
+ {
+ id
+ },
+ ctx
+ );
+ if (result.errors) {
+ throw new Error(result.errors[0]);
+ }
+ return result.data.answer.validation;
+};
+
+const mutateValidationToggle = async input => {
+ const result = await executeQuery(
+ toggleAnswerValidation,
+ {
+ input
+ },
+ ctx
+ );
+
+ return result.data.toggleValidationRule;
+};
+
+const mutateValidationParameters = async input => {
+ const result = await executeQuery(
+ updateAnswerValidation,
+ {
+ input
+ },
+ ctx
+ );
+ if (result.errors) {
+ throw new Error(result.errors[0]);
+ }
+ return result.data.updateValidationRule;
+};
+
+describe("resolvers", () => {
+ let questionnaire;
+ let sections;
+ let pages;
+ let firstPage;
+
+ beforeAll(() => db.migrate.latest());
+ afterAll(() => db.destroy());
+ afterEach(() => db("Questionnaires").delete());
+
+ beforeEach(async () => {
+ questionnaire = await createNewQuestionnaire();
+ sections = questionnaire.sections;
+ pages = first(sections).pages;
+ firstPage = first(pages);
+ });
+
+ describe("All", () => {
+ it("can toggle any validation rule on and off without affecting another", async () => {
+ const currencyAnswer = await createNewAnswer(firstPage, "Currency");
+ let currencyValidation = await queryAnswerValidations(currencyAnswer.id);
+
+ await mutateValidationToggle({
+ id: currencyValidation.minValue.id,
+ enabled: true
+ });
+
+ currencyValidation = await queryAnswerValidations(currencyAnswer.id);
+
+ expect(currencyValidation.minValue).toHaveProperty("enabled", true);
+ expect(currencyValidation.maxValue).toHaveProperty("enabled", false);
+
+ await mutateValidationToggle({
+ id: currencyValidation.minValue.id,
+ enabled: false
+ });
+
+ currencyValidation = await queryAnswerValidations(currencyAnswer.id);
+
+ expect(currencyValidation.minValue).toHaveProperty("enabled", false);
+ expect(currencyValidation.maxValue).toHaveProperty("enabled", false);
+ });
+ });
+
+ describe("Number and Currency", () => {
+ it("should create min and max validation db entries for Currency and Number answers", async () => {
+ const currencyAnswer = await createNewAnswer(firstPage, "Currency");
+ const currencyValidation = await queryAnswerValidations(
+ currencyAnswer.id
+ );
+
+ const numberAnswer = await createNewAnswer(firstPage, "Number");
+ const numberValidation = await queryAnswerValidations(numberAnswer.id);
+
+ const validationObject = (minValueId, maxValueId) => ({
+ minValue: {
+ id: minValueId,
+ enabled: false,
+ inclusive: false,
+ custom: null
+ },
+ maxValue: {
+ id: maxValueId,
+ enabled: false,
+ inclusive: false,
+ custom: null,
+ entityType: CUSTOM
+ }
+ });
+ expect(currencyValidation).toEqual(
+ validationObject(
+ currencyValidation.minValue.id,
+ currencyValidation.maxValue.id
+ )
+ );
+ expect(numberValidation).toEqual(
+ validationObject(
+ numberValidation.minValue.id,
+ numberValidation.maxValue.id
+ )
+ );
+ });
+
+ it("can update inclusive and custom min values", async () => {
+ const currencyAnswer = await createNewAnswer(firstPage, "Currency");
+ const currencyValidation = await queryAnswerValidations(
+ currencyAnswer.id
+ );
+
+ const result = await mutateValidationParameters({
+ id: currencyValidation.minValue.id,
+ minValueInput: {
+ custom: 10,
+ inclusive: true
+ }
+ });
+ expect(result).toMatchObject({
+ id: currencyValidation.minValue.id,
+ custom: 10,
+ inclusive: true
+ });
+ });
+
+ it("can update inclusive and custom max values", async () => {
+ const currencyAnswer = await createNewAnswer(firstPage, "Currency");
+ const currencyValidation = await queryAnswerValidations(
+ currencyAnswer.id
+ );
+
+ const result = await mutateValidationParameters({
+ id: currencyValidation.maxValue.id,
+ maxValueInput: {
+ custom: 10,
+ inclusive: true
+ }
+ });
+
+ expect(result).toMatchObject({
+ id: currencyValidation.maxValue.id,
+ custom: 10,
+ inclusive: true
+ });
+ });
+
+ it("can update inclusive and previous answer max values", async () => {
+ const previousAnswer = await createNewAnswer(firstPage, "Number");
+ const currencyAnswer = await createNewAnswer(firstPage, "Currency");
+ const currencyValidation = await queryAnswerValidations(
+ currencyAnswer.id
+ );
+
+ const result = await mutateValidationParameters({
+ id: currencyValidation.maxValue.id,
+ maxValueInput: {
+ previousAnswer: previousAnswer.id,
+ inclusive: true
+ }
+ });
+
+ expect(result).toMatchObject({
+ id: currencyValidation.maxValue.id,
+ previousAnswer: {
+ id: previousAnswer.id
+ },
+ inclusive: true
+ });
+ });
+
+ it("can update inclusive and entity type", async () => {
+ const currencyAnswer = await createNewAnswer(firstPage, "Currency");
+ const currencyValidation = await queryAnswerValidations(
+ currencyAnswer.id
+ );
+
+ const entityTypes = [CUSTOM, PREVIOUS_ANSWER, METADATA];
+
+ const promises = entityTypes.map(async entityType => {
+ const result = await mutateValidationParameters({
+ id: currencyValidation.maxValue.id,
+ maxValueInput: {
+ entityType,
+ inclusive: true
+ }
+ });
+
+ expect(result).toMatchObject({
+ id: currencyValidation.maxValue.id,
+ entityType
+ });
+ });
+
+ await Promise.all(promises);
+ });
+ });
+
+ describe("Date", () => {
+ let params;
+
+ beforeEach(() => {
+ params = {
+ entityType: CUSTOM,
+ offset: {
+ value: 8,
+ unit: "Months"
+ },
+ relativePosition: "After"
+ };
+ });
+
+ it("should create earliest validation db entries for Date answers", async () => {
+ const answer = await createNewAnswer(firstPage, "Date");
+ const validation = await queryAnswerValidations(answer.id);
+ const validationObject = (earliestId, latestId) => ({
+ earliestDate: {
+ id: earliestId,
+ enabled: false,
+ offset: {
+ value: 0,
+ unit: "Days"
+ },
+ relativePosition: "Before",
+ custom: null
+ },
+ latestDate: {
+ id: latestId,
+ offset: {
+ value: 0,
+ unit: "Days"
+ },
+ relativePosition: "After",
+ custom: null
+ }
+ });
+
+ expect(validation).toMatchObject(
+ validationObject(validation.earliestDate.id, validation.latestDate.id)
+ );
+ });
+
+ describe("Earliest", () => {
+ it("should be able to update properties", async () => {
+ const answer = await createNewAnswer(firstPage, "Date");
+ const validation = await queryAnswerValidations(answer.id);
+ const result = await mutateValidationParameters({
+ id: validation.earliestDate.id,
+ earliestDateInput: {
+ ...params,
+ custom: "2017-01-01"
+ }
+ });
+ const expected = {
+ id: validation.earliestDate.id,
+ ...params,
+ customDate: "2017-01-01",
+ previousAnswer: null,
+ metadata: null
+ };
+ expect(result).toEqual(expected);
+ });
+
+ it("can update previous answer", async () => {
+ const previousAnswer = await createNewAnswer(firstPage, "Date");
+ const answer = await createNewAnswer(firstPage, "Date");
+ const validation = await queryAnswerValidations(answer.id);
+
+ const result = await mutateValidationParameters({
+ id: validation.earliestDate.id,
+ earliestDateInput: {
+ ...params,
+ entityType: PREVIOUS_ANSWER,
+ previousAnswer: previousAnswer.id
+ }
+ });
+
+ expect(result).toMatchObject({
+ entityType: PREVIOUS_ANSWER,
+ previousAnswer: {
+ id: previousAnswer.id
+ }
+ });
+ });
+
+ it("can update metadata", async () => {
+ const metadata = await createMetadata(questionnaire.id);
+ const answer = await createNewAnswer(firstPage, "Date");
+ const validation = await queryAnswerValidations(answer.id);
+
+ const result = await mutateValidationParameters({
+ id: validation.earliestDate.id,
+ earliestDateInput: {
+ ...params,
+ entityType: METADATA,
+ metadata: metadata.id
+ }
+ });
+
+ expect(result).toMatchObject({
+ entityType: METADATA,
+ metadata: {
+ id: metadata.id
+ }
+ });
+ });
+
+ it("can update entity type", async () => {
+ const answer = await createNewAnswer(firstPage, "Date");
+ const validation = await queryAnswerValidations(answer.id);
+
+ const entityTypes = [CUSTOM, PREVIOUS_ANSWER, METADATA, NOW];
+
+ const promises = entityTypes.map(async entityType => {
+ const result = await mutateValidationParameters({
+ id: validation.earliestDate.id,
+ earliestDateInput: {
+ ...params,
+ entityType
+ }
+ });
+
+ expect(result).toMatchObject({
+ id: validation.earliestDate.id,
+ entityType
+ });
+ });
+
+ await Promise.all(promises);
+ });
+ });
+
+ describe("Latest", () => {
+ it("should be able to update properties", async () => {
+ const answer = await createNewAnswer(firstPage, "Date");
+ const validation = await queryAnswerValidations(answer.id);
+ const result = await mutateValidationParameters({
+ id: validation.latestDate.id,
+ latestDateInput: {
+ custom: "2017-01-01",
+ ...params
+ }
+ });
+ const expected = {
+ id: validation.latestDate.id,
+ customDate: "2017-01-01",
+ previousAnswer: null,
+ metadata: null,
+ ...params
+ };
+
+ expect(result).toEqual(expected);
+ });
+
+ it("can update previous answer", async () => {
+ const previousAnswer = await createNewAnswer(firstPage, "Date");
+ const answer = await createNewAnswer(firstPage, "Date");
+ const validation = await queryAnswerValidations(answer.id);
+
+ const result = await mutateValidationParameters({
+ id: validation.latestDate.id,
+ latestDateInput: {
+ ...params,
+ entityType: PREVIOUS_ANSWER,
+ previousAnswer: previousAnswer.id
+ }
+ });
+
+ expect(result).toMatchObject({
+ entityType: PREVIOUS_ANSWER,
+ previousAnswer: {
+ id: previousAnswer.id
+ }
+ });
+ });
+
+ it("can update metadata", async () => {
+ const metadata = await createMetadata(questionnaire.id);
+ const answer = await createNewAnswer(firstPage, "Date");
+ const validation = await queryAnswerValidations(answer.id);
+
+ const result = await mutateValidationParameters({
+ id: validation.latestDate.id,
+ latestDateInput: {
+ ...params,
+ entityType: METADATA,
+ metadata: metadata.id
+ }
+ });
+
+ expect(result).toMatchObject({
+ entityType: METADATA,
+ metadata: {
+ id: metadata.id
+ }
+ });
+ });
+
+ it("can update entity type", async () => {
+ const answer = await createNewAnswer(firstPage, "Date");
+ const validation = await queryAnswerValidations(answer.id);
+
+ const entityTypes = [CUSTOM, PREVIOUS_ANSWER, METADATA, NOW];
+
+ const promises = entityTypes.map(async entityType => {
+ const result = await mutateValidationParameters({
+ id: validation.latestDate.id,
+ latestDateInput: {
+ ...params,
+ entityType
+ }
+ });
+
+ expect(result).toMatchObject({
+ id: validation.latestDate.id,
+ entityType
+ });
+ });
+
+ await Promise.all(promises);
+ });
+ });
+ });
+});
diff --git a/eq-author-api/scripts/run_app.sh b/eq-author-api/scripts/run_app.sh
new file mode 100755
index 0000000000..780b075eec
--- /dev/null
+++ b/eq-author-api/scripts/run_app.sh
@@ -0,0 +1,5 @@
+#!/bin/bash
+
+yarn install
+yarn knex -- migrate:latest
+yarn start
diff --git a/eq-author-api/scripts/test.sh b/eq-author-api/scripts/test.sh
new file mode 100755
index 0000000000..cb0999be99
--- /dev/null
+++ b/eq-author-api/scripts/test.sh
@@ -0,0 +1,28 @@
+#!/bin/bash
+set -e
+
+POSTGRES_USER=postgres
+POSTGRES_PASSWORD=mysecretpassword
+POSTGRES_DB=postgres
+
+echo "starting postgres docker..."
+
+CONTAINER_ID=$(docker run -tid -P -e POSTGRES_USER=$POSTGRES_USER -e POSTGRES_PASSWORD=$POSTGRES_PASSWORD -e POSTGRES_DB=$POSTGRES_DB postgres:9.4-alpine)
+POSTGRES_HOST=$(docker port $CONTAINER_ID 5432)
+POSTGRES_PORT=$(echo $POSTGRES_HOST | awk -F: '{print $NF}')
+
+echo "docker started at: $POSTGRES_HOST"
+
+function finish {
+ echo "killing docker..."
+ docker rm -vf $CONTAINER_ID
+}
+trap finish EXIT
+
+echo "waiting on postgres to start..."
+
+./node_modules/.bin/wait-for-postgres --quiet --port $POSTGRES_PORT --password $POSTGRES_PASSWORD
+
+echo "running tests..."
+
+NODE_ENV=test DB_CONNECTION_URI="postgres://$POSTGRES_USER:$POSTGRES_PASSWORD@$POSTGRES_HOST/postgres" yarn jest --runInBand "$@"
diff --git a/eq-author-api/tests/fixtures/data.json b/eq-author-api/tests/fixtures/data.json
new file mode 100644
index 0000000000..c338011d4a
--- /dev/null
+++ b/eq-author-api/tests/fixtures/data.json
@@ -0,0 +1,50 @@
+{
+ "questionnaire": {
+ "title": "Nick's Questionnaire",
+ "description": "This is a dummy questionnaire",
+ "theme": "meh",
+ "legalBasis": "Voluntary",
+ "navigation": true,
+ "surveyId": "1"
+ },
+
+ "section": {
+ "title": "Cheese",
+ "description": "Let's talk about cheese...",
+ "questionnaireId": 1
+ },
+
+ "question": {
+ "title": "What are you opinions on cheese?",
+ "description": "Please enter your thoughts and feelings about that much beloved dairy product",
+ "guidance": "this is some guidance",
+ "type": "General",
+ "sectionId": 1
+ },
+
+ "answer": [
+ {
+ "label": "How much do you like cheese?",
+ "description": "On a scale of 1-5",
+ "guidance": "1 being liking cheese, 5 being loving cheese. There is no option for not liking cheese",
+ "type": "Integer",
+ "properties": {
+ "required": true,
+ "decimals": 1
+ },
+ "questionPageId": 1
+ },
+
+ {
+ "label": "What's your favourite cheese?",
+ "description": "If you could only eat one cheese for the rest of your life, what would it be?",
+ "guidance": "Think hard about this, it's a legally binding contract",
+ "type": "Integer",
+ "properties": {
+ "required": true,
+ "decimals": 0
+ },
+ "questionPageId": 1
+ }
+ ]
+}
diff --git a/eq-author-api/tests/fixtures/queries.gql b/eq-author-api/tests/fixtures/queries.gql
new file mode 100644
index 0000000000..050bc06ff7
--- /dev/null
+++ b/eq-author-api/tests/fixtures/queries.gql
@@ -0,0 +1,217 @@
+query GetQuestionnaires {
+ questionnaires {
+ id
+ }
+}
+
+query GetQuestionnaire {
+ questionnaire(id: 1) {
+ id
+ title
+ surveyId
+ sections {
+ title
+ pages {
+ id
+ title
+ ... on QuestionPage {
+ guidance
+ answers {
+ id
+ description
+ guidance
+ qCode
+ label
+ type
+ questionPageId
+ }
+ }
+ }
+ }
+ }
+}
+
+mutation CreateQuestionnaire(
+ $title: String!
+ $description: String!
+ $theme: String!
+ $legalBasis: LegalBasis!
+ $navigation: Boolean
+ $surveyId: String!
+) {
+ createQuestionnaire(
+ title: $title
+ description: $description
+ theme: $theme
+ legalBasis: $legalBasis
+ navigation: $navigation
+ surveyId: $surveyId
+ ) {
+ id
+ }
+}
+
+mutation UpdateQuestionnaire(
+ $id: Int!
+ $title: String!
+ $description: String!
+ $theme: String!
+ $legalBasis: LegalBasis!
+ $navigation: Boolean
+) {
+ updateQuestionnaire(
+ id: $id
+ title: $title
+ description: $description
+ theme: $theme
+ legalBasis: $legalBasis
+ navigation: $navigation
+ ) {
+ id
+ }
+}
+
+mutation DeleteQuestionnaire($id: Int = 2) {
+ deleteQuestionnaire(id: $id) {
+ id
+ }
+}
+
+mutation CreateSection(
+ $title: String!
+ $questionnaireId: Int!
+) {
+ createSection(
+ title: $title
+ questionnaireId: $questionnaireId
+ ) {
+ id
+ }
+}
+
+mutation UpdateSection($id: Int!, $title: String!) {
+ updateSection(id: $id, title: $title) {
+ id
+ }
+}
+
+mutation DeleteSection($id: Int!) {
+ deleteSection(id: $id) {
+ id
+ }
+}
+
+query GetPage {
+ page(id: 1) {
+ title
+ }
+}
+
+mutation CreatePage($title: String!, $description: String!, $sectionId: Int!) {
+ createPage(title: $title, description: $description, sectionId: $sectionId) {
+ id
+ }
+}
+
+mutation UpdatePage($id: Int!, $title: String!, $description: String!) {
+ updatePage(id: $id, title: $title, description: $description) {
+ id
+ }
+}
+
+mutation DeletePage($id: Int!) {
+ deletePage(id: $id) {
+ id
+ }
+}
+
+mutation CreateQuestionPage(
+ $title: String!
+ $description: String!
+ $guidance: String
+ $type: QuestionType!
+ $sectionId: Int!
+) {
+ createQuestionPage(
+ title: $title
+ description: $description
+ guidance: $guidance
+ type: $type
+ sectionId: $sectionId
+ ) {
+ id
+ }
+}
+
+mutation UpdateQuestionPage(
+ $id: Int!
+ $title: String
+ $description: String
+ $guidance: String
+ $type: QuestionType
+) {
+ updateQuestionPage(
+ id: $id
+ title: $title
+ description: $description
+ guidance: $guidance
+ type: $type
+ ) {
+ id
+ }
+}
+
+mutation DeleteQuestionPage($id: Int!) {
+ deleteQuestionPage(id: $id) {
+ id
+ }
+}
+
+mutation CreateAnswer(
+ $description: String
+ $guidance: String
+ $qCode: String
+ $label: String
+ $type: AnswerType!
+ $questionPageId: Int!
+) {
+ createAnswer(
+ description: $description
+ guidance: $guidance
+ qCode: $qCode
+ label: $label
+ type: $type
+ questionPageId: $questionPageId
+ ) {
+ id
+ }
+}
+
+mutation UpdateAnswer(
+ $id: Int!
+ $description: String
+ $guidance: String
+ $qCode: String
+ $label: String
+ $type: AnswerType
+ $properties: JSON
+) {
+ updateAnswer(
+ id: $id
+ description: $description
+ guidance: $guidance
+ qCode: $qCode
+ label: $label
+ type: $type
+ properties: $properties
+ ) {
+ id
+ properties
+ }
+}
+
+mutation DeleteAnswer($id: Int!) {
+ deleteAnswer(id: $id) {
+ id
+ }
+}
diff --git a/eq-author-api/tests/schema/mutations/createAnswer.test.js b/eq-author-api/tests/schema/mutations/createAnswer.test.js
new file mode 100644
index 0000000000..9b5f262f88
--- /dev/null
+++ b/eq-author-api/tests/schema/mutations/createAnswer.test.js
@@ -0,0 +1,56 @@
+const executeQuery = require("../../utils/executeQuery");
+const mockRepository = require("../../utils/mockRepository");
+
+describe("createAnswer", () => {
+ const createAnswer = `
+ mutation CreateAnswer($input: CreateAnswerInput!) {
+ createAnswer(input: $input) {
+ id,
+ description,
+ guidance,
+ qCode,
+ label,
+ type,
+ ... on MultipleChoiceAnswer {
+ options {
+ id
+ }
+ }
+ }
+ }
+ `;
+
+ let repositories;
+
+ beforeEach(() => {
+ repositories = {
+ Answer: mockRepository({
+ insert: {
+ id: "1",
+ type: "TextField",
+ page: {
+ id: "1"
+ }
+ }
+ })
+ };
+ });
+
+ it("should call createAnswer on create mutation", async () => {
+ const input = {
+ description: "Test answer description",
+ guidance: "Test answer guidance",
+ type: "TextField",
+ questionPageId: "1"
+ };
+
+ const result = await executeQuery(
+ createAnswer,
+ { input },
+ { repositories }
+ );
+
+ expect(result.errors).toBeUndefined();
+ expect(repositories.Answer.createAnswer).toHaveBeenCalled();
+ });
+});
diff --git a/eq-author-api/tests/schema/mutations/createMetadata.test.js b/eq-author-api/tests/schema/mutations/createMetadata.test.js
new file mode 100644
index 0000000000..21d3b1e8bf
--- /dev/null
+++ b/eq-author-api/tests/schema/mutations/createMetadata.test.js
@@ -0,0 +1,33 @@
+const executeQuery = require("../../utils/executeQuery");
+const mockRepository = require("../../utils/mockRepository");
+const { createMetadataMutation } = require("../../utils/graphql");
+
+describe("createMetadata", () => {
+ const METADATA_ID = "100";
+ const QUESTIONNAIRE_ID = "101";
+ let repositories;
+
+ beforeEach(() => {
+ repositories = {
+ Metadata: mockRepository({
+ insert: { id: METADATA_ID, questionnaireId: QUESTIONNAIRE_ID }
+ })
+ };
+ });
+
+ it("should allow creation of Metadata", async () => {
+ const input = {
+ questionnaireId: QUESTIONNAIRE_ID
+ };
+
+ const result = await executeQuery(
+ createMetadataMutation,
+ { input },
+ { repositories }
+ );
+
+ expect(result.errors).toBeUndefined();
+ expect(repositories.Metadata.insert).toHaveBeenCalledWith(input);
+ expect(result.data.createMetadata.id).toBe(METADATA_ID);
+ });
+});
diff --git a/eq-author-api/tests/schema/mutations/createPage.test.js b/eq-author-api/tests/schema/mutations/createPage.test.js
new file mode 100644
index 0000000000..ba532bb6f9
--- /dev/null
+++ b/eq-author-api/tests/schema/mutations/createPage.test.js
@@ -0,0 +1,38 @@
+const executeQuery = require("../../utils/executeQuery");
+const mockRepository = require("../../utils/mockRepository");
+
+describe("createPage", () => {
+ const createPage = `
+ mutation CreatePage($input: CreatePageInput!) {
+ createPage(input: $input) {
+ id,
+ title,
+ description,
+ ... on QuestionPage {
+ guidance
+ }
+ }
+ }
+ `;
+
+ let repositories;
+
+ beforeEach(() => {
+ repositories = {
+ Page: mockRepository()
+ };
+ });
+
+ it("should allow creation of Page", async () => {
+ const input = {
+ title: "Test page",
+ description: "Test page description",
+ sectionId: "1"
+ };
+
+ const result = await executeQuery(createPage, { input }, { repositories });
+
+ expect(result.errors).toBeUndefined();
+ expect(repositories.Page.insert).toHaveBeenCalledWith(input);
+ });
+});
diff --git a/eq-author-api/tests/schema/mutations/createQuestionPage.test.js b/eq-author-api/tests/schema/mutations/createQuestionPage.test.js
new file mode 100644
index 0000000000..02fbe5afaa
--- /dev/null
+++ b/eq-author-api/tests/schema/mutations/createQuestionPage.test.js
@@ -0,0 +1,46 @@
+const executeQuery = require("../../utils/executeQuery");
+const mockRepository = require("../../utils/mockRepository");
+
+describe("createQuestionPage", () => {
+ const createQuestionPage = `
+ mutation CreateQuestionPage($input: CreateQuestionPageInput!) {
+ createQuestionPage(input: $input) {
+ id
+ title
+ alias
+ description
+ guidance
+ }
+ }
+ `;
+
+ let repositories;
+
+ beforeEach(() => {
+ repositories = {
+ Page: mockRepository()
+ };
+ });
+
+ it("should allow creation of Question", async () => {
+ const input = {
+ title: "Test question",
+ alias: "Test alias",
+ description: "Test question description",
+ guidance: "Test question guidance",
+ sectionId: "1"
+ };
+
+ const result = await executeQuery(
+ createQuestionPage,
+ { input },
+ { repositories }
+ );
+
+ expect(result.errors).toBeUndefined();
+ expect(repositories.Page.insert).toHaveBeenCalledWith({
+ ...input,
+ pageType: "QuestionPage"
+ });
+ });
+});
diff --git a/eq-author-api/tests/schema/mutations/createQuestionnaire.test.js b/eq-author-api/tests/schema/mutations/createQuestionnaire.test.js
new file mode 100644
index 0000000000..5895eab724
--- /dev/null
+++ b/eq-author-api/tests/schema/mutations/createQuestionnaire.test.js
@@ -0,0 +1,63 @@
+const executeQuery = require("../../utils/executeQuery");
+const mockRepository = require("../../utils/mockRepository");
+
+describe("createQuestionnaire", () => {
+ const createQuestionnaire = `
+ mutation CreateQuestionnaire($input: CreateQuestionnaireInput!) {
+ createQuestionnaire(input: $input) {
+ id,
+ title,
+ description,
+ navigation,
+ legalBasis,
+ theme
+ }
+ }
+ `;
+
+ let repositories;
+
+ const QUESTIONNAIRE_ID = "123";
+ const SECTION_ID = "456";
+
+ beforeEach(() => {
+ repositories = {
+ Questionnaire: mockRepository({
+ insert: { id: QUESTIONNAIRE_ID }
+ }),
+ Section: mockRepository({
+ insert: { id: SECTION_ID }
+ }),
+ Page: mockRepository()
+ };
+ });
+
+ it("should allow creation of Questionnaire", async () => {
+ const input = {
+ title: "Test questionnaire",
+ description: "This is a test questionnaire",
+ theme: "test theme",
+ legalBasis: "Voluntary",
+ navigation: true,
+ surveyId: "abc",
+ createdBy: "John Doe"
+ };
+
+ const result = await executeQuery(
+ createQuestionnaire,
+ { input },
+ { repositories }
+ );
+
+ expect(result.errors).toBeUndefined();
+ expect(result.data.createQuestionnaire.id).toBe(QUESTIONNAIRE_ID);
+
+ expect(repositories.Questionnaire.insert).toHaveBeenCalledWith(input);
+ expect(repositories.Section.insert).toHaveBeenCalledWith(
+ expect.objectContaining({ questionnaireId: QUESTIONNAIRE_ID })
+ );
+ expect(repositories.Page.insert).toHaveBeenCalledWith(
+ expect.objectContaining({ sectionId: SECTION_ID })
+ );
+ });
+});
diff --git a/eq-author-api/tests/schema/mutations/createSection.test.js b/eq-author-api/tests/schema/mutations/createSection.test.js
new file mode 100644
index 0000000000..d808bf32d6
--- /dev/null
+++ b/eq-author-api/tests/schema/mutations/createSection.test.js
@@ -0,0 +1,52 @@
+const executeQuery = require("../../utils/executeQuery");
+const mockRepository = require("../../utils/mockRepository");
+
+describe("createSection", () => {
+ const createSection = `
+ mutation CreateSection($input: CreateSectionInput!) {
+ createSection(input: $input) {
+ id
+ title
+ alias
+ }
+ }
+ `;
+
+ let repositories;
+ const QUESTIONNAIRE_ID = "123";
+ const SECTION_ID = "456";
+
+ beforeEach(() => {
+ repositories = {
+ Section: mockRepository({
+ insert: { id: SECTION_ID, title: "Test section" }
+ }),
+ Page: mockRepository({})
+ };
+ });
+
+ it("should allow creation of Section", async () => {
+ const input = {
+ alias: "Section alias",
+ title: "Test section",
+ questionnaireId: QUESTIONNAIRE_ID
+ };
+
+ const result = await executeQuery(
+ createSection,
+ { input },
+ { repositories }
+ );
+
+ expect(result.errors).toBeUndefined();
+ expect(result.data.createSection.id).toBe(SECTION_ID);
+
+ expect(repositories.Section.insert).toHaveBeenCalledWith(
+ expect.objectContaining(input)
+ );
+
+ expect(repositories.Page.insert).toHaveBeenCalledWith(
+ expect.objectContaining({ sectionId: SECTION_ID })
+ );
+ });
+});
diff --git a/eq-author-api/tests/schema/mutations/deleteAnswer.test.js b/eq-author-api/tests/schema/mutations/deleteAnswer.test.js
new file mode 100644
index 0000000000..9f94311c75
--- /dev/null
+++ b/eq-author-api/tests/schema/mutations/deleteAnswer.test.js
@@ -0,0 +1,32 @@
+const executeQuery = require("../../utils/executeQuery");
+const mockRepository = require("../../utils/mockRepository");
+
+describe("deleteAnswer", () => {
+ const deleteAnswer = `
+ mutation DeleteAnswer($input:DeleteAnswerInput!) {
+ deleteAnswer(input:$input){
+ id
+ }
+ }
+ `;
+
+ let repositories;
+
+ beforeEach(() => {
+ repositories = {
+ Answer: mockRepository()
+ };
+ });
+
+ it("should allow deletion of Answer", async () => {
+ const input = { id: "1" };
+ const result = await executeQuery(
+ deleteAnswer,
+ { input },
+ { repositories }
+ );
+
+ expect(result.errors).toBeUndefined();
+ expect(repositories.Answer.remove).toHaveBeenCalledWith(input.id);
+ });
+});
diff --git a/eq-author-api/tests/schema/mutations/deleteMetadata.test.js b/eq-author-api/tests/schema/mutations/deleteMetadata.test.js
new file mode 100644
index 0000000000..539b4530c2
--- /dev/null
+++ b/eq-author-api/tests/schema/mutations/deleteMetadata.test.js
@@ -0,0 +1,34 @@
+const executeQuery = require("../../utils/executeQuery");
+const mockRepository = require("../../utils/mockRepository");
+
+describe("deleteMetadata", () => {
+ const deleteMetadata = `
+ mutation DeleteMetadata($input: DeleteMetadataInput!) {
+ deleteMetadata(input: $input) {
+ id
+ }
+ }
+ `;
+
+ let repositories;
+
+ beforeEach(() => {
+ repositories = {
+ Metadata: mockRepository({
+ remove: { id: "1" }
+ })
+ };
+ });
+
+ it("should allow deletion of Metadata", async () => {
+ const input = { id: "1" };
+ const result = await executeQuery(
+ deleteMetadata,
+ { input },
+ { repositories }
+ );
+
+ expect(result.errors).toBeUndefined();
+ expect(repositories.Metadata.remove).toHaveBeenCalledWith(input.id);
+ });
+});
diff --git a/eq-author-api/tests/schema/mutations/deletePage.test.js b/eq-author-api/tests/schema/mutations/deletePage.test.js
new file mode 100644
index 0000000000..e38a96e28d
--- /dev/null
+++ b/eq-author-api/tests/schema/mutations/deletePage.test.js
@@ -0,0 +1,28 @@
+const executeQuery = require("../../utils/executeQuery");
+const mockRepository = require("../../utils/mockRepository");
+
+describe("deletePage", () => {
+ const deletePage = `
+ mutation DeletePage($input:DeletePageInput!) {
+ deletePage(input:$input) {
+ id
+ }
+ }
+ `;
+
+ let repositories;
+
+ beforeEach(() => {
+ repositories = {
+ Page: mockRepository()
+ };
+ });
+
+ it("should allow deletion of Page", async () => {
+ const input = { id: "1" };
+ const result = await executeQuery(deletePage, { input }, { repositories });
+
+ expect(result.errors).toBeUndefined();
+ expect(repositories.Page.remove).toHaveBeenCalledWith(input.id);
+ });
+});
diff --git a/eq-author-api/tests/schema/mutations/deleteQuestionPage.test.js b/eq-author-api/tests/schema/mutations/deleteQuestionPage.test.js
new file mode 100644
index 0000000000..e29e3aa593
--- /dev/null
+++ b/eq-author-api/tests/schema/mutations/deleteQuestionPage.test.js
@@ -0,0 +1,32 @@
+const executeQuery = require("../../utils/executeQuery");
+const mockRepository = require("../../utils/mockRepository");
+
+describe("deleteQuestionPage", () => {
+ const deleteQuestionPage = `
+ mutation DeleteQuestionPage($input:DeleteQuestionPageInput!) {
+ deleteQuestionPage(input:$input){
+ id
+ }
+ }
+ `;
+
+ let repositories;
+
+ beforeEach(() => {
+ repositories = {
+ QuestionPage: mockRepository()
+ };
+ });
+
+ it("should allow deletion of Question", async () => {
+ const input = { id: "1" };
+ const result = await executeQuery(
+ deleteQuestionPage,
+ { input },
+ { repositories }
+ );
+
+ expect(result.errors).toBeUndefined();
+ expect(repositories.QuestionPage.remove).toHaveBeenCalledWith(input.id);
+ });
+});
diff --git a/eq-author-api/tests/schema/mutations/deleteQuestionnaire.test.js b/eq-author-api/tests/schema/mutations/deleteQuestionnaire.test.js
new file mode 100644
index 0000000000..db356b5c78
--- /dev/null
+++ b/eq-author-api/tests/schema/mutations/deleteQuestionnaire.test.js
@@ -0,0 +1,32 @@
+const executeQuery = require("../../utils/executeQuery");
+const mockRepository = require("../../utils/mockRepository");
+
+describe("deleteQuestionnaire", () => {
+ const deleteQuestionnaire = `
+ mutation DeleteQuestionnaire($input:DeleteQuestionnaireInput!) {
+ deleteQuestionnaire(input:$input){
+ id
+ }
+ }
+ `;
+
+ let repositories;
+
+ beforeEach(() => {
+ repositories = {
+ Questionnaire: mockRepository()
+ };
+ });
+
+ it("should allow deletion of Questionnaire", async () => {
+ const input = { id: "1" };
+ const result = await executeQuery(
+ deleteQuestionnaire,
+ { input },
+ { repositories }
+ );
+
+ expect(result.errors).toBeUndefined();
+ expect(repositories.Questionnaire.remove).toHaveBeenCalledWith(input.id);
+ });
+});
diff --git a/eq-author-api/tests/schema/mutations/deleteSection.test.js b/eq-author-api/tests/schema/mutations/deleteSection.test.js
new file mode 100644
index 0000000000..6f6e220e1d
--- /dev/null
+++ b/eq-author-api/tests/schema/mutations/deleteSection.test.js
@@ -0,0 +1,32 @@
+const executeQuery = require("../../utils/executeQuery");
+const mockRepository = require("../../utils/mockRepository");
+
+describe("deleteSection", () => {
+ const deleteSection = `
+ mutation DeleteSection($input:DeleteSectionInput!) {
+ deleteSection(input:$input) {
+ id
+ }
+ }
+ `;
+
+ let repositories;
+
+ beforeEach(() => {
+ repositories = {
+ Section: mockRepository()
+ };
+ });
+
+ it("should allow deletion of Section", async () => {
+ const input = { id: "1" };
+ const result = await executeQuery(
+ deleteSection,
+ { input },
+ { repositories }
+ );
+
+ expect(result.errors).toBeUndefined();
+ expect(repositories.Section.remove).toHaveBeenCalledWith(input.id);
+ });
+});
diff --git a/eq-author-api/tests/schema/mutations/updateAnswer.test.js b/eq-author-api/tests/schema/mutations/updateAnswer.test.js
new file mode 100644
index 0000000000..666d9578f5
--- /dev/null
+++ b/eq-author-api/tests/schema/mutations/updateAnswer.test.js
@@ -0,0 +1,67 @@
+const executeQuery = require("../../utils/executeQuery");
+const mockRepository = require("../../utils/mockRepository");
+
+describe("updateAnswer", () => {
+ const updateAnswer = `
+ mutation UpdateAnswer($input: UpdateAnswerInput!) {
+ updateAnswer(input: $input) {
+ id,
+ description,
+ guidance,
+ qCode,
+ label,
+ type
+ properties
+ }
+ }
+ `;
+
+ let repositories;
+
+ beforeEach(() => {
+ repositories = {
+ Answer: mockRepository()
+ };
+ });
+
+ it("should allow update of Answer", async () => {
+ const input = {
+ id: "1",
+ description: "This is an updated answer description",
+ guidance: "This is an update answer guidance",
+ qCode: "123",
+ label: "updated test answer",
+ type: "Date",
+ properties: { required: true }
+ };
+
+ const result = await executeQuery(
+ updateAnswer,
+ { input },
+ { repositories }
+ );
+
+ expect(result.errors).toBeUndefined();
+ expect(repositories.Answer.update).toHaveBeenCalledWith(input);
+ });
+
+ it("should allow update of Answer with deprecated fields", async () => {
+ const input = {
+ id: "1",
+ description: "This is an updated answer description",
+ guidance: "This is an update answer guidance",
+ qCode: "123",
+ label: "updated test answer",
+ type: "Date"
+ };
+
+ const result = await executeQuery(
+ updateAnswer,
+ { input },
+ { repositories }
+ );
+
+ expect(result.errors).toBeUndefined();
+ expect(repositories.Answer.update).toHaveBeenCalledWith(input);
+ });
+});
diff --git a/eq-author-api/tests/schema/mutations/updateMetadata.test.js b/eq-author-api/tests/schema/mutations/updateMetadata.test.js
new file mode 100644
index 0000000000..81570f144a
--- /dev/null
+++ b/eq-author-api/tests/schema/mutations/updateMetadata.test.js
@@ -0,0 +1,127 @@
+const executeQuery = require("../../utils/executeQuery");
+const mockRepository = require("../../utils/mockRepository");
+
+describe("updateMetadata", () => {
+ const updateMetadata = `
+ mutation UpdateMetadata($input: UpdateMetadataInput!) {
+ updateMetadata(input: $input) {
+ id
+ }
+ }
+ `;
+
+ const METADATA_ID = "100";
+ const basicInput = {
+ id: METADATA_ID,
+ key: "ru_ref",
+ alias: "Reporting Unit Reference"
+ };
+
+ let repositories;
+
+ beforeEach(() => {
+ repositories = {
+ Metadata: mockRepository({
+ update: {
+ id: METADATA_ID
+ }
+ })
+ };
+ });
+
+ it("should allow update of Metadata for textValue", async () => {
+ const input = {
+ ...basicInput,
+ type: "Text",
+ textValue: "test value"
+ };
+
+ const expected = {
+ ...basicInput,
+ type: "Text",
+ textValue: "test value"
+ };
+
+ const result = await executeQuery(
+ updateMetadata,
+ { input },
+ { repositories }
+ );
+
+ expect(result.errors).toBeUndefined();
+ expect(repositories.Metadata.update).toHaveBeenCalledWith(expected);
+ expect(result.data.updateMetadata.id).toBe(METADATA_ID);
+ });
+
+ it("should allow update of Metadata for regionValue", async () => {
+ const input = {
+ ...basicInput,
+ type: "Region",
+ regionValue: "GB_ENG"
+ };
+
+ const expected = {
+ ...basicInput,
+ type: "Region",
+ regionValue: "GB_ENG"
+ };
+
+ const result = await executeQuery(
+ updateMetadata,
+ { input },
+ { repositories }
+ );
+
+ expect(result.errors).toBeUndefined();
+ expect(repositories.Metadata.update).toHaveBeenCalledWith(expected);
+ expect(result.data.updateMetadata.id).toBe(METADATA_ID);
+ });
+
+ it("should allow update of Metadata for languageValue", async () => {
+ const input = {
+ ...basicInput,
+ type: "Language",
+ languageValue: "cy"
+ };
+
+ const expected = {
+ ...basicInput,
+ type: "Language",
+ languageValue: "cy"
+ };
+
+ const result = await executeQuery(
+ updateMetadata,
+ { input },
+ { repositories }
+ );
+
+ expect(result.errors).toBeUndefined();
+ expect(repositories.Metadata.update).toHaveBeenCalledWith(expected);
+ expect(result.data.updateMetadata.id).toBe(METADATA_ID);
+ });
+
+ it("should allow update of Metadata for dateValue", async () => {
+ const input = {
+ ...basicInput,
+ type: "Date",
+ dateValue: "2007-12-03"
+ };
+
+ const expected = {
+ ...basicInput,
+ type: "Date",
+ dateValue: new Date("2007-12-03")
+ };
+
+ const result = await executeQuery(
+ updateMetadata,
+ { input },
+ { repositories }
+ );
+
+ expect(result.errors).toBeUndefined();
+ expect(repositories.Metadata.update).toHaveBeenCalledWith(expected);
+ expect(result.data.updateMetadata.id).toBe(METADATA_ID);
+ });
+});
diff --git a/eq-author-api/tests/schema/mutations/updatePage.test.js b/eq-author-api/tests/schema/mutations/updatePage.test.js
new file mode 100644
index 0000000000..07f95a6b60
--- /dev/null
+++ b/eq-author-api/tests/schema/mutations/updatePage.test.js
@@ -0,0 +1,38 @@
+const executeQuery = require("../../utils/executeQuery");
+const mockRepository = require("../../utils/mockRepository");
+
+describe("updatePage", () => {
+ const updatePage = `
+ mutation UpdatePage($input: UpdatePageInput!) {
+ updatePage(input: $input) {
+ id,
+ title,
+ description,
+ ... on QuestionPage {
+ guidance
+ }
+ }
+ }
+ `;
+
+ let repositories;
+
+ beforeEach(() => {
+ repositories = {
+ Page: mockRepository()
+ };
+ });
+
+ it("should allow update of Page", async () => {
+ const input = {
+ id: "1",
+ title: "Updated page title",
+ description: "This is an updated page description"
+ };
+
+ const result = await executeQuery(updatePage, { input }, { repositories });
+
+ expect(result.errors).toBeUndefined();
+ expect(repositories.Page.update).toHaveBeenCalledWith(input);
+ });
+});
diff --git a/eq-author-api/tests/schema/mutations/updateQuestionPage.test.js b/eq-author-api/tests/schema/mutations/updateQuestionPage.test.js
new file mode 100644
index 0000000000..5c98093e8f
--- /dev/null
+++ b/eq-author-api/tests/schema/mutations/updateQuestionPage.test.js
@@ -0,0 +1,43 @@
+const executeQuery = require("../../utils/executeQuery");
+const mockRepository = require("../../utils/mockRepository");
+
+describe("updateQuestionPage", () => {
+ const updateQuestionPage = `
+ mutation UpdateQuestionPage($input: UpdateQuestionPageInput!) {
+ updateQuestionPage(input: $input) {
+ id
+ alias
+ title
+ description
+ guidance
+ }
+ }
+ `;
+
+ let repositories;
+
+ beforeEach(() => {
+ repositories = {
+ QuestionPage: mockRepository()
+ };
+ });
+
+ it("should allow update of Question", async () => {
+ const input = {
+ id: "1",
+ alias: "Updated question alias",
+ title: "Updated question title",
+ description: "This is an updated question description",
+ guidance: "Updated question description"
+ };
+
+ const result = await executeQuery(
+ updateQuestionPage,
+ { input },
+ { repositories }
+ );
+
+ expect(result.errors).toBeUndefined();
+ expect(repositories.QuestionPage.update).toHaveBeenCalledWith(input);
+ });
+});
diff --git a/eq-author-api/tests/schema/mutations/updateQuestionnaire.test.js b/eq-author-api/tests/schema/mutations/updateQuestionnaire.test.js
new file mode 100644
index 0000000000..24e44067df
--- /dev/null
+++ b/eq-author-api/tests/schema/mutations/updateQuestionnaire.test.js
@@ -0,0 +1,40 @@
+const executeQuery = require("../../utils/executeQuery");
+const mockRepository = require("../../utils/mockRepository");
+
+describe("updateQuestionnaire", () => {
+ const updateQuestionnaire = `
+ mutation UpdateQuestionnaire($input: UpdateQuestionnaireInput!) {
+ updateQuestionnaire(input: $input) {
+ id
+ }
+ }
+ `;
+
+ let repositories;
+
+ beforeEach(() => {
+ repositories = {
+ Questionnaire: mockRepository()
+ };
+ });
+
+ it("should allow update of Questionnaire", async () => {
+ const input = {
+ id: "1",
+ title: "Test questionnaire",
+ description: "This is a test questionnaire",
+ theme: "test theme",
+ legalBasis: "Voluntary",
+ navigation: false
+ };
+
+ const result = await executeQuery(
+ updateQuestionnaire,
+ { input },
+ { repositories }
+ );
+
+ expect(result.errors).toBeUndefined();
+ expect(repositories.Questionnaire.update).toHaveBeenCalledWith(input);
+ });
+});
diff --git a/eq-author-api/tests/schema/mutations/updateSection.test.js b/eq-author-api/tests/schema/mutations/updateSection.test.js
new file mode 100644
index 0000000000..c039e27204
--- /dev/null
+++ b/eq-author-api/tests/schema/mutations/updateSection.test.js
@@ -0,0 +1,51 @@
+const executeQuery = require("../../utils/executeQuery");
+const mockRepository = require("../../utils/mockRepository");
+
+describe("updateSection", () => {
+ const updateSection = `
+ mutation UpdateSection($input: UpdateSectionInput!) {
+ updateSection(input: $input) {
+ id
+ title
+ alias
+ introductionTitle
+ introductionContent
+ introductionEnabled
+ }
+ }
+ `;
+
+ let repositories, input;
+
+ beforeEach(() => {
+ input = {
+ id: "1",
+ title: "Updated section title",
+ alias: "Updated section alias",
+ description: "This is an updated section description",
+ introductionTitle: "updated intro title",
+ introductionContent: "updated intro content",
+ introductionEnabled: true
+ };
+ repositories = {
+ Section: mockRepository()
+ };
+ });
+
+ it("should allow update of Section", async () => {
+ input = {
+ id: "1",
+ title: "Updated section title",
+ alias: "Updated section alias"
+ };
+
+ const result = await executeQuery(
+ updateSection,
+ { input },
+ { repositories }
+ );
+
+ expect(result.errors).toBeUndefined();
+ expect(repositories.Section.update).toHaveBeenCalledWith(input);
+ });
+});
diff --git a/eq-author-api/tests/schema/queries/answer.test.js b/eq-author-api/tests/schema/queries/answer.test.js
new file mode 100644
index 0000000000..a55e9ba78b
--- /dev/null
+++ b/eq-author-api/tests/schema/queries/answer.test.js
@@ -0,0 +1,86 @@
+const executeQuery = require("../../utils/executeQuery");
+const mockRepository = require("../../utils/mockRepository");
+
+describe("answer query", () => {
+ const answer = `
+ query GetAnswer($id: ID!) {
+ answer(id: $id) {
+ id
+ }
+ }
+ `;
+
+ const answerWithOption = `
+ query GetAnswer($id: ID!) {
+ answer(id: $id) {
+ id,
+ ... on MultipleChoiceAnswer {
+ options {
+ id
+ }
+ }
+ }
+ }
+ `;
+
+ const answerWithPage = `
+ query GetAnswer($id: ID!) {
+ answer(id: $id) {
+ id,
+ page {
+ id
+ }
+ }
+ }
+ `;
+
+ let repositories;
+ const id = "1";
+ const questionPageId = "2";
+
+ beforeEach(() => {
+ repositories = {
+ Answer: mockRepository({
+ getById: {
+ id,
+ questionPageId,
+ type: "Checkbox"
+ }
+ }),
+ Option: mockRepository(),
+ QuestionPage: mockRepository()
+ };
+ });
+
+ it("should fetch answer by id", async () => {
+ const result = await executeQuery(answer, { id }, { repositories });
+
+ expect(result.errors).toBeUndefined();
+ expect(repositories.Answer.getById).toHaveBeenCalledWith(id);
+ });
+
+ it("should have an association with Option", async () => {
+ const result = await executeQuery(
+ answerWithOption,
+ { id },
+ { repositories }
+ );
+
+ expect(result.errors).toBeUndefined();
+ expect(repositories.Answer.getById).toHaveBeenCalledWith(id);
+ expect(repositories.Option.findAll).toHaveBeenCalledWith({
+ answerId: id,
+ mutuallyExclusive: false
+ });
+ });
+
+ it("should have an association with Page", async () => {
+ const result = await executeQuery(answerWithPage, { id }, { repositories });
+
+ expect(result.errors).toBeUndefined();
+ expect(repositories.Answer.getById).toHaveBeenCalledWith(id);
+ expect(repositories.QuestionPage.getById).toHaveBeenCalledWith(
+ questionPageId
+ );
+ });
+});
diff --git a/eq-author-api/tests/schema/queries/answers.test.js b/eq-author-api/tests/schema/queries/answers.test.js
new file mode 100644
index 0000000000..39f0f1af19
--- /dev/null
+++ b/eq-author-api/tests/schema/queries/answers.test.js
@@ -0,0 +1,41 @@
+const executeQuery = require("../../utils/executeQuery");
+const mockRepository = require("../../utils/mockRepository");
+
+describe("answers query", () => {
+ const answer = `
+ query GetAnswers($ids: [ID]!) {
+ answers(ids: $ids) {
+ label
+ ... on BasicAnswer {
+ secondaryLabel
+ }
+ id
+ }
+ }
+ `;
+
+ let repositories;
+ const ids = ["1", "2", "3"];
+ const answers = ids.map(id => ({
+ id,
+ label: `Label${1}`,
+ secondaryLabel: `Label${1}`
+ }));
+
+ beforeEach(() => {
+ repositories = {
+ Answer: mockRepository({
+ getAnswers: answers
+ })
+ };
+ });
+
+ it("should fetch answers by id", async () => {
+ const result = await executeQuery(answer, { ids }, { repositories });
+ const { getAnswers } = repositories.Answer;
+
+ expect(result.errors).toBeUndefined();
+ expect(result.data.answers).toEqual(answers);
+ expect(getAnswers).toHaveBeenCalledWith(["1", "2", "3"]);
+ });
+});
diff --git a/eq-author-api/tests/schema/queries/metadata.test.js b/eq-author-api/tests/schema/queries/metadata.test.js
new file mode 100644
index 0000000000..4a58e2a115
--- /dev/null
+++ b/eq-author-api/tests/schema/queries/metadata.test.js
@@ -0,0 +1,77 @@
+const executeQuery = require("../../utils/executeQuery");
+const mockRepository = require("../../utils/mockRepository");
+
+describe("Metadata query", () => {
+ const metadata = `
+ query GetQuestionnaireWithMetadata($questionnaireId: ID!) {
+ questionnaire(id: $questionnaireId) {
+ id
+ metadata {
+ id
+ type
+ textValue
+ languageValue
+ regionValue
+ dateValue
+ displayName
+ }
+ }
+ }
+ `;
+
+ const QUESTIONNAIRE_ID = "2";
+
+ let repositories;
+
+ beforeEach(() => {
+ repositories = {
+ Questionnaire: mockRepository({
+ getById: {
+ id: QUESTIONNAIRE_ID
+ }
+ }),
+ Metadata: mockRepository({
+ findAll: [
+ {
+ id: 1,
+ type: "Text",
+ value: "hello",
+ alias: "Metadata1",
+ key: "UnusedKey"
+ },
+ { id: 2, type: "Language", value: "en", key: "Metadata2" },
+ { id: 3, type: "Region", value: "GB_ENG" },
+ { id: 4, type: "Date", value: "2018-01-01" }
+ ]
+ })
+ };
+ });
+
+ it("should fetch all metadata for a questionnaire", async () => {
+ const result = await executeQuery(
+ metadata,
+ { questionnaireId: QUESTIONNAIRE_ID },
+ { repositories }
+ );
+ expect(result.errors).toBeUndefined();
+ expect(repositories.Metadata.findAll).toHaveBeenCalledWith({
+ questionnaireId: QUESTIONNAIRE_ID
+ });
+ expect(result.data.questionnaire.metadata).toHaveLength(4);
+ expect(result.data.questionnaire.metadata[0]).toMatchObject({
+ textValue: "hello",
+ displayName: "Metadata1"
+ });
+ expect(result.data.questionnaire.metadata[1]).toMatchObject({
+ languageValue: "en",
+ displayName: "Metadata2"
+ });
+ expect(result.data.questionnaire.metadata[2]).toMatchObject({
+ regionValue: "GB_ENG",
+ displayName: "Untitled Metadata"
+ });
+ expect(result.data.questionnaire.metadata[3]).toMatchObject({
+ dateValue: "2018-01-01"
+ });
+ });
+});
diff --git a/eq-author-api/tests/schema/queries/option.test.js b/eq-author-api/tests/schema/queries/option.test.js
new file mode 100644
index 0000000000..739d7f4c3e
--- /dev/null
+++ b/eq-author-api/tests/schema/queries/option.test.js
@@ -0,0 +1,59 @@
+const executeQuery = require("../../utils/executeQuery");
+const mockRepository = require("../../utils/mockRepository");
+
+describe("option query", () => {
+ const option = `
+ query GetOption($id: ID!) {
+ option(id: $id) {
+ id,
+ description
+ }
+ }
+ `;
+
+ const optionWithAnswer = `
+ query GetOption($id: ID!) {
+ option(id: $id) {
+ id,
+ answer {
+ id
+ }
+ }
+ }
+ `;
+
+ let repositories;
+ const id = "1";
+ const answerId = "2";
+
+ beforeEach(() => {
+ repositories = {
+ Option: mockRepository({
+ getById: {
+ id,
+ answerId
+ }
+ }),
+ Answer: mockRepository()
+ };
+ });
+
+ it("should fetch option by id", async () => {
+ const result = await executeQuery(option, { id }, { repositories });
+
+ expect(result.errors).toBeUndefined();
+ expect(repositories.Option.getById).toHaveBeenCalledWith(id);
+ });
+
+ it("should have an association with Answer", async () => {
+ const result = await executeQuery(
+ optionWithAnswer,
+ { id },
+ { repositories }
+ );
+
+ expect(result.errors).toBeUndefined();
+ expect(repositories.Option.getById).toHaveBeenCalledWith(id);
+ expect(repositories.Answer.getById).toHaveBeenCalledWith(answerId);
+ });
+});
diff --git a/eq-author-api/tests/schema/queries/page.test.js b/eq-author-api/tests/schema/queries/page.test.js
new file mode 100644
index 0000000000..c8e27ad0f7
--- /dev/null
+++ b/eq-author-api/tests/schema/queries/page.test.js
@@ -0,0 +1,61 @@
+const executeQuery = require("../../utils/executeQuery");
+const mockRepository = require("../../utils/mockRepository");
+
+describe("Page query", () => {
+ const page = `
+ query GetPage($id: ID!) {
+ page(id: $id) {
+ id
+ }
+ }
+ `;
+
+ const pageWithSection = `
+ query GetPage($id: ID!) {
+ page(id: $id) {
+ id,
+ section {
+ id
+ }
+ }
+ }
+ `;
+
+ const id = "1";
+ const sectionId = "2";
+ let repositories;
+
+ beforeEach(() => {
+ repositories = {
+ Page: mockRepository({
+ getById: {
+ id,
+ sectionId,
+ pageType: "QuestionPage"
+ }
+ }),
+ QuestionPage: mockRepository(),
+ Section: mockRepository()
+ };
+ });
+
+ it("should fetch page by id", async () => {
+ const result = await executeQuery(page, { id }, { repositories });
+
+ expect(result.errors).toBeUndefined();
+ expect(repositories.Page.getById).toHaveBeenCalledWith(id);
+ expect(repositories.QuestionPage.findAll).not.toHaveBeenCalled();
+ });
+
+ it("should have association with Section", async () => {
+ const result = await executeQuery(
+ pageWithSection,
+ { id },
+ { repositories }
+ );
+
+ expect(result.errors).toBeUndefined();
+ expect(repositories.Page.getById).toHaveBeenCalledWith(id);
+ expect(repositories.Section.getById).toHaveBeenCalledWith(sectionId);
+ });
+});
diff --git a/eq-author-api/tests/schema/queries/questionPage.test.js b/eq-author-api/tests/schema/queries/questionPage.test.js
new file mode 100644
index 0000000000..0f42affec7
--- /dev/null
+++ b/eq-author-api/tests/schema/queries/questionPage.test.js
@@ -0,0 +1,87 @@
+const executeQuery = require("../../utils/executeQuery");
+const mockRepository = require("../../utils/mockRepository");
+
+describe("QuestionPage query", () => {
+ const questionPage = `
+ query GetQuestionPage($id: ID!) {
+ questionPage(id: $id) {
+ id,
+ section {
+ id
+ }
+ }
+ }
+ `;
+
+ const questionPageWithAnswers = `
+ query GetQuestionPageWithAnswers($id: ID!) {
+ questionPage(id: $id) {
+ id,
+ answers {
+ id
+ }
+ }
+ }
+ `;
+
+ const questionPageWithSection = `
+ query GetQuestionPageWithSection($id: ID!) {
+ questionPage(id: $id) {
+ id,
+ section {
+ id
+ }
+ }
+ }
+ `;
+
+ const id = "1";
+ const sectionId = "1";
+ let repositories;
+
+ beforeEach(() => {
+ repositories = {
+ QuestionPage: mockRepository({
+ getById: {
+ id,
+ sectionId
+ }
+ }),
+ Answer: mockRepository(),
+ Section: mockRepository()
+ };
+ });
+
+ it("should fetch QuestionPage by id", async () => {
+ const result = await executeQuery(questionPage, { id }, { repositories });
+
+ expect(result.errors).toBeUndefined();
+ expect(repositories.QuestionPage.getById).toHaveBeenCalledWith(id);
+ });
+
+ it("should have an association with Answer", async () => {
+ const result = await executeQuery(
+ questionPageWithAnswers,
+ { id },
+ { repositories }
+ );
+
+ expect(result.errors).toBeUndefined();
+ expect(repositories.QuestionPage.getById).toHaveBeenCalledWith(id);
+ expect(repositories.Answer.findAll).toHaveBeenCalledWith({
+ questionPageId: id
+ });
+ });
+
+ it("should have association with Section", async () => {
+ const result = await executeQuery(
+ questionPageWithSection,
+ { id },
+ { repositories }
+ );
+
+ expect(result.errors).toBeUndefined();
+ expect(repositories.QuestionPage.getById).toHaveBeenCalledWith(id);
+ expect(repositories.Section.getById).toHaveBeenCalledWith(sectionId);
+ });
+});
diff --git a/eq-author-api/tests/schema/queries/questionnaire.test.js b/eq-author-api/tests/schema/queries/questionnaire.test.js
new file mode 100644
index 0000000000..6bb98d62a3
--- /dev/null
+++ b/eq-author-api/tests/schema/queries/questionnaire.test.js
@@ -0,0 +1,83 @@
+const executeQuery = require("../../utils/executeQuery");
+const mockRepository = require("../../utils/mockRepository");
+
+describe("questionnaire query", () => {
+ const questionnaire = `
+ query GetQuestionnaire($id : ID!) {
+ questionnaire(id: $id) {
+ id
+ }
+ }
+ `;
+
+ const questionnaireWithSections = `
+ query GetQuestionnaireWithSections($id : ID!) {
+ questionnaire(id: $id) {
+ id,
+ sections {
+ id
+ }
+ }
+ }
+ `;
+
+ const questionnaireWithCreatedBy = `
+ query GetQuestionnaireWithSections($id : ID!) {
+ questionnaire(id: $id) {
+ id,
+ createdBy {
+ name
+ }
+ }
+ }
+`;
+
+ const id = "1";
+ let repositories;
+
+ beforeEach(() => {
+ repositories = {
+ Questionnaire: mockRepository({
+ getById: { id, createdBy: "foo" }
+ }),
+ Section: mockRepository()
+ };
+ });
+
+ it("should fetch questionnaire by id", async () => {
+ const result = await executeQuery(questionnaire, { id }, { repositories });
+
+ expect(result.errors).toBeUndefined();
+ expect(repositories.Questionnaire.getById).toHaveBeenCalledWith(id);
+ expect(repositories.Section.findAll).not.toHaveBeenCalled();
+ });
+
+ it("should have an association with Sections", async () => {
+ const result = await executeQuery(
+ questionnaireWithSections,
+ { id },
+ { repositories }
+ );
+
+ expect(result.errors).toBeUndefined();
+ expect(repositories.Questionnaire.getById).toHaveBeenCalledWith(id);
+ expect(repositories.Section.findAll).toHaveBeenCalledWith({
+ questionnaireId: id
+ });
+ });
+
+ it("should have an association with a User", async () => {
+ const result = await executeQuery(
+ questionnaireWithCreatedBy,
+ { id },
+ { repositories }
+ );
+
+ expect(result.errors).toBeUndefined();
+ expect(result.data.questionnaire).toMatchObject({
+ createdBy: {
+ name: "foo"
+ }
+ });
+ });
+});
diff --git a/eq-author-api/tests/schema/queries/questionnaires.test.js b/eq-author-api/tests/schema/queries/questionnaires.test.js
new file mode 100644
index 0000000000..e5f42e2d5d
--- /dev/null
+++ b/eq-author-api/tests/schema/queries/questionnaires.test.js
@@ -0,0 +1,33 @@
+const executeQuery = require("../../utils/executeQuery");
+const mockRepository = require("../../utils/mockRepository");
+
+describe("questionnaire query" , () => {
+
+ const questionnaires = `
+ query GetQuestionnaires {
+ questionnaires {
+ id,
+ title,
+ description,
+ navigation,
+ legalBasis,
+ theme
+ }
+ }
+ `;
+
+ let repositories;
+
+ beforeEach(() => {
+ repositories = {
+ Questionnaire : mockRepository()
+ }
+ });
+
+ it("should fetch all Questionnaires", async () => {
+ const result = await executeQuery(questionnaires, {}, { repositories });
+
+ expect(result.errors).toBeUndefined();
+ expect(repositories.Questionnaire.findAll).toHaveBeenCalled();
+ });
+});
\ No newline at end of file
diff --git a/eq-author-api/tests/schema/queries/section.test.js b/eq-author-api/tests/schema/queries/section.test.js
new file mode 100644
index 0000000000..34058ef592
--- /dev/null
+++ b/eq-author-api/tests/schema/queries/section.test.js
@@ -0,0 +1,86 @@
+const executeQuery = require("../../utils/executeQuery");
+const mockRepository = require("../../utils/mockRepository");
+
+describe("Section query", () => {
+ const section = `
+ query GetSection($id: ID!) {
+ section(id: $id) {
+ id
+ }
+ }
+ `;
+
+ const sectionWithPages = `
+ query GetSection($id: ID!) {
+ section(id: $id) {
+ id,
+ pages {
+ id
+ }
+ }
+ }
+ `;
+
+ const sectionWithQuestionnaire = `
+ query GetSection($id: ID!) {
+ section(id: $id) {
+ id,
+ questionnaire {
+ id
+ }
+ }
+ }
+ `;
+
+ const id = "1";
+ const questionnaireId = "2";
+
+ let repositories;
+
+ beforeEach(() => {
+ repositories = {
+ Section: mockRepository({
+ getById: {
+ id,
+ questionnaireId
+ }
+ }),
+ Page: mockRepository(),
+ Questionnaire: mockRepository()
+ };
+ });
+
+ it("should fetch page by id", async () => {
+ const result = await executeQuery(section, { id }, { repositories });
+
+ expect(result.errors).toBeUndefined();
+ expect(repositories.Section.getById).toHaveBeenCalledWith(id);
+ expect(repositories.Page.findAll).not.toHaveBeenCalled();
+ });
+
+ it("should have an association with Pages", async () => {
+ const result = await executeQuery(
+ sectionWithPages,
+ { id },
+ { repositories }
+ );
+
+ expect(result.errors).toBeUndefined();
+ expect(repositories.Section.getById).toHaveBeenCalledWith(id);
+ expect(repositories.Page.findAll).toHaveBeenCalledWith({ sectionId: id });
+ });
+
+ it("should have an association with Questionnaire", async () => {
+ const result = await executeQuery(
+ sectionWithQuestionnaire,
+ { id },
+ { repositories }
+ );
+
+ expect(result.errors).toBeUndefined();
+ expect(repositories.Section.getById).toHaveBeenCalledWith(id);
+ expect(repositories.Questionnaire.getById).toHaveBeenCalledWith(
+ questionnaireId
+ );
+ });
+});
diff --git a/eq-author-api/tests/utils/buildTestQuestionnaire.js b/eq-author-api/tests/utils/buildTestQuestionnaire.js
new file mode 100644
index 0000000000..2f1c565d11
--- /dev/null
+++ b/eq-author-api/tests/utils/buildTestQuestionnaire.js
@@ -0,0 +1,517 @@
+const cheerio = require("cheerio");
+
+const QuestionnaireRepository = require("../../repositories/QuestionnaireRepository");
+const SectionRepository = require("../../repositories/SectionRepository");
+const PageRepository = require("../../repositories/PageRepository");
+const AnswerRepository = require("../../repositories/AnswerRepository");
+const OptionRepository = require("../../repositories/OptionRepository");
+const ValidationRepository = require("../../repositories/ValidationRepository");
+const MetadataRepository = require("../../repositories/MetadataRepository");
+const RoutingRepository = require("../../repositories/RoutingRepository");
+
+const {
+ getValidationEntity
+} = require("../../repositories/strategies/validationStrategy");
+const { validationRuleMap } = require("../../utils/defaultAnswerValidations");
+
+const replacePiping = (field, references) => {
+ if (!field || field.indexOf(" {
+ const $el = $(el);
+ const pipeType = $el.data("piped");
+ const id = $el.data("id");
+
+ const newId = references[pipeType][id];
+
+ // Can't use data as it doesn't work
+ // https://github.com/cheeriojs/cheerio/issues/1240
+ $el.attr("data-id", newId);
+
+ return $.html($el);
+ });
+
+ return $("body").html();
+};
+
+const buildValidations = async (validationConfigs = {}, answer, references) => {
+ const validationEntityType = getValidationEntity(answer.type);
+ const validationTypes = validationRuleMap[validationEntityType] || [];
+
+ let validations = {};
+ for (let i = 0; i < validationTypes.length; ++i) {
+ const validationType = validationTypes[i];
+ const existingValidation = await ValidationRepository.findByAnswerIdAndValidationType(
+ answer,
+ validationType
+ );
+
+ let validation = existingValidation;
+ const validationConfig = validationConfigs[validationType];
+ if (validationConfig) {
+ const {
+ enabled,
+ previousAnswer,
+ metadata,
+ ...restOfConfig
+ } = validationConfig;
+ if (enabled) {
+ await ValidationRepository.toggleValidationRule({
+ id: validation.id,
+ enabled
+ });
+ }
+ const update = {
+ id: validation.id,
+ [`${validationType}Input`]: {
+ ...restOfConfig,
+ previousAnswer: references.answers[(previousAnswer || {}).id],
+ metadata: references.metadata[(metadata || {}).id]
+ }
+ };
+ validation = await ValidationRepository.updateValidationRule(update);
+ }
+
+ validations[validationType] = validation;
+ }
+
+ return validations;
+};
+
+const createOrUpdateOption = async (
+ optionConfig,
+ existingOption,
+ answer,
+ references
+) => {
+ const { id, ...config } = optionConfig;
+
+ const optionDetails = {
+ ...config,
+ answerId: answer.id
+ };
+
+ let option;
+ if (existingOption) {
+ option = await OptionRepository.update({
+ ...existingOption,
+ ...optionDetails
+ });
+ } else {
+ option = await OptionRepository.insert(optionDetails);
+ }
+ if (id) {
+ references.options[id] = option.id;
+ }
+
+ return option;
+};
+
+const buildOptions = async (optionConfigs = [], answer, references) => {
+ let options = [];
+
+ const existingOptions = await OptionRepository.findAll({
+ answerId: answer.id,
+ mutuallyExclusive: false
+ });
+
+ for (let i = 0; i < optionConfigs.length; ++i) {
+ const option = await createOrUpdateOption(
+ optionConfigs[i],
+ existingOptions[i],
+ answer,
+ references
+ );
+ options.push(option);
+ }
+
+ return options;
+};
+
+const buildOtherAnswer = async (
+ { answer: answerConfig, option: optionConfig },
+ parentAnswer
+) => {
+ const { answer, option } = await AnswerRepository.createOtherAnswer(
+ parentAnswer
+ );
+ const otherAnswer = await AnswerRepository.update({
+ ...answerConfig,
+ id: answer.id
+ });
+ otherAnswer.options = [
+ await OptionRepository.update({
+ ...optionConfig,
+ id: option.id
+ })
+ ];
+ return otherAnswer;
+};
+
+const buildAnswers = async (answerConfigs = [], page, references) => {
+ let answers = [];
+ for (let i = 0; i < answerConfigs.length; ++i) {
+ const {
+ options,
+ mutuallyExclusiveOption,
+ validation,
+ other,
+ id,
+ childAnswers,
+ ...answerConfig
+ } = answerConfigs[i];
+
+ let secondaryLabel;
+ if (childAnswers) {
+ secondaryLabel = childAnswers[1].label;
+ }
+
+ let answer = await AnswerRepository.createAnswer({
+ type: "TextField",
+ ...answerConfig,
+ secondaryLabel,
+ questionPageId: page.id
+ });
+
+ if (answerConfig.isDeleted) {
+ answer = await AnswerRepository.remove(answer.id);
+ }
+
+ if (id) {
+ references.answers[id] = answer.id;
+ }
+
+ answer.options = await buildOptions(options, answer, references);
+ if (mutuallyExclusiveOption) {
+ answer.mutuallyExclusiveOption = await createOrUpdateOption(
+ { ...mutuallyExclusiveOption, mutuallyExclusive: true },
+ null,
+ answer,
+ references
+ );
+ }
+
+ answer.validation = await buildValidations(validation, answer, references);
+ if (other) {
+ answer.otherAnswer = await buildOtherAnswer(other, answer);
+ }
+
+ answers.push(answer);
+ }
+
+ return answers;
+};
+
+const buildPages = async (pageConfigs, section, references) => {
+ let pages = [];
+ for (let i = 0; i < pageConfigs.length; ++i) {
+ const { answers, id, ...pageConfig } = pageConfigs[i];
+ let page = await PageRepository.insert({
+ pageType: "QuestionPage",
+ ...pageConfig,
+ title: replacePiping(pageConfig.title || "Untitled Page", references),
+ description: replacePiping(pageConfig.description, references),
+ guidance: replacePiping(pageConfig.guidance, references),
+ sectionId: section.id
+ });
+
+ if (pageConfig.isDeleted) {
+ page = await PageRepository.remove(page.id);
+ }
+ if (pageConfig.routingRuleSet) {
+ references.pagesWithRouting.push({ page, pageConfig });
+ }
+ if (id) {
+ references.pages[id] = page.id;
+ }
+
+ page.answers = await buildAnswers(answers, page, references);
+
+ pages.push(page);
+ }
+
+ return pages;
+};
+
+const buildSections = async (sectionConfigs, questionnaire, references) => {
+ let sections = [];
+
+ for (let i = 0; i < sectionConfigs.length; ++i) {
+ const { pages, id, ...sectionConfig } = sectionConfigs[i];
+ const section = await SectionRepository.insert({
+ title: "Test section",
+ ...sectionConfig,
+ questionnaireId: questionnaire.id
+ });
+
+ if (id) {
+ references.sections[id] = section.id;
+ }
+
+ section.pages = await buildPages(pages, section, references);
+
+ sections.push(section);
+ }
+
+ return sections;
+};
+
+const buildMetadata = async (
+ metadataConfigs = [],
+ questionnaire,
+ references
+) => {
+ let metadatas = [];
+
+ for (let i = 0; i < metadataConfigs.length; ++i) {
+ const { id, ...metadataConfig } = metadataConfigs[i];
+ let metadata = await MetadataRepository.insert({
+ questionnaireId: questionnaire.id
+ });
+ metadata = await MetadataRepository.update({
+ ...metadataConfig,
+ id: metadata.id
+ });
+
+ if (id) {
+ references.metadata[id] = metadata.id;
+ }
+
+ metadatas.push(metadata);
+ }
+
+ return metadatas;
+};
+
+const buildConditionValues = async (
+ routingValueConfig,
+ conditionId,
+ references
+) => {
+ const { value, numberValue } = routingValueConfig;
+ if (value) {
+ const newValues = await Promise.all(
+ value.map(v => {
+ return RoutingRepository.toggleConditionOption({
+ conditionId,
+ optionId: references.options[v],
+ checked: true
+ });
+ })
+ );
+ return { value: newValues };
+ }
+
+ if (numberValue) {
+ const existingValues = await RoutingRepository.findAllRoutingConditionValues(
+ {
+ conditionId
+ }
+ );
+ const existingValue = existingValues[0];
+ let valueToUpdate = existingValue;
+ if (!existingValue) {
+ valueToUpdate = await RoutingRepository.createConditionValue({
+ conditionId
+ });
+ }
+ const newConditionValue = await RoutingRepository.updateConditionValue({
+ ...valueToUpdate,
+ customNumber: numberValue
+ });
+
+ return { numberValue: newConditionValue };
+ }
+};
+
+const buildConditions = async (
+ conditionConfigs,
+ ruleId,
+ pageId,
+ references
+) => {
+ let conditions = [];
+
+ for (let i = 0; i < conditionConfigs.length; ++i) {
+ const { answer, routingValue, ...rest } = conditionConfigs[i];
+
+ const condition = await RoutingRepository.createRoutingCondition({
+ answerId: references.answers[answer.id],
+ questionPageId: pageId,
+ routingRuleId: ruleId,
+ comparator: rest.comparator || "Equal"
+ });
+
+ condition.routingValue = await buildConditionValues(
+ routingValue,
+ condition.id,
+ references
+ );
+
+ conditions.push(condition);
+ }
+
+ return conditions;
+};
+
+const transformDestinationConfig = (
+ { logicalDestination, absoluteDestination },
+ references
+) => {
+ if (logicalDestination) {
+ return {
+ logicalDestination: {
+ destinationType: logicalDestination
+ }
+ };
+ }
+
+ const { __typename, id } = absoluteDestination;
+
+ const typenameToRef = {
+ Section: "sections",
+ QuestionPage: "pages"
+ };
+
+ return {
+ absoluteDestination: {
+ destinationType: __typename,
+ destinationId: references[typenameToRef[__typename]][id]
+ }
+ };
+};
+
+const buildRules = async (ruleConfigs, ruleSetId, pageId, references) => {
+ let rules = [];
+ const existingRules = await RoutingRepository.findAllRoutingRules({
+ routingRuleSetId: ruleSetId
+ });
+
+ for (let i = 0; i < ruleConfigs.length; ++i) {
+ const { goto, conditions } = ruleConfigs[i];
+ let existingRule = existingRules[i];
+ if (!existingRule) {
+ existingRule = await RoutingRepository.createRoutingRule({
+ routingRuleSetId: ruleSetId
+ });
+ }
+
+ const rule = await RoutingRepository.updateRoutingRule({
+ id: existingRule.id,
+ goto: {
+ id: existingRule.routingDestinationId,
+ ...transformDestinationConfig(goto, references)
+ }
+ });
+
+ rule.goto = await RoutingRepository.getRoutingDestination(
+ rule.routingDestinationId
+ );
+
+ const existingConditions = await RoutingRepository.findAllRoutingConditions(
+ {
+ routingRuleId: rule.id
+ }
+ );
+
+ await Promise.all(
+ existingConditions.map(existingCondition =>
+ RoutingRepository.removeRoutingCondition(existingCondition)
+ )
+ );
+
+ rule.conditions = await buildConditions(
+ conditions,
+ rule.id,
+ pageId,
+ references
+ );
+
+ rules.push(rule);
+ }
+
+ return rules;
+};
+
+const buildRuleSet = async (ruleSetConfig, pageId, references) => {
+ const ruleSet = await RoutingRepository.createRoutingRuleSet({
+ questionPageId: pageId
+ });
+
+ const { else: elseConfig, routingRules } = ruleSetConfig;
+
+ await RoutingRepository.updateRoutingRuleSet({
+ id: ruleSet.id,
+ else: {
+ id: ruleSet.routingDestinationId,
+ ...transformDestinationConfig(elseConfig, references)
+ }
+ });
+
+ ruleSet.else = await RoutingRepository.getRoutingDestination(
+ ruleSet.routingDestinationId
+ );
+
+ ruleSet.rules = await buildRules(
+ routingRules,
+ ruleSet.id,
+ pageId,
+ references
+ );
+
+ return ruleSet;
+};
+
+const buildQuestionnaire = async questionnaireConfig => {
+ const { sections, metadata, ...questionnaireProps } = questionnaireConfig;
+
+ const questionnaire = await QuestionnaireRepository.insert({
+ title: "Questionnaire",
+ surveyId: "1",
+ theme: "default",
+ legalBasis: "Voluntary",
+ navigation: false,
+ createdBy: "test-suite",
+ ...questionnaireProps
+ });
+
+ const references = {
+ options: {},
+ answers: {},
+ pages: {},
+ sections: {},
+ metadata: {},
+ pagesWithRouting: []
+ };
+
+ questionnaire.metadata = await buildMetadata(
+ metadata,
+ questionnaire,
+ references
+ );
+ questionnaire.sections = await buildSections(
+ sections,
+ questionnaire,
+ references
+ );
+
+ const buildRoutingActions = references.pagesWithRouting.map(
+ async ({ page, pageConfig }) => {
+ page.ruleSet = await buildRuleSet(
+ pageConfig.routingRuleSet,
+ page.id,
+ references
+ );
+ }
+ );
+
+ await Promise.all(buildRoutingActions);
+
+ return questionnaire;
+};
+
+module.exports = buildQuestionnaire;
diff --git a/eq-author-api/tests/utils/executeQuery.js b/eq-author-api/tests/utils/executeQuery.js
new file mode 100644
index 0000000000..cd943c3b6d
--- /dev/null
+++ b/eq-author-api/tests/utils/executeQuery.js
@@ -0,0 +1,8 @@
+const schema = require("../../schema");
+const { graphql } = require("graphql");
+
+function executeQuery(query, args = {}, ctx = {}) {
+ return graphql(schema, query, {}, ctx, args);
+}
+
+module.exports = executeQuery;
\ No newline at end of file
diff --git a/eq-author-api/tests/utils/graphql.js b/eq-author-api/tests/utils/graphql.js
new file mode 100644
index 0000000000..234eb86806
--- /dev/null
+++ b/eq-author-api/tests/utils/graphql.js
@@ -0,0 +1,743 @@
+const createQuestionnaireMutation = `mutation CreateQuestionnaire($input: CreateQuestionnaireInput!) {
+ createQuestionnaire(input: $input) {
+ id
+ title
+ description
+ navigation
+ legalBasis
+ theme
+ sections {
+ id
+ pages {
+ id
+ }
+ }
+ }
+ }
+`;
+
+const getSectionQuery = `
+query getSection($id: ID!) {
+ section(id: $id) {
+ id
+ title
+ alias
+ displayName
+ pages{
+ id
+ }
+ introductionTitle
+ introductionContent
+ introductionEnabled
+ }
+}
+`;
+
+const createSectionMutation = `
+ mutation CreateSection($input: CreateSectionInput!){
+ createSection(input: $input){
+ id
+ }
+ }
+`;
+
+const deletePageMutation = `
+ mutation DeletePage($input: DeletePageInput!){
+ deletePage(input: $input){
+ id
+ }
+ }
+`;
+
+const deleteAnswerMutation = `
+mutation DeleteAnswer($input: DeleteAnswerInput!){
+ deleteAnswer(input: $input){
+ id
+ }
+}
+`;
+
+const deleteOptionMutation = `
+mutation DeleteOption($input: DeleteOptionInput!){
+ deleteOption(input: $input){
+ id
+ }
+}
+`;
+
+const createOptionMutation = `
+mutation CreateOption($input: CreateOptionInput!){
+ createOption(input: $input){
+ id
+ }
+}
+`;
+
+const createExclusiveMutation = `
+mutation createMutuallyExclusiveOption($input: CreateMutuallyExclusiveOptionInput!) {
+ createMutuallyExclusiveOption(input: $input) {
+ id
+ description
+ label
+ }
+}
+`;
+
+const createQuestionPageMutation = `
+ mutation CreateQuestionPage($input: CreateQuestionPageInput!){
+ createQuestionPage(input: $input){
+ id
+ }
+ }
+`;
+
+const getPageQuery = `
+ query GetQuestionPage($id: ID!){
+ questionPage(id:$id){
+ id
+ guidance
+ description
+ pageType
+ position
+ section{
+ id
+ }
+ }
+ }
+`;
+
+const createAnswerMutation = `
+ mutation CreateAnswer($input: CreateAnswerInput!) {
+ createAnswer(input: $input) {
+ id,
+ description,
+ guidance,
+ qCode,
+ label,
+ type,
+ ... on MultipleChoiceAnswer {
+ options {
+ id
+ },
+ other {
+ answer {
+ id
+ type
+ },
+ option {
+ id
+ }
+ }
+ }
+ }
+ }
+`;
+
+const createOtherMutation = `
+ mutation CreateOther($input: CreateOtherInput!) {
+ createOther(input: $input) {
+ option {
+ id
+ }
+ answer {
+ id
+ type
+ description
+ }
+ }
+ }
+`;
+
+const deleteOtherMutation = `
+ mutation DeleteOther($input: DeleteOtherInput!) {
+ deleteOther(input: $input) {
+ option {
+ id
+ }
+ answer {
+ id
+ type
+ description
+ }
+ }
+ }
+`;
+
+const getAnswerQuery = `
+ query GetAnswer($id: ID!) {
+ answer(id: $id) {
+ id
+ description
+ guidance
+ qCode
+ label
+ type
+ properties
+ ...on CompositeAnswer{
+ childAnswers{
+ id
+ label
+ }
+ }
+ ... on MultipleChoiceAnswer {
+ options {
+ id
+ },
+ other {
+ answer {
+ id
+ type
+ description
+ }
+ option {
+ id
+ }
+ }
+ }
+ }
+ }
+`;
+
+const getAnswersQuery = `
+ query GetAnswers($id: ID!) {
+ page(id: $id) {
+ ... on QuestionPage {
+ answers {
+ id
+ type
+ ...on CompositeAnswer{
+ childAnswers{
+ id
+ label
+ }
+ }
+ }
+ }
+ }
+ }
+`;
+
+const getPipableAnswersQuery = `
+ query GetAnswers($ids: [ID]!) {
+ answers(ids: $ids) {
+ id
+ description
+ guidance
+ label
+ type
+ properties
+ ...on CompositeAnswer{
+ childAnswers{
+ id
+ label
+ }
+ }
+ }
+ }
+
+`;
+
+const getBasicRoutingQuery = `
+query GetPage($id: ID!){
+ page(id: $id){
+ ...on QuestionPage{
+ id
+ routingRuleSet{
+ id
+ questionPage{
+ id
+ }
+ routingRules{
+ id
+ conditions{
+ id
+ routingValue{
+ ...on IDArrayValue{
+ value
+ }
+ ...on NumberValue {
+ id
+ numberValue
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+`;
+
+const createRoutingRuleSet = `
+ mutation CreateRoutingRuleSet($input: CreateRoutingRuleSetInput!){
+ createRoutingRuleSet(input: $input)
+ {
+ id
+ questionPage{
+ id
+ }
+ routingRules{
+ id
+ }
+ else {
+ ... on LogicalDestination {
+ logicalDestination
+ }
+ }
+ }
+ }
+`;
+
+const createRoutingRule = `
+ mutation CreateRoutingRule($input: CreateRoutingRuleInput!){
+ createRoutingRule(input: $input)
+ {
+ id
+ operation
+ conditions {
+ id
+ }
+ goto {
+ ... on LogicalDestination {
+ logicalDestination
+ }
+ ... on AbsoluteDestination {
+ absoluteDestination {
+ ...on QuestionPage{
+ id
+ section{
+ id
+ }
+ }
+ ...on Section{
+ id
+ }
+ }
+ }
+ }
+ }
+ }
+`;
+
+const createRoutingCondition = `
+ mutation CreateRoutingCondition($input: CreateRoutingConditionInput!){
+ createRoutingCondition(input: $input)
+ {
+ id
+ comparator
+ questionPage {
+ id
+ }
+ answer {
+ id
+ }
+ routingValue {
+ ... on IDArrayValue {
+ value
+ }
+ ...on NumberValue{
+ numberValue
+ }
+ }
+ }
+ }
+`;
+
+const updateRoutingRuleSet = `
+mutation UpdateRoutingRuleSet($input: UpdateRoutingRuleSetInput!){
+ updateRoutingRuleSet (input: $input)
+ {
+ id
+ else {
+ ... on LogicalDestination {
+ logicalDestination
+ }
+ ... on AbsoluteDestination {
+ absoluteDestination {
+ ...on QuestionPage{
+ id
+ section{
+ id
+ }
+ }
+ ...on Section{
+ id
+ }
+ }
+ }
+ }
+ }
+}
+`;
+
+const deleteRoutingRuleSet = `
+mutation DeleteRoutingRuleSet($input: DeleteRoutingRuleSetInput!) {
+ deleteRoutingRuleSet(input: $input) {
+ id
+ }
+}
+`;
+
+const updateRoutingRule = `
+mutation UpdateRoutingRule($input: UpdateRoutingRuleInput!){
+ updateRoutingRule (input: $input)
+ {
+ id
+ goto {
+ ... on LogicalDestination {
+ logicalDestination
+ }
+ ... on AbsoluteDestination {
+ absoluteDestination {
+ __typename
+ ...on QuestionPage{
+ id
+ section{
+ id
+ }
+ }
+ ...on Section{
+ id
+ }
+ }
+ }
+ }
+ }
+}
+`;
+
+const updateCondition = `
+mutation($input: UpdateRoutingConditionInput!){
+ updateRoutingCondition (input: $input)
+ {
+ id
+ comparator
+ }
+}
+`;
+
+const deleteRoutingRule = `
+ mutation($input: DeleteRoutingRuleInput!) {
+ deleteRoutingRule(input: $input) {
+ id
+ }
+ }
+`;
+
+const deleteRoutingCondition = `
+ mutation($input: DeleteRoutingConditionInput!) {
+ deleteRoutingCondition(input: $input) {
+ id
+ }
+ }
+`;
+
+const updateConditionValue = `
+ mutation($input: UpdateConditionValueInput!) {
+ updateConditionValue(input: $input) {
+ ...on NumberValue {
+ id
+ numberValue
+ }
+ }
+ }
+`;
+
+const toggleConditionOption = `
+ mutation($input: ToggleConditionOptionInput!) {
+ toggleConditionOption (input: $input)
+ {
+ ...on IDArrayValue{
+ value
+ }
+ }
+ }
+`;
+
+const getEntireRoutingStructure = `
+query QuestionPage($id: ID!) {
+ questionPage(id: $id) {
+ routingRuleSet {
+ id
+ questionPage {
+ id
+ }
+ else {
+ ... on LogicalDestination {
+ logicalDestination
+ }
+ ... on AbsoluteDestination {
+ absoluteDestination {
+ ...on QuestionPage{
+ id
+ section{
+ id
+ }
+ }
+ ...on Section{
+ id
+ }
+ }
+ }
+ }
+ routingRules {
+ id
+ operation
+ goto {
+ ... on LogicalDestination {
+ logicalDestination
+ }
+ ... on AbsoluteDestination {
+ absoluteDestination {
+ ...on QuestionPage{
+ id
+ section{
+ id
+ }
+ }
+ ...on Section{
+ id
+ }
+ }
+ }
+ }
+ conditions {
+ id
+ comparator
+ questionPage {
+ id
+ }
+ answer {
+ id
+ }
+ routingValue {
+ ...on IDArrayValue {
+ value
+ }
+ ...on NumberValue {
+ id
+ numberValue
+ }
+ }
+ }
+ }
+ }
+ }
+}
+`;
+
+const getAvailableRoutingDestinations = `
+query QuestionPage($id: ID!) {
+ availableRoutingDestinations(pageId: $id) {
+ ... on AvailableRoutingDestinations {
+ logicalDestinations {
+ logicalDestination
+ }
+ questionPages {
+ id
+ }
+ sections {
+ id
+ }
+ }
+ }
+}
+`;
+
+const getQuestionnaire = `
+query QuestionPage($id: ID!) {
+ questionnaire(id: $id) {
+ id
+ sections{
+ id
+ pages{
+ id
+ }
+ }
+ }
+}
+`;
+
+const updateAnswerMutation = `
+mutation UpdateAnswer($input: UpdateAnswerInput!) {
+ updateAnswer(input: $input) {
+ id,
+ description,
+ guidance,
+ qCode,
+ label,
+ type
+ ...on CompositeAnswer{
+ childAnswers{
+ id
+ label
+ }
+ }
+ }
+}
+`;
+
+const moveSectionMutation = `
+mutation MoveSection($input: MoveSectionInput!) {
+ moveSection(input: $input) {
+ id
+ position
+ __typename
+ }
+}`;
+
+const getAnswerValidations = `
+ query QuestionPage($id: ID!) {
+ answer(id: $id){
+ id
+ ...on BasicAnswer {
+ validation{
+ ...on NumberValidation{
+ minValue{
+ id
+ enabled
+ inclusive
+ custom
+ }
+ maxValue{
+ id
+ enabled
+ inclusive
+ custom
+ entityType
+ }
+ }
+ ...on DateValidation {
+ earliestDate {
+ id
+ enabled
+ custom
+ offset {
+ value
+ unit
+ }
+ relativePosition
+ }
+ latestDate {
+ id
+ enabled
+ custom
+ offset {
+ value
+ unit
+ }
+ relativePosition
+ }
+ }
+ }
+ }
+ }
+ }
+`;
+
+const toggleAnswerValidation = `
+ mutation toggleValidation($input: ToggleValidationRuleInput!){
+ toggleValidationRule(input: $input){
+ id
+ enabled
+ }
+ }
+`;
+
+const updateAnswerValidation = `
+mutation updateValidation($input: UpdateValidationRuleInput!){
+ updateValidationRule(input: $input){
+ id
+ ...on MinValueValidationRule {
+ custom
+ inclusive
+ }
+ ...on MaxValueValidationRule {
+ custom
+ inclusive
+ entityType
+ previousAnswer {
+ id
+ }
+ }
+ ...on EarliestDateValidationRule {
+ customDate: custom
+ offset {
+ value
+ unit
+ }
+ relativePosition
+ entityType
+ previousAnswer {
+ id
+ }
+ metadata {
+ id
+ }
+ }
+ ...on LatestDateValidationRule {
+ customDate: custom
+ offset {
+ value
+ unit
+ }
+ relativePosition
+ entityType
+ previousAnswer {
+ id
+ }
+ metadata {
+ id
+ }
+ }
+ }
+}
+`;
+
+const createMetadataMutation = `
+ mutation CreateMetadata($input: CreateMetadataInput!) {
+ createMetadata(input: $input) {
+ id
+ }
+ }
+`;
+
+module.exports = {
+ getPipableAnswersQuery,
+ createQuestionnaireMutation,
+ getSectionQuery,
+ createAnswerMutation,
+ createOtherMutation,
+ deleteOtherMutation,
+ getAnswerQuery,
+ getAnswersQuery,
+ createRoutingRuleSet,
+ toggleConditionOption,
+ getEntireRoutingStructure,
+ updateAnswerMutation,
+ getBasicRoutingQuery,
+ updateRoutingRule,
+ updateCondition,
+ createSectionMutation,
+ createQuestionPageMutation,
+ getAvailableRoutingDestinations,
+ getQuestionnaire,
+ getPageQuery,
+ updateRoutingRuleSet,
+ createRoutingRule,
+ deleteRoutingRule,
+ createRoutingCondition,
+ deleteRoutingCondition,
+ deleteRoutingRuleSet,
+ deletePageMutation,
+ deleteAnswerMutation,
+ deleteOptionMutation,
+ createOptionMutation,
+ moveSectionMutation,
+ getAnswerValidations,
+ toggleAnswerValidation,
+ updateConditionValue,
+ updateAnswerValidation,
+ createExclusiveMutation,
+ createMetadataMutation
+};
diff --git a/eq-author-api/tests/utils/mockRepository.js b/eq-author-api/tests/utils/mockRepository.js
new file mode 100644
index 0000000000..9815c257b5
--- /dev/null
+++ b/eq-author-api/tests/utils/mockRepository.js
@@ -0,0 +1,11 @@
+module.exports = function mockRepository(returnValues = {}) {
+ return {
+ createAnswer: jest.fn(() => returnValues.createAnswer),
+ getById: jest.fn(() => returnValues.getById),
+ getAnswers: jest.fn(() => returnValues.getAnswers),
+ findAll: jest.fn(() => returnValues.findAll),
+ insert: jest.fn(() => returnValues.insert),
+ update: jest.fn(() => returnValues.update),
+ remove: jest.fn(() => returnValues.remove)
+ };
+};
diff --git a/eq-author-api/utils/addPrefix.js b/eq-author-api/utils/addPrefix.js
new file mode 100644
index 0000000000..5aff55c573
--- /dev/null
+++ b/eq-author-api/utils/addPrefix.js
@@ -0,0 +1,15 @@
+const { replace, isEmpty, isNil } = require("lodash");
+
+const fn = group => (isNil(group) ? "" : group);
+
+module.exports = (value, prefix = "Copy of ") => {
+ if (isNil(value) || isEmpty(value)) {
+ return "";
+ }
+
+ return replace(value, /(<\w+>)?([^<]*)(<\/\w+>)?/, (_, a, b, c) => {
+ return isEmpty(b)
+ ? `${fn(a)}${b}${fn(c)}`
+ : `${fn(a)}${prefix}${b}${fn(c)}`;
+ });
+};
diff --git a/eq-author-api/utils/addPrefix.test.js b/eq-author-api/utils/addPrefix.test.js
new file mode 100644
index 0000000000..4758f75280
--- /dev/null
+++ b/eq-author-api/utils/addPrefix.test.js
@@ -0,0 +1,65 @@
+const addPrefix = require("./addPrefix");
+
+describe("Add Prefix", () => {
+ it("should prepend 'Copy of' to a value without markup tags when no prefix is supplied", () => {
+ const value = "Hello";
+
+ const updatedValue = addPrefix(value);
+
+ expect(updatedValue).toBe(`Copy of ${value}`);
+ });
+
+ it("should prepend 'Copy of' to a value with markup tags when no prefix is supplied", () => {
+ const value = "Hello
";
+
+ const updatedValue = addPrefix(value);
+
+ const startsWithCopyOf = updatedValue.startsWith("Copy of ", 3);
+
+ expect(startsWithCopyOf).toBe(true);
+ });
+
+ it("should not prepend 'Copy of' to a value without markup tags when a prefix is supplied", () => {
+ const value = "Hello";
+
+ const updatedValue = addPrefix(value, "Oh,");
+
+ expect(updatedValue).not.toBe(`Copy of ${value}`);
+ });
+
+ it("should not prepend 'Copy of' to a value with markup tags when a prefix is supplied", () => {
+ const value = "Hello
";
+
+ const updatedValue = addPrefix(value, "Oh,");
+
+ const startsWithCopyOf = updatedValue.startsWith("Copy of ", 3);
+
+ expect(startsWithCopyOf).toBe(false);
+ });
+
+ it("should prepend a value without markup tags with a given prefix", () => {
+ const value = "Hello";
+
+ const updatedValue = addPrefix(value, "Oh, ");
+
+ expect(updatedValue).toBe(`Oh, ${value}`);
+ });
+
+ it("should prepend a value with markup tags with a given prefix", () => {
+ const value = "Hello
";
+
+ const updatedValue = addPrefix(value, "Oh, ");
+
+ const startsWithCopyOf = updatedValue.startsWith("Oh, ", 3);
+
+ expect(startsWithCopyOf).toBe(true);
+ });
+
+ it("should not prepend an empty value", () => {
+ expect(addPrefix("")).toEqual("");
+ });
+
+ it("should not prepend an empty value surrounded by tags", () => {
+ expect(addPrefix("
")).toEqual("
");
+ });
+});
diff --git a/eq-author-api/utils/childAnswerParser.js b/eq-author-api/utils/childAnswerParser.js
new file mode 100644
index 0000000000..082dcbb468
--- /dev/null
+++ b/eq-author-api/utils/childAnswerParser.js
@@ -0,0 +1,12 @@
+const { endsWith } = require("lodash/fp");
+
+module.exports = id => {
+ let answerType = {};
+ if (endsWith("from", id)) {
+ return answerType;
+ } else if (endsWith("to", id)) {
+ return "secondary";
+ } else {
+ return null;
+ }
+};
diff --git a/eq-author-api/utils/createLogger.js b/eq-author-api/utils/createLogger.js
new file mode 100644
index 0000000000..3bdb94fce3
--- /dev/null
+++ b/eq-author-api/utils/createLogger.js
@@ -0,0 +1,8 @@
+const createLogger = logger => ({
+ log: err => {
+ logger.error(err);
+ return err;
+ }
+});
+
+module.exports = createLogger;
diff --git a/eq-author-api/utils/defaultAnswerProperties.js b/eq-author-api/utils/defaultAnswerProperties.js
new file mode 100644
index 0000000000..005dafb9fb
--- /dev/null
+++ b/eq-author-api/utils/defaultAnswerProperties.js
@@ -0,0 +1,12 @@
+module.exports = type => {
+ switch (type) {
+ case "Currency":
+ return { required: false, decimals: 0 };
+ case "Number":
+ return { required: false, decimals: 0 };
+ case "Date":
+ return { required: false, format: "dd/mm/yyyy" };
+ default:
+ return { required: false };
+ }
+};
diff --git a/eq-author-api/utils/defaultAnswerValidations.js b/eq-author-api/utils/defaultAnswerValidations.js
new file mode 100644
index 0000000000..3f7c1b9c6d
--- /dev/null
+++ b/eq-author-api/utils/defaultAnswerValidations.js
@@ -0,0 +1,56 @@
+const { CUSTOM, NOW } = require("../constants/validation-entity-types");
+
+const answerTypeMap = {
+ number: ["Currency", "Number"],
+ date: ["Date"]
+};
+
+const validationRuleMap = {
+ number: ["minValue", "maxValue"],
+ date: ["earliestDate", "latestDate"]
+};
+
+const defaultValidationRuleConfigs = {
+ minValue: {
+ inclusive: false
+ },
+ maxValue: {
+ inclusive: false
+ },
+ earliestDate: {
+ offset: {
+ value: 0,
+ unit: "Days"
+ },
+ relativePosition: "Before"
+ },
+ latestDate: {
+ offset: {
+ value: 0,
+ unit: "Days"
+ },
+ relativePosition: "After"
+ }
+};
+
+const defaultValidationEntityTypes = {
+ minValue: {
+ entityType: CUSTOM
+ },
+ maxValue: {
+ entityType: CUSTOM
+ },
+ earliestDate: {
+ entityType: NOW
+ },
+ latestDate: {
+ entityType: NOW
+ }
+};
+
+Object.assign(module.exports, {
+ answerTypeMap,
+ validationRuleMap,
+ defaultValidationRuleConfigs,
+ defaultValidationEntityTypes
+});
diff --git a/eq-author-api/utils/defaultMetadata.js b/eq-author-api/utils/defaultMetadata.js
new file mode 100644
index 0000000000..e3c3573803
--- /dev/null
+++ b/eq-author-api/utils/defaultMetadata.js
@@ -0,0 +1,100 @@
+const defaultTypeValueNames = {
+ Date: "dateValue",
+ Text: "textValue",
+ Region: "regionValue",
+ Language: "languageValue"
+};
+
+const defaultTypeValues = {
+ Date: null,
+ Text: null,
+ Region: "GB_ENG",
+ Language: "en"
+};
+
+const defaultValues = [
+ {
+ key: "ru_ref",
+ alias: "Ru Ref",
+ type: "Text",
+ value: "12346789012A"
+ },
+ {
+ key: "ru_name",
+ alias: "Ru Name",
+ type: "Text",
+ value: "ESSENTIAL ENTERPRISE LTD."
+ },
+ {
+ key: "trad_as",
+ alias: "Trad As",
+ type: "Text",
+ value: "ESSENTIAL ENTERPRISE LTD."
+ },
+ {
+ key: "period_id",
+ alias: "Period Id",
+ type: "Text",
+ value: "201605"
+ },
+ {
+ key: "period_str",
+ alias: "Period Str",
+ type: "Text",
+ value: "May 2017"
+ },
+ {
+ key: "language_code",
+ alias: "Language",
+ type: "Language",
+ value: "en"
+ },
+ {
+ key: "ref_p_start_date",
+ alias: "Start Date",
+ type: "Date",
+ value: "01/05/2016"
+ },
+ {
+ key: "ref_p_end_date",
+ alias: "End Date",
+ type: "Date",
+ value: "12/06/2016"
+ },
+ {
+ key: "return_by",
+ alias: "Return By",
+ type: "Date",
+ value: "12/06/2016"
+ },
+ {
+ key: "employmentDate",
+ alias: "Employment Date",
+ type: "Date",
+ value: "10/06/2016"
+ },
+ {
+ key: "region_code",
+ alias: "Region",
+ type: "Region",
+ value: "GB_ENG"
+ },
+ {
+ key: "display_address",
+ alias: "Display Address",
+ type: "Text",
+ value: "68 Abingdon Road, Goathill, PE12 5EH"
+ },
+ {
+ key: "country",
+ alias: "Country",
+ type: "Text",
+ value: "E"
+ }
+];
+
+Object.assign(module.exports, {
+ defaultTypeValueNames,
+ defaultTypeValues,
+ defaultValues
+});
diff --git a/eq-author-api/utils/formatRichText.js b/eq-author-api/utils/formatRichText.js
new file mode 100644
index 0000000000..cad7071d1c
--- /dev/null
+++ b/eq-author-api/utils/formatRichText.js
@@ -0,0 +1,3 @@
+const cheerio = require("cheerio");
+module.exports = (value, format) =>
+ format === "Plaintext" ? cheerio(value).text() : value;
diff --git a/eq-author-api/utils/formatRichText.test.js b/eq-author-api/utils/formatRichText.test.js
new file mode 100644
index 0000000000..d677616d29
--- /dev/null
+++ b/eq-author-api/utils/formatRichText.test.js
@@ -0,0 +1,20 @@
+const formatRichText = require("./formatRichText");
+
+describe("formatRichText", () => {
+ let value;
+
+ beforeEach(() => {
+ value =
+ "A heading with bold and italic text within ";
+ });
+
+ it("should return the HTML by default", () => {
+ expect(formatRichText(value)).toEqual(value);
+ });
+
+ it("should return the Plaintext value", () => {
+ expect(formatRichText(value, "Plaintext")).toEqual(
+ "A heading with bold and italic text within"
+ );
+ });
+});
diff --git a/eq-author-api/utils/getName.js b/eq-author-api/utils/getName.js
new file mode 100644
index 0000000000..56d0753468
--- /dev/null
+++ b/eq-author-api/utils/getName.js
@@ -0,0 +1,31 @@
+const { find, pick, isEmpty } = require("lodash");
+const { stripTags } = require("./html");
+
+const defaultNames = {
+ Section: "Untitled Section",
+ QuestionPage: "Untitled Page",
+ Option: "Untitled Label",
+ BasicAnswer: "Untitled Answer",
+ MultipleChoiceAnswer: "Untitled Answer",
+ CompositeAnswer: "Untitled Answer",
+ Metadata: "Untitled Metadata"
+};
+
+const getName = (entity, typeName) => {
+ const title = find(
+ pick(entity, ["alias", "title", "label", "key"]),
+ value => {
+ if (!value) {
+ return false;
+ }
+ return !isEmpty(stripTags(value).trim());
+ }
+ );
+
+ return title ? stripTags(title) : defaultNames[typeName];
+};
+
+module.exports = {
+ getName,
+ defaultNames
+};
diff --git a/eq-author-api/utils/getName.test.js b/eq-author-api/utils/getName.test.js
new file mode 100644
index 0000000000..c7c6cbfd3c
--- /dev/null
+++ b/eq-author-api/utils/getName.test.js
@@ -0,0 +1,105 @@
+const { keys, map } = require("lodash");
+const { getName, defaultNames } = require("./getName");
+
+describe("getName", () => {
+ let entity;
+
+ it("should correctly use default names", () => {
+ entity = {
+ alias: "",
+ title: "",
+ label: ""
+ };
+
+ map(keys(defaultNames), typeName =>
+ expect(getName(entity, typeName)).toEqual(defaultNames[typeName])
+ );
+ });
+
+ it("should use correct key", () => {
+ entity = {
+ alias: "I am an alias",
+ title: "I am a title",
+ label: "I am a label",
+ key: "I am a key"
+ };
+
+ map(keys(defaultNames), typeName =>
+ expect(getName(entity, typeName)).toEqual(entity.alias)
+ );
+ entity = {
+ alias: "",
+ title: "I am a title",
+ label: "I am a label",
+ key: "I am a key"
+ };
+
+ map(keys(defaultNames), typeName =>
+ expect(getName(entity, typeName)).toEqual(entity.title)
+ );
+
+ entity = {
+ alias: "",
+ title: "",
+ label: "I am a label",
+ key: "I am a key"
+ };
+
+ map(keys(defaultNames), typeName =>
+ expect(getName(entity, typeName)).toEqual(entity.label)
+ );
+
+ entity = {
+ alias: "",
+ title: "",
+ label: "",
+ key: "I am a key"
+ };
+
+ map(keys(defaultNames), typeName =>
+ expect(getName(entity, typeName)).toEqual(entity.key)
+ );
+ });
+
+ it("should ignore any html markup", () => {
+ entity = {
+ alias: "
",
+ title: "I am a title
",
+ label: "I am a label
"
+ };
+
+ map(keys(defaultNames), typeName =>
+ expect(getName(entity, typeName)).toEqual("I am a title")
+ );
+ });
+
+ it("should ignore invalid keys", () => {
+ entity = {
+ alias: "
",
+ foo: "I am a title",
+ label: "I am a label"
+ };
+
+ map(keys(defaultNames), typeName =>
+ expect(getName(entity, typeName)).toEqual(entity.label)
+ );
+ });
+
+ it("should ignore whitespace", () => {
+ entity = {
+ alias: "
",
+ label: "Some label"
+ };
+ map(keys(defaultNames), typeName =>
+ expect(getName(entity, typeName)).toEqual(entity.label)
+ );
+
+ entity = {
+ alias: " ",
+ label: "Some label"
+ };
+ map(keys(defaultNames), typeName =>
+ expect(getName(entity, typeName)).toEqual(entity.label)
+ );
+ });
+});
diff --git a/eq-author-api/utils/html.js b/eq-author-api/utils/html.js
new file mode 100644
index 0000000000..7e1ae38ed7
--- /dev/null
+++ b/eq-author-api/utils/html.js
@@ -0,0 +1,12 @@
+const cheerio = require("cheerio");
+const { isNull, toString } = require("lodash");
+
+const isHtml = value => !isNull(cheerio(value).html());
+
+const stripTags = value =>
+ isHtml(toString(value)) ? cheerio(value).text() : value;
+
+module.exports = {
+ isHtml,
+ stripTags
+};
diff --git a/eq-author-api/utils/html.test.js b/eq-author-api/utils/html.test.js
new file mode 100644
index 0000000000..ce86186488
--- /dev/null
+++ b/eq-author-api/utils/html.test.js
@@ -0,0 +1,40 @@
+const { isHtml, stripTags } = require("./html");
+
+describe("html", () => {
+ describe("isHtml", () => {
+ it("should correctly determine if value is HTML", () => {
+ expect(isHtml("test
")).toEqual(true);
+ expect(isHtml("test")).toEqual(true);
+ expect(isHtml("")).toEqual(true);
+ });
+
+ it("should correctly determine if value is not HTML", () => {
+ expect(isHtml("< i am NOT valid html >")).toEqual(false);
+ expect(isHtml("Just text...")).toEqual(false);
+ expect(isHtml("<>")).toEqual(false);
+ expect(isHtml("")).toEqual(false);
+ expect(isHtml(true)).toEqual(false);
+ expect(isHtml(null)).toEqual(false);
+ expect(isHtml(false)).toEqual(false);
+ expect(isHtml([])).toEqual(false);
+ expect(isHtml({})).toEqual(false);
+ expect(isHtml(1)).toEqual(false);
+ });
+ });
+
+ describe("stripTags", () => {
+ it("should correctly strip html tags", () => {
+ expect(stripTags("test
")).toEqual("test");
+ expect(stripTags("
")).toEqual("");
+ expect(stripTags("test")).toEqual("test");
+ expect(stripTags("")).toEqual("");
+ expect(stripTags(true)).toEqual(true);
+ expect(stripTags(null)).toEqual(null);
+ expect(stripTags(false)).toEqual(false);
+ expect(stripTags([])).toEqual([]);
+ expect(stripTags("[]
")).toEqual("[]");
+ expect(stripTags({})).toEqual({});
+ expect(stripTags(1)).toEqual(1);
+ });
+ });
+});
diff --git a/eq-author-api/utils/jwtHelper.js b/eq-author-api/utils/jwtHelper.js
new file mode 100644
index 0000000000..f69e2f2db4
--- /dev/null
+++ b/eq-author-api/utils/jwtHelper.js
@@ -0,0 +1,67 @@
+/* eslint-disable camelcase*/
+const KJUR = require("jsrsasign");
+const fs = require("fs");
+const JSONWebKey = require("json-web-key");
+const { JWK, JWE } = require("node-jose");
+const { find, keys, assign, flow, map } = require("lodash/fp");
+const yaml = require("js-yaml");
+const SIGNING_ALGORITHM = "RS256";
+const keysFile = process.env.KEYS_FILE || "./keys.yml";
+
+const getKeyByUse = (json, useCase) =>
+ flow(
+ keys,
+ map(kid => assign(json[kid], { kid })),
+ find(keyObject => keyObject.use === useCase)
+ )(json);
+
+const keysYaml = yaml.safeLoad(fs.readFileSync(keysFile, "utf8"));
+const keysJson = JSON.parse(JSON.stringify(keysYaml));
+
+module.exports.generateToken = function(payload) {
+ const signingKeyObject = getKeyByUse(keysJson.keys, "signing");
+ const encryptionKeyObject = getKeyByUse(keysJson.keys, "encryption");
+
+ // Header
+ const jwtHeader = JSON.stringify({
+ alg: SIGNING_ALGORITHM,
+ typ: "JWT",
+ kid: signingKeyObject.kid
+ });
+
+ // Payload
+ const jwtPayload = JSON.stringify(payload);
+
+ const prvKey = KJUR.KEYUTIL.getKey(signingKeyObject.value);
+
+ const signedJWT = KJUR.jws.JWS.sign(
+ SIGNING_ALGORITHM,
+ jwtHeader,
+ jwtPayload,
+ prvKey
+ );
+
+ const webKey = JSONWebKey.fromPEM(encryptionKeyObject.value);
+
+ return JWK.asKey(webKey.toJSON())
+ .then(function(jwk) {
+ const cfg = {
+ contentAlg: "A256GCM"
+ };
+ const recipient = {
+ key: jwk,
+ header: {
+ alg: "RSA-OAEP",
+ kid: encryptionKeyObject.kid
+ }
+ };
+ const jwe = JWE.createEncrypt(cfg, recipient);
+ return jwe.update(signedJWT).final();
+ })
+ .then(
+ result =>
+ `${result.protected}.${result.recipients[0].encrypted_key}.${
+ result.iv
+ }.${result.ciphertext}.${result.tag}`
+ );
+};
diff --git a/eq-author-api/utils/migrateEnumChecks.js b/eq-author-api/utils/migrateEnumChecks.js
new file mode 100644
index 0000000000..ced547af6f
--- /dev/null
+++ b/eq-author-api/utils/migrateEnumChecks.js
@@ -0,0 +1,9 @@
+module.exports = (tableName, columnName, enums) => {
+ const constraintName = `${tableName}_${columnName}_check`;
+ return [
+ `ALTER TABLE "${tableName}" DROP CONSTRAINT IF EXISTS "${constraintName}";`,
+ `ALTER TABLE "${tableName}" ADD CONSTRAINT "${constraintName}" CHECK ("${columnName}" = ANY (ARRAY['${enums.join(
+ "'::text, '"
+ )}'::text]));`
+ ].join("\n");
+};
diff --git a/eq-author-api/utils/sanitiseMetadata.js b/eq-author-api/utils/sanitiseMetadata.js
new file mode 100644
index 0000000000..6fad773199
--- /dev/null
+++ b/eq-author-api/utils/sanitiseMetadata.js
@@ -0,0 +1,41 @@
+/* eslint-disable camelcase*/
+const { omit, assign } = require("lodash/fp");
+const uuid = require("uuid/v1");
+const EXPIRY_OFFSET_SECONDS = 100;
+
+const filterUnacceptableMeta = omit([
+ "survey_url",
+ "tx_id",
+ "iat",
+ "exp",
+ "eq_id",
+ "form_type",
+ "account_service_url"
+]);
+
+const defaultMetadata = (questionnaireId, tokenIssueTime, surveyUrl) => ({
+ tx_id: uuid(),
+ jti: uuid(),
+ iat: tokenIssueTime,
+ exp: tokenIssueTime + EXPIRY_OFFSET_SECONDS,
+ user_id: "UNKNOWN",
+ case_id: uuid(),
+ ru_ref: "12346789012A",
+ ru_name: "ESSENTIAL ENTERPRISE LTD",
+ trad_as: "ESSENTIAL ENTERPRISE LTD",
+ eq_id: questionnaireId,
+ collection_exercise_sid: uuid(),
+ period_id: "201605",
+ form_type: questionnaireId,
+ survey_url: `${surveyUrl}${questionnaireId}?r${tokenIssueTime}`
+});
+
+module.exports.sanitiseMetadata = (metadata, questionnaireId) => {
+ const surveyUrl = process.env.PUBLISHER_URL;
+ const refinedMetadata = filterUnacceptableMeta(metadata);
+ const tokenIssueTime = Math.round(new Date().getTime() / 1000);
+ return assign(
+ defaultMetadata(questionnaireId, tokenIssueTime, surveyUrl),
+ refinedMetadata
+ );
+};
diff --git a/eq-author-api/utils/sanitiseMetadata.test.js b/eq-author-api/utils/sanitiseMetadata.test.js
new file mode 100644
index 0000000000..d896add6c1
--- /dev/null
+++ b/eq-author-api/utils/sanitiseMetadata.test.js
@@ -0,0 +1,54 @@
+/* eslint-disable camelcase*/
+const { sanitiseMetadata } = require("./sanitiseMetadata");
+jest.mock("uuid/v1", () => () => 123);
+describe("sanitise Metadata", () => {
+ let defaultMetadata;
+
+ beforeEach(() => {
+ defaultMetadata = {
+ tx_id: 123,
+ jti: 123,
+ iat: expect.any(Number),
+ exp: expect.any(Number),
+ user_id: "UNKNOWN",
+ case_id: 123,
+ ru_ref: "12346789012A",
+ ru_name: "ESSENTIAL ENTERPRISE LTD",
+ trad_as: "ESSENTIAL ENTERPRISE LTD",
+ eq_id: 1,
+ collection_exercise_sid: 123,
+ period_id: "201605",
+ form_type: 1,
+ survey_url: expect.any(String)
+ };
+ });
+
+ it("should add the correct defaults when no metadata is defined", () => {
+ const sanitisedMetadata = sanitiseMetadata({}, 1);
+ expect(sanitisedMetadata).toMatchObject(defaultMetadata);
+ });
+
+ it("should overwrite user defined metadata when it uses reserved keys", () => {
+ const sanitisedMetadata = sanitiseMetadata(
+ {
+ iat: "foo",
+ exp: "bar"
+ },
+ 1
+ );
+ expect(sanitisedMetadata).toMatchObject(defaultMetadata);
+ });
+
+ it("should allow user defined metadata to overwrite the defaults where not reserved", () => {
+ defaultMetadata.ru_name = "foo";
+ defaultMetadata.trad_as = "bar";
+ const sanitisedMetadata = sanitiseMetadata(
+ {
+ ru_name: "foo",
+ trad_as: "bar"
+ },
+ 1
+ );
+ expect(sanitisedMetadata).toMatchObject(defaultMetadata);
+ });
+});
diff --git a/eq-author-api/yarn.lock b/eq-author-api/yarn.lock
new file mode 100644
index 0000000000..f70a7e8218
--- /dev/null
+++ b/eq-author-api/yarn.lock
@@ -0,0 +1,5464 @@
+# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
+# yarn lockfile v1
+
+
+"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.0.0-beta.35":
+ version "7.0.0"
+ resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.0.0.tgz#06e2ab19bdb535385559aabb5ba59729482800f8"
+ dependencies:
+ "@babel/highlight" "^7.0.0"
+
+"@babel/highlight@^7.0.0":
+ version "7.0.0"
+ resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.0.0.tgz#f710c38c8d458e6dd9a201afb637fcb781ce99e4"
+ dependencies:
+ chalk "^2.0.0"
+ esutils "^2.0.2"
+ js-tokens "^4.0.0"
+
+"@samverschueren/stream-to-observable@^0.3.0":
+ version "0.3.0"
+ resolved "https://registry.yarnpkg.com/@samverschueren/stream-to-observable/-/stream-to-observable-0.3.0.tgz#ecdf48d532c58ea477acfcab80348424f8d0662f"
+ dependencies:
+ any-observable "^0.3.0"
+
+"@types/node@*":
+ version "10.12.0"
+ resolved "https://registry.yarnpkg.com/@types/node/-/node-10.12.0.tgz#ea6dcbddbc5b584c83f06c60e82736d8fbb0c235"
+
+abab@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.0.tgz#aba0ab4c5eee2d4c79d3487d85450fb2376ebb0f"
+
+abbrev@1:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8"
+
+accepts@~1.3.5:
+ version "1.3.5"
+ resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.5.tgz#eb777df6011723a3b14e8a72c0805c8e86746bd2"
+ dependencies:
+ mime-types "~2.1.18"
+ negotiator "0.6.1"
+
+acorn-globals@^4.1.0:
+ version "4.3.0"
+ resolved "https://registry.yarnpkg.com/acorn-globals/-/acorn-globals-4.3.0.tgz#e3b6f8da3c1552a95ae627571f7dd6923bb54103"
+ dependencies:
+ acorn "^6.0.1"
+ acorn-walk "^6.0.1"
+
+acorn-jsx@^4.1.1:
+ version "4.1.1"
+ resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-4.1.1.tgz#e8e41e48ea2fe0c896740610ab6a4ffd8add225e"
+ dependencies:
+ acorn "^5.0.3"
+
+acorn-walk@^6.0.1:
+ version "6.1.0"
+ resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-6.1.0.tgz#c957f4a1460da46af4a0388ce28b4c99355b0cbc"
+
+acorn@^5.0.3, acorn@^5.5.3, acorn@^5.6.0:
+ version "5.7.3"
+ resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.7.3.tgz#67aa231bf8812974b85235a96771eb6bd07ea279"
+
+acorn@^6.0.1:
+ version "6.0.2"
+ resolved "https://registry.yarnpkg.com/acorn/-/acorn-6.0.2.tgz#6a459041c320ab17592c6317abbfdf4bbaa98ca4"
+
+ajv@^5.3.0:
+ version "5.5.2"
+ resolved "https://registry.yarnpkg.com/ajv/-/ajv-5.5.2.tgz#73b5eeca3fab653e3d3f9422b341ad42205dc965"
+ dependencies:
+ co "^4.6.0"
+ fast-deep-equal "^1.0.0"
+ fast-json-stable-stringify "^2.0.0"
+ json-schema-traverse "^0.3.0"
+
+ajv@^6.5.3:
+ version "6.5.4"
+ resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.5.4.tgz#247d5274110db653706b550fcc2b797ca28cfc59"
+ dependencies:
+ fast-deep-equal "^2.0.1"
+ fast-json-stable-stringify "^2.0.0"
+ json-schema-traverse "^0.4.1"
+ uri-js "^4.2.2"
+
+ansi-align@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/ansi-align/-/ansi-align-2.0.0.tgz#c36aeccba563b89ceb556f3690f0b1d9e3547f7f"
+ dependencies:
+ string-width "^2.0.0"
+
+ansi-escapes@^1.0.0:
+ version "1.4.0"
+ resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-1.4.0.tgz#d3a8a83b319aa67793662b13e761c7911422306e"
+
+ansi-escapes@^3.0.0:
+ version "3.1.0"
+ resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-3.1.0.tgz#f73207bb81207d75fd6c83f125af26eea378ca30"
+
+ansi-regex@^2.0.0:
+ version "2.1.1"
+ resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df"
+
+ansi-regex@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-3.0.0.tgz#ed0317c322064f79466c02966bddb605ab37d998"
+
+ansi-styles@^2.2.1:
+ version "2.2.1"
+ resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe"
+
+ansi-styles@^3.2.0, ansi-styles@^3.2.1:
+ version "3.2.1"
+ resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d"
+ dependencies:
+ color-convert "^1.9.0"
+
+any-observable@^0.3.0:
+ version "0.3.0"
+ resolved "https://registry.yarnpkg.com/any-observable/-/any-observable-0.3.0.tgz#af933475e5806a67d0d7df090dd5e8bef65d119b"
+
+anymatch@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-2.0.0.tgz#bcb24b4f37934d9aa7ac17b4adaf89e7c76ef2eb"
+ dependencies:
+ micromatch "^3.1.4"
+ normalize-path "^2.1.1"
+
+apollo-cache-control@^0.1.0:
+ version "0.1.1"
+ resolved "https://registry.yarnpkg.com/apollo-cache-control/-/apollo-cache-control-0.1.1.tgz#173d14ceb3eb9e7cb53de7eb8b61bee6159d4171"
+ dependencies:
+ graphql-extensions "^0.0.x"
+
+apollo-link@^1.2.3:
+ version "1.2.3"
+ resolved "https://registry.yarnpkg.com/apollo-link/-/apollo-link-1.2.3.tgz#9bd8d5fe1d88d31dc91dae9ecc22474d451fb70d"
+ dependencies:
+ apollo-utilities "^1.0.0"
+ zen-observable-ts "^0.8.10"
+
+apollo-server-core@^1.4.0:
+ version "1.4.0"
+ resolved "https://registry.yarnpkg.com/apollo-server-core/-/apollo-server-core-1.4.0.tgz#4faff7f110bfdd6c3f47008302ae24140f94c592"
+ dependencies:
+ apollo-cache-control "^0.1.0"
+ apollo-tracing "^0.1.0"
+ graphql-extensions "^0.0.x"
+
+apollo-server-express@^1.4.0:
+ version "1.4.0"
+ resolved "https://registry.yarnpkg.com/apollo-server-express/-/apollo-server-express-1.4.0.tgz#7d7c58d6d6f9892b83fe575669093bb66738b125"
+ dependencies:
+ apollo-server-core "^1.4.0"
+ apollo-server-module-graphiql "^1.4.0"
+
+apollo-server-module-graphiql@^1.4.0:
+ version "1.4.0"
+ resolved "https://registry.yarnpkg.com/apollo-server-module-graphiql/-/apollo-server-module-graphiql-1.4.0.tgz#c559efa285578820709f1769bb85d3b3eed3d8ec"
+
+apollo-tracing@^0.1.0:
+ version "0.1.4"
+ resolved "https://registry.yarnpkg.com/apollo-tracing/-/apollo-tracing-0.1.4.tgz#5b8ae1b01526b160ee6e552a7f131923a9aedcc7"
+ dependencies:
+ graphql-extensions "~0.0.9"
+
+apollo-utilities@^1.0.0, apollo-utilities@^1.0.1:
+ version "1.0.21"
+ resolved "https://registry.yarnpkg.com/apollo-utilities/-/apollo-utilities-1.0.21.tgz#cb8b5779fe275850b16046ff8373f4af2de90765"
+ dependencies:
+ fast-json-stable-stringify "^2.0.0"
+ fclone "^1.0.11"
+
+append-transform@^0.4.0:
+ version "0.4.0"
+ resolved "https://registry.yarnpkg.com/append-transform/-/append-transform-0.4.0.tgz#d76ebf8ca94d276e247a36bad44a4b74ab611991"
+ dependencies:
+ default-require-extensions "^1.0.0"
+
+aproba@^1.0.3:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a"
+
+are-we-there-yet@~1.1.2:
+ version "1.1.5"
+ resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-1.1.5.tgz#4b35c2944f062a8bfcda66410760350fe9ddfc21"
+ dependencies:
+ delegates "^1.0.0"
+ readable-stream "^2.0.6"
+
+argparse@^1.0.7:
+ version "1.0.10"
+ resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911"
+ dependencies:
+ sprintf-js "~1.0.2"
+
+aria-query@^0.7.0:
+ version "0.7.1"
+ resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-0.7.1.tgz#26cbb5aff64144b0a825be1846e0b16cfa00b11e"
+ dependencies:
+ ast-types-flow "0.0.7"
+ commander "^2.11.0"
+
+arr-diff@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-2.0.0.tgz#8f3b827f955a8bd669697e4a4256ac3ceae356cf"
+ dependencies:
+ arr-flatten "^1.0.1"
+
+arr-diff@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-4.0.0.tgz#d6461074febfec71e7e15235761a329a5dc7c520"
+
+arr-flatten@^1.0.1, arr-flatten@^1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/arr-flatten/-/arr-flatten-1.1.0.tgz#36048bbff4e7b47e136644316c99669ea5ae91f1"
+
+arr-union@^3.1.0:
+ version "3.1.0"
+ resolved "https://registry.yarnpkg.com/arr-union/-/arr-union-3.1.0.tgz#e39b09aea9def866a8f206e288af63919bae39c4"
+
+array-each@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/array-each/-/array-each-1.0.1.tgz#a794af0c05ab1752846ee753a1f211a05ba0c44f"
+
+array-equal@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/array-equal/-/array-equal-1.0.0.tgz#8c2a5ef2472fd9ea742b04c77a75093ba2757c93"
+
+array-flatten@1.1.1:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2"
+
+array-includes@^3.0.3:
+ version "3.0.3"
+ resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.0.3.tgz#184b48f62d92d7452bb31b323165c7f8bd02266d"
+ dependencies:
+ define-properties "^1.1.2"
+ es-abstract "^1.7.0"
+
+array-slice@^1.0.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/array-slice/-/array-slice-1.1.0.tgz#e368ea15f89bc7069f7ffb89aec3a6c7d4ac22d4"
+
+array-union@^1.0.1:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/array-union/-/array-union-1.0.2.tgz#9a34410e4f4e3da23dea375be5be70f24778ec39"
+ dependencies:
+ array-uniq "^1.0.1"
+
+array-uniq@^1.0.1:
+ version "1.0.3"
+ resolved "https://registry.yarnpkg.com/array-uniq/-/array-uniq-1.0.3.tgz#af6ac877a25cc7f74e058894753858dfdb24fdb6"
+
+array-unique@^0.2.1:
+ version "0.2.1"
+ resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.2.1.tgz#a1d97ccafcbc2625cc70fadceb36a50c58b01a53"
+
+array-unique@^0.3.2:
+ version "0.3.2"
+ resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.3.2.tgz#a894b75d4bc4f6cd679ef3244a9fd8f46ae2d428"
+
+arrify@^1.0.0, arrify@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d"
+
+asn1.js@^5.0.1:
+ version "5.0.1"
+ resolved "https://registry.yarnpkg.com/asn1.js/-/asn1.js-5.0.1.tgz#7668b56416953f0ce3421adbb3893ace59c96f59"
+ dependencies:
+ bn.js "^4.0.0"
+ inherits "^2.0.1"
+ minimalistic-assert "^1.0.0"
+
+asn1@~0.2.3:
+ version "0.2.4"
+ resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.4.tgz#8d2475dfab553bb33e77b54e59e880bb8ce23136"
+ dependencies:
+ safer-buffer "~2.1.0"
+
+assert-plus@1.0.0, assert-plus@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525"
+
+assign-symbols@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/assign-symbols/-/assign-symbols-1.0.0.tgz#59667f41fadd4f20ccbc2bb96b8d4f7f78ec0367"
+
+ast-types-flow@0.0.7:
+ version "0.0.7"
+ resolved "https://registry.yarnpkg.com/ast-types-flow/-/ast-types-flow-0.0.7.tgz#f70b735c6bca1a5c9c22d982c3e39e7feba3bdad"
+
+astral-regex@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-1.0.0.tgz#6c8c3fb827dd43ee3918f27b82782ab7658a6fd9"
+
+async-each@^1.0.0:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/async-each/-/async-each-1.0.1.tgz#19d386a1d9edc6e7c1c85d388aedbcc56d33602d"
+
+async-limiter@~1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.0.tgz#78faed8c3d074ab81f22b4e985d79e8738f720f8"
+
+async@^2.1.4, async@^2.5.0:
+ version "2.6.1"
+ resolved "https://registry.yarnpkg.com/async/-/async-2.6.1.tgz#b245a23ca71930044ec53fa46aa00a3e87c6a610"
+ dependencies:
+ lodash "^4.17.10"
+
+asynckit@^0.4.0:
+ version "0.4.0"
+ resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79"
+
+atob@^2.1.1:
+ version "2.1.2"
+ resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9"
+
+aws-sign2@~0.7.0:
+ version "0.7.0"
+ resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8"
+
+aws4@^1.8.0:
+ version "1.8.0"
+ resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.8.0.tgz#f0e003d9ca9e7f59c7a508945d7b2ef9a04a542f"
+
+axobject-query@^0.1.0:
+ version "0.1.0"
+ resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-0.1.0.tgz#62f59dbc59c9f9242759ca349960e7a2fe3c36c0"
+ dependencies:
+ ast-types-flow "0.0.7"
+
+babel-code-frame@^6.26.0:
+ version "6.26.0"
+ resolved "https://registry.yarnpkg.com/babel-code-frame/-/babel-code-frame-6.26.0.tgz#63fd43f7dc1e3bb7ce35947db8fe369a3f58c74b"
+ dependencies:
+ chalk "^1.1.3"
+ esutils "^2.0.2"
+ js-tokens "^3.0.2"
+
+babel-core@^6.0.0, babel-core@^6.26.0:
+ version "6.26.3"
+ resolved "https://registry.yarnpkg.com/babel-core/-/babel-core-6.26.3.tgz#b2e2f09e342d0f0c88e2f02e067794125e75c207"
+ dependencies:
+ babel-code-frame "^6.26.0"
+ babel-generator "^6.26.0"
+ babel-helpers "^6.24.1"
+ babel-messages "^6.23.0"
+ babel-register "^6.26.0"
+ babel-runtime "^6.26.0"
+ babel-template "^6.26.0"
+ babel-traverse "^6.26.0"
+ babel-types "^6.26.0"
+ babylon "^6.18.0"
+ convert-source-map "^1.5.1"
+ debug "^2.6.9"
+ json5 "^0.5.1"
+ lodash "^4.17.4"
+ minimatch "^3.0.4"
+ path-is-absolute "^1.0.1"
+ private "^0.1.8"
+ slash "^1.0.0"
+ source-map "^0.5.7"
+
+babel-generator@^6.18.0, babel-generator@^6.26.0:
+ version "6.26.1"
+ resolved "https://registry.yarnpkg.com/babel-generator/-/babel-generator-6.26.1.tgz#1844408d3b8f0d35a404ea7ac180f087a601bd90"
+ dependencies:
+ babel-messages "^6.23.0"
+ babel-runtime "^6.26.0"
+ babel-types "^6.26.0"
+ detect-indent "^4.0.0"
+ jsesc "^1.3.0"
+ lodash "^4.17.4"
+ source-map "^0.5.7"
+ trim-right "^1.0.1"
+
+babel-helpers@^6.24.1:
+ version "6.24.1"
+ resolved "https://registry.yarnpkg.com/babel-helpers/-/babel-helpers-6.24.1.tgz#3471de9caec388e5c850e597e58a26ddf37602b2"
+ dependencies:
+ babel-runtime "^6.22.0"
+ babel-template "^6.24.1"
+
+babel-jest@^23.6.0:
+ version "23.6.0"
+ resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-23.6.0.tgz#a644232366557a2240a0c083da6b25786185a2f1"
+ dependencies:
+ babel-plugin-istanbul "^4.1.6"
+ babel-preset-jest "^23.2.0"
+
+babel-messages@^6.23.0:
+ version "6.23.0"
+ resolved "https://registry.yarnpkg.com/babel-messages/-/babel-messages-6.23.0.tgz#f3cdf4703858035b2a2951c6ec5edf6c62f2630e"
+ dependencies:
+ babel-runtime "^6.22.0"
+
+babel-plugin-istanbul@^4.1.6:
+ version "4.1.6"
+ resolved "https://registry.yarnpkg.com/babel-plugin-istanbul/-/babel-plugin-istanbul-4.1.6.tgz#36c59b2192efce81c5b378321b74175add1c9a45"
+ dependencies:
+ babel-plugin-syntax-object-rest-spread "^6.13.0"
+ find-up "^2.1.0"
+ istanbul-lib-instrument "^1.10.1"
+ test-exclude "^4.2.1"
+
+babel-plugin-jest-hoist@^23.2.0:
+ version "23.2.0"
+ resolved "https://registry.yarnpkg.com/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-23.2.0.tgz#e61fae05a1ca8801aadee57a6d66b8cefaf44167"
+
+babel-plugin-syntax-object-rest-spread@^6.13.0:
+ version "6.13.0"
+ resolved "https://registry.yarnpkg.com/babel-plugin-syntax-object-rest-spread/-/babel-plugin-syntax-object-rest-spread-6.13.0.tgz#fd6536f2bce13836ffa3a5458c4903a597bb3bf5"
+
+babel-preset-jest@^23.2.0:
+ version "23.2.0"
+ resolved "https://registry.yarnpkg.com/babel-preset-jest/-/babel-preset-jest-23.2.0.tgz#8ec7a03a138f001a1a8fb1e8113652bf1a55da46"
+ dependencies:
+ babel-plugin-jest-hoist "^23.2.0"
+ babel-plugin-syntax-object-rest-spread "^6.13.0"
+
+babel-register@^6.26.0:
+ version "6.26.0"
+ resolved "https://registry.yarnpkg.com/babel-register/-/babel-register-6.26.0.tgz#6ed021173e2fcb486d7acb45c6009a856f647071"
+ dependencies:
+ babel-core "^6.26.0"
+ babel-runtime "^6.26.0"
+ core-js "^2.5.0"
+ home-or-tmp "^2.0.0"
+ lodash "^4.17.4"
+ mkdirp "^0.5.1"
+ source-map-support "^0.4.15"
+
+babel-runtime@^6.22.0, babel-runtime@^6.26.0:
+ version "6.26.0"
+ resolved "https://registry.yarnpkg.com/babel-runtime/-/babel-runtime-6.26.0.tgz#965c7058668e82b55d7bfe04ff2337bc8b5647fe"
+ dependencies:
+ core-js "^2.4.0"
+ regenerator-runtime "^0.11.0"
+
+babel-template@^6.16.0, babel-template@^6.24.1, babel-template@^6.26.0:
+ version "6.26.0"
+ resolved "https://registry.yarnpkg.com/babel-template/-/babel-template-6.26.0.tgz#de03e2d16396b069f46dd9fff8521fb1a0e35e02"
+ dependencies:
+ babel-runtime "^6.26.0"
+ babel-traverse "^6.26.0"
+ babel-types "^6.26.0"
+ babylon "^6.18.0"
+ lodash "^4.17.4"
+
+babel-traverse@^6.0.0, babel-traverse@^6.18.0, babel-traverse@^6.26.0:
+ version "6.26.0"
+ resolved "https://registry.yarnpkg.com/babel-traverse/-/babel-traverse-6.26.0.tgz#46a9cbd7edcc62c8e5c064e2d2d8d0f4035766ee"
+ dependencies:
+ babel-code-frame "^6.26.0"
+ babel-messages "^6.23.0"
+ babel-runtime "^6.26.0"
+ babel-types "^6.26.0"
+ babylon "^6.18.0"
+ debug "^2.6.8"
+ globals "^9.18.0"
+ invariant "^2.2.2"
+ lodash "^4.17.4"
+
+babel-types@^6.0.0, babel-types@^6.18.0, babel-types@^6.26.0:
+ version "6.26.0"
+ resolved "https://registry.yarnpkg.com/babel-types/-/babel-types-6.26.0.tgz#a3b073f94ab49eb6fa55cd65227a334380632497"
+ dependencies:
+ babel-runtime "^6.26.0"
+ esutils "^2.0.2"
+ lodash "^4.17.4"
+ to-fast-properties "^1.0.3"
+
+babylon@^6.18.0:
+ version "6.18.0"
+ resolved "https://registry.yarnpkg.com/babylon/-/babylon-6.18.0.tgz#af2f3b88fa6f5c1e4c634d1a0f8eac4f55b395e3"
+
+balanced-match@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767"
+
+base64url@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/base64url/-/base64url-3.0.0.tgz#f2ba30b15f80413d88e3e6116c4f3f7f61e28a2a"
+
+base@^0.11.1:
+ version "0.11.2"
+ resolved "https://registry.yarnpkg.com/base/-/base-0.11.2.tgz#7bde5ced145b6d551a90db87f83c558b4eb48a8f"
+ dependencies:
+ cache-base "^1.0.1"
+ class-utils "^0.3.5"
+ component-emitter "^1.2.1"
+ define-property "^1.0.0"
+ isobject "^3.0.1"
+ mixin-deep "^1.2.0"
+ pascalcase "^0.1.1"
+
+bcrypt-pbkdf@^1.0.0:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e"
+ dependencies:
+ tweetnacl "^0.14.3"
+
+binary-extensions@^1.0.0:
+ version "1.12.0"
+ resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.12.0.tgz#c2d780f53d45bba8317a8902d4ceeaf3a6385b14"
+
+bluebird@^3.5.0, bluebird@^3.5.1:
+ version "3.5.2"
+ resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.2.tgz#1be0908e054a751754549c270489c1505d4ab15a"
+
+bn.js@^4.0.0:
+ version "4.11.8"
+ resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.8.tgz#2cde09eb5ee341f484746bb0309b3253b1b1442f"
+
+body-parser@1.18.3, body-parser@^1.17.2:
+ version "1.18.3"
+ resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.18.3.tgz#5b292198ffdd553b3a0f20ded0592b956955c8b4"
+ dependencies:
+ bytes "3.0.0"
+ content-type "~1.0.4"
+ debug "2.6.9"
+ depd "~1.1.2"
+ http-errors "~1.6.3"
+ iconv-lite "0.4.23"
+ on-finished "~2.3.0"
+ qs "6.5.2"
+ raw-body "2.3.3"
+ type-is "~1.6.16"
+
+boolbase@~1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e"
+
+boxen@^1.2.1:
+ version "1.3.0"
+ resolved "https://registry.yarnpkg.com/boxen/-/boxen-1.3.0.tgz#55c6c39a8ba58d9c61ad22cd877532deb665a20b"
+ dependencies:
+ ansi-align "^2.0.0"
+ camelcase "^4.0.0"
+ chalk "^2.0.1"
+ cli-boxes "^1.0.0"
+ string-width "^2.0.0"
+ term-size "^1.2.0"
+ widest-line "^2.0.0"
+
+brace-expansion@^1.1.7:
+ version "1.1.11"
+ resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
+ dependencies:
+ balanced-match "^1.0.0"
+ concat-map "0.0.1"
+
+braces@^1.8.2:
+ version "1.8.5"
+ resolved "https://registry.yarnpkg.com/braces/-/braces-1.8.5.tgz#ba77962e12dff969d6b76711e914b737857bf6a7"
+ dependencies:
+ expand-range "^1.8.1"
+ preserve "^0.2.0"
+ repeat-element "^1.1.2"
+
+braces@^2.3.0, braces@^2.3.1:
+ version "2.3.2"
+ resolved "https://registry.yarnpkg.com/braces/-/braces-2.3.2.tgz#5979fd3f14cd531565e5fa2df1abfff1dfaee729"
+ dependencies:
+ arr-flatten "^1.1.0"
+ array-unique "^0.3.2"
+ extend-shallow "^2.0.1"
+ fill-range "^4.0.0"
+ isobject "^3.0.1"
+ repeat-element "^1.1.2"
+ snapdragon "^0.8.1"
+ snapdragon-node "^2.0.1"
+ split-string "^3.0.2"
+ to-regex "^3.0.1"
+
+browser-process-hrtime@^0.1.2:
+ version "0.1.3"
+ resolved "https://registry.yarnpkg.com/browser-process-hrtime/-/browser-process-hrtime-0.1.3.tgz#616f00faef1df7ec1b5bf9cfe2bdc3170f26c7b4"
+
+browser-resolve@^1.11.3:
+ version "1.11.3"
+ resolved "https://registry.yarnpkg.com/browser-resolve/-/browser-resolve-1.11.3.tgz#9b7cbb3d0f510e4cb86bdbd796124d28b5890af6"
+ dependencies:
+ resolve "1.1.7"
+
+bser@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/bser/-/bser-2.0.0.tgz#9ac78d3ed5d915804fd87acb158bc797147a1719"
+ dependencies:
+ node-int64 "^0.4.0"
+
+buffer-from@^1.0.0:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef"
+
+buffer-writer@1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/buffer-writer/-/buffer-writer-1.0.1.tgz#22a936901e3029afcd7547eb4487ceb697a3bf08"
+
+builtin-modules@^1.0.0:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-1.1.1.tgz#270f076c5a72c02f5b65a47df94c5fe3a278892f"
+
+bytes@3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.0.0.tgz#d32815404d689699f85a4ea4fa8755dd13a96048"
+
+cache-base@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/cache-base/-/cache-base-1.0.1.tgz#0a7f46416831c8b662ee36fe4e7c59d76f666ab2"
+ dependencies:
+ collection-visit "^1.0.0"
+ component-emitter "^1.2.1"
+ get-value "^2.0.6"
+ has-value "^1.0.0"
+ isobject "^3.0.1"
+ set-value "^2.0.0"
+ to-object-path "^0.3.0"
+ union-value "^1.0.0"
+ unset-value "^1.0.0"
+
+caller-path@^0.1.0:
+ version "0.1.0"
+ resolved "https://registry.yarnpkg.com/caller-path/-/caller-path-0.1.0.tgz#94085ef63581ecd3daa92444a8fe94e82577751f"
+ dependencies:
+ callsites "^0.2.0"
+
+callsites@^0.2.0:
+ version "0.2.0"
+ resolved "https://registry.yarnpkg.com/callsites/-/callsites-0.2.0.tgz#afab96262910a7f33c19a5775825c69f34e350ca"
+
+callsites@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/callsites/-/callsites-2.0.0.tgz#06eb84f00eea413da86affefacbffb36093b3c50"
+
+camelcase@^4.0.0, camelcase@^4.1.0:
+ version "4.1.0"
+ resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-4.1.0.tgz#d545635be1e33c542649c69173e5de6acfae34dd"
+
+capture-exit@^1.2.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/capture-exit/-/capture-exit-1.2.0.tgz#1c5fcc489fd0ab00d4f1ac7ae1072e3173fbab6f"
+ dependencies:
+ rsvp "^3.3.3"
+
+capture-stack-trace@^1.0.0:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/capture-stack-trace/-/capture-stack-trace-1.0.1.tgz#a6c0bbe1f38f3aa0b92238ecb6ff42c344d4135d"
+
+caseless@~0.12.0:
+ version "0.12.0"
+ resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc"
+
+chalk@2.3.2:
+ version "2.3.2"
+ resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.3.2.tgz#250dc96b07491bfd601e648d66ddf5f60c7a5c65"
+ dependencies:
+ ansi-styles "^3.2.1"
+ escape-string-regexp "^1.0.5"
+ supports-color "^5.3.0"
+
+chalk@^1.0.0, chalk@^1.1.3:
+ version "1.1.3"
+ resolved "https://registry.yarnpkg.com/chalk/-/chalk-1.1.3.tgz#a8115c55e4a702fe4d150abd3872822a7e09fc98"
+ dependencies:
+ ansi-styles "^2.2.1"
+ escape-string-regexp "^1.0.2"
+ has-ansi "^2.0.0"
+ strip-ansi "^3.0.0"
+ supports-color "^2.0.0"
+
+chalk@^2.0.0, chalk@^2.0.1, chalk@^2.1.0, chalk@^2.3.1:
+ version "2.4.1"
+ resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.1.tgz#18c49ab16a037b6eb0152cc83e3471338215b66e"
+ dependencies:
+ ansi-styles "^3.2.1"
+ escape-string-regexp "^1.0.5"
+ supports-color "^5.3.0"
+
+chardet@^0.7.0:
+ version "0.7.0"
+ resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e"
+
+cheerio@^1.0.0-rc.2:
+ version "1.0.0-rc.2"
+ resolved "https://registry.yarnpkg.com/cheerio/-/cheerio-1.0.0-rc.2.tgz#4b9f53a81b27e4d5dac31c0ffd0cfa03cc6830db"
+ dependencies:
+ css-select "~1.2.0"
+ dom-serializer "~0.1.0"
+ entities "~1.1.1"
+ htmlparser2 "^3.9.1"
+ lodash "^4.15.0"
+ parse5 "^3.0.1"
+
+chokidar@^2.0.2:
+ version "2.0.4"
+ resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-2.0.4.tgz#356ff4e2b0e8e43e322d18a372460bbcf3accd26"
+ dependencies:
+ anymatch "^2.0.0"
+ async-each "^1.0.0"
+ braces "^2.3.0"
+ glob-parent "^3.1.0"
+ inherits "^2.0.1"
+ is-binary-path "^1.0.0"
+ is-glob "^4.0.0"
+ lodash.debounce "^4.0.8"
+ normalize-path "^2.1.1"
+ path-is-absolute "^1.0.0"
+ readdirp "^2.0.0"
+ upath "^1.0.5"
+ optionalDependencies:
+ fsevents "^1.2.2"
+
+chownr@^1.0.1:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.1.tgz#54726b8b8fff4df053c42187e801fb4412df1494"
+
+ci-info@^1.5.0:
+ version "1.6.0"
+ resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-1.6.0.tgz#2ca20dbb9ceb32d4524a683303313f0304b1e497"
+
+circular-json@^0.3.1:
+ version "0.3.3"
+ resolved "https://registry.yarnpkg.com/circular-json/-/circular-json-0.3.3.tgz#815c99ea84f6809529d2f45791bdf82711352d66"
+
+class-utils@^0.3.5:
+ version "0.3.6"
+ resolved "https://registry.yarnpkg.com/class-utils/-/class-utils-0.3.6.tgz#f93369ae8b9a7ce02fd41faad0ca83033190c463"
+ dependencies:
+ arr-union "^3.1.0"
+ define-property "^0.2.5"
+ isobject "^3.0.0"
+ static-extend "^0.1.1"
+
+cli-boxes@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/cli-boxes/-/cli-boxes-1.0.0.tgz#4fa917c3e59c94a004cd61f8ee509da651687143"
+
+cli-cursor@^1.0.2:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-1.0.2.tgz#64da3f7d56a54412e59794bd62dc35295e8f2987"
+ dependencies:
+ restore-cursor "^1.0.1"
+
+cli-cursor@^2.1.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-2.1.0.tgz#b35dac376479facc3e94747d41d0d0f5238ffcb5"
+ dependencies:
+ restore-cursor "^2.0.0"
+
+cli-truncate@^0.2.1:
+ version "0.2.1"
+ resolved "https://registry.yarnpkg.com/cli-truncate/-/cli-truncate-0.2.1.tgz#9f15cfbb0705005369216c626ac7d05ab90dd574"
+ dependencies:
+ slice-ansi "0.0.4"
+ string-width "^1.0.1"
+
+cli-width@^2.0.0:
+ version "2.2.0"
+ resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-2.2.0.tgz#ff19ede8a9a5e579324147b0c11f0fbcbabed639"
+
+cliui@^4.0.0:
+ version "4.1.0"
+ resolved "https://registry.yarnpkg.com/cliui/-/cliui-4.1.0.tgz#348422dbe82d800b3022eef4f6ac10bf2e4d1b49"
+ dependencies:
+ string-width "^2.1.1"
+ strip-ansi "^4.0.0"
+ wrap-ansi "^2.0.0"
+
+co@^4.6.0:
+ version "4.6.0"
+ resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184"
+
+code-point-at@^1.0.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77"
+
+collection-visit@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/collection-visit/-/collection-visit-1.0.0.tgz#4bc0373c164bc3291b4d368c829cf1a80a59dca0"
+ dependencies:
+ map-visit "^1.0.0"
+ object-visit "^1.0.0"
+
+color-convert@^1.9.0:
+ version "1.9.3"
+ resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8"
+ dependencies:
+ color-name "1.1.3"
+
+color-name@1.1.3:
+ version "1.1.3"
+ resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25"
+
+colors@^1.1.2:
+ version "1.3.2"
+ resolved "https://registry.yarnpkg.com/colors/-/colors-1.3.2.tgz#2df8ff573dfbf255af562f8ce7181d6b971a359b"
+
+combined-stream@^1.0.6, combined-stream@~1.0.6:
+ version "1.0.7"
+ resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.7.tgz#2d1d24317afb8abe95d6d2c0b07b57813539d828"
+ dependencies:
+ delayed-stream "~1.0.0"
+
+commander@^2.11.0, commander@^2.14.1, commander@^2.16.0, commander@^2.9.0:
+ version "2.19.0"
+ resolved "https://registry.yarnpkg.com/commander/-/commander-2.19.0.tgz#f6198aa84e5b83c46054b94ddedbfed5ee9ff12a"
+
+commander@~2.17.1:
+ version "2.17.1"
+ resolved "https://registry.yarnpkg.com/commander/-/commander-2.17.1.tgz#bd77ab7de6de94205ceacc72f1716d29f20a77bf"
+
+component-emitter@^1.2.1:
+ version "1.2.1"
+ resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.2.1.tgz#137918d6d78283f7df7a6b7c5a63e140e69425e6"
+
+concat-map@0.0.1:
+ version "0.0.1"
+ resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
+
+configstore@^3.0.0:
+ version "3.1.2"
+ resolved "https://registry.yarnpkg.com/configstore/-/configstore-3.1.2.tgz#c6f25defaeef26df12dd33414b001fe81a543f8f"
+ dependencies:
+ dot-prop "^4.1.0"
+ graceful-fs "^4.1.2"
+ make-dir "^1.0.0"
+ unique-string "^1.0.0"
+ write-file-atomic "^2.0.0"
+ xdg-basedir "^3.0.0"
+
+console-control-strings@^1.0.0, console-control-strings@~1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e"
+
+contains-path@^0.1.0:
+ version "0.1.0"
+ resolved "https://registry.yarnpkg.com/contains-path/-/contains-path-0.1.0.tgz#fe8cf184ff6670b6baef01a9d4861a5cbec4120a"
+
+content-disposition@0.5.2:
+ version "0.5.2"
+ resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.2.tgz#0cf68bb9ddf5f2be7961c3a85178cb85dba78cb4"
+
+content-type@~1.0.4:
+ version "1.0.4"
+ resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b"
+
+convert-source-map@^1.4.0, convert-source-map@^1.5.1:
+ version "1.6.0"
+ resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.6.0.tgz#51b537a8c43e0f04dec1993bffcdd504e758ac20"
+ dependencies:
+ safe-buffer "~5.1.1"
+
+cookie-signature@1.0.6:
+ version "1.0.6"
+ resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c"
+
+cookie@0.3.1:
+ version "0.3.1"
+ resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.3.1.tgz#e7e0a1f9ef43b4c8ba925c5c5a96e806d16873bb"
+
+copy-descriptor@^0.1.0:
+ version "0.1.1"
+ resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d"
+
+core-js@^2.4.0, core-js@^2.5.0, core-js@^2.5.3:
+ version "2.5.7"
+ resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.5.7.tgz#f972608ff0cead68b841a16a932d0b183791814e"
+
+core-util-is@1.0.2, core-util-is@~1.0.0:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7"
+
+cors@^2.8.3:
+ version "2.8.4"
+ resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.4.tgz#2bd381f2eb201020105cd50ea59da63090694686"
+ dependencies:
+ object-assign "^4"
+ vary "^1"
+
+cosmiconfig@^5.0.2, cosmiconfig@^5.0.6:
+ version "5.0.6"
+ resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-5.0.6.tgz#dca6cf680a0bd03589aff684700858c81abeeb39"
+ dependencies:
+ is-directory "^0.3.1"
+ js-yaml "^3.9.0"
+ parse-json "^4.0.0"
+
+create-error-class@^3.0.0:
+ version "3.0.2"
+ resolved "https://registry.yarnpkg.com/create-error-class/-/create-error-class-3.0.2.tgz#06be7abef947a3f14a30fd610671d401bca8b7b6"
+ dependencies:
+ capture-stack-trace "^1.0.0"
+
+cross-spawn@^5.0.1:
+ version "5.1.0"
+ resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-5.1.0.tgz#e8bd0efee58fcff6f8f94510a0a554bbfa235449"
+ dependencies:
+ lru-cache "^4.0.1"
+ shebang-command "^1.2.0"
+ which "^1.2.9"
+
+cross-spawn@^6.0.5:
+ version "6.0.5"
+ resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4"
+ dependencies:
+ nice-try "^1.0.4"
+ path-key "^2.0.1"
+ semver "^5.5.0"
+ shebang-command "^1.2.0"
+ which "^1.2.9"
+
+crypto-random-string@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-1.0.0.tgz#a230f64f568310e1498009940790ec99545bca7e"
+
+css-select@~1.2.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/css-select/-/css-select-1.2.0.tgz#2b3a110539c5355f1cd8d314623e870b121ec858"
+ dependencies:
+ boolbase "~1.0.0"
+ css-what "2.1"
+ domutils "1.5.1"
+ nth-check "~1.0.1"
+
+css-what@2.1:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/css-what/-/css-what-2.1.0.tgz#9467d032c38cfaefb9f2d79501253062f87fa1bd"
+
+cssom@0.3.x, "cssom@>= 0.3.2 < 0.4.0":
+ version "0.3.4"
+ resolved "https://registry.yarnpkg.com/cssom/-/cssom-0.3.4.tgz#8cd52e8a3acfd68d3aed38ee0a640177d2f9d797"
+
+cssstyle@^1.0.0:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/cssstyle/-/cssstyle-1.1.1.tgz#18b038a9c44d65f7a8e428a653b9f6fe42faf5fb"
+ dependencies:
+ cssom "0.3.x"
+
+damerau-levenshtein@^1.0.0:
+ version "1.0.4"
+ resolved "https://registry.yarnpkg.com/damerau-levenshtein/-/damerau-levenshtein-1.0.4.tgz#03191c432cb6eea168bb77f3a55ffdccb8978514"
+
+dashdash@^1.12.0:
+ version "1.14.1"
+ resolved "https://registry.yarnpkg.com/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0"
+ dependencies:
+ assert-plus "^1.0.0"
+
+data-urls@^1.0.0:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-1.0.1.tgz#d416ac3896918f29ca84d81085bc3705834da579"
+ dependencies:
+ abab "^2.0.0"
+ whatwg-mimetype "^2.1.0"
+ whatwg-url "^7.0.0"
+
+date-fns@^1.27.2:
+ version "1.29.0"
+ resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-1.29.0.tgz#12e609cdcb935127311d04d33334e2960a2a54e6"
+
+debug@2.6.9, debug@^2.1.2, debug@^2.2.0, debug@^2.3.3, debug@^2.6.8, debug@^2.6.9:
+ version "2.6.9"
+ resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
+ dependencies:
+ ms "2.0.0"
+
+debug@3.1.0:
+ version "3.1.0"
+ resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261"
+ dependencies:
+ ms "2.0.0"
+
+debug@^3.1.0:
+ version "3.2.6"
+ resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b"
+ dependencies:
+ ms "^2.1.1"
+
+debug@^4.0.1:
+ version "4.1.0"
+ resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.0.tgz#373687bffa678b38b1cd91f861b63850035ddc87"
+ dependencies:
+ ms "^2.1.1"
+
+decamelize@^1.1.1:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290"
+
+decode-uri-component@^0.2.0:
+ version "0.2.0"
+ resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545"
+
+dedent@^0.7.0:
+ version "0.7.0"
+ resolved "https://registry.yarnpkg.com/dedent/-/dedent-0.7.0.tgz#2495ddbaf6eb874abb0e1be9df22d2e5a544326c"
+
+deep-extend@^0.6.0:
+ version "0.6.0"
+ resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac"
+
+deep-is@~0.1.3:
+ version "0.1.3"
+ resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34"
+
+default-require-extensions@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/default-require-extensions/-/default-require-extensions-1.0.0.tgz#f37ea15d3e13ffd9b437d33e1a75b5fb97874cb8"
+ dependencies:
+ strip-bom "^2.0.0"
+
+define-properties@^1.1.2:
+ version "1.1.3"
+ resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.1.3.tgz#cf88da6cbee26fe6db7094f61d870cbd84cee9f1"
+ dependencies:
+ object-keys "^1.0.12"
+
+define-property@^0.2.5:
+ version "0.2.5"
+ resolved "https://registry.yarnpkg.com/define-property/-/define-property-0.2.5.tgz#c35b1ef918ec3c990f9a5bc57be04aacec5c8116"
+ dependencies:
+ is-descriptor "^0.1.0"
+
+define-property@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/define-property/-/define-property-1.0.0.tgz#769ebaaf3f4a63aad3af9e8d304c9bbe79bfb0e6"
+ dependencies:
+ is-descriptor "^1.0.0"
+
+define-property@^2.0.2:
+ version "2.0.2"
+ resolved "https://registry.yarnpkg.com/define-property/-/define-property-2.0.2.tgz#d459689e8d654ba77e02a817f8710d702cb16e9d"
+ dependencies:
+ is-descriptor "^1.0.2"
+ isobject "^3.0.1"
+
+del@^2.0.2:
+ version "2.2.2"
+ resolved "https://registry.yarnpkg.com/del/-/del-2.2.2.tgz#c12c981d067846c84bcaf862cff930d907ffd1a8"
+ dependencies:
+ globby "^5.0.0"
+ is-path-cwd "^1.0.0"
+ is-path-in-cwd "^1.0.0"
+ object-assign "^4.0.1"
+ pify "^2.0.0"
+ pinkie-promise "^2.0.0"
+ rimraf "^2.2.8"
+
+delayed-stream@~1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
+
+delegates@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a"
+
+depd@~1.1.2:
+ version "1.1.2"
+ resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9"
+
+deprecated-decorator@^0.1.6:
+ version "0.1.6"
+ resolved "https://registry.yarnpkg.com/deprecated-decorator/-/deprecated-decorator-0.1.6.tgz#00966317b7a12fe92f3cc831f7583af329b86c37"
+
+destroy@~1.0.4:
+ version "1.0.4"
+ resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.0.4.tgz#978857442c44749e4206613e37946205826abd80"
+
+detect-file@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/detect-file/-/detect-file-1.0.0.tgz#f0d66d03672a825cb1b73bdb3fe62310c8e552b7"
+
+detect-indent@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-4.0.0.tgz#f76d064352cdf43a1cb6ce619c4ee3a9475de208"
+ dependencies:
+ repeating "^2.0.0"
+
+detect-libc@^1.0.2:
+ version "1.0.3"
+ resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b"
+
+detect-newline@^2.1.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-2.1.0.tgz#f41f1c10be4b00e87b5f13da680759f2c5bfd3e2"
+
+diff@^3.2.0:
+ version "3.5.0"
+ resolved "https://registry.yarnpkg.com/diff/-/diff-3.5.0.tgz#800c0dd1e0a8bfbc95835c202ad220fe317e5a12"
+
+doctrine@1.5.0:
+ version "1.5.0"
+ resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-1.5.0.tgz#379dce730f6166f76cefa4e6707a159b02c5a6fa"
+ dependencies:
+ esutils "^2.0.2"
+ isarray "^1.0.0"
+
+doctrine@^2.1.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d"
+ dependencies:
+ esutils "^2.0.2"
+
+dom-serializer@0, dom-serializer@~0.1.0:
+ version "0.1.0"
+ resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.1.0.tgz#073c697546ce0780ce23be4a28e293e40bc30c82"
+ dependencies:
+ domelementtype "~1.1.1"
+ entities "~1.1.1"
+
+domelementtype@1, domelementtype@^1.3.0:
+ version "1.3.0"
+ resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-1.3.0.tgz#b17aed82e8ab59e52dd9c19b1756e0fc187204c2"
+
+domelementtype@~1.1.1:
+ version "1.1.3"
+ resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-1.1.3.tgz#bd28773e2642881aec51544924299c5cd822185b"
+
+domexception@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/domexception/-/domexception-1.0.1.tgz#937442644ca6a31261ef36e3ec677fe805582c90"
+ dependencies:
+ webidl-conversions "^4.0.2"
+
+domhandler@^2.3.0:
+ version "2.4.2"
+ resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-2.4.2.tgz#8805097e933d65e85546f726d60f5eb88b44f803"
+ dependencies:
+ domelementtype "1"
+
+domutils@1.5.1:
+ version "1.5.1"
+ resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.5.1.tgz#dcd8488a26f563d61079e48c9f7b7e32373682cf"
+ dependencies:
+ dom-serializer "0"
+ domelementtype "1"
+
+domutils@^1.5.1:
+ version "1.7.0"
+ resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.7.0.tgz#56ea341e834e06e6748af7a1cb25da67ea9f8c2a"
+ dependencies:
+ dom-serializer "0"
+ domelementtype "1"
+
+dot-prop@^4.1.0:
+ version "4.2.0"
+ resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-4.2.0.tgz#1f19e0c2e1aa0e32797c49799f2837ac6af69c57"
+ dependencies:
+ is-obj "^1.0.0"
+
+dotenv@^6.0.0:
+ version "6.1.0"
+ resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-6.1.0.tgz#9853b6ca98292acb7dec67a95018fa40bccff42c"
+
+duplexer3@^0.1.4:
+ version "0.1.4"
+ resolved "https://registry.yarnpkg.com/duplexer3/-/duplexer3-0.1.4.tgz#ee01dd1cac0ed3cbc7fdbea37dc0a8f1ce002ce2"
+
+duplexer@^0.1.1, duplexer@~0.1.1:
+ version "0.1.1"
+ resolved "https://registry.yarnpkg.com/duplexer/-/duplexer-0.1.1.tgz#ace6ff808c1ce66b57d1ebf97977acb02334cfc1"
+
+durations@^3.0.0:
+ version "3.4.1"
+ resolved "https://registry.yarnpkg.com/durations/-/durations-3.4.1.tgz#4bde42895d461ca76be255d74baac28bcb84e580"
+
+ecc-jsbn@~0.1.1:
+ version "0.1.2"
+ resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9"
+ dependencies:
+ jsbn "~0.1.0"
+ safer-buffer "^2.1.0"
+
+ee-first@1.1.1:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
+
+elegant-spinner@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/elegant-spinner/-/elegant-spinner-1.0.1.tgz#db043521c95d7e303fd8f345bedc3349cfb0729e"
+
+emoji-regex@^6.1.0:
+ version "6.5.1"
+ resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-6.5.1.tgz#9baea929b155565c11ea41c6626eaa65cef992c2"
+
+encodeurl@~1.0.2:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59"
+
+end-of-stream@^1.1.0:
+ version "1.4.1"
+ resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.1.tgz#ed29634d19baba463b6ce6b80a37213eab71ec43"
+ dependencies:
+ once "^1.4.0"
+
+entities@^1.1.1, entities@~1.1.1:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/entities/-/entities-1.1.1.tgz#6e5c2d0a5621b5dadaecef80b90edfb5cd7772f0"
+
+eq-author-graphql-schema@^0.40.0:
+ version "0.40.0"
+ resolved "https://registry.yarnpkg.com/eq-author-graphql-schema/-/eq-author-graphql-schema-0.40.0.tgz#46b3e69cbd3ba22b93c5389448fd4bc15aaa5dd8"
+
+error-ex@^1.2.0, error-ex@^1.3.1:
+ version "1.3.2"
+ resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf"
+ dependencies:
+ is-arrayish "^0.2.1"
+
+es-abstract@^1.5.1, es-abstract@^1.7.0:
+ version "1.12.0"
+ resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.12.0.tgz#9dbbdd27c6856f0001421ca18782d786bf8a6165"
+ dependencies:
+ es-to-primitive "^1.1.1"
+ function-bind "^1.1.1"
+ has "^1.0.1"
+ is-callable "^1.1.3"
+ is-regex "^1.0.4"
+
+es-to-primitive@^1.1.1:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.2.0.tgz#edf72478033456e8dda8ef09e00ad9650707f377"
+ dependencies:
+ is-callable "^1.1.4"
+ is-date-object "^1.0.1"
+ is-symbol "^1.0.2"
+
+es6-promise@^4.0.5:
+ version "4.2.5"
+ resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-4.2.5.tgz#da6d0d5692efb461e082c14817fe2427d8f5d054"
+
+escape-html@~1.0.3:
+ version "1.0.3"
+ resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988"
+
+escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.5:
+ version "1.0.5"
+ resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
+
+escodegen@^1.9.1:
+ version "1.11.0"
+ resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-1.11.0.tgz#b27a9389481d5bfd5bec76f7bb1eb3f8f4556589"
+ dependencies:
+ esprima "^3.1.3"
+ estraverse "^4.2.0"
+ esutils "^2.0.2"
+ optionator "^0.8.1"
+ optionalDependencies:
+ source-map "~0.6.1"
+
+eslint-config-eq-author@^2.0.1:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/eslint-config-eq-author/-/eslint-config-eq-author-2.0.1.tgz#4c3e5745abecb6f59d0ded6e37ee40c65f5bd5be"
+ dependencies:
+ eslint-config-prettier "^2.1.1"
+ eslint-config-react-app "^1.0.4"
+ eslint-plugin-babel "^4.1.1"
+ eslint-plugin-cypress "^2.0.1"
+ eslint-plugin-flowtype "^2.34.0"
+ eslint-plugin-import "^2.3.0"
+ eslint-plugin-jasmine "^2.2.0"
+ eslint-plugin-jest "^20.0.3"
+ eslint-plugin-jsx-a11y "^5.0.3"
+ eslint-plugin-react "^7.0.1"
+
+eslint-config-prettier@^2.1.1:
+ version "2.10.0"
+ resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-2.10.0.tgz#ec07bc1d01f87d09f61d3840d112dc8a9791e30b"
+ dependencies:
+ get-stdin "^5.0.1"
+
+eslint-config-react-app@^1.0.4:
+ version "1.0.5"
+ resolved "https://registry.yarnpkg.com/eslint-config-react-app/-/eslint-config-react-app-1.0.5.tgz#98337597bc01cc22991fcbdda07451f3b4511718"
+
+eslint-import-resolver-node@^0.3.1:
+ version "0.3.2"
+ resolved "https://registry.yarnpkg.com/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.2.tgz#58f15fb839b8d0576ca980413476aab2472db66a"
+ dependencies:
+ debug "^2.6.9"
+ resolve "^1.5.0"
+
+eslint-module-utils@^2.2.0:
+ version "2.2.0"
+ resolved "https://registry.yarnpkg.com/eslint-module-utils/-/eslint-module-utils-2.2.0.tgz#b270362cd88b1a48ad308976ce7fa54e98411746"
+ dependencies:
+ debug "^2.6.8"
+ pkg-dir "^1.0.0"
+
+eslint-plugin-babel@^4.1.1:
+ version "4.1.2"
+ resolved "https://registry.yarnpkg.com/eslint-plugin-babel/-/eslint-plugin-babel-4.1.2.tgz#79202a0e35757dd92780919b2336f1fa2fe53c1e"
+
+eslint-plugin-cypress@^2.0.1:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/eslint-plugin-cypress/-/eslint-plugin-cypress-2.0.1.tgz#647e942cacbfd71b0f1a1ed6978472fbd475c60a"
+ dependencies:
+ globals "^11.0.1"
+
+eslint-plugin-flowtype@^2.34.0:
+ version "2.50.3"
+ resolved "https://registry.yarnpkg.com/eslint-plugin-flowtype/-/eslint-plugin-flowtype-2.50.3.tgz#61379d6dce1d010370acd6681740fd913d68175f"
+ dependencies:
+ lodash "^4.17.10"
+
+eslint-plugin-import@^2.3.0:
+ version "2.14.0"
+ resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.14.0.tgz#6b17626d2e3e6ad52cfce8807a845d15e22111a8"
+ dependencies:
+ contains-path "^0.1.0"
+ debug "^2.6.8"
+ doctrine "1.5.0"
+ eslint-import-resolver-node "^0.3.1"
+ eslint-module-utils "^2.2.0"
+ has "^1.0.1"
+ lodash "^4.17.4"
+ minimatch "^3.0.3"
+ read-pkg-up "^2.0.0"
+ resolve "^1.6.0"
+
+eslint-plugin-jasmine@^2.2.0:
+ version "2.10.1"
+ resolved "https://registry.yarnpkg.com/eslint-plugin-jasmine/-/eslint-plugin-jasmine-2.10.1.tgz#5733b709e751f4bc40e31e1c16989bd2cdfbec97"
+
+eslint-plugin-jest@^20.0.3:
+ version "20.0.3"
+ resolved "https://registry.yarnpkg.com/eslint-plugin-jest/-/eslint-plugin-jest-20.0.3.tgz#ec15eba6ac0ab44a67ebf6e02672ca9d7e7cba29"
+
+eslint-plugin-jsx-a11y@^5.0.3:
+ version "5.1.1"
+ resolved "https://registry.yarnpkg.com/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-5.1.1.tgz#5c96bb5186ca14e94db1095ff59b3e2bd94069b1"
+ dependencies:
+ aria-query "^0.7.0"
+ array-includes "^3.0.3"
+ ast-types-flow "0.0.7"
+ axobject-query "^0.1.0"
+ damerau-levenshtein "^1.0.0"
+ emoji-regex "^6.1.0"
+ jsx-ast-utils "^1.4.0"
+
+eslint-plugin-react@^7.0.1:
+ version "7.11.1"
+ resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.11.1.tgz#c01a7af6f17519457d6116aa94fc6d2ccad5443c"
+ dependencies:
+ array-includes "^3.0.3"
+ doctrine "^2.1.0"
+ has "^1.0.3"
+ jsx-ast-utils "^2.0.1"
+ prop-types "^15.6.2"
+
+eslint-scope@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-4.0.0.tgz#50bf3071e9338bcdc43331794a0cb533f0136172"
+ dependencies:
+ esrecurse "^4.1.0"
+ estraverse "^4.1.1"
+
+eslint-utils@^1.3.1:
+ version "1.3.1"
+ resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-1.3.1.tgz#9a851ba89ee7c460346f97cf8939c7298827e512"
+
+eslint-visitor-keys@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz#3f3180fb2e291017716acb4c9d6d5b5c34a6a81d"
+
+eslint@^5.6.1:
+ version "5.7.0"
+ resolved "https://registry.yarnpkg.com/eslint/-/eslint-5.7.0.tgz#55c326d6fb2ad45fcbd0ce17c3846f025d1d819c"
+ dependencies:
+ "@babel/code-frame" "^7.0.0"
+ ajv "^6.5.3"
+ chalk "^2.1.0"
+ cross-spawn "^6.0.5"
+ debug "^4.0.1"
+ doctrine "^2.1.0"
+ eslint-scope "^4.0.0"
+ eslint-utils "^1.3.1"
+ eslint-visitor-keys "^1.0.0"
+ espree "^4.0.0"
+ esquery "^1.0.1"
+ esutils "^2.0.2"
+ file-entry-cache "^2.0.0"
+ functional-red-black-tree "^1.0.1"
+ glob "^7.1.2"
+ globals "^11.7.0"
+ ignore "^4.0.6"
+ imurmurhash "^0.1.4"
+ inquirer "^6.1.0"
+ is-resolvable "^1.1.0"
+ js-yaml "^3.12.0"
+ json-stable-stringify-without-jsonify "^1.0.1"
+ levn "^0.3.0"
+ lodash "^4.17.5"
+ minimatch "^3.0.4"
+ mkdirp "^0.5.1"
+ natural-compare "^1.4.0"
+ optionator "^0.8.2"
+ path-is-inside "^1.0.2"
+ pluralize "^7.0.0"
+ progress "^2.0.0"
+ regexpp "^2.0.1"
+ require-uncached "^1.0.3"
+ semver "^5.5.1"
+ strip-ansi "^4.0.0"
+ strip-json-comments "^2.0.1"
+ table "^5.0.2"
+ text-table "^0.2.0"
+
+espree@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/espree/-/espree-4.0.0.tgz#253998f20a0f82db5d866385799d912a83a36634"
+ dependencies:
+ acorn "^5.6.0"
+ acorn-jsx "^4.1.1"
+
+esprima@^3.1.3:
+ version "3.1.3"
+ resolved "https://registry.yarnpkg.com/esprima/-/esprima-3.1.3.tgz#fdca51cee6133895e3c88d535ce49dbff62a4633"
+
+esprima@^4.0.0:
+ version "4.0.1"
+ resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71"
+
+esquery@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.0.1.tgz#406c51658b1f5991a5f9b62b1dc25b00e3e5c708"
+ dependencies:
+ estraverse "^4.0.0"
+
+esrecurse@^4.1.0:
+ version "4.2.1"
+ resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.2.1.tgz#007a3b9fdbc2b3bb87e4879ea19c92fdbd3942cf"
+ dependencies:
+ estraverse "^4.1.0"
+
+estraverse@^4.0.0, estraverse@^4.1.0, estraverse@^4.1.1, estraverse@^4.2.0:
+ version "4.2.0"
+ resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.2.0.tgz#0dee3fed31fcd469618ce7342099fc1afa0bdb13"
+
+esutils@^2.0.2:
+ version "2.0.2"
+ resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.2.tgz#0abf4f1caa5bcb1f7a9d8acc6dea4faaa04bac9b"
+
+etag@~1.8.1:
+ version "1.8.1"
+ resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887"
+
+event-stream@~3.3.0:
+ version "3.3.6"
+ resolved "https://registry.yarnpkg.com/event-stream/-/event-stream-3.3.6.tgz#cac1230890e07e73ec9cacd038f60a5b66173eef"
+ dependencies:
+ duplexer "^0.1.1"
+ flatmap-stream "^0.1.0"
+ from "^0.1.7"
+ map-stream "0.0.7"
+ pause-stream "^0.0.11"
+ split "^1.0.1"
+ stream-combiner "^0.2.2"
+ through "^2.3.8"
+
+exec-sh@^0.2.0:
+ version "0.2.2"
+ resolved "https://registry.yarnpkg.com/exec-sh/-/exec-sh-0.2.2.tgz#2a5e7ffcbd7d0ba2755bdecb16e5a427dfbdec36"
+ dependencies:
+ merge "^1.2.0"
+
+execa@^0.7.0:
+ version "0.7.0"
+ resolved "https://registry.yarnpkg.com/execa/-/execa-0.7.0.tgz#944becd34cc41ee32a63a9faf27ad5a65fc59777"
+ dependencies:
+ cross-spawn "^5.0.1"
+ get-stream "^3.0.0"
+ is-stream "^1.1.0"
+ npm-run-path "^2.0.0"
+ p-finally "^1.0.0"
+ signal-exit "^3.0.0"
+ strip-eof "^1.0.0"
+
+execa@^0.9.0:
+ version "0.9.0"
+ resolved "https://registry.yarnpkg.com/execa/-/execa-0.9.0.tgz#adb7ce62cf985071f60580deb4a88b9e34712d01"
+ dependencies:
+ cross-spawn "^5.0.1"
+ get-stream "^3.0.0"
+ is-stream "^1.1.0"
+ npm-run-path "^2.0.0"
+ p-finally "^1.0.0"
+ signal-exit "^3.0.0"
+ strip-eof "^1.0.0"
+
+exit-hook@^1.0.0:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/exit-hook/-/exit-hook-1.1.1.tgz#f05ca233b48c05d54fff07765df8507e95c02ff8"
+
+exit@^0.1.2:
+ version "0.1.2"
+ resolved "https://registry.yarnpkg.com/exit/-/exit-0.1.2.tgz#0632638f8d877cc82107d30a0fff1a17cba1cd0c"
+
+expand-brackets@^0.1.4:
+ version "0.1.5"
+ resolved "https://registry.yarnpkg.com/expand-brackets/-/expand-brackets-0.1.5.tgz#df07284e342a807cd733ac5af72411e581d1177b"
+ dependencies:
+ is-posix-bracket "^0.1.0"
+
+expand-brackets@^2.1.4:
+ version "2.1.4"
+ resolved "https://registry.yarnpkg.com/expand-brackets/-/expand-brackets-2.1.4.tgz#b77735e315ce30f6b6eff0f83b04151a22449622"
+ dependencies:
+ debug "^2.3.3"
+ define-property "^0.2.5"
+ extend-shallow "^2.0.1"
+ posix-character-classes "^0.1.0"
+ regex-not "^1.0.0"
+ snapdragon "^0.8.1"
+ to-regex "^3.0.1"
+
+expand-range@^1.8.1:
+ version "1.8.2"
+ resolved "https://registry.yarnpkg.com/expand-range/-/expand-range-1.8.2.tgz#a299effd335fe2721ebae8e257ec79644fc85337"
+ dependencies:
+ fill-range "^2.1.0"
+
+expand-tilde@^2.0.0, expand-tilde@^2.0.2:
+ version "2.0.2"
+ resolved "https://registry.yarnpkg.com/expand-tilde/-/expand-tilde-2.0.2.tgz#97e801aa052df02454de46b02bf621642cdc8502"
+ dependencies:
+ homedir-polyfill "^1.0.1"
+
+expect@^23.6.0:
+ version "23.6.0"
+ resolved "https://registry.yarnpkg.com/expect/-/expect-23.6.0.tgz#1e0c8d3ba9a581c87bd71fb9bc8862d443425f98"
+ dependencies:
+ ansi-styles "^3.2.0"
+ jest-diff "^23.6.0"
+ jest-get-type "^22.1.0"
+ jest-matcher-utils "^23.6.0"
+ jest-message-util "^23.4.0"
+ jest-regex-util "^23.3.0"
+
+express-pino-logger@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/express-pino-logger/-/express-pino-logger-4.0.0.tgz#775cf253a4e0e7ee2c24804f8a32900d6d0168ca"
+ dependencies:
+ pino-http "^4.0.0"
+
+express@^4.15.3:
+ version "4.16.4"
+ resolved "https://registry.yarnpkg.com/express/-/express-4.16.4.tgz#fddef61926109e24c515ea97fd2f1bdbf62df12e"
+ dependencies:
+ accepts "~1.3.5"
+ array-flatten "1.1.1"
+ body-parser "1.18.3"
+ content-disposition "0.5.2"
+ content-type "~1.0.4"
+ cookie "0.3.1"
+ cookie-signature "1.0.6"
+ debug "2.6.9"
+ depd "~1.1.2"
+ encodeurl "~1.0.2"
+ escape-html "~1.0.3"
+ etag "~1.8.1"
+ finalhandler "1.1.1"
+ fresh "0.5.2"
+ merge-descriptors "1.0.1"
+ methods "~1.1.2"
+ on-finished "~2.3.0"
+ parseurl "~1.3.2"
+ path-to-regexp "0.1.7"
+ proxy-addr "~2.0.4"
+ qs "6.5.2"
+ range-parser "~1.2.0"
+ safe-buffer "5.1.2"
+ send "0.16.2"
+ serve-static "1.13.2"
+ setprototypeof "1.1.0"
+ statuses "~1.4.0"
+ type-is "~1.6.16"
+ utils-merge "1.0.1"
+ vary "~1.1.2"
+
+extend-shallow@^2.0.1:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-2.0.1.tgz#51af7d614ad9a9f610ea1bafbb989d6b1c56890f"
+ dependencies:
+ is-extendable "^0.1.0"
+
+extend-shallow@^3.0.0, extend-shallow@^3.0.2:
+ version "3.0.2"
+ resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-3.0.2.tgz#26a71aaf073b39fb2127172746131c2704028db8"
+ dependencies:
+ assign-symbols "^1.0.0"
+ is-extendable "^1.0.1"
+
+extend@^3.0.0, extend@~3.0.2:
+ version "3.0.2"
+ resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa"
+
+external-editor@^3.0.0:
+ version "3.0.3"
+ resolved "https://registry.yarnpkg.com/external-editor/-/external-editor-3.0.3.tgz#5866db29a97826dbe4bf3afd24070ead9ea43a27"
+ dependencies:
+ chardet "^0.7.0"
+ iconv-lite "^0.4.24"
+ tmp "^0.0.33"
+
+extglob@^0.3.1:
+ version "0.3.2"
+ resolved "https://registry.yarnpkg.com/extglob/-/extglob-0.3.2.tgz#2e18ff3d2f49ab2765cec9023f011daa8d8349a1"
+ dependencies:
+ is-extglob "^1.0.0"
+
+extglob@^2.0.4:
+ version "2.0.4"
+ resolved "https://registry.yarnpkg.com/extglob/-/extglob-2.0.4.tgz#ad00fe4dc612a9232e8718711dc5cb5ab0285543"
+ dependencies:
+ array-unique "^0.3.2"
+ define-property "^1.0.0"
+ expand-brackets "^2.1.4"
+ extend-shallow "^2.0.1"
+ fragment-cache "^0.2.1"
+ regex-not "^1.0.0"
+ snapdragon "^0.8.1"
+ to-regex "^3.0.1"
+
+extsprintf@1.3.0:
+ version "1.3.0"
+ resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.3.0.tgz#96918440e3041a7a414f8c52e3c574eb3c3e1e05"
+
+extsprintf@^1.2.0:
+ version "1.4.0"
+ resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.4.0.tgz#e2689f8f356fad62cca65a3a91c5df5f9551692f"
+
+fast-deep-equal@^1.0.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-1.1.0.tgz#c053477817c86b51daa853c81e059b733d023614"
+
+fast-deep-equal@^2.0.1:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz#7b05218ddf9667bf7f370bf7fdb2cb15fdd0aa49"
+
+fast-json-parse@^1.0.3:
+ version "1.0.3"
+ resolved "https://registry.yarnpkg.com/fast-json-parse/-/fast-json-parse-1.0.3.tgz#43e5c61ee4efa9265633046b770fb682a7577c4d"
+
+fast-json-stable-stringify@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz#d5142c0caee6b1189f87d3a76111064f86c8bbf2"
+
+fast-levenshtein@~2.0.4:
+ version "2.0.6"
+ resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917"
+
+fast-redact@^1.2.0:
+ version "1.3.0"
+ resolved "https://registry.yarnpkg.com/fast-redact/-/fast-redact-1.3.0.tgz#c3a41bfecba796f0206c4c71a4613af020e524cc"
+
+fast-safe-stringify@^2.0.6:
+ version "2.0.6"
+ resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.0.6.tgz#04b26106cc56681f51a044cfc0d76cf0008ac2c2"
+
+fb-watchman@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/fb-watchman/-/fb-watchman-2.0.0.tgz#54e9abf7dfa2f26cd9b1636c588c1afc05de5d58"
+ dependencies:
+ bser "^2.0.0"
+
+fclone@^1.0.11:
+ version "1.0.11"
+ resolved "https://registry.yarnpkg.com/fclone/-/fclone-1.0.11.tgz#10e85da38bfea7fc599341c296ee1d77266ee640"
+
+figures@^1.7.0:
+ version "1.7.0"
+ resolved "https://registry.yarnpkg.com/figures/-/figures-1.7.0.tgz#cbe1e3affcf1cd44b80cadfed28dc793a9701d2e"
+ dependencies:
+ escape-string-regexp "^1.0.5"
+ object-assign "^4.1.0"
+
+figures@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/figures/-/figures-2.0.0.tgz#3ab1a2d2a62c8bfb431a0c94cb797a2fce27c962"
+ dependencies:
+ escape-string-regexp "^1.0.5"
+
+file-entry-cache@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-2.0.0.tgz#c392990c3e684783d838b8c84a45d8a048458361"
+ dependencies:
+ flat-cache "^1.2.1"
+ object-assign "^4.0.1"
+
+filename-regex@^2.0.0:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/filename-regex/-/filename-regex-2.0.1.tgz#c1c4b9bee3e09725ddb106b75c1e301fe2f18b26"
+
+fileset@^2.0.2:
+ version "2.0.3"
+ resolved "https://registry.yarnpkg.com/fileset/-/fileset-2.0.3.tgz#8e7548a96d3cc2327ee5e674168723a333bba2a0"
+ dependencies:
+ glob "^7.0.3"
+ minimatch "^3.0.3"
+
+fill-range@^2.1.0:
+ version "2.2.4"
+ resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-2.2.4.tgz#eb1e773abb056dcd8df2bfdf6af59b8b3a936565"
+ dependencies:
+ is-number "^2.1.0"
+ isobject "^2.0.0"
+ randomatic "^3.0.0"
+ repeat-element "^1.1.2"
+ repeat-string "^1.5.2"
+
+fill-range@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-4.0.0.tgz#d544811d428f98eb06a63dc402d2403c328c38f7"
+ dependencies:
+ extend-shallow "^2.0.1"
+ is-number "^3.0.0"
+ repeat-string "^1.6.1"
+ to-regex-range "^2.1.0"
+
+finalhandler@1.1.1:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.1.1.tgz#eebf4ed840079c83f4249038c9d703008301b105"
+ dependencies:
+ debug "2.6.9"
+ encodeurl "~1.0.2"
+ escape-html "~1.0.3"
+ on-finished "~2.3.0"
+ parseurl "~1.3.2"
+ statuses "~1.4.0"
+ unpipe "~1.0.0"
+
+find-parent-dir@^0.3.0:
+ version "0.3.0"
+ resolved "https://registry.yarnpkg.com/find-parent-dir/-/find-parent-dir-0.3.0.tgz#33c44b429ab2b2f0646299c5f9f718f376ff8d54"
+
+find-up@^1.0.0:
+ version "1.1.2"
+ resolved "https://registry.yarnpkg.com/find-up/-/find-up-1.1.2.tgz#6b2e9822b1a2ce0a60ab64d610eccad53cb24d0f"
+ dependencies:
+ path-exists "^2.0.0"
+ pinkie-promise "^2.0.0"
+
+find-up@^2.0.0, find-up@^2.1.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/find-up/-/find-up-2.1.0.tgz#45d1b7e506c717ddd482775a2b77920a3c0c57a7"
+ dependencies:
+ locate-path "^2.0.0"
+
+find-up@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/find-up/-/find-up-3.0.0.tgz#49169f1d7993430646da61ecc5ae355c21c97b73"
+ dependencies:
+ locate-path "^3.0.0"
+
+findup-sync@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/findup-sync/-/findup-sync-2.0.0.tgz#9326b1488c22d1a6088650a86901b2d9a90a2cbc"
+ dependencies:
+ detect-file "^1.0.0"
+ is-glob "^3.1.0"
+ micromatch "^3.0.4"
+ resolve-dir "^1.0.1"
+
+fined@^1.0.1:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/fined/-/fined-1.1.0.tgz#b37dc844b76a2f5e7081e884f7c0ae344f153476"
+ dependencies:
+ expand-tilde "^2.0.2"
+ is-plain-object "^2.0.3"
+ object.defaults "^1.1.0"
+ object.pick "^1.2.0"
+ parse-filepath "^1.0.1"
+
+flagged-respawn@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/flagged-respawn/-/flagged-respawn-1.0.0.tgz#4e79ae9b2eb38bf86b3bb56bf3e0a56aa5fcabd7"
+
+flat-cache@^1.2.1:
+ version "1.3.0"
+ resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-1.3.0.tgz#d3030b32b38154f4e3b7e9c709f490f7ef97c481"
+ dependencies:
+ circular-json "^0.3.1"
+ del "^2.0.2"
+ graceful-fs "^4.1.2"
+ write "^0.2.1"
+
+flatmap-stream@^0.1.0:
+ version "0.1.1"
+ resolved "https://registry.yarnpkg.com/flatmap-stream/-/flatmap-stream-0.1.1.tgz#d34f39ef3b9aa5a2fc225016bd3adf28ac5ae6ea"
+
+flatstr@^1.0.5:
+ version "1.0.8"
+ resolved "https://registry.yarnpkg.com/flatstr/-/flatstr-1.0.8.tgz#0e849229751f2b9f6a0919f8e81e1229e84ba901"
+
+for-in@^1.0.1, for-in@^1.0.2:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80"
+
+for-own@^0.1.4:
+ version "0.1.5"
+ resolved "https://registry.yarnpkg.com/for-own/-/for-own-0.1.5.tgz#5265c681a4f294dabbf17c9509b6763aa84510ce"
+ dependencies:
+ for-in "^1.0.1"
+
+for-own@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/for-own/-/for-own-1.0.0.tgz#c63332f415cedc4b04dbfe70cf836494c53cb44b"
+ dependencies:
+ for-in "^1.0.1"
+
+forever-agent@~0.6.1:
+ version "0.6.1"
+ resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91"
+
+form-data@~2.3.2:
+ version "2.3.3"
+ resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.3.tgz#dcce52c05f644f298c6a7ab936bd724ceffbf3a6"
+ dependencies:
+ asynckit "^0.4.0"
+ combined-stream "^1.0.6"
+ mime-types "^2.1.12"
+
+forwarded@~0.1.2:
+ version "0.1.2"
+ resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.1.2.tgz#98c23dab1175657b8c0573e8ceccd91b0ff18c84"
+
+fragment-cache@^0.2.1:
+ version "0.2.1"
+ resolved "https://registry.yarnpkg.com/fragment-cache/-/fragment-cache-0.2.1.tgz#4290fad27f13e89be7f33799c6bc5a0abfff0d19"
+ dependencies:
+ map-cache "^0.2.2"
+
+fresh@0.5.2:
+ version "0.5.2"
+ resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7"
+
+from@^0.1.7:
+ version "0.1.7"
+ resolved "https://registry.yarnpkg.com/from/-/from-0.1.7.tgz#83c60afc58b9c56997007ed1a768b3ab303a44fe"
+
+fs-minipass@^1.2.5:
+ version "1.2.5"
+ resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-1.2.5.tgz#06c277218454ec288df77ada54a03b8702aacb9d"
+ dependencies:
+ minipass "^2.2.1"
+
+fs.realpath@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
+
+fsevents@^1.2.2, fsevents@^1.2.3:
+ version "1.2.4"
+ resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-1.2.4.tgz#f41dcb1af2582af3692da36fc55cbd8e1041c426"
+ dependencies:
+ nan "^2.9.2"
+ node-pre-gyp "^0.10.0"
+
+function-bind@^1.1.1:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d"
+
+functional-red-black-tree@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327"
+
+gauge@~2.7.3:
+ version "2.7.4"
+ resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.7.4.tgz#2c03405c7538c39d7eb37b317022e325fb018bf7"
+ dependencies:
+ aproba "^1.0.3"
+ console-control-strings "^1.0.0"
+ has-unicode "^2.0.0"
+ object-assign "^4.1.0"
+ signal-exit "^3.0.0"
+ string-width "^1.0.1"
+ strip-ansi "^3.0.1"
+ wide-align "^1.1.0"
+
+generic-pool@2.4.3:
+ version "2.4.3"
+ resolved "https://registry.yarnpkg.com/generic-pool/-/generic-pool-2.4.3.tgz#780c36f69dfad05a5a045dd37be7adca11a4f6ff"
+
+get-caller-file@^1.0.1:
+ version "1.0.3"
+ resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-1.0.3.tgz#f978fa4c90d1dfe7ff2d6beda2a515e713bdcf4a"
+
+get-own-enumerable-property-symbols@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.0.tgz#b877b49a5c16aefac3655f2ed2ea5b684df8d203"
+
+get-stdin@^5.0.1:
+ version "5.0.1"
+ resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-5.0.1.tgz#122e161591e21ff4c52530305693f20e6393a398"
+
+get-stdin@^6.0.0:
+ version "6.0.0"
+ resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-6.0.0.tgz#9e09bf712b360ab9225e812048f71fde9c89657b"
+
+get-stream@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-3.0.0.tgz#8e943d1358dc37555054ecbe2edb05aa174ede14"
+
+get-value@^2.0.3, get-value@^2.0.6:
+ version "2.0.6"
+ resolved "https://registry.yarnpkg.com/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28"
+
+getpass@^0.1.1:
+ version "0.1.7"
+ resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa"
+ dependencies:
+ assert-plus "^1.0.0"
+
+glob-base@^0.3.0:
+ version "0.3.0"
+ resolved "https://registry.yarnpkg.com/glob-base/-/glob-base-0.3.0.tgz#dbb164f6221b1c0b1ccf82aea328b497df0ea3c4"
+ dependencies:
+ glob-parent "^2.0.0"
+ is-glob "^2.0.0"
+
+glob-parent@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-2.0.0.tgz#81383d72db054fcccf5336daa902f182f6edbb28"
+ dependencies:
+ is-glob "^2.0.0"
+
+glob-parent@^3.1.0:
+ version "3.1.0"
+ resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-3.1.0.tgz#9e6af6299d8d3bd2bd40430832bd113df906c5ae"
+ dependencies:
+ is-glob "^3.1.0"
+ path-dirname "^1.0.0"
+
+glob@^7.0.3, glob@^7.0.5, glob@^7.1.1, glob@^7.1.2:
+ version "7.1.3"
+ resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.3.tgz#3960832d3f1574108342dafd3a67b332c0969df1"
+ dependencies:
+ fs.realpath "^1.0.0"
+ inflight "^1.0.4"
+ inherits "2"
+ minimatch "^3.0.4"
+ once "^1.3.0"
+ path-is-absolute "^1.0.0"
+
+global-dirs@^0.1.0:
+ version "0.1.1"
+ resolved "https://registry.yarnpkg.com/global-dirs/-/global-dirs-0.1.1.tgz#b319c0dd4607f353f3be9cca4c72fc148c49f445"
+ dependencies:
+ ini "^1.3.4"
+
+global-modules@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/global-modules/-/global-modules-1.0.0.tgz#6d770f0eb523ac78164d72b5e71a8877265cc3ea"
+ dependencies:
+ global-prefix "^1.0.1"
+ is-windows "^1.0.1"
+ resolve-dir "^1.0.0"
+
+global-prefix@^1.0.1:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/global-prefix/-/global-prefix-1.0.2.tgz#dbf743c6c14992593c655568cb66ed32c0122ebe"
+ dependencies:
+ expand-tilde "^2.0.2"
+ homedir-polyfill "^1.0.1"
+ ini "^1.3.4"
+ is-windows "^1.0.1"
+ which "^1.2.14"
+
+globals@^11.0.1, globals@^11.7.0:
+ version "11.8.0"
+ resolved "https://registry.yarnpkg.com/globals/-/globals-11.8.0.tgz#c1ef45ee9bed6badf0663c5cb90e8d1adec1321d"
+
+globals@^9.18.0:
+ version "9.18.0"
+ resolved "https://registry.yarnpkg.com/globals/-/globals-9.18.0.tgz#aa3896b3e69b487f17e31ed2143d69a8e30c2d8a"
+
+globby@^5.0.0:
+ version "5.0.0"
+ resolved "https://registry.yarnpkg.com/globby/-/globby-5.0.0.tgz#ebd84667ca0dbb330b99bcfc68eac2bc54370e0d"
+ dependencies:
+ array-union "^1.0.1"
+ arrify "^1.0.0"
+ glob "^7.0.3"
+ object-assign "^4.0.1"
+ pify "^2.0.0"
+ pinkie-promise "^2.0.0"
+
+got@^6.7.1:
+ version "6.7.1"
+ resolved "https://registry.yarnpkg.com/got/-/got-6.7.1.tgz#240cd05785a9a18e561dc1b44b41c763ef1e8db0"
+ dependencies:
+ create-error-class "^3.0.0"
+ duplexer3 "^0.1.4"
+ get-stream "^3.0.0"
+ is-redirect "^1.0.0"
+ is-retry-allowed "^1.0.0"
+ is-stream "^1.0.0"
+ lowercase-keys "^1.0.0"
+ safe-buffer "^5.0.1"
+ timed-out "^4.0.0"
+ unzip-response "^2.0.1"
+ url-parse-lax "^1.0.0"
+
+graceful-fs@^4.1.11, graceful-fs@^4.1.2:
+ version "4.1.11"
+ resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.1.11.tgz#0e8bdfe4d1ddb8854d64e04ea7c00e2a026e5658"
+
+graphql-extensions@^0.0.x, graphql-extensions@~0.0.9:
+ version "0.0.10"
+ resolved "https://registry.yarnpkg.com/graphql-extensions/-/graphql-extensions-0.0.10.tgz#34bdb2546d43f6a5bc89ab23c295ec0466c6843d"
+ dependencies:
+ core-js "^2.5.3"
+ source-map-support "^0.5.1"
+
+graphql-iso-date@^3.3.0:
+ version "3.6.1"
+ resolved "https://registry.yarnpkg.com/graphql-iso-date/-/graphql-iso-date-3.6.1.tgz#bd2d0dc886e0f954cbbbc496bbf1d480b57ffa96"
+
+graphql-relay@^0.5.2:
+ version "0.5.5"
+ resolved "https://registry.yarnpkg.com/graphql-relay/-/graphql-relay-0.5.5.tgz#d6815e6edd618e878d5d921c13fc66033ec867e2"
+
+graphql-server-express@^1.3.2:
+ version "1.4.0"
+ resolved "https://registry.yarnpkg.com/graphql-server-express/-/graphql-server-express-1.4.0.tgz#f62b49dc70c860b653e76e21defa7f27324b764d"
+ dependencies:
+ apollo-server-express "^1.4.0"
+
+graphql-tools@^4.0.0:
+ version "4.0.1"
+ resolved "https://registry.yarnpkg.com/graphql-tools/-/graphql-tools-4.0.1.tgz#c995a4e25c2967d108c975e508322d12969c8c0e"
+ dependencies:
+ apollo-link "^1.2.3"
+ apollo-utilities "^1.0.1"
+ deprecated-decorator "^0.1.6"
+ iterall "^1.1.3"
+ uuid "^3.1.0"
+
+graphql-type-json@^0.2.1:
+ version "0.2.1"
+ resolved "https://registry.yarnpkg.com/graphql-type-json/-/graphql-type-json-0.2.1.tgz#d2c177e2f1b17d87f81072cd05311c0754baa420"
+
+graphql@^14.0.2:
+ version "14.0.2"
+ resolved "https://registry.yarnpkg.com/graphql/-/graphql-14.0.2.tgz#7dded337a4c3fd2d075692323384034b357f5650"
+ dependencies:
+ iterall "^1.2.2"
+
+growly@^1.3.0:
+ version "1.3.0"
+ resolved "https://registry.yarnpkg.com/growly/-/growly-1.3.0.tgz#f10748cbe76af964b7c96c93c6bcc28af120c081"
+
+handlebars@^4.0.3:
+ version "4.0.12"
+ resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.0.12.tgz#2c15c8a96d46da5e266700518ba8cb8d919d5bc5"
+ dependencies:
+ async "^2.5.0"
+ optimist "^0.6.1"
+ source-map "^0.6.1"
+ optionalDependencies:
+ uglify-js "^3.1.4"
+
+har-schema@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92"
+
+har-validator@~5.1.0:
+ version "5.1.0"
+ resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-5.1.0.tgz#44657f5688a22cfd4b72486e81b3a3fb11742c29"
+ dependencies:
+ ajv "^5.3.0"
+ har-schema "^2.0.0"
+
+has-ansi@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/has-ansi/-/has-ansi-2.0.0.tgz#34f5049ce1ecdf2b0649af3ef24e45ed35416d91"
+ dependencies:
+ ansi-regex "^2.0.0"
+
+has-flag@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-1.0.0.tgz#9d9e793165ce017a00f00418c43f942a7b1d11fa"
+
+has-flag@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd"
+
+has-symbols@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.0.tgz#ba1a8f1af2a0fc39650f5c850367704122063b44"
+
+has-unicode@^2.0.0:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9"
+
+has-value@^0.3.1:
+ version "0.3.1"
+ resolved "https://registry.yarnpkg.com/has-value/-/has-value-0.3.1.tgz#7b1f58bada62ca827ec0a2078025654845995e1f"
+ dependencies:
+ get-value "^2.0.3"
+ has-values "^0.1.4"
+ isobject "^2.0.0"
+
+has-value@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/has-value/-/has-value-1.0.0.tgz#18b281da585b1c5c51def24c930ed29a0be6b177"
+ dependencies:
+ get-value "^2.0.6"
+ has-values "^1.0.0"
+ isobject "^3.0.0"
+
+has-values@^0.1.4:
+ version "0.1.4"
+ resolved "https://registry.yarnpkg.com/has-values/-/has-values-0.1.4.tgz#6d61de95d91dfca9b9a02089ad384bff8f62b771"
+
+has-values@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/has-values/-/has-values-1.0.0.tgz#95b0b63fec2146619a6fe57fe75628d5a39efe4f"
+ dependencies:
+ is-number "^3.0.0"
+ kind-of "^4.0.0"
+
+has@^1.0.1, has@^1.0.3:
+ version "1.0.3"
+ resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796"
+ dependencies:
+ function-bind "^1.1.1"
+
+hoek@4.x.x:
+ version "4.2.1"
+ resolved "https://registry.yarnpkg.com/hoek/-/hoek-4.2.1.tgz#9634502aa12c445dd5a7c5734b572bb8738aacbb"
+
+home-or-tmp@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/home-or-tmp/-/home-or-tmp-2.0.0.tgz#e36c3f2d2cae7d746a857e38d18d5f32a7882db8"
+ dependencies:
+ os-homedir "^1.0.0"
+ os-tmpdir "^1.0.1"
+
+homedir-polyfill@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/homedir-polyfill/-/homedir-polyfill-1.0.1.tgz#4c2bbc8a758998feebf5ed68580f76d46768b4bc"
+ dependencies:
+ parse-passwd "^1.0.0"
+
+hosted-git-info@^2.1.4:
+ version "2.7.1"
+ resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.7.1.tgz#97f236977bd6e125408930ff6de3eec6281ec047"
+
+html-encoding-sniffer@^1.0.2:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-1.0.2.tgz#e70d84b94da53aa375e11fe3a351be6642ca46f8"
+ dependencies:
+ whatwg-encoding "^1.0.1"
+
+htmlparser2@^3.9.1:
+ version "3.9.2"
+ resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-3.9.2.tgz#1bdf87acca0f3f9e53fa4fcceb0f4b4cbb00b338"
+ dependencies:
+ domelementtype "^1.3.0"
+ domhandler "^2.3.0"
+ domutils "^1.5.1"
+ entities "^1.1.1"
+ inherits "^2.0.1"
+ readable-stream "^2.0.2"
+
+http-errors@1.6.3, http-errors@~1.6.2, http-errors@~1.6.3:
+ version "1.6.3"
+ resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.6.3.tgz#8b55680bb4be283a0b5bf4ea2e38580be1d9320d"
+ dependencies:
+ depd "~1.1.2"
+ inherits "2.0.3"
+ setprototypeof "1.1.0"
+ statuses ">= 1.4.0 < 2"
+
+http-signature@~1.2.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.2.0.tgz#9aecd925114772f3d95b65a60abb8f7c18fbace1"
+ dependencies:
+ assert-plus "^1.0.0"
+ jsprim "^1.2.2"
+ sshpk "^1.7.0"
+
+husky@^1.0.1:
+ version "1.1.2"
+ resolved "https://registry.yarnpkg.com/husky/-/husky-1.1.2.tgz#574c2bb16958db8a8120b63306efaff110525c23"
+ dependencies:
+ cosmiconfig "^5.0.6"
+ execa "^0.9.0"
+ find-up "^3.0.0"
+ get-stdin "^6.0.0"
+ is-ci "^1.2.1"
+ pkg-dir "^3.0.0"
+ please-upgrade-node "^3.1.1"
+ read-pkg "^4.0.1"
+ run-node "^1.0.0"
+ slash "^2.0.0"
+
+iconv-lite@0.4.23:
+ version "0.4.23"
+ resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.23.tgz#297871f63be507adcfbfca715d0cd0eed84e9a63"
+ dependencies:
+ safer-buffer ">= 2.1.2 < 3"
+
+iconv-lite@0.4.24, iconv-lite@^0.4.24, iconv-lite@^0.4.4:
+ version "0.4.24"
+ resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
+ dependencies:
+ safer-buffer ">= 2.1.2 < 3"
+
+ignore-by-default@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/ignore-by-default/-/ignore-by-default-1.0.1.tgz#48ca6d72f6c6a3af00a9ad4ae6876be3889e2b09"
+
+ignore-walk@^3.0.1:
+ version "3.0.1"
+ resolved "https://registry.yarnpkg.com/ignore-walk/-/ignore-walk-3.0.1.tgz#a83e62e7d272ac0e3b551aaa82831a19b69f82f8"
+ dependencies:
+ minimatch "^3.0.4"
+
+ignore@^4.0.6:
+ version "4.0.6"
+ resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc"
+
+import-lazy@^2.1.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/import-lazy/-/import-lazy-2.1.0.tgz#05698e3d45c88e8d7e9d92cb0584e77f096f3e43"
+
+import-local@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/import-local/-/import-local-1.0.0.tgz#5e4ffdc03f4fe6c009c6729beb29631c2f8227bc"
+ dependencies:
+ pkg-dir "^2.0.0"
+ resolve-cwd "^2.0.0"
+
+imurmurhash@^0.1.4:
+ version "0.1.4"
+ resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea"
+
+indent-string@^3.0.0:
+ version "3.2.0"
+ resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-3.2.0.tgz#4a5fd6d27cc332f37e5419a504dbb837105c9289"
+
+inflight@^1.0.4:
+ version "1.0.6"
+ resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9"
+ dependencies:
+ once "^1.3.0"
+ wrappy "1"
+
+inherits@2, inherits@2.0.3, inherits@^2.0.1, inherits@~2.0.3:
+ version "2.0.3"
+ resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de"
+
+ini@^1.3.4, ini@~1.3.0:
+ version "1.3.5"
+ resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927"
+
+inquirer@^6.1.0:
+ version "6.2.0"
+ resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-6.2.0.tgz#51adcd776f661369dc1e894859c2560a224abdd8"
+ dependencies:
+ ansi-escapes "^3.0.0"
+ chalk "^2.0.0"
+ cli-cursor "^2.1.0"
+ cli-width "^2.0.0"
+ external-editor "^3.0.0"
+ figures "^2.0.0"
+ lodash "^4.17.10"
+ mute-stream "0.0.7"
+ run-async "^2.2.0"
+ rxjs "^6.1.0"
+ string-width "^2.1.0"
+ strip-ansi "^4.0.0"
+ through "^2.3.6"
+
+interpret@^1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.1.0.tgz#7ed1b1410c6a0e0f78cf95d3b8440c63f78b8614"
+
+invariant@^2.2.2, invariant@^2.2.4:
+ version "2.2.4"
+ resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6"
+ dependencies:
+ loose-envify "^1.0.0"
+
+invert-kv@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/invert-kv/-/invert-kv-1.0.0.tgz#104a8e4aaca6d3d8cd157a8ef8bfab2d7a3ffdb6"
+
+ipaddr.js@1.8.0:
+ version "1.8.0"
+ resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.8.0.tgz#eaa33d6ddd7ace8f7f6fe0c9ca0440e706738b1e"
+
+is-absolute@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/is-absolute/-/is-absolute-1.0.0.tgz#395e1ae84b11f26ad1795e73c17378e48a301576"
+ dependencies:
+ is-relative "^1.0.0"
+ is-windows "^1.0.1"
+
+is-accessor-descriptor@^0.1.6:
+ version "0.1.6"
+ resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz#a9e12cb3ae8d876727eeef3843f8a0897b5c98d6"
+ dependencies:
+ kind-of "^3.0.2"
+
+is-accessor-descriptor@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz#169c2f6d3df1f992618072365c9b0ea1f6878656"
+ dependencies:
+ kind-of "^6.0.0"
+
+is-arrayish@^0.2.1:
+ version "0.2.1"
+ resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d"
+
+is-binary-path@^1.0.0:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-1.0.1.tgz#75f16642b480f187a711c814161fd3a4a7655898"
+ dependencies:
+ binary-extensions "^1.0.0"
+
+is-buffer@^1.1.5:
+ version "1.1.6"
+ resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be"
+
+is-builtin-module@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/is-builtin-module/-/is-builtin-module-1.0.0.tgz#540572d34f7ac3119f8f76c30cbc1b1e037affbe"
+ dependencies:
+ builtin-modules "^1.0.0"
+
+is-callable@^1.1.3, is-callable@^1.1.4:
+ version "1.1.4"
+ resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.1.4.tgz#1e1adf219e1eeb684d691f9d6a05ff0d30a24d75"
+
+is-ci@^1.0.10, is-ci@^1.2.1:
+ version "1.2.1"
+ resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-1.2.1.tgz#e3779c8ee17fccf428488f6e281187f2e632841c"
+ dependencies:
+ ci-info "^1.5.0"
+
+is-data-descriptor@^0.1.4:
+ version "0.1.4"
+ resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz#0b5ee648388e2c860282e793f1856fec3f301b56"
+ dependencies:
+ kind-of "^3.0.2"
+
+is-data-descriptor@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz#d84876321d0e7add03990406abbbbd36ba9268c7"
+ dependencies:
+ kind-of "^6.0.0"
+
+is-date-object@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.0.1.tgz#9aa20eb6aeebbff77fbd33e74ca01b33581d3a16"
+
+is-descriptor@^0.1.0:
+ version "0.1.6"
+ resolved "https://registry.yarnpkg.com/is-descriptor/-/is-descriptor-0.1.6.tgz#366d8240dde487ca51823b1ab9f07a10a78251ca"
+ dependencies:
+ is-accessor-descriptor "^0.1.6"
+ is-data-descriptor "^0.1.4"
+ kind-of "^5.0.0"
+
+is-descriptor@^1.0.0, is-descriptor@^1.0.2:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/is-descriptor/-/is-descriptor-1.0.2.tgz#3b159746a66604b04f8c81524ba365c5f14d86ec"
+ dependencies:
+ is-accessor-descriptor "^1.0.0"
+ is-data-descriptor "^1.0.0"
+ kind-of "^6.0.2"
+
+is-directory@^0.3.1:
+ version "0.3.1"
+ resolved "https://registry.yarnpkg.com/is-directory/-/is-directory-0.3.1.tgz#61339b6f2475fc772fd9c9d83f5c8575dc154ae1"
+
+is-dotfile@^1.0.0:
+ version "1.0.3"
+ resolved "https://registry.yarnpkg.com/is-dotfile/-/is-dotfile-1.0.3.tgz#a6a2f32ffd2dfb04f5ca25ecd0f6b83cf798a1e1"
+
+is-equal-shallow@^0.1.3:
+ version "0.1.3"
+ resolved "https://registry.yarnpkg.com/is-equal-shallow/-/is-equal-shallow-0.1.3.tgz#2238098fc221de0bcfa5d9eac4c45d638aa1c534"
+ dependencies:
+ is-primitive "^2.0.0"
+
+is-extendable@^0.1.0, is-extendable@^0.1.1:
+ version "0.1.1"
+ resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-0.1.1.tgz#62b110e289a471418e3ec36a617d472e301dfc89"
+
+is-extendable@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-1.0.1.tgz#a7470f9e426733d81bd81e1155264e3a3507cab4"
+ dependencies:
+ is-plain-object "^2.0.4"
+
+is-extglob@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-1.0.0.tgz#ac468177c4943405a092fc8f29760c6ffc6206c0"
+
+is-extglob@^2.1.0, is-extglob@^2.1.1:
+ version "2.1.1"
+ resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2"
+
+is-finite@^1.0.0:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/is-finite/-/is-finite-1.0.2.tgz#cc6677695602be550ef11e8b4aa6305342b6d0aa"
+ dependencies:
+ number-is-nan "^1.0.0"
+
+is-fullwidth-code-point@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz#ef9e31386f031a7f0d643af82fde50c457ef00cb"
+ dependencies:
+ number-is-nan "^1.0.0"
+
+is-fullwidth-code-point@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f"
+
+is-generator-fn@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/is-generator-fn/-/is-generator-fn-1.0.0.tgz#969d49e1bb3329f6bb7f09089be26578b2ddd46a"
+
+is-glob@^2.0.0, is-glob@^2.0.1:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-2.0.1.tgz#d096f926a3ded5600f3fdfd91198cb0888c2d863"
+ dependencies:
+ is-extglob "^1.0.0"
+
+is-glob@^3.1.0:
+ version "3.1.0"
+ resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-3.1.0.tgz#7ba5ae24217804ac70707b96922567486cc3e84a"
+ dependencies:
+ is-extglob "^2.1.0"
+
+is-glob@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.0.tgz#9521c76845cc2610a85203ddf080a958c2ffabc0"
+ dependencies:
+ is-extglob "^2.1.1"
+
+is-installed-globally@^0.1.0:
+ version "0.1.0"
+ resolved "https://registry.yarnpkg.com/is-installed-globally/-/is-installed-globally-0.1.0.tgz#0dfd98f5a9111716dd535dda6492f67bf3d25a80"
+ dependencies:
+ global-dirs "^0.1.0"
+ is-path-inside "^1.0.0"
+
+is-npm@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/is-npm/-/is-npm-1.0.0.tgz#f2fb63a65e4905b406c86072765a1a4dc793b9f4"
+
+is-number@^2.1.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/is-number/-/is-number-2.1.0.tgz#01fcbbb393463a548f2f466cce16dece49db908f"
+ dependencies:
+ kind-of "^3.0.2"
+
+is-number@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/is-number/-/is-number-3.0.0.tgz#24fd6201a4782cf50561c810276afc7d12d71195"
+ dependencies:
+ kind-of "^3.0.2"
+
+is-number@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/is-number/-/is-number-4.0.0.tgz#0026e37f5454d73e356dfe6564699867c6a7f0ff"
+
+is-obj@^1.0.0, is-obj@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-1.0.1.tgz#3e4729ac1f5fde025cd7d83a896dab9f4f67db0f"
+
+is-observable@^1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/is-observable/-/is-observable-1.1.0.tgz#b3e986c8f44de950867cab5403f5a3465005975e"
+ dependencies:
+ symbol-observable "^1.1.0"
+
+is-path-cwd@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/is-path-cwd/-/is-path-cwd-1.0.0.tgz#d225ec23132e89edd38fda767472e62e65f1106d"
+
+is-path-in-cwd@^1.0.0:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/is-path-in-cwd/-/is-path-in-cwd-1.0.1.tgz#5ac48b345ef675339bd6c7a48a912110b241cf52"
+ dependencies:
+ is-path-inside "^1.0.0"
+
+is-path-inside@^1.0.0:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-1.0.1.tgz#8ef5b7de50437a3fdca6b4e865ef7aa55cb48036"
+ dependencies:
+ path-is-inside "^1.0.1"
+
+is-plain-object@^2.0.1, is-plain-object@^2.0.3, is-plain-object@^2.0.4:
+ version "2.0.4"
+ resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677"
+ dependencies:
+ isobject "^3.0.1"
+
+is-posix-bracket@^0.1.0:
+ version "0.1.1"
+ resolved "https://registry.yarnpkg.com/is-posix-bracket/-/is-posix-bracket-0.1.1.tgz#3334dc79774368e92f016e6fbc0a88f5cd6e6bc4"
+
+is-primitive@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/is-primitive/-/is-primitive-2.0.0.tgz#207bab91638499c07b2adf240a41a87210034575"
+
+is-promise@^2.1.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-2.1.0.tgz#79a2a9ece7f096e80f36d2b2f3bc16c1ff4bf3fa"
+
+is-redirect@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/is-redirect/-/is-redirect-1.0.0.tgz#1d03dded53bd8db0f30c26e4f95d36fc7c87dc24"
+
+is-regex@^1.0.4:
+ version "1.0.4"
+ resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.0.4.tgz#5517489b547091b0930e095654ced25ee97e9491"
+ dependencies:
+ has "^1.0.1"
+
+is-regexp@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/is-regexp/-/is-regexp-1.0.0.tgz#fd2d883545c46bac5a633e7b9a09e87fa2cb5069"
+
+is-relative@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/is-relative/-/is-relative-1.0.0.tgz#a1bb6935ce8c5dba1e8b9754b9b2dcc020e2260d"
+ dependencies:
+ is-unc-path "^1.0.0"
+
+is-resolvable@^1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/is-resolvable/-/is-resolvable-1.1.0.tgz#fb18f87ce1feb925169c9a407c19318a3206ed88"
+
+is-retry-allowed@^1.0.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/is-retry-allowed/-/is-retry-allowed-1.1.0.tgz#11a060568b67339444033d0125a61a20d564fb34"
+
+is-stream@^1.0.0, is-stream@^1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44"
+
+is-symbol@^1.0.2:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.0.2.tgz#a055f6ae57192caee329e7a860118b497a950f38"
+ dependencies:
+ has-symbols "^1.0.0"
+
+is-typedarray@~1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a"
+
+is-unc-path@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/is-unc-path/-/is-unc-path-1.0.0.tgz#d731e8898ed090a12c352ad2eaed5095ad322c9d"
+ dependencies:
+ unc-path-regex "^0.1.2"
+
+is-utf8@^0.2.0:
+ version "0.2.1"
+ resolved "https://registry.yarnpkg.com/is-utf8/-/is-utf8-0.2.1.tgz#4b0da1442104d1b336340e80797e865cf39f7d72"
+
+is-windows@^1.0.1, is-windows@^1.0.2:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-1.0.2.tgz#d1850eb9791ecd18e6182ce12a30f396634bb19d"
+
+isarray@1.0.0, isarray@^1.0.0, isarray@~1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11"
+
+isemail@2.x.x:
+ version "2.2.1"
+ resolved "https://registry.yarnpkg.com/isemail/-/isemail-2.2.1.tgz#0353d3d9a62951080c262c2aa0a42b8ea8e9e2a6"
+
+isexe@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
+
+isobject@^2.0.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/isobject/-/isobject-2.1.0.tgz#f065561096a3f1da2ef46272f815c840d87e0c89"
+ dependencies:
+ isarray "1.0.0"
+
+isobject@^3.0.0, isobject@^3.0.1:
+ version "3.0.1"
+ resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df"
+
+isstream@~0.1.2:
+ version "0.1.2"
+ resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a"
+
+istanbul-api@^1.3.1:
+ version "1.3.7"
+ resolved "https://registry.yarnpkg.com/istanbul-api/-/istanbul-api-1.3.7.tgz#a86c770d2b03e11e3f778cd7aedd82d2722092aa"
+ dependencies:
+ async "^2.1.4"
+ fileset "^2.0.2"
+ istanbul-lib-coverage "^1.2.1"
+ istanbul-lib-hook "^1.2.2"
+ istanbul-lib-instrument "^1.10.2"
+ istanbul-lib-report "^1.1.5"
+ istanbul-lib-source-maps "^1.2.6"
+ istanbul-reports "^1.5.1"
+ js-yaml "^3.7.0"
+ mkdirp "^0.5.1"
+ once "^1.4.0"
+
+istanbul-lib-coverage@^1.2.0, istanbul-lib-coverage@^1.2.1:
+ version "1.2.1"
+ resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-1.2.1.tgz#ccf7edcd0a0bb9b8f729feeb0930470f9af664f0"
+
+istanbul-lib-hook@^1.2.2:
+ version "1.2.2"
+ resolved "https://registry.yarnpkg.com/istanbul-lib-hook/-/istanbul-lib-hook-1.2.2.tgz#bc6bf07f12a641fbf1c85391d0daa8f0aea6bf86"
+ dependencies:
+ append-transform "^0.4.0"
+
+istanbul-lib-instrument@^1.10.1, istanbul-lib-instrument@^1.10.2:
+ version "1.10.2"
+ resolved "https://registry.yarnpkg.com/istanbul-lib-instrument/-/istanbul-lib-instrument-1.10.2.tgz#1f55ed10ac3c47f2bdddd5307935126754d0a9ca"
+ dependencies:
+ babel-generator "^6.18.0"
+ babel-template "^6.16.0"
+ babel-traverse "^6.18.0"
+ babel-types "^6.18.0"
+ babylon "^6.18.0"
+ istanbul-lib-coverage "^1.2.1"
+ semver "^5.3.0"
+
+istanbul-lib-report@^1.1.5:
+ version "1.1.5"
+ resolved "https://registry.yarnpkg.com/istanbul-lib-report/-/istanbul-lib-report-1.1.5.tgz#f2a657fc6282f96170aaf281eb30a458f7f4170c"
+ dependencies:
+ istanbul-lib-coverage "^1.2.1"
+ mkdirp "^0.5.1"
+ path-parse "^1.0.5"
+ supports-color "^3.1.2"
+
+istanbul-lib-source-maps@^1.2.4, istanbul-lib-source-maps@^1.2.6:
+ version "1.2.6"
+ resolved "https://registry.yarnpkg.com/istanbul-lib-source-maps/-/istanbul-lib-source-maps-1.2.6.tgz#37b9ff661580f8fca11232752ee42e08c6675d8f"
+ dependencies:
+ debug "^3.1.0"
+ istanbul-lib-coverage "^1.2.1"
+ mkdirp "^0.5.1"
+ rimraf "^2.6.1"
+ source-map "^0.5.3"
+
+istanbul-reports@^1.5.1:
+ version "1.5.1"
+ resolved "https://registry.yarnpkg.com/istanbul-reports/-/istanbul-reports-1.5.1.tgz#97e4dbf3b515e8c484caea15d6524eebd3ff4e1a"
+ dependencies:
+ handlebars "^4.0.3"
+
+items@2.x.x:
+ version "2.1.1"
+ resolved "https://registry.yarnpkg.com/items/-/items-2.1.1.tgz#8bd16d9c83b19529de5aea321acaada78364a198"
+
+iterall@^1.1.3, iterall@^1.2.2:
+ version "1.2.2"
+ resolved "https://registry.yarnpkg.com/iterall/-/iterall-1.2.2.tgz#92d70deb8028e0c39ff3164fdbf4d8b088130cd7"
+
+jest-changed-files@^23.4.2:
+ version "23.4.2"
+ resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-23.4.2.tgz#1eed688370cd5eebafe4ae93d34bb3b64968fe83"
+ dependencies:
+ throat "^4.0.0"
+
+jest-cli@^23.6.0:
+ version "23.6.0"
+ resolved "https://registry.yarnpkg.com/jest-cli/-/jest-cli-23.6.0.tgz#61ab917744338f443ef2baa282ddffdd658a5da4"
+ dependencies:
+ ansi-escapes "^3.0.0"
+ chalk "^2.0.1"
+ exit "^0.1.2"
+ glob "^7.1.2"
+ graceful-fs "^4.1.11"
+ import-local "^1.0.0"
+ is-ci "^1.0.10"
+ istanbul-api "^1.3.1"
+ istanbul-lib-coverage "^1.2.0"
+ istanbul-lib-instrument "^1.10.1"
+ istanbul-lib-source-maps "^1.2.4"
+ jest-changed-files "^23.4.2"
+ jest-config "^23.6.0"
+ jest-environment-jsdom "^23.4.0"
+ jest-get-type "^22.1.0"
+ jest-haste-map "^23.6.0"
+ jest-message-util "^23.4.0"
+ jest-regex-util "^23.3.0"
+ jest-resolve-dependencies "^23.6.0"
+ jest-runner "^23.6.0"
+ jest-runtime "^23.6.0"
+ jest-snapshot "^23.6.0"
+ jest-util "^23.4.0"
+ jest-validate "^23.6.0"
+ jest-watcher "^23.4.0"
+ jest-worker "^23.2.0"
+ micromatch "^2.3.11"
+ node-notifier "^5.2.1"
+ prompts "^0.1.9"
+ realpath-native "^1.0.0"
+ rimraf "^2.5.4"
+ slash "^1.0.0"
+ string-length "^2.0.0"
+ strip-ansi "^4.0.0"
+ which "^1.2.12"
+ yargs "^11.0.0"
+
+jest-config@^23.6.0:
+ version "23.6.0"
+ resolved "https://registry.yarnpkg.com/jest-config/-/jest-config-23.6.0.tgz#f82546a90ade2d8c7026fbf6ac5207fc22f8eb1d"
+ dependencies:
+ babel-core "^6.0.0"
+ babel-jest "^23.6.0"
+ chalk "^2.0.1"
+ glob "^7.1.1"
+ jest-environment-jsdom "^23.4.0"
+ jest-environment-node "^23.4.0"
+ jest-get-type "^22.1.0"
+ jest-jasmine2 "^23.6.0"
+ jest-regex-util "^23.3.0"
+ jest-resolve "^23.6.0"
+ jest-util "^23.4.0"
+ jest-validate "^23.6.0"
+ micromatch "^2.3.11"
+ pretty-format "^23.6.0"
+
+jest-diff@^23.6.0:
+ version "23.6.0"
+ resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-23.6.0.tgz#1500f3f16e850bb3d71233408089be099f610c7d"
+ dependencies:
+ chalk "^2.0.1"
+ diff "^3.2.0"
+ jest-get-type "^22.1.0"
+ pretty-format "^23.6.0"
+
+jest-docblock@^23.2.0:
+ version "23.2.0"
+ resolved "https://registry.yarnpkg.com/jest-docblock/-/jest-docblock-23.2.0.tgz#f085e1f18548d99fdd69b20207e6fd55d91383a7"
+ dependencies:
+ detect-newline "^2.1.0"
+
+jest-each@^23.6.0:
+ version "23.6.0"
+ resolved "https://registry.yarnpkg.com/jest-each/-/jest-each-23.6.0.tgz#ba0c3a82a8054387016139c733a05242d3d71575"
+ dependencies:
+ chalk "^2.0.1"
+ pretty-format "^23.6.0"
+
+jest-environment-jsdom@^23.4.0:
+ version "23.4.0"
+ resolved "https://registry.yarnpkg.com/jest-environment-jsdom/-/jest-environment-jsdom-23.4.0.tgz#056a7952b3fea513ac62a140a2c368c79d9e6023"
+ dependencies:
+ jest-mock "^23.2.0"
+ jest-util "^23.4.0"
+ jsdom "^11.5.1"
+
+jest-environment-node@^23.4.0:
+ version "23.4.0"
+ resolved "https://registry.yarnpkg.com/jest-environment-node/-/jest-environment-node-23.4.0.tgz#57e80ed0841dea303167cce8cd79521debafde10"
+ dependencies:
+ jest-mock "^23.2.0"
+ jest-util "^23.4.0"
+
+jest-get-type@^22.1.0:
+ version "22.4.3"
+ resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-22.4.3.tgz#e3a8504d8479342dd4420236b322869f18900ce4"
+
+jest-haste-map@^23.6.0:
+ version "23.6.0"
+ resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-23.6.0.tgz#2e3eb997814ca696d62afdb3f2529f5bbc935e16"
+ dependencies:
+ fb-watchman "^2.0.0"
+ graceful-fs "^4.1.11"
+ invariant "^2.2.4"
+ jest-docblock "^23.2.0"
+ jest-serializer "^23.0.1"
+ jest-worker "^23.2.0"
+ micromatch "^2.3.11"
+ sane "^2.0.0"
+
+jest-jasmine2@^23.6.0:
+ version "23.6.0"
+ resolved "https://registry.yarnpkg.com/jest-jasmine2/-/jest-jasmine2-23.6.0.tgz#840e937f848a6c8638df24360ab869cc718592e0"
+ dependencies:
+ babel-traverse "^6.0.0"
+ chalk "^2.0.1"
+ co "^4.6.0"
+ expect "^23.6.0"
+ is-generator-fn "^1.0.0"
+ jest-diff "^23.6.0"
+ jest-each "^23.6.0"
+ jest-matcher-utils "^23.6.0"
+ jest-message-util "^23.4.0"
+ jest-snapshot "^23.6.0"
+ jest-util "^23.4.0"
+ pretty-format "^23.6.0"
+
+jest-leak-detector@^23.6.0:
+ version "23.6.0"
+ resolved "https://registry.yarnpkg.com/jest-leak-detector/-/jest-leak-detector-23.6.0.tgz#e4230fd42cf381a1a1971237ad56897de7e171de"
+ dependencies:
+ pretty-format "^23.6.0"
+
+jest-matcher-utils@^23.6.0:
+ version "23.6.0"
+ resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-23.6.0.tgz#726bcea0c5294261a7417afb6da3186b4b8cac80"
+ dependencies:
+ chalk "^2.0.1"
+ jest-get-type "^22.1.0"
+ pretty-format "^23.6.0"
+
+jest-message-util@^23.4.0:
+ version "23.4.0"
+ resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-23.4.0.tgz#17610c50942349508d01a3d1e0bda2c079086a9f"
+ dependencies:
+ "@babel/code-frame" "^7.0.0-beta.35"
+ chalk "^2.0.1"
+ micromatch "^2.3.11"
+ slash "^1.0.0"
+ stack-utils "^1.0.1"
+
+jest-mock@^23.2.0:
+ version "23.2.0"
+ resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-23.2.0.tgz#ad1c60f29e8719d47c26e1138098b6d18b261134"
+
+jest-regex-util@^23.3.0:
+ version "23.3.0"
+ resolved "https://registry.yarnpkg.com/jest-regex-util/-/jest-regex-util-23.3.0.tgz#5f86729547c2785c4002ceaa8f849fe8ca471bc5"
+
+jest-resolve-dependencies@^23.6.0:
+ version "23.6.0"
+ resolved "https://registry.yarnpkg.com/jest-resolve-dependencies/-/jest-resolve-dependencies-23.6.0.tgz#b4526af24c8540d9a3fab102c15081cf509b723d"
+ dependencies:
+ jest-regex-util "^23.3.0"
+ jest-snapshot "^23.6.0"
+
+jest-resolve@^23.6.0:
+ version "23.6.0"
+ resolved "https://registry.yarnpkg.com/jest-resolve/-/jest-resolve-23.6.0.tgz#cf1d1a24ce7ee7b23d661c33ba2150f3aebfa0ae"
+ dependencies:
+ browser-resolve "^1.11.3"
+ chalk "^2.0.1"
+ realpath-native "^1.0.0"
+
+jest-runner@^23.6.0:
+ version "23.6.0"
+ resolved "https://registry.yarnpkg.com/jest-runner/-/jest-runner-23.6.0.tgz#3894bd219ffc3f3cb94dc48a4170a2e6f23a5a38"
+ dependencies:
+ exit "^0.1.2"
+ graceful-fs "^4.1.11"
+ jest-config "^23.6.0"
+ jest-docblock "^23.2.0"
+ jest-haste-map "^23.6.0"
+ jest-jasmine2 "^23.6.0"
+ jest-leak-detector "^23.6.0"
+ jest-message-util "^23.4.0"
+ jest-runtime "^23.6.0"
+ jest-util "^23.4.0"
+ jest-worker "^23.2.0"
+ source-map-support "^0.5.6"
+ throat "^4.0.0"
+
+jest-runtime@^23.6.0:
+ version "23.6.0"
+ resolved "https://registry.yarnpkg.com/jest-runtime/-/jest-runtime-23.6.0.tgz#059e58c8ab445917cd0e0d84ac2ba68de8f23082"
+ dependencies:
+ babel-core "^6.0.0"
+ babel-plugin-istanbul "^4.1.6"
+ chalk "^2.0.1"
+ convert-source-map "^1.4.0"
+ exit "^0.1.2"
+ fast-json-stable-stringify "^2.0.0"
+ graceful-fs "^4.1.11"
+ jest-config "^23.6.0"
+ jest-haste-map "^23.6.0"
+ jest-message-util "^23.4.0"
+ jest-regex-util "^23.3.0"
+ jest-resolve "^23.6.0"
+ jest-snapshot "^23.6.0"
+ jest-util "^23.4.0"
+ jest-validate "^23.6.0"
+ micromatch "^2.3.11"
+ realpath-native "^1.0.0"
+ slash "^1.0.0"
+ strip-bom "3.0.0"
+ write-file-atomic "^2.1.0"
+ yargs "^11.0.0"
+
+jest-serializer@^23.0.1:
+ version "23.0.1"
+ resolved "https://registry.yarnpkg.com/jest-serializer/-/jest-serializer-23.0.1.tgz#a3776aeb311e90fe83fab9e533e85102bd164165"
+
+jest-snapshot@^23.6.0:
+ version "23.6.0"
+ resolved "https://registry.yarnpkg.com/jest-snapshot/-/jest-snapshot-23.6.0.tgz#f9c2625d1b18acda01ec2d2b826c0ce58a5aa17a"
+ dependencies:
+ babel-types "^6.0.0"
+ chalk "^2.0.1"
+ jest-diff "^23.6.0"
+ jest-matcher-utils "^23.6.0"
+ jest-message-util "^23.4.0"
+ jest-resolve "^23.6.0"
+ mkdirp "^0.5.1"
+ natural-compare "^1.4.0"
+ pretty-format "^23.6.0"
+ semver "^5.5.0"
+
+jest-util@^23.4.0:
+ version "23.4.0"
+ resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-23.4.0.tgz#4d063cb927baf0a23831ff61bec2cbbf49793561"
+ dependencies:
+ callsites "^2.0.0"
+ chalk "^2.0.1"
+ graceful-fs "^4.1.11"
+ is-ci "^1.0.10"
+ jest-message-util "^23.4.0"
+ mkdirp "^0.5.1"
+ slash "^1.0.0"
+ source-map "^0.6.0"
+
+jest-validate@^23.5.0, jest-validate@^23.6.0:
+ version "23.6.0"
+ resolved "https://registry.yarnpkg.com/jest-validate/-/jest-validate-23.6.0.tgz#36761f99d1ed33fcd425b4e4c5595d62b6597474"
+ dependencies:
+ chalk "^2.0.1"
+ jest-get-type "^22.1.0"
+ leven "^2.1.0"
+ pretty-format "^23.6.0"
+
+jest-watcher@^23.4.0:
+ version "23.4.0"
+ resolved "https://registry.yarnpkg.com/jest-watcher/-/jest-watcher-23.4.0.tgz#d2e28ce74f8dad6c6afc922b92cabef6ed05c91c"
+ dependencies:
+ ansi-escapes "^3.0.0"
+ chalk "^2.0.1"
+ string-length "^2.0.0"
+
+jest-worker@^23.2.0:
+ version "23.2.0"
+ resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-23.2.0.tgz#faf706a8da36fae60eb26957257fa7b5d8ea02b9"
+ dependencies:
+ merge-stream "^1.0.1"
+
+jest@^23.0.0:
+ version "23.6.0"
+ resolved "https://registry.yarnpkg.com/jest/-/jest-23.6.0.tgz#ad5835e923ebf6e19e7a1d7529a432edfee7813d"
+ dependencies:
+ import-local "^1.0.0"
+ jest-cli "^23.6.0"
+
+joi@^10.6.0:
+ version "10.6.0"
+ resolved "https://registry.yarnpkg.com/joi/-/joi-10.6.0.tgz#52587f02d52b8b75cdb0c74f0b164a191a0e1fc2"
+ dependencies:
+ hoek "4.x.x"
+ isemail "2.x.x"
+ items "2.x.x"
+ topo "2.x.x"
+
+js-string-escape@1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/js-string-escape/-/js-string-escape-1.0.1.tgz#e2625badbc0d67c7533e9edc1068c587ae4137ef"
+
+"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
+
+js-tokens@^3.0.2:
+ version "3.0.2"
+ resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-3.0.2.tgz#9866df395102130e38f7f996bceb65443209c25b"
+
+js-yaml@^3.12.0, js-yaml@^3.7.0, js-yaml@^3.9.0:
+ version "3.12.0"
+ resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.12.0.tgz#eaed656ec8344f10f527c6bfa1b6e2244de167d1"
+ dependencies:
+ argparse "^1.0.7"
+ esprima "^4.0.0"
+
+jsbn@~0.1.0:
+ version "0.1.1"
+ resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513"
+
+jsdom@^11.5.1:
+ version "11.12.0"
+ resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-11.12.0.tgz#1a80d40ddd378a1de59656e9e6dc5a3ba8657bc8"
+ dependencies:
+ abab "^2.0.0"
+ acorn "^5.5.3"
+ acorn-globals "^4.1.0"
+ array-equal "^1.0.0"
+ cssom ">= 0.3.2 < 0.4.0"
+ cssstyle "^1.0.0"
+ data-urls "^1.0.0"
+ domexception "^1.0.1"
+ escodegen "^1.9.1"
+ html-encoding-sniffer "^1.0.2"
+ left-pad "^1.3.0"
+ nwsapi "^2.0.7"
+ parse5 "4.0.0"
+ pn "^1.1.0"
+ request "^2.87.0"
+ request-promise-native "^1.0.5"
+ sax "^1.2.4"
+ symbol-tree "^3.2.2"
+ tough-cookie "^2.3.4"
+ w3c-hr-time "^1.0.1"
+ webidl-conversions "^4.0.2"
+ whatwg-encoding "^1.0.3"
+ whatwg-mimetype "^2.1.0"
+ whatwg-url "^6.4.1"
+ ws "^5.2.0"
+ xml-name-validator "^3.0.0"
+
+jsesc@^1.3.0:
+ version "1.3.0"
+ resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-1.3.0.tgz#46c3fec8c1892b12b0833db9bc7622176dbab34b"
+
+json-parse-better-errors@^1.0.1:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9"
+
+json-schema-traverse@^0.3.0:
+ version "0.3.1"
+ resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.3.1.tgz#349a6d44c53a51de89b40805c5d5e59b417d3340"
+
+json-schema-traverse@^0.4.1:
+ version "0.4.1"
+ resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660"
+
+json-schema@0.2.3:
+ version "0.2.3"
+ resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13"
+
+json-stable-stringify-without-jsonify@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651"
+
+json-stringify-safe@~5.0.1:
+ version "5.0.1"
+ resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb"
+
+json-web-key@^0.4.0:
+ version "0.4.0"
+ resolved "https://registry.yarnpkg.com/json-web-key/-/json-web-key-0.4.0.tgz#a8e7268d1741c3a87c51c5070ea7ea988e9a78b7"
+ dependencies:
+ asn1.js "^5.0.1"
+
+json5@^0.5.1:
+ version "0.5.1"
+ resolved "https://registry.yarnpkg.com/json5/-/json5-0.5.1.tgz#1eade7acc012034ad84e2396767ead9fa5495821"
+
+jsprim@^1.2.2:
+ version "1.4.1"
+ resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.1.tgz#313e66bc1e5cc06e438bc1b7499c2e5c56acb6a2"
+ dependencies:
+ assert-plus "1.0.0"
+ extsprintf "1.3.0"
+ json-schema "0.2.3"
+ verror "1.10.0"
+
+jsrsasign@^8.0.12:
+ version "8.0.12"
+ resolved "https://registry.yarnpkg.com/jsrsasign/-/jsrsasign-8.0.12.tgz#22abb9656d34a30b9530436720835e89c2e5c316"
+
+jsx-ast-utils@^1.4.0:
+ version "1.4.1"
+ resolved "https://registry.yarnpkg.com/jsx-ast-utils/-/jsx-ast-utils-1.4.1.tgz#3867213e8dd79bf1e8f2300c0cfc1efb182c0df1"
+
+jsx-ast-utils@^2.0.1:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/jsx-ast-utils/-/jsx-ast-utils-2.0.1.tgz#e801b1b39985e20fffc87b40e3748080e2dcac7f"
+ dependencies:
+ array-includes "^3.0.3"
+
+kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0:
+ version "3.2.2"
+ resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64"
+ dependencies:
+ is-buffer "^1.1.5"
+
+kind-of@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-4.0.0.tgz#20813df3d712928b207378691a45066fae72dd57"
+ dependencies:
+ is-buffer "^1.1.5"
+
+kind-of@^5.0.0:
+ version "5.1.0"
+ resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-5.1.0.tgz#729c91e2d857b7a419a1f9aa65685c4c33f5845d"
+
+kind-of@^6.0.0, kind-of@^6.0.2:
+ version "6.0.2"
+ resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.2.tgz#01146b36a6218e64e58f3a8d66de5d7fc6f6d051"
+
+kleur@^2.0.1:
+ version "2.0.2"
+ resolved "https://registry.yarnpkg.com/kleur/-/kleur-2.0.2.tgz#b704f4944d95e255d038f0cb05fb8a602c55a300"
+
+knex@^0.15.2:
+ version "0.15.2"
+ resolved "https://registry.yarnpkg.com/knex/-/knex-0.15.2.tgz#6059b87489605f4cc87599a6d2a9d265709e9340"
+ dependencies:
+ babel-runtime "^6.26.0"
+ bluebird "^3.5.1"
+ chalk "2.3.2"
+ commander "^2.16.0"
+ debug "3.1.0"
+ inherits "~2.0.3"
+ interpret "^1.1.0"
+ liftoff "2.5.0"
+ lodash "^4.17.10"
+ minimist "1.2.0"
+ mkdirp "^0.5.1"
+ pg-connection-string "2.0.0"
+ tarn "^1.1.4"
+ tildify "1.2.0"
+ uuid "^3.3.2"
+ v8flags "^3.1.1"
+
+latest-version@^3.0.0:
+ version "3.1.0"
+ resolved "https://registry.yarnpkg.com/latest-version/-/latest-version-3.1.0.tgz#a205383fea322b33b5ae3b18abee0dc2f356ee15"
+ dependencies:
+ package-json "^4.0.0"
+
+lcid@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/lcid/-/lcid-1.0.0.tgz#308accafa0bc483a3867b4b6f2b9506251d1b835"
+ dependencies:
+ invert-kv "^1.0.0"
+
+left-pad@^1.3.0:
+ version "1.3.0"
+ resolved "https://registry.yarnpkg.com/left-pad/-/left-pad-1.3.0.tgz#5b8a3a7765dfe001261dde915589e782f8c94d1e"
+
+leven@^2.1.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/leven/-/leven-2.1.0.tgz#c2e7a9f772094dee9d34202ae8acce4687875580"
+
+levn@^0.3.0, levn@~0.3.0:
+ version "0.3.0"
+ resolved "https://registry.yarnpkg.com/levn/-/levn-0.3.0.tgz#3b09924edf9f083c0490fdd4c0bc4421e04764ee"
+ dependencies:
+ prelude-ls "~1.1.2"
+ type-check "~0.3.2"
+
+liftoff@2.5.0:
+ version "2.5.0"
+ resolved "https://registry.yarnpkg.com/liftoff/-/liftoff-2.5.0.tgz#2009291bb31cea861bbf10a7c15a28caf75c31ec"
+ dependencies:
+ extend "^3.0.0"
+ findup-sync "^2.0.0"
+ fined "^1.0.1"
+ flagged-respawn "^1.0.0"
+ is-plain-object "^2.0.4"
+ object.map "^1.0.0"
+ rechoir "^0.6.2"
+ resolve "^1.1.7"
+
+lint-staged@^7.0.5:
+ version "7.3.0"
+ resolved "https://registry.yarnpkg.com/lint-staged/-/lint-staged-7.3.0.tgz#90ff33e5ca61ed3dbac35b6f6502dbefdc0db58d"
+ dependencies:
+ chalk "^2.3.1"
+ commander "^2.14.1"
+ cosmiconfig "^5.0.2"
+ debug "^3.1.0"
+ dedent "^0.7.0"
+ execa "^0.9.0"
+ find-parent-dir "^0.3.0"
+ is-glob "^4.0.0"
+ is-windows "^1.0.2"
+ jest-validate "^23.5.0"
+ listr "^0.14.1"
+ lodash "^4.17.5"
+ log-symbols "^2.2.0"
+ micromatch "^3.1.8"
+ npm-which "^3.0.1"
+ p-map "^1.1.1"
+ path-is-inside "^1.0.2"
+ pify "^3.0.0"
+ please-upgrade-node "^3.0.2"
+ staged-git-files "1.1.1"
+ string-argv "^0.0.2"
+ stringify-object "^3.2.2"
+
+listr-silent-renderer@^1.1.1:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/listr-silent-renderer/-/listr-silent-renderer-1.1.1.tgz#924b5a3757153770bf1a8e3fbf74b8bbf3f9242e"
+
+listr-update-renderer@^0.4.0:
+ version "0.4.0"
+ resolved "https://registry.yarnpkg.com/listr-update-renderer/-/listr-update-renderer-0.4.0.tgz#344d980da2ca2e8b145ba305908f32ae3f4cc8a7"
+ dependencies:
+ chalk "^1.1.3"
+ cli-truncate "^0.2.1"
+ elegant-spinner "^1.0.1"
+ figures "^1.7.0"
+ indent-string "^3.0.0"
+ log-symbols "^1.0.2"
+ log-update "^1.0.2"
+ strip-ansi "^3.0.1"
+
+listr-verbose-renderer@^0.4.0:
+ version "0.4.1"
+ resolved "https://registry.yarnpkg.com/listr-verbose-renderer/-/listr-verbose-renderer-0.4.1.tgz#8206f4cf6d52ddc5827e5fd14989e0e965933a35"
+ dependencies:
+ chalk "^1.1.3"
+ cli-cursor "^1.0.2"
+ date-fns "^1.27.2"
+ figures "^1.7.0"
+
+listr@^0.14.1:
+ version "0.14.2"
+ resolved "https://registry.yarnpkg.com/listr/-/listr-0.14.2.tgz#cbe44b021100a15376addfc2d79349ee430bfe14"
+ dependencies:
+ "@samverschueren/stream-to-observable" "^0.3.0"
+ is-observable "^1.1.0"
+ is-promise "^2.1.0"
+ is-stream "^1.1.0"
+ listr-silent-renderer "^1.1.1"
+ listr-update-renderer "^0.4.0"
+ listr-verbose-renderer "^0.4.0"
+ p-map "^1.1.1"
+ rxjs "^6.1.0"
+
+load-json-file@^1.0.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-1.1.0.tgz#956905708d58b4bab4c2261b04f59f31c99374c0"
+ dependencies:
+ graceful-fs "^4.1.2"
+ parse-json "^2.2.0"
+ pify "^2.0.0"
+ pinkie-promise "^2.0.0"
+ strip-bom "^2.0.0"
+
+load-json-file@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-2.0.0.tgz#7947e42149af80d696cbf797bcaabcfe1fe29ca8"
+ dependencies:
+ graceful-fs "^4.1.2"
+ parse-json "^2.2.0"
+ pify "^2.0.0"
+ strip-bom "^3.0.0"
+
+locate-path@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-2.0.0.tgz#2b568b265eec944c6d9c0de9c3dbbbca0354cd8e"
+ dependencies:
+ p-locate "^2.0.0"
+ path-exists "^3.0.0"
+
+locate-path@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-3.0.0.tgz#dbec3b3ab759758071b58fe59fc41871af21400e"
+ dependencies:
+ p-locate "^3.0.0"
+ path-exists "^3.0.0"
+
+lodash.assign@^4.0.8:
+ version "4.2.0"
+ resolved "https://registry.yarnpkg.com/lodash.assign/-/lodash.assign-4.2.0.tgz#0d99f3ccd7a6d261d19bdaeb9245005d285808e7"
+
+lodash.clone@^4.3.2:
+ version "4.5.0"
+ resolved "https://registry.yarnpkg.com/lodash.clone/-/lodash.clone-4.5.0.tgz#195870450f5a13192478df4bc3d23d2dea1907b6"
+
+lodash.debounce@^4.0.8:
+ version "4.0.8"
+ resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af"
+
+lodash.fill@^3.2.2:
+ version "3.4.0"
+ resolved "https://registry.yarnpkg.com/lodash.fill/-/lodash.fill-3.4.0.tgz#a3c74ae640d053adf0dc2079f8720788e8bfef85"
+
+lodash.flatten@^4.2.0:
+ version "4.4.0"
+ resolved "https://registry.yarnpkg.com/lodash.flatten/-/lodash.flatten-4.4.0.tgz#f31c22225a9632d2bbf8e4addbef240aa765a61f"
+
+lodash.intersection@^4.1.2:
+ version "4.4.0"
+ resolved "https://registry.yarnpkg.com/lodash.intersection/-/lodash.intersection-4.4.0.tgz#0a11ba631d0e95c23c7f2f4cbb9a692ed178e705"
+
+lodash.merge@^4.3.5:
+ version "4.6.1"
+ resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.1.tgz#adc25d9cb99b9391c59624f379fbba60d7111d54"
+
+lodash.omit@^4.2.1:
+ version "4.5.0"
+ resolved "https://registry.yarnpkg.com/lodash.omit/-/lodash.omit-4.5.0.tgz#6eb19ae5a1ee1dd9df0b969e66ce0b7fa30b5e60"
+
+lodash.partialright@^4.1.3:
+ version "4.2.1"
+ resolved "https://registry.yarnpkg.com/lodash.partialright/-/lodash.partialright-4.2.1.tgz#0130d80e83363264d40074f329b8a3e7a8a1cc4b"
+
+lodash.pick@^4.2.0:
+ version "4.4.0"
+ resolved "https://registry.yarnpkg.com/lodash.pick/-/lodash.pick-4.4.0.tgz#52f05610fff9ded422611441ed1fc123a03001b3"
+
+lodash.sortby@^4.7.0:
+ version "4.7.0"
+ resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438"
+
+lodash.uniq@^4.2.1:
+ version "4.5.0"
+ resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773"
+
+lodash@^4.13.1, lodash@^4.15.0, lodash@^4.17.10, lodash@^4.17.4, lodash@^4.17.5:
+ version "4.17.11"
+ resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.11.tgz#b39ea6229ef607ecd89e2c8df12536891cac9b8d"
+
+log-symbols@^1.0.2:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-1.0.2.tgz#376ff7b58ea3086a0f09facc74617eca501e1a18"
+ dependencies:
+ chalk "^1.0.0"
+
+log-symbols@^2.2.0:
+ version "2.2.0"
+ resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-2.2.0.tgz#5740e1c5d6f0dfda4ad9323b5332107ef6b4c40a"
+ dependencies:
+ chalk "^2.0.1"
+
+log-update@^1.0.2:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/log-update/-/log-update-1.0.2.tgz#19929f64c4093d2d2e7075a1dad8af59c296b8d1"
+ dependencies:
+ ansi-escapes "^1.0.0"
+ cli-cursor "^1.0.2"
+
+long@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/long/-/long-4.0.0.tgz#9a7b71cfb7d361a194ea555241c92f7468d5bf28"
+
+loose-envify@^1.0.0, loose-envify@^1.3.1:
+ version "1.4.0"
+ resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
+ dependencies:
+ js-tokens "^3.0.0 || ^4.0.0"
+
+lowercase-keys@^1.0.0:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-1.0.1.tgz#6f9e30b47084d971a7c820ff15a6c5167b74c26f"
+
+lru-cache@^4.0.1:
+ version "4.1.3"
+ resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.3.tgz#a1175cf3496dfc8436c156c334b4955992bce69c"
+ dependencies:
+ pseudomap "^1.0.2"
+ yallist "^2.1.2"
+
+make-dir@^1.0.0:
+ version "1.3.0"
+ resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-1.3.0.tgz#79c1033b80515bd6d24ec9933e860ca75ee27f0c"
+ dependencies:
+ pify "^3.0.0"
+
+make-iterator@^1.0.0:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/make-iterator/-/make-iterator-1.0.1.tgz#29b33f312aa8f547c4a5e490f56afcec99133ad6"
+ dependencies:
+ kind-of "^6.0.2"
+
+makeerror@1.0.x:
+ version "1.0.11"
+ resolved "https://registry.yarnpkg.com/makeerror/-/makeerror-1.0.11.tgz#e01a5c9109f2af79660e4e8b9587790184f5a96c"
+ dependencies:
+ tmpl "1.0.x"
+
+map-cache@^0.2.0, map-cache@^0.2.2:
+ version "0.2.2"
+ resolved "https://registry.yarnpkg.com/map-cache/-/map-cache-0.2.2.tgz#c32abd0bd6525d9b051645bb4f26ac5dc98a0dbf"
+
+map-stream@0.0.7:
+ version "0.0.7"
+ resolved "https://registry.yarnpkg.com/map-stream/-/map-stream-0.0.7.tgz#8a1f07896d82b10926bd3744a2420009f88974a8"
+
+map-visit@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/map-visit/-/map-visit-1.0.0.tgz#ecdca8f13144e660f1b5bd41f12f3479d98dfb8f"
+ dependencies:
+ object-visit "^1.0.0"
+
+math-random@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/math-random/-/math-random-1.0.1.tgz#8b3aac588b8a66e4975e3cdea67f7bb329601fac"
+
+media-typer@0.3.0:
+ version "0.3.0"
+ resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748"
+
+mem@^1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/mem/-/mem-1.1.0.tgz#5edd52b485ca1d900fe64895505399a0dfa45f76"
+ dependencies:
+ mimic-fn "^1.0.0"
+
+merge-descriptors@1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61"
+
+merge-stream@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-1.0.1.tgz#4041202d508a342ba00174008df0c251b8c135e1"
+ dependencies:
+ readable-stream "^2.0.1"
+
+merge@^1.2.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/merge/-/merge-1.2.0.tgz#7531e39d4949c281a66b8c5a6e0265e8b05894da"
+
+methods@~1.1.2:
+ version "1.1.2"
+ resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee"
+
+micromatch@^2.3.11:
+ version "2.3.11"
+ resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-2.3.11.tgz#86677c97d1720b363431d04d0d15293bd38c1565"
+ dependencies:
+ arr-diff "^2.0.0"
+ array-unique "^0.2.1"
+ braces "^1.8.2"
+ expand-brackets "^0.1.4"
+ extglob "^0.3.1"
+ filename-regex "^2.0.0"
+ is-extglob "^1.0.0"
+ is-glob "^2.0.1"
+ kind-of "^3.0.2"
+ normalize-path "^2.0.1"
+ object.omit "^2.0.0"
+ parse-glob "^3.0.4"
+ regex-cache "^0.4.2"
+
+micromatch@^3.0.4, micromatch@^3.1.10, micromatch@^3.1.4, micromatch@^3.1.8:
+ version "3.1.10"
+ resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-3.1.10.tgz#70859bc95c9840952f359a068a3fc49f9ecfac23"
+ dependencies:
+ arr-diff "^4.0.0"
+ array-unique "^0.3.2"
+ braces "^2.3.1"
+ define-property "^2.0.2"
+ extend-shallow "^3.0.2"
+ extglob "^2.0.4"
+ fragment-cache "^0.2.1"
+ kind-of "^6.0.2"
+ nanomatch "^1.2.9"
+ object.pick "^1.3.0"
+ regex-not "^1.0.0"
+ snapdragon "^0.8.1"
+ to-regex "^3.0.2"
+
+mime-db@~1.36.0:
+ version "1.36.0"
+ resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.36.0.tgz#5020478db3c7fe93aad7bbcc4dcf869c43363397"
+
+mime-types@^2.1.12, mime-types@~2.1.18, mime-types@~2.1.19:
+ version "2.1.20"
+ resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.20.tgz#930cb719d571e903738520f8470911548ca2cc19"
+ dependencies:
+ mime-db "~1.36.0"
+
+mime@1.4.1:
+ version "1.4.1"
+ resolved "https://registry.yarnpkg.com/mime/-/mime-1.4.1.tgz#121f9ebc49e3766f311a76e1fa1c8003c4b03aa6"
+
+mimic-fn@^1.0.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-1.2.0.tgz#820c86a39334640e99516928bd03fca88057d022"
+
+minimalistic-assert@^1.0.0:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7"
+
+minimatch@^3.0.3, minimatch@^3.0.4:
+ version "3.0.4"
+ resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083"
+ dependencies:
+ brace-expansion "^1.1.7"
+
+minimist@0.0.8:
+ version "0.0.8"
+ resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d"
+
+minimist@1.2.0, minimist@^1.1.1, minimist@^1.2.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.0.tgz#a35008b20f41383eec1fb914f4cd5df79a264284"
+
+minimist@~0.0.1:
+ version "0.0.10"
+ resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.10.tgz#de3f98543dbf96082be48ad1a0c7cda836301dcf"
+
+minipass@^2.2.1, minipass@^2.3.3:
+ version "2.3.4"
+ resolved "https://registry.yarnpkg.com/minipass/-/minipass-2.3.4.tgz#4768d7605ed6194d6d576169b9e12ef71e9d9957"
+ dependencies:
+ safe-buffer "^5.1.2"
+ yallist "^3.0.0"
+
+minizlib@^1.1.0:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-1.1.1.tgz#6734acc045a46e61d596a43bb9d9cd326e19cc42"
+ dependencies:
+ minipass "^2.2.1"
+
+mixin-deep@^1.2.0:
+ version "1.3.1"
+ resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.1.tgz#a49e7268dce1a0d9698e45326c5626df3543d0fe"
+ dependencies:
+ for-in "^1.0.2"
+ is-extendable "^1.0.1"
+
+mkdirp@^0.5.0, mkdirp@^0.5.1:
+ version "0.5.1"
+ resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903"
+ dependencies:
+ minimist "0.0.8"
+
+ms@2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
+
+ms@^2.1.1:
+ version "2.1.1"
+ resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.1.tgz#30a5864eb3ebb0a66f2ebe6d727af06a09d86e0a"
+
+mute-stream@0.0.7:
+ version "0.0.7"
+ resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.7.tgz#3075ce93bc21b8fab43e1bc4da7e8115ed1e7bab"
+
+nan@^2.9.2:
+ version "2.11.1"
+ resolved "https://registry.yarnpkg.com/nan/-/nan-2.11.1.tgz#90e22bccb8ca57ea4cd37cc83d3819b52eea6766"
+
+nan@~2.10.0:
+ version "2.10.0"
+ resolved "https://registry.yarnpkg.com/nan/-/nan-2.10.0.tgz#96d0cd610ebd58d4b4de9cc0c6828cda99c7548f"
+
+nanomatch@^1.2.9:
+ version "1.2.13"
+ resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.13.tgz#b87a8aa4fc0de8fe6be88895b38983ff265bd119"
+ dependencies:
+ arr-diff "^4.0.0"
+ array-unique "^0.3.2"
+ define-property "^2.0.2"
+ extend-shallow "^3.0.2"
+ fragment-cache "^0.2.1"
+ is-windows "^1.0.2"
+ kind-of "^6.0.2"
+ object.pick "^1.3.0"
+ regex-not "^1.0.0"
+ snapdragon "^0.8.1"
+ to-regex "^3.0.1"
+
+natural-compare@^1.4.0:
+ version "1.4.0"
+ resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
+
+needle@^2.2.1:
+ version "2.2.4"
+ resolved "https://registry.yarnpkg.com/needle/-/needle-2.2.4.tgz#51931bff82533b1928b7d1d69e01f1b00ffd2a4e"
+ dependencies:
+ debug "^2.1.2"
+ iconv-lite "^0.4.4"
+ sax "^1.2.4"
+
+negotiator@0.6.1:
+ version "0.6.1"
+ resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.1.tgz#2b327184e8992101177b28563fb5e7102acd0ca9"
+
+nice-try@^1.0.4:
+ version "1.0.5"
+ resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366"
+
+node-forge@^0.7.1:
+ version "0.7.6"
+ resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.7.6.tgz#fdf3b418aee1f94f0ef642cd63486c77ca9724ac"
+
+node-int64@^0.4.0:
+ version "0.4.0"
+ resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b"
+
+node-jose@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/node-jose/-/node-jose-1.0.0.tgz#fe0c17244f5496aec973a7a274c542d18355ece5"
+ dependencies:
+ base64url "^3.0.0"
+ es6-promise "^4.0.5"
+ lodash.assign "^4.0.8"
+ lodash.clone "^4.3.2"
+ lodash.fill "^3.2.2"
+ lodash.flatten "^4.2.0"
+ lodash.intersection "^4.1.2"
+ lodash.merge "^4.3.5"
+ lodash.omit "^4.2.1"
+ lodash.partialright "^4.1.3"
+ lodash.pick "^4.2.0"
+ lodash.uniq "^4.2.1"
+ long "^4.0.0"
+ node-forge "^0.7.1"
+ uuid "^3.0.1"
+
+node-notifier@^5.2.1:
+ version "5.2.1"
+ resolved "https://registry.yarnpkg.com/node-notifier/-/node-notifier-5.2.1.tgz#fa313dd08f5517db0e2502e5758d664ac69f9dea"
+ dependencies:
+ growly "^1.3.0"
+ semver "^5.4.1"
+ shellwords "^0.1.1"
+ which "^1.3.0"
+
+node-pre-gyp@^0.10.0, node-pre-gyp@^0.10.3:
+ version "0.10.3"
+ resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.10.3.tgz#3070040716afdc778747b61b6887bf78880b80fc"
+ dependencies:
+ detect-libc "^1.0.2"
+ mkdirp "^0.5.1"
+ needle "^2.2.1"
+ nopt "^4.0.1"
+ npm-packlist "^1.1.6"
+ npmlog "^4.0.2"
+ rc "^1.2.7"
+ rimraf "^2.6.1"
+ semver "^5.3.0"
+ tar "^4"
+
+nodemon@^1.11.0:
+ version "1.18.4"
+ resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-1.18.4.tgz#873f65fdb53220eb166180cf106b1354ac5d714d"
+ dependencies:
+ chokidar "^2.0.2"
+ debug "^3.1.0"
+ ignore-by-default "^1.0.1"
+ minimatch "^3.0.4"
+ pstree.remy "^1.1.0"
+ semver "^5.5.0"
+ supports-color "^5.2.0"
+ touch "^3.1.0"
+ undefsafe "^2.0.2"
+ update-notifier "^2.3.0"
+
+nopt@^4.0.1:
+ version "4.0.1"
+ resolved "https://registry.yarnpkg.com/nopt/-/nopt-4.0.1.tgz#d0d4685afd5415193c8c7505602d0d17cd64474d"
+ dependencies:
+ abbrev "1"
+ osenv "^0.1.4"
+
+nopt@~1.0.10:
+ version "1.0.10"
+ resolved "https://registry.yarnpkg.com/nopt/-/nopt-1.0.10.tgz#6ddd21bd2a31417b92727dd585f8a6f37608ebee"
+ dependencies:
+ abbrev "1"
+
+normalize-package-data@^2.3.2:
+ version "2.4.0"
+ resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.4.0.tgz#12f95a307d58352075a04907b84ac8be98ac012f"
+ dependencies:
+ hosted-git-info "^2.1.4"
+ is-builtin-module "^1.0.0"
+ semver "2 || 3 || 4 || 5"
+ validate-npm-package-license "^3.0.1"
+
+normalize-path@^2.0.1, normalize-path@^2.1.1:
+ version "2.1.1"
+ resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-2.1.1.tgz#1ab28b556e198363a8c1a6f7e6fa20137fe6aed9"
+ dependencies:
+ remove-trailing-separator "^1.0.1"
+
+npm-bundled@^1.0.1:
+ version "1.0.5"
+ resolved "https://registry.yarnpkg.com/npm-bundled/-/npm-bundled-1.0.5.tgz#3c1732b7ba936b3a10325aef616467c0ccbcc979"
+
+npm-packlist@^1.1.6:
+ version "1.1.12"
+ resolved "https://registry.yarnpkg.com/npm-packlist/-/npm-packlist-1.1.12.tgz#22bde2ebc12e72ca482abd67afc51eb49377243a"
+ dependencies:
+ ignore-walk "^3.0.1"
+ npm-bundled "^1.0.1"
+
+npm-path@^2.0.2:
+ version "2.0.4"
+ resolved "https://registry.yarnpkg.com/npm-path/-/npm-path-2.0.4.tgz#c641347a5ff9d6a09e4d9bce5580c4f505278e64"
+ dependencies:
+ which "^1.2.10"
+
+npm-run-path@^2.0.0:
+ version "2.0.2"
+ resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-2.0.2.tgz#35a9232dfa35d7067b4cb2ddf2357b1871536c5f"
+ dependencies:
+ path-key "^2.0.0"
+
+npm-which@^3.0.1:
+ version "3.0.1"
+ resolved "https://registry.yarnpkg.com/npm-which/-/npm-which-3.0.1.tgz#9225f26ec3a285c209cae67c3b11a6b4ab7140aa"
+ dependencies:
+ commander "^2.9.0"
+ npm-path "^2.0.2"
+ which "^1.2.10"
+
+npmlog@^4.0.2:
+ version "4.1.2"
+ resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.1.2.tgz#08a7f2a8bf734604779a9efa4ad5cc717abb954b"
+ dependencies:
+ are-we-there-yet "~1.1.2"
+ console-control-strings "~1.1.0"
+ gauge "~2.7.3"
+ set-blocking "~2.0.0"
+
+nth-check@~1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-1.0.1.tgz#9929acdf628fc2c41098deab82ac580cf149aae4"
+ dependencies:
+ boolbase "~1.0.0"
+
+number-is-nan@^1.0.0:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d"
+
+nwsapi@^2.0.7:
+ version "2.0.9"
+ resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.0.9.tgz#77ac0cdfdcad52b6a1151a84e73254edc33ed016"
+
+oauth-sign@~0.9.0:
+ version "0.9.0"
+ resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.9.0.tgz#47a7b016baa68b5fa0ecf3dee08a85c679ac6455"
+
+object-assign@4.1.0:
+ version "4.1.0"
+ resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.0.tgz#7a3b3d0e98063d43f4c03f2e8ae6cd51a86883a0"
+
+object-assign@^4, object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1:
+ version "4.1.1"
+ resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
+
+object-copy@^0.1.0:
+ version "0.1.0"
+ resolved "https://registry.yarnpkg.com/object-copy/-/object-copy-0.1.0.tgz#7e7d858b781bd7c991a41ba975ed3812754e998c"
+ dependencies:
+ copy-descriptor "^0.1.0"
+ define-property "^0.2.5"
+ kind-of "^3.0.3"
+
+object-keys@^1.0.12:
+ version "1.0.12"
+ resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.0.12.tgz#09c53855377575310cca62f55bb334abff7b3ed2"
+
+object-visit@^1.0.0:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/object-visit/-/object-visit-1.0.1.tgz#f79c4493af0c5377b59fe39d395e41042dd045bb"
+ dependencies:
+ isobject "^3.0.0"
+
+object.defaults@^1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/object.defaults/-/object.defaults-1.1.0.tgz#3a7f868334b407dea06da16d88d5cd29e435fecf"
+ dependencies:
+ array-each "^1.0.1"
+ array-slice "^1.0.0"
+ for-own "^1.0.0"
+ isobject "^3.0.0"
+
+object.getownpropertydescriptors@^2.0.3:
+ version "2.0.3"
+ resolved "https://registry.yarnpkg.com/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.0.3.tgz#8758c846f5b407adab0f236e0986f14b051caa16"
+ dependencies:
+ define-properties "^1.1.2"
+ es-abstract "^1.5.1"
+
+object.map@^1.0.0:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/object.map/-/object.map-1.0.1.tgz#cf83e59dc8fcc0ad5f4250e1f78b3b81bd801d37"
+ dependencies:
+ for-own "^1.0.0"
+ make-iterator "^1.0.0"
+
+object.omit@^2.0.0:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/object.omit/-/object.omit-2.0.1.tgz#1a9c744829f39dbb858c76ca3579ae2a54ebd1fa"
+ dependencies:
+ for-own "^0.1.4"
+ is-extendable "^0.1.1"
+
+object.pick@^1.2.0, object.pick@^1.3.0:
+ version "1.3.0"
+ resolved "https://registry.yarnpkg.com/object.pick/-/object.pick-1.3.0.tgz#87a10ac4c1694bd2e1cbf53591a66141fb5dd747"
+ dependencies:
+ isobject "^3.0.1"
+
+on-finished@~2.3.0:
+ version "2.3.0"
+ resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947"
+ dependencies:
+ ee-first "1.1.1"
+
+once@^1.3.0, once@^1.3.1, once@^1.4.0:
+ version "1.4.0"
+ resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1"
+ dependencies:
+ wrappy "1"
+
+onetime@^1.0.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/onetime/-/onetime-1.1.0.tgz#a1f7838f8314c516f05ecefcbc4ccfe04b4ed789"
+
+onetime@^2.0.0:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/onetime/-/onetime-2.0.1.tgz#067428230fd67443b2794b22bba528b6867962d4"
+ dependencies:
+ mimic-fn "^1.0.0"
+
+optimist@^0.6.1:
+ version "0.6.1"
+ resolved "https://registry.yarnpkg.com/optimist/-/optimist-0.6.1.tgz#da3ea74686fa21a19a111c326e90eb15a0196686"
+ dependencies:
+ minimist "~0.0.1"
+ wordwrap "~0.0.2"
+
+optionator@^0.8.1, optionator@^0.8.2:
+ version "0.8.2"
+ resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.2.tgz#364c5e409d3f4d6301d6c0b4c05bba50180aeb64"
+ dependencies:
+ deep-is "~0.1.3"
+ fast-levenshtein "~2.0.4"
+ levn "~0.3.0"
+ prelude-ls "~1.1.2"
+ type-check "~0.3.2"
+ wordwrap "~1.0.0"
+
+os-homedir@^1.0.0:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/os-homedir/-/os-homedir-1.0.2.tgz#ffbc4988336e0e833de0c168c7ef152121aa7fb3"
+
+os-locale@^2.0.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/os-locale/-/os-locale-2.1.0.tgz#42bc2900a6b5b8bd17376c8e882b65afccf24bf2"
+ dependencies:
+ execa "^0.7.0"
+ lcid "^1.0.0"
+ mem "^1.1.0"
+
+os-tmpdir@^1.0.0, os-tmpdir@^1.0.1, os-tmpdir@~1.0.2:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274"
+
+osenv@^0.1.4:
+ version "0.1.5"
+ resolved "https://registry.yarnpkg.com/osenv/-/osenv-0.1.5.tgz#85cdfafaeb28e8677f416e287592b5f3f49ea410"
+ dependencies:
+ os-homedir "^1.0.0"
+ os-tmpdir "^1.0.0"
+
+p-finally@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae"
+
+p-limit@^1.1.0:
+ version "1.3.0"
+ resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-1.3.0.tgz#b86bd5f0c25690911c7590fcbfc2010d54b3ccb8"
+ dependencies:
+ p-try "^1.0.0"
+
+p-limit@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.0.0.tgz#e624ed54ee8c460a778b3c9f3670496ff8a57aec"
+ dependencies:
+ p-try "^2.0.0"
+
+p-locate@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-2.0.0.tgz#20a0103b222a70c8fd39cc2e580680f3dde5ec43"
+ dependencies:
+ p-limit "^1.1.0"
+
+p-locate@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-3.0.0.tgz#322d69a05c0264b25997d9f40cd8a891ab0064a4"
+ dependencies:
+ p-limit "^2.0.0"
+
+p-map@^1.1.1:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/p-map/-/p-map-1.2.0.tgz#e4e94f311eabbc8633a1e79908165fca26241b6b"
+
+p-try@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/p-try/-/p-try-1.0.0.tgz#cbc79cdbaf8fd4228e13f621f2b1a237c1b207b3"
+
+p-try@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.0.0.tgz#85080bb87c64688fa47996fe8f7dfbe8211760b1"
+
+package-json@^4.0.0:
+ version "4.0.1"
+ resolved "https://registry.yarnpkg.com/package-json/-/package-json-4.0.1.tgz#8869a0401253661c4c4ca3da6c2121ed555f5eed"
+ dependencies:
+ got "^6.7.1"
+ registry-auth-token "^3.0.1"
+ registry-url "^3.0.3"
+ semver "^5.1.0"
+
+packet-reader@0.3.1:
+ version "0.3.1"
+ resolved "https://registry.yarnpkg.com/packet-reader/-/packet-reader-0.3.1.tgz#cd62e60af8d7fea8a705ec4ff990871c46871f27"
+
+parse-filepath@^1.0.1:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/parse-filepath/-/parse-filepath-1.0.2.tgz#a632127f53aaf3d15876f5872f3ffac763d6c891"
+ dependencies:
+ is-absolute "^1.0.0"
+ map-cache "^0.2.0"
+ path-root "^0.1.1"
+
+parse-glob@^3.0.4:
+ version "3.0.4"
+ resolved "https://registry.yarnpkg.com/parse-glob/-/parse-glob-3.0.4.tgz#b2c376cfb11f35513badd173ef0bb6e3a388391c"
+ dependencies:
+ glob-base "^0.3.0"
+ is-dotfile "^1.0.0"
+ is-extglob "^1.0.0"
+ is-glob "^2.0.0"
+
+parse-json@^2.2.0:
+ version "2.2.0"
+ resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-2.2.0.tgz#f480f40434ef80741f8469099f8dea18f55a4dc9"
+ dependencies:
+ error-ex "^1.2.0"
+
+parse-json@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-4.0.0.tgz#be35f5425be1f7f6c747184f98a788cb99477ee0"
+ dependencies:
+ error-ex "^1.3.1"
+ json-parse-better-errors "^1.0.1"
+
+parse-passwd@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/parse-passwd/-/parse-passwd-1.0.0.tgz#6d5b934a456993b23d37f40a382d6f1666a8e5c6"
+
+parse5@4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/parse5/-/parse5-4.0.0.tgz#6d78656e3da8d78b4ec0b906f7c08ef1dfe3f608"
+
+parse5@^3.0.1:
+ version "3.0.3"
+ resolved "https://registry.yarnpkg.com/parse5/-/parse5-3.0.3.tgz#042f792ffdd36851551cf4e9e066b3874ab45b5c"
+ dependencies:
+ "@types/node" "*"
+
+parseurl@~1.3.2:
+ version "1.3.2"
+ resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.2.tgz#fc289d4ed8993119460c156253262cdc8de65bf3"
+
+pascalcase@^0.1.1:
+ version "0.1.1"
+ resolved "https://registry.yarnpkg.com/pascalcase/-/pascalcase-0.1.1.tgz#b363e55e8006ca6fe21784d2db22bd15d7917f14"
+
+path-dirname@^1.0.0:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/path-dirname/-/path-dirname-1.0.2.tgz#cc33d24d525e099a5388c0336c6e32b9160609e0"
+
+path-exists@^2.0.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-2.1.0.tgz#0feb6c64f0fc518d9a754dd5efb62c7022761f4b"
+ dependencies:
+ pinkie-promise "^2.0.0"
+
+path-exists@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515"
+
+path-is-absolute@^1.0.0, path-is-absolute@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f"
+
+path-is-inside@^1.0.1, path-is-inside@^1.0.2:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/path-is-inside/-/path-is-inside-1.0.2.tgz#365417dede44430d1c11af61027facf074bdfc53"
+
+path-key@^2.0.0, path-key@^2.0.1:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/path-key/-/path-key-2.0.1.tgz#411cadb574c5a140d3a4b1910d40d80cc9f40b40"
+
+path-parse@^1.0.5:
+ version "1.0.6"
+ resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.6.tgz#d62dbb5679405d72c4737ec58600e9ddcf06d24c"
+
+path-root-regex@^0.1.0:
+ version "0.1.2"
+ resolved "https://registry.yarnpkg.com/path-root-regex/-/path-root-regex-0.1.2.tgz#bfccdc8df5b12dc52c8b43ec38d18d72c04ba96d"
+
+path-root@^0.1.1:
+ version "0.1.1"
+ resolved "https://registry.yarnpkg.com/path-root/-/path-root-0.1.1.tgz#9a4a6814cac1c0cd73360a95f32083c8ea4745b7"
+ dependencies:
+ path-root-regex "^0.1.0"
+
+path-to-regexp@0.1.7:
+ version "0.1.7"
+ resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c"
+
+path-type@^1.0.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/path-type/-/path-type-1.1.0.tgz#59c44f7ee491da704da415da5a4070ba4f8fe441"
+ dependencies:
+ graceful-fs "^4.1.2"
+ pify "^2.0.0"
+ pinkie-promise "^2.0.0"
+
+path-type@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/path-type/-/path-type-2.0.0.tgz#f012ccb8415b7096fc2daa1054c3d72389594c73"
+ dependencies:
+ pify "^2.0.0"
+
+pause-stream@^0.0.11:
+ version "0.0.11"
+ resolved "https://registry.yarnpkg.com/pause-stream/-/pause-stream-0.0.11.tgz#fe5a34b0cbce12b5aa6a2b403ee2e73b602f1445"
+ dependencies:
+ through "~2.3"
+
+performance-now@^2.1.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b"
+
+pg-connection-string@0.1.3:
+ version "0.1.3"
+ resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-0.1.3.tgz#da1847b20940e42ee1492beaf65d49d91b245df7"
+
+pg-connection-string@2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.0.0.tgz#3eefe5997e06d94821e4d502e42b6a1c73f8df82"
+
+pg-hstore@^2.3.2:
+ version "2.3.2"
+ resolved "https://registry.yarnpkg.com/pg-hstore/-/pg-hstore-2.3.2.tgz#f7ef053e7b9b892ae986af2f7cbe86432dfcf24f"
+ dependencies:
+ underscore "^1.7.0"
+
+pg-int8@1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/pg-int8/-/pg-int8-1.0.1.tgz#943bd463bf5b71b4170115f80f8efc9a0c0eb78c"
+
+pg-pool@1.*:
+ version "1.8.0"
+ resolved "https://registry.yarnpkg.com/pg-pool/-/pg-pool-1.8.0.tgz#f7ec73824c37a03f076f51bfdf70e340147c4f37"
+ dependencies:
+ generic-pool "2.4.3"
+ object-assign "4.1.0"
+
+pg-pool@~2.0.3:
+ version "2.0.3"
+ resolved "https://registry.yarnpkg.com/pg-pool/-/pg-pool-2.0.3.tgz#c022032c8949f312a4f91fb6409ce04076be3257"
+
+pg-types@1.*:
+ version "1.13.0"
+ resolved "https://registry.yarnpkg.com/pg-types/-/pg-types-1.13.0.tgz#75f490b8a8abf75f1386ef5ec4455ecf6b345c63"
+ dependencies:
+ pg-int8 "1.0.1"
+ postgres-array "~1.0.0"
+ postgres-bytea "~1.0.0"
+ postgres-date "~1.0.0"
+ postgres-interval "^1.1.0"
+
+pg-types@~1.12.1:
+ version "1.12.1"
+ resolved "https://registry.yarnpkg.com/pg-types/-/pg-types-1.12.1.tgz#d64087e3903b58ffaad279e7595c52208a14c3d2"
+ dependencies:
+ postgres-array "~1.0.0"
+ postgres-bytea "~1.0.0"
+ postgres-date "~1.0.0"
+ postgres-interval "^1.1.0"
+
+pg@^6.1.2:
+ version "6.4.2"
+ resolved "https://registry.yarnpkg.com/pg/-/pg-6.4.2.tgz#c364011060eac7a507a2ae063eb857ece910e27f"
+ dependencies:
+ buffer-writer "1.0.1"
+ js-string-escape "1.0.1"
+ packet-reader "0.3.1"
+ pg-connection-string "0.1.3"
+ pg-pool "1.*"
+ pg-types "1.*"
+ pgpass "1.*"
+ semver "4.3.2"
+
+pg@^7.4.1:
+ version "7.5.0"
+ resolved "https://registry.yarnpkg.com/pg/-/pg-7.5.0.tgz#c2853bef2fcb91424ba2f649fd951ce866a84760"
+ dependencies:
+ buffer-writer "1.0.1"
+ packet-reader "0.3.1"
+ pg-connection-string "0.1.3"
+ pg-pool "~2.0.3"
+ pg-types "~1.12.1"
+ pgpass "1.x"
+ semver "4.3.2"
+
+pgpass@1.*, pgpass@1.x:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/pgpass/-/pgpass-1.0.2.tgz#2a7bb41b6065b67907e91da1b07c1847c877b306"
+ dependencies:
+ split "^1.0.0"
+
+pify@^2.0.0:
+ version "2.3.0"
+ resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c"
+
+pify@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/pify/-/pify-3.0.0.tgz#e5a4acd2c101fdf3d9a4d07f0dbc4db49dd28176"
+
+pinkie-promise@^2.0.0:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/pinkie-promise/-/pinkie-promise-2.0.1.tgz#2135d6dfa7a358c069ac9b178776288228450ffa"
+ dependencies:
+ pinkie "^2.0.0"
+
+pinkie@^2.0.0:
+ version "2.0.4"
+ resolved "https://registry.yarnpkg.com/pinkie/-/pinkie-2.0.4.tgz#72556b80cfa0d48a974e80e77248e80ed4f7f870"
+
+pino-http@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/pino-http/-/pino-http-4.0.0.tgz#cc2eb3437b3454ecd2239697a45d6e9ae5e1b10d"
+ dependencies:
+ pino "^5.0.0"
+ pino-std-serializers "^2.1.0"
+
+pino-std-serializers@^2.1.0, pino-std-serializers@^2.3.0:
+ version "2.3.0"
+ resolved "https://registry.yarnpkg.com/pino-std-serializers/-/pino-std-serializers-2.3.0.tgz#34eeaab97c055c28e22c0542ae55978e7e427786"
+
+pino@^5.0.0:
+ version "5.8.0"
+ resolved "https://registry.yarnpkg.com/pino/-/pino-5.8.0.tgz#b566f78617c53c2bae91d30ad62d52cd1074df4a"
+ dependencies:
+ fast-json-parse "^1.0.3"
+ fast-redact "^1.2.0"
+ fast-safe-stringify "^2.0.6"
+ flatstr "^1.0.5"
+ pino-std-serializers "^2.3.0"
+ pump "^3.0.0"
+ quick-format-unescaped "^3.0.0"
+ sonic-boom "^0.6.1"
+
+pkg-dir@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-1.0.0.tgz#7a4b508a8d5bb2d629d447056ff4e9c9314cf3d4"
+ dependencies:
+ find-up "^1.0.0"
+
+pkg-dir@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-2.0.0.tgz#f6d5d1109e19d63edf428e0bd57e12777615334b"
+ dependencies:
+ find-up "^2.1.0"
+
+pkg-dir@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-3.0.0.tgz#2749020f239ed990881b1f71210d51eb6523bea3"
+ dependencies:
+ find-up "^3.0.0"
+
+please-upgrade-node@^3.0.2, please-upgrade-node@^3.1.1:
+ version "3.1.1"
+ resolved "https://registry.yarnpkg.com/please-upgrade-node/-/please-upgrade-node-3.1.1.tgz#ed320051dfcc5024fae696712c8288993595e8ac"
+ dependencies:
+ semver-compare "^1.0.0"
+
+pluralize@^7.0.0:
+ version "7.0.0"
+ resolved "https://registry.yarnpkg.com/pluralize/-/pluralize-7.0.0.tgz#298b89df8b93b0221dbf421ad2b1b1ea23fc6777"
+
+pn@^1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/pn/-/pn-1.1.0.tgz#e2f4cef0e219f463c179ab37463e4e1ecdccbafb"
+
+posix-character-classes@^0.1.0:
+ version "0.1.1"
+ resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab"
+
+postgres-array@~1.0.0:
+ version "1.0.3"
+ resolved "https://registry.yarnpkg.com/postgres-array/-/postgres-array-1.0.3.tgz#c561fc3b266b21451fc6555384f4986d78ec80f5"
+
+postgres-bytea@~1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/postgres-bytea/-/postgres-bytea-1.0.0.tgz#027b533c0aa890e26d172d47cf9ccecc521acd35"
+
+postgres-date@~1.0.0:
+ version "1.0.3"
+ resolved "https://registry.yarnpkg.com/postgres-date/-/postgres-date-1.0.3.tgz#e2d89702efdb258ff9d9cee0fe91bd06975257a8"
+
+postgres-interval@^1.1.0:
+ version "1.1.2"
+ resolved "https://registry.yarnpkg.com/postgres-interval/-/postgres-interval-1.1.2.tgz#bf71ff902635f21cb241a013fc421d81d1db15a9"
+ dependencies:
+ xtend "^4.0.0"
+
+prelude-ls@~1.1.2:
+ version "1.1.2"
+ resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54"
+
+prepend-http@^1.0.1:
+ version "1.0.4"
+ resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-1.0.4.tgz#d4f4562b0ce3696e41ac52d0e002e57a635dc6dc"
+
+preserve@^0.2.0:
+ version "0.2.0"
+ resolved "https://registry.yarnpkg.com/preserve/-/preserve-0.2.0.tgz#815ed1f6ebc65926f865b310c0713bcb3315ce4b"
+
+prettier@^1.5.3:
+ version "1.14.3"
+ resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.14.3.tgz#90238dd4c0684b7edce5f83b0fb7328e48bd0895"
+
+pretty-format@^23.6.0:
+ version "23.6.0"
+ resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-23.6.0.tgz#5eaac8eeb6b33b987b7fe6097ea6a8a146ab5760"
+ dependencies:
+ ansi-regex "^3.0.0"
+ ansi-styles "^3.2.0"
+
+private@^0.1.8:
+ version "0.1.8"
+ resolved "https://registry.yarnpkg.com/private/-/private-0.1.8.tgz#2381edb3689f7a53d653190060fcf822d2f368ff"
+
+process-nextick-args@~2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.0.tgz#a37d732f4271b4ab1ad070d35508e8290788ffaa"
+
+progress@^2.0.0:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.1.tgz#c9242169342b1c29d275889c95734621b1952e31"
+
+prompts@^0.1.9:
+ version "0.1.14"
+ resolved "https://registry.yarnpkg.com/prompts/-/prompts-0.1.14.tgz#a8e15c612c5c9ec8f8111847df3337c9cbd443b2"
+ dependencies:
+ kleur "^2.0.1"
+ sisteransi "^0.1.1"
+
+prop-types@^15.6.2:
+ version "15.6.2"
+ resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.6.2.tgz#05d5ca77b4453e985d60fc7ff8c859094a497102"
+ dependencies:
+ loose-envify "^1.3.1"
+ object-assign "^4.1.1"
+
+proxy-addr@~2.0.4:
+ version "2.0.4"
+ resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.4.tgz#ecfc733bf22ff8c6f407fa275327b9ab67e48b93"
+ dependencies:
+ forwarded "~0.1.2"
+ ipaddr.js "1.8.0"
+
+ps-tree@^1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/ps-tree/-/ps-tree-1.1.0.tgz#b421b24140d6203f1ed3c76996b4427b08e8c014"
+ dependencies:
+ event-stream "~3.3.0"
+
+pseudomap@^1.0.2:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3"
+
+psl@^1.1.24:
+ version "1.1.29"
+ resolved "https://registry.yarnpkg.com/psl/-/psl-1.1.29.tgz#60f580d360170bb722a797cc704411e6da850c67"
+
+pstree.remy@^1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/pstree.remy/-/pstree.remy-1.1.0.tgz#f2af27265bd3e5b32bbfcc10e80bac55ba78688b"
+ dependencies:
+ ps-tree "^1.1.0"
+
+pump@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64"
+ dependencies:
+ end-of-stream "^1.1.0"
+ once "^1.3.1"
+
+punycode@^1.4.1:
+ version "1.4.1"
+ resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e"
+
+punycode@^2.1.0:
+ version "2.1.1"
+ resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec"
+
+q@^1.4.1:
+ version "1.5.1"
+ resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7"
+
+qs@6.5.2, qs@~6.5.2:
+ version "6.5.2"
+ resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36"
+
+quick-format-unescaped@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/quick-format-unescaped/-/quick-format-unescaped-3.0.0.tgz#21e7fdc5b08b079ef71ed0728ed0725e58671e41"
+
+randomatic@^3.0.0:
+ version "3.1.0"
+ resolved "https://registry.yarnpkg.com/randomatic/-/randomatic-3.1.0.tgz#36f2ca708e9e567f5ed2ec01949026d50aa10116"
+ dependencies:
+ is-number "^4.0.0"
+ kind-of "^6.0.0"
+ math-random "^1.0.1"
+
+range-parser@~1.2.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.0.tgz#f49be6b487894ddc40dcc94a322f611092e00d5e"
+
+raw-body@2.3.3:
+ version "2.3.3"
+ resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.3.3.tgz#1b324ece6b5706e153855bc1148c65bb7f6ea0c3"
+ dependencies:
+ bytes "3.0.0"
+ http-errors "1.6.3"
+ iconv-lite "0.4.23"
+ unpipe "1.0.0"
+
+rc@^1.0.1, rc@^1.1.6, rc@^1.2.7:
+ version "1.2.8"
+ resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed"
+ dependencies:
+ deep-extend "^0.6.0"
+ ini "~1.3.0"
+ minimist "^1.2.0"
+ strip-json-comments "~2.0.1"
+
+read-pkg-up@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-1.0.1.tgz#9d63c13276c065918d57f002a57f40a1b643fb02"
+ dependencies:
+ find-up "^1.0.0"
+ read-pkg "^1.0.0"
+
+read-pkg-up@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-2.0.0.tgz#6b72a8048984e0c41e79510fd5e9fa99b3b549be"
+ dependencies:
+ find-up "^2.0.0"
+ read-pkg "^2.0.0"
+
+read-pkg@^1.0.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-1.1.0.tgz#f5ffaa5ecd29cb31c0474bca7d756b6bb29e3f28"
+ dependencies:
+ load-json-file "^1.0.0"
+ normalize-package-data "^2.3.2"
+ path-type "^1.0.0"
+
+read-pkg@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-2.0.0.tgz#8ef1c0623c6a6db0dc6713c4bfac46332b2368f8"
+ dependencies:
+ load-json-file "^2.0.0"
+ normalize-package-data "^2.3.2"
+ path-type "^2.0.0"
+
+read-pkg@^4.0.1:
+ version "4.0.1"
+ resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-4.0.1.tgz#963625378f3e1c4d48c85872b5a6ec7d5d093237"
+ dependencies:
+ normalize-package-data "^2.3.2"
+ parse-json "^4.0.0"
+ pify "^3.0.0"
+
+readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.6:
+ version "2.3.6"
+ resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.6.tgz#b11c27d88b8ff1fbe070643cf94b0c79ae1b0aaf"
+ dependencies:
+ core-util-is "~1.0.0"
+ inherits "~2.0.3"
+ isarray "~1.0.0"
+ process-nextick-args "~2.0.0"
+ safe-buffer "~5.1.1"
+ string_decoder "~1.1.1"
+ util-deprecate "~1.0.1"
+
+readdirp@^2.0.0:
+ version "2.2.1"
+ resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-2.2.1.tgz#0e87622a3325aa33e892285caf8b4e846529a525"
+ dependencies:
+ graceful-fs "^4.1.11"
+ micromatch "^3.1.10"
+ readable-stream "^2.0.2"
+
+realpath-native@^1.0.0:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/realpath-native/-/realpath-native-1.0.2.tgz#cd51ce089b513b45cf9b1516c82989b51ccc6560"
+ dependencies:
+ util.promisify "^1.0.0"
+
+rechoir@^0.6.2:
+ version "0.6.2"
+ resolved "https://registry.yarnpkg.com/rechoir/-/rechoir-0.6.2.tgz#85204b54dba82d5742e28c96756ef43af50e3384"
+ dependencies:
+ resolve "^1.1.6"
+
+regenerator-runtime@^0.11.0:
+ version "0.11.1"
+ resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz#be05ad7f9bf7d22e056f9726cee5017fbf19e2e9"
+
+regex-cache@^0.4.2:
+ version "0.4.4"
+ resolved "https://registry.yarnpkg.com/regex-cache/-/regex-cache-0.4.4.tgz#75bdc58a2a1496cec48a12835bc54c8d562336dd"
+ dependencies:
+ is-equal-shallow "^0.1.3"
+
+regex-not@^1.0.0, regex-not@^1.0.2:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/regex-not/-/regex-not-1.0.2.tgz#1f4ece27e00b0b65e0247a6810e6a85d83a5752c"
+ dependencies:
+ extend-shallow "^3.0.2"
+ safe-regex "^1.1.0"
+
+regexpp@^2.0.1:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-2.0.1.tgz#8d19d31cf632482b589049f8281f93dbcba4d07f"
+
+registry-auth-token@^3.0.1:
+ version "3.3.2"
+ resolved "https://registry.yarnpkg.com/registry-auth-token/-/registry-auth-token-3.3.2.tgz#851fd49038eecb586911115af845260eec983f20"
+ dependencies:
+ rc "^1.1.6"
+ safe-buffer "^5.0.1"
+
+registry-url@^3.0.3:
+ version "3.1.0"
+ resolved "https://registry.yarnpkg.com/registry-url/-/registry-url-3.1.0.tgz#3d4ef870f73dde1d77f0cf9a381432444e174942"
+ dependencies:
+ rc "^1.0.1"
+
+remove-trailing-separator@^1.0.1:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz#c24bce2a283adad5bc3f58e0d48249b92379d8ef"
+
+repeat-element@^1.1.2:
+ version "1.1.3"
+ resolved "https://registry.yarnpkg.com/repeat-element/-/repeat-element-1.1.3.tgz#782e0d825c0c5a3bb39731f84efee6b742e6b1ce"
+
+repeat-string@^1.5.2, repeat-string@^1.6.1:
+ version "1.6.1"
+ resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637"
+
+repeating@^2.0.0:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/repeating/-/repeating-2.0.1.tgz#5214c53a926d3552707527fbab415dbc08d06dda"
+ dependencies:
+ is-finite "^1.0.0"
+
+request-promise-core@1.1.1:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/request-promise-core/-/request-promise-core-1.1.1.tgz#3eee00b2c5aa83239cfb04c5700da36f81cd08b6"
+ dependencies:
+ lodash "^4.13.1"
+
+request-promise-native@^1.0.5:
+ version "1.0.5"
+ resolved "https://registry.yarnpkg.com/request-promise-native/-/request-promise-native-1.0.5.tgz#5281770f68e0c9719e5163fd3fab482215f4fda5"
+ dependencies:
+ request-promise-core "1.1.1"
+ stealthy-require "^1.1.0"
+ tough-cookie ">=2.3.3"
+
+request@^2.87.0:
+ version "2.88.0"
+ resolved "https://registry.yarnpkg.com/request/-/request-2.88.0.tgz#9c2fca4f7d35b592efe57c7f0a55e81052124fef"
+ dependencies:
+ aws-sign2 "~0.7.0"
+ aws4 "^1.8.0"
+ caseless "~0.12.0"
+ combined-stream "~1.0.6"
+ extend "~3.0.2"
+ forever-agent "~0.6.1"
+ form-data "~2.3.2"
+ har-validator "~5.1.0"
+ http-signature "~1.2.0"
+ is-typedarray "~1.0.0"
+ isstream "~0.1.2"
+ json-stringify-safe "~5.0.1"
+ mime-types "~2.1.19"
+ oauth-sign "~0.9.0"
+ performance-now "^2.1.0"
+ qs "~6.5.2"
+ safe-buffer "^5.1.2"
+ tough-cookie "~2.4.3"
+ tunnel-agent "^0.6.0"
+ uuid "^3.3.2"
+
+require-directory@^2.1.1:
+ version "2.1.1"
+ resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42"
+
+require-main-filename@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-1.0.1.tgz#97f717b69d48784f5f526a6c5aa8ffdda055a4d1"
+
+require-uncached@^1.0.3:
+ version "1.0.3"
+ resolved "https://registry.yarnpkg.com/require-uncached/-/require-uncached-1.0.3.tgz#4e0d56d6c9662fd31e43011c4b95aa49955421d3"
+ dependencies:
+ caller-path "^0.1.0"
+ resolve-from "^1.0.0"
+
+resolve-cwd@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/resolve-cwd/-/resolve-cwd-2.0.0.tgz#00a9f7387556e27038eae232caa372a6a59b665a"
+ dependencies:
+ resolve-from "^3.0.0"
+
+resolve-dir@^1.0.0, resolve-dir@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/resolve-dir/-/resolve-dir-1.0.1.tgz#79a40644c362be82f26effe739c9bb5382046f43"
+ dependencies:
+ expand-tilde "^2.0.0"
+ global-modules "^1.0.0"
+
+resolve-from@^1.0.0:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-1.0.1.tgz#26cbfe935d1aeeeabb29bc3fe5aeb01e93d44226"
+
+resolve-from@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-3.0.0.tgz#b22c7af7d9d6881bc8b6e653335eebcb0a188748"
+
+resolve-url@^0.2.1:
+ version "0.2.1"
+ resolved "https://registry.yarnpkg.com/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a"
+
+resolve@1.1.7:
+ version "1.1.7"
+ resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.1.7.tgz#203114d82ad2c5ed9e8e0411b3932875e889e97b"
+
+resolve@^1.1.6, resolve@^1.1.7, resolve@^1.5.0, resolve@^1.6.0:
+ version "1.8.1"
+ resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.8.1.tgz#82f1ec19a423ac1fbd080b0bab06ba36e84a7a26"
+ dependencies:
+ path-parse "^1.0.5"
+
+restore-cursor@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-1.0.1.tgz#34661f46886327fed2991479152252df92daa541"
+ dependencies:
+ exit-hook "^1.0.0"
+ onetime "^1.0.0"
+
+restore-cursor@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-2.0.0.tgz#9f7ee287f82fd326d4fd162923d62129eee0dfaf"
+ dependencies:
+ onetime "^2.0.0"
+ signal-exit "^3.0.2"
+
+ret@~0.1.10:
+ version "0.1.15"
+ resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc"
+
+rimraf@^2.2.8, rimraf@^2.5.4, rimraf@^2.6.1:
+ version "2.6.2"
+ resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.2.tgz#2ed8150d24a16ea8651e6d6ef0f47c4158ce7a36"
+ dependencies:
+ glob "^7.0.5"
+
+rsvp@^3.3.3:
+ version "3.6.2"
+ resolved "https://registry.yarnpkg.com/rsvp/-/rsvp-3.6.2.tgz#2e96491599a96cde1b515d5674a8f7a91452926a"
+
+run-async@^2.2.0:
+ version "2.3.0"
+ resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.3.0.tgz#0371ab4ae0bdd720d4166d7dfda64ff7a445a6c0"
+ dependencies:
+ is-promise "^2.1.0"
+
+run-node@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/run-node/-/run-node-1.0.0.tgz#46b50b946a2aa2d4947ae1d886e9856fd9cabe5e"
+
+rxjs@^6.1.0:
+ version "6.3.3"
+ resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.3.3.tgz#3c6a7fa420e844a81390fb1158a9ec614f4bad55"
+ dependencies:
+ tslib "^1.9.0"
+
+safe-buffer@5.1.2, safe-buffer@^5.0.1, safe-buffer@^5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1:
+ version "5.1.2"
+ resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
+
+safe-regex@^1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/safe-regex/-/safe-regex-1.1.0.tgz#40a3669f3b077d1e943d44629e157dd48023bf2e"
+ dependencies:
+ ret "~0.1.10"
+
+"safer-buffer@>= 2.1.2 < 3", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0:
+ version "2.1.2"
+ resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
+
+sane@^2.0.0:
+ version "2.5.2"
+ resolved "https://registry.yarnpkg.com/sane/-/sane-2.5.2.tgz#b4dc1861c21b427e929507a3e751e2a2cb8ab3fa"
+ dependencies:
+ anymatch "^2.0.0"
+ capture-exit "^1.2.0"
+ exec-sh "^0.2.0"
+ fb-watchman "^2.0.0"
+ micromatch "^3.1.4"
+ minimist "^1.1.1"
+ walker "~1.0.5"
+ watch "~0.18.0"
+ optionalDependencies:
+ fsevents "^1.2.3"
+
+sax@^1.2.4:
+ version "1.2.4"
+ resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9"
+
+semver-compare@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/semver-compare/-/semver-compare-1.0.0.tgz#0dee216a1c941ab37e9efb1788f6afc5ff5537fc"
+
+semver-diff@^2.0.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/semver-diff/-/semver-diff-2.1.0.tgz#4bbb8437c8d37e4b0cf1a68fd726ec6d645d6d36"
+ dependencies:
+ semver "^5.0.3"
+
+"semver@2 || 3 || 4 || 5", semver@^5.0.3, semver@^5.1.0, semver@^5.3.0, semver@^5.4.1, semver@^5.5.0, semver@^5.5.1:
+ version "5.6.0"
+ resolved "https://registry.yarnpkg.com/semver/-/semver-5.6.0.tgz#7e74256fbaa49c75aa7c7a205cc22799cac80004"
+
+semver@4.3.2:
+ version "4.3.2"
+ resolved "https://registry.yarnpkg.com/semver/-/semver-4.3.2.tgz#c7a07158a80bedd052355b770d82d6640f803be7"
+
+send@0.16.2:
+ version "0.16.2"
+ resolved "https://registry.yarnpkg.com/send/-/send-0.16.2.tgz#6ecca1e0f8c156d141597559848df64730a6bbc1"
+ dependencies:
+ debug "2.6.9"
+ depd "~1.1.2"
+ destroy "~1.0.4"
+ encodeurl "~1.0.2"
+ escape-html "~1.0.3"
+ etag "~1.8.1"
+ fresh "0.5.2"
+ http-errors "~1.6.2"
+ mime "1.4.1"
+ ms "2.0.0"
+ on-finished "~2.3.0"
+ range-parser "~1.2.0"
+ statuses "~1.4.0"
+
+serve-static@1.13.2:
+ version "1.13.2"
+ resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.13.2.tgz#095e8472fd5b46237db50ce486a43f4b86c6cec1"
+ dependencies:
+ encodeurl "~1.0.2"
+ escape-html "~1.0.3"
+ parseurl "~1.3.2"
+ send "0.16.2"
+
+set-blocking@^2.0.0, set-blocking@~2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7"
+
+set-value@^0.4.3:
+ version "0.4.3"
+ resolved "https://registry.yarnpkg.com/set-value/-/set-value-0.4.3.tgz#7db08f9d3d22dc7f78e53af3c3bf4666ecdfccf1"
+ dependencies:
+ extend-shallow "^2.0.1"
+ is-extendable "^0.1.1"
+ is-plain-object "^2.0.1"
+ to-object-path "^0.3.0"
+
+set-value@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/set-value/-/set-value-2.0.0.tgz#71ae4a88f0feefbbf52d1ea604f3fb315ebb6274"
+ dependencies:
+ extend-shallow "^2.0.1"
+ is-extendable "^0.1.1"
+ is-plain-object "^2.0.3"
+ split-string "^3.0.1"
+
+setprototypeof@1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.0.tgz#d0bd85536887b6fe7c0d818cb962d9d91c54e656"
+
+shebang-command@^1.2.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea"
+ dependencies:
+ shebang-regex "^1.0.0"
+
+shebang-regex@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3"
+
+shellwords@^0.1.1:
+ version "0.1.1"
+ resolved "https://registry.yarnpkg.com/shellwords/-/shellwords-0.1.1.tgz#d6b9181c1a48d397324c84871efbcfc73fc0654b"
+
+signal-exit@^3.0.0, signal-exit@^3.0.2:
+ version "3.0.2"
+ resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d"
+
+sisteransi@^0.1.1:
+ version "0.1.1"
+ resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-0.1.1.tgz#5431447d5f7d1675aac667ccd0b865a4994cb3ce"
+
+slash@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/slash/-/slash-1.0.0.tgz#c41f2f6c39fc16d1cd17ad4b5d896114ae470d55"
+
+slash@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/slash/-/slash-2.0.0.tgz#de552851a1759df3a8f206535442f5ec4ddeab44"
+
+slice-ansi@0.0.4:
+ version "0.0.4"
+ resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-0.0.4.tgz#edbf8903f66f7ce2f8eafd6ceed65e264c831b35"
+
+slice-ansi@1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-1.0.0.tgz#044f1a49d8842ff307aad6b505ed178bd950134d"
+ dependencies:
+ is-fullwidth-code-point "^2.0.0"
+
+snapdragon-node@^2.0.1:
+ version "2.1.1"
+ resolved "https://registry.yarnpkg.com/snapdragon-node/-/snapdragon-node-2.1.1.tgz#6c175f86ff14bdb0724563e8f3c1b021a286853b"
+ dependencies:
+ define-property "^1.0.0"
+ isobject "^3.0.0"
+ snapdragon-util "^3.0.1"
+
+snapdragon-util@^3.0.1:
+ version "3.0.1"
+ resolved "https://registry.yarnpkg.com/snapdragon-util/-/snapdragon-util-3.0.1.tgz#f956479486f2acd79700693f6f7b805e45ab56e2"
+ dependencies:
+ kind-of "^3.2.0"
+
+snapdragon@^0.8.1:
+ version "0.8.2"
+ resolved "https://registry.yarnpkg.com/snapdragon/-/snapdragon-0.8.2.tgz#64922e7c565b0e14204ba1aa7d6964278d25182d"
+ dependencies:
+ base "^0.11.1"
+ debug "^2.2.0"
+ define-property "^0.2.5"
+ extend-shallow "^2.0.1"
+ map-cache "^0.2.2"
+ source-map "^0.5.6"
+ source-map-resolve "^0.5.0"
+ use "^3.1.0"
+
+sonic-boom@^0.6.1:
+ version "0.6.1"
+ resolved "https://registry.yarnpkg.com/sonic-boom/-/sonic-boom-0.6.1.tgz#da832af4ed7c477eb5317fe4c5132c75f7556245"
+ dependencies:
+ flatstr "^1.0.5"
+
+source-map-resolve@^0.5.0:
+ version "0.5.2"
+ resolved "https://registry.yarnpkg.com/source-map-resolve/-/source-map-resolve-0.5.2.tgz#72e2cc34095543e43b2c62b2c4c10d4a9054f259"
+ dependencies:
+ atob "^2.1.1"
+ decode-uri-component "^0.2.0"
+ resolve-url "^0.2.1"
+ source-map-url "^0.4.0"
+ urix "^0.1.0"
+
+source-map-support@^0.4.15:
+ version "0.4.18"
+ resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.4.18.tgz#0286a6de8be42641338594e97ccea75f0a2c585f"
+ dependencies:
+ source-map "^0.5.6"
+
+source-map-support@^0.5.1, source-map-support@^0.5.6:
+ version "0.5.9"
+ resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.9.tgz#41bc953b2534267ea2d605bccfa7bfa3111ced5f"
+ dependencies:
+ buffer-from "^1.0.0"
+ source-map "^0.6.0"
+
+source-map-url@^0.4.0:
+ version "0.4.0"
+ resolved "https://registry.yarnpkg.com/source-map-url/-/source-map-url-0.4.0.tgz#3e935d7ddd73631b97659956d55128e87b5084a3"
+
+source-map@^0.5.3, source-map@^0.5.6, source-map@^0.5.7:
+ version "0.5.7"
+ resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc"
+
+source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.1:
+ version "0.6.1"
+ resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
+
+spdx-correct@^3.0.0:
+ version "3.0.2"
+ resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-3.0.2.tgz#19bb409e91b47b1ad54159243f7312a858db3c2e"
+ dependencies:
+ spdx-expression-parse "^3.0.0"
+ spdx-license-ids "^3.0.0"
+
+spdx-exceptions@^2.1.0:
+ version "2.2.0"
+ resolved "https://registry.yarnpkg.com/spdx-exceptions/-/spdx-exceptions-2.2.0.tgz#2ea450aee74f2a89bfb94519c07fcd6f41322977"
+
+spdx-expression-parse@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/spdx-expression-parse/-/spdx-expression-parse-3.0.0.tgz#99e119b7a5da00e05491c9fa338b7904823b41d0"
+ dependencies:
+ spdx-exceptions "^2.1.0"
+ spdx-license-ids "^3.0.0"
+
+spdx-license-ids@^3.0.0:
+ version "3.0.1"
+ resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.1.tgz#e2a303236cac54b04031fa7a5a79c7e701df852f"
+
+split-string@^3.0.1, split-string@^3.0.2:
+ version "3.1.0"
+ resolved "https://registry.yarnpkg.com/split-string/-/split-string-3.1.0.tgz#7cb09dda3a86585705c64b39a6466038682e8fe2"
+ dependencies:
+ extend-shallow "^3.0.0"
+
+split@^1.0.0, split@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/split/-/split-1.0.1.tgz#605bd9be303aa59fb35f9229fbea0ddec9ea07d9"
+ dependencies:
+ through "2"
+
+sprintf-js@~1.0.2:
+ version "1.0.3"
+ resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c"
+
+sqlite3@^4.0.0:
+ version "4.0.2"
+ resolved "https://registry.yarnpkg.com/sqlite3/-/sqlite3-4.0.2.tgz#1bbeb68b03ead5d499e42a3a1b140064791c5a64"
+ dependencies:
+ nan "~2.10.0"
+ node-pre-gyp "^0.10.3"
+ request "^2.87.0"
+
+sshpk@^1.7.0:
+ version "1.15.1"
+ resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.15.1.tgz#b79a089a732e346c6e0714830f36285cd38191a2"
+ dependencies:
+ asn1 "~0.2.3"
+ assert-plus "^1.0.0"
+ bcrypt-pbkdf "^1.0.0"
+ dashdash "^1.12.0"
+ ecc-jsbn "~0.1.1"
+ getpass "^0.1.1"
+ jsbn "~0.1.0"
+ safer-buffer "^2.0.2"
+ tweetnacl "~0.14.0"
+
+stack-utils@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/stack-utils/-/stack-utils-1.0.1.tgz#d4f33ab54e8e38778b0ca5cfd3b3afb12db68620"
+
+staged-git-files@1.1.1:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/staged-git-files/-/staged-git-files-1.1.1.tgz#37c2218ef0d6d26178b1310719309a16a59f8f7b"
+
+static-extend@^0.1.1:
+ version "0.1.2"
+ resolved "https://registry.yarnpkg.com/static-extend/-/static-extend-0.1.2.tgz#60809c39cbff55337226fd5e0b520f341f1fb5c6"
+ dependencies:
+ define-property "^0.2.5"
+ object-copy "^0.1.0"
+
+"statuses@>= 1.4.0 < 2":
+ version "1.5.0"
+ resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c"
+
+statuses@~1.4.0:
+ version "1.4.0"
+ resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.4.0.tgz#bb73d446da2796106efcc1b601a253d6c46bd087"
+
+stealthy-require@^1.1.0:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/stealthy-require/-/stealthy-require-1.1.1.tgz#35b09875b4ff49f26a777e509b3090a3226bf24b"
+
+stream-combiner@^0.2.2:
+ version "0.2.2"
+ resolved "https://registry.yarnpkg.com/stream-combiner/-/stream-combiner-0.2.2.tgz#aec8cbac177b56b6f4fa479ced8c1912cee52858"
+ dependencies:
+ duplexer "~0.1.1"
+ through "~2.3.4"
+
+string-argv@^0.0.2:
+ version "0.0.2"
+ resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.0.2.tgz#dac30408690c21f3c3630a3ff3a05877bdcbd736"
+
+string-length@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/string-length/-/string-length-2.0.0.tgz#d40dbb686a3ace960c1cffca562bf2c45f8363ed"
+ dependencies:
+ astral-regex "^1.0.0"
+ strip-ansi "^4.0.0"
+
+string-width@^1.0.1:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3"
+ dependencies:
+ code-point-at "^1.0.0"
+ is-fullwidth-code-point "^1.0.0"
+ strip-ansi "^3.0.0"
+
+"string-width@^1.0.2 || 2", string-width@^2.0.0, string-width@^2.1.0, string-width@^2.1.1:
+ version "2.1.1"
+ resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e"
+ dependencies:
+ is-fullwidth-code-point "^2.0.0"
+ strip-ansi "^4.0.0"
+
+string_decoder@~1.1.1:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8"
+ dependencies:
+ safe-buffer "~5.1.0"
+
+stringify-object@^3.2.2:
+ version "3.3.0"
+ resolved "https://registry.yarnpkg.com/stringify-object/-/stringify-object-3.3.0.tgz#703065aefca19300d3ce88af4f5b3956d7556629"
+ dependencies:
+ get-own-enumerable-property-symbols "^3.0.0"
+ is-obj "^1.0.1"
+ is-regexp "^1.0.0"
+
+strip-ansi@^3.0.0, strip-ansi@^3.0.1:
+ version "3.0.1"
+ resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf"
+ dependencies:
+ ansi-regex "^2.0.0"
+
+strip-ansi@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-4.0.0.tgz#a8479022eb1ac368a871389b635262c505ee368f"
+ dependencies:
+ ansi-regex "^3.0.0"
+
+strip-bom@3.0.0, strip-bom@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3"
+
+strip-bom@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-2.0.0.tgz#6219a85616520491f35788bdbf1447a99c7e6b0e"
+ dependencies:
+ is-utf8 "^0.2.0"
+
+strip-eof@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/strip-eof/-/strip-eof-1.0.0.tgz#bb43ff5598a6eb05d89b59fcd129c983313606bf"
+
+strip-json-comments@^2.0.1, strip-json-comments@~2.0.1:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a"
+
+supports-color@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-2.0.0.tgz#535d045ce6b6363fa40117084629995e9df324c7"
+
+supports-color@^3.1.2:
+ version "3.2.3"
+ resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-3.2.3.tgz#65ac0504b3954171d8a64946b2ae3cbb8a5f54f6"
+ dependencies:
+ has-flag "^1.0.0"
+
+supports-color@^5.2.0, supports-color@^5.3.0:
+ version "5.5.0"
+ resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f"
+ dependencies:
+ has-flag "^3.0.0"
+
+symbol-observable@^1.1.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804"
+
+symbol-tree@^3.2.2:
+ version "3.2.2"
+ resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.2.tgz#ae27db38f660a7ae2e1c3b7d1bc290819b8519e6"
+
+table@^5.0.2:
+ version "5.1.0"
+ resolved "https://registry.yarnpkg.com/table/-/table-5.1.0.tgz#69a54644f6f01ad1628f8178715b408dc6bf11f7"
+ dependencies:
+ ajv "^6.5.3"
+ lodash "^4.17.10"
+ slice-ansi "1.0.0"
+ string-width "^2.1.1"
+
+tar@^4:
+ version "4.4.6"
+ resolved "https://registry.yarnpkg.com/tar/-/tar-4.4.6.tgz#63110f09c00b4e60ac8bcfe1bf3c8660235fbc9b"
+ dependencies:
+ chownr "^1.0.1"
+ fs-minipass "^1.2.5"
+ minipass "^2.3.3"
+ minizlib "^1.1.0"
+ mkdirp "^0.5.0"
+ safe-buffer "^5.1.2"
+ yallist "^3.0.2"
+
+tarn@^1.1.4:
+ version "1.1.4"
+ resolved "https://registry.yarnpkg.com/tarn/-/tarn-1.1.4.tgz#aeeb85964b1afa0bbf381359c1167df237c27b6a"
+
+term-size@^1.2.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/term-size/-/term-size-1.2.0.tgz#458b83887f288fc56d6fffbfad262e26638efa69"
+ dependencies:
+ execa "^0.7.0"
+
+test-exclude@^4.2.1:
+ version "4.2.3"
+ resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-4.2.3.tgz#a9a5e64474e4398339245a0a769ad7c2f4a97c20"
+ dependencies:
+ arrify "^1.0.1"
+ micromatch "^2.3.11"
+ object-assign "^4.1.0"
+ read-pkg-up "^1.0.1"
+ require-main-filename "^1.0.1"
+
+text-table@^0.2.0:
+ version "0.2.0"
+ resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4"
+
+throat@^4.0.0:
+ version "4.1.0"
+ resolved "https://registry.yarnpkg.com/throat/-/throat-4.1.0.tgz#89037cbc92c56ab18926e6ba4cbb200e15672a6a"
+
+through@2, through@^2.3.6, through@^2.3.8, through@~2.3, through@~2.3.4:
+ version "2.3.8"
+ resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5"
+
+tildify@1.2.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/tildify/-/tildify-1.2.0.tgz#dcec03f55dca9b7aa3e5b04f21817eb56e63588a"
+ dependencies:
+ os-homedir "^1.0.0"
+
+timed-out@^4.0.0:
+ version "4.0.1"
+ resolved "https://registry.yarnpkg.com/timed-out/-/timed-out-4.0.1.tgz#f32eacac5a175bea25d7fab565ab3ed8741ef56f"
+
+tmp@^0.0.33:
+ version "0.0.33"
+ resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9"
+ dependencies:
+ os-tmpdir "~1.0.2"
+
+tmpl@1.0.x:
+ version "1.0.4"
+ resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.4.tgz#23640dd7b42d00433911140820e5cf440e521dd1"
+
+to-fast-properties@^1.0.3:
+ version "1.0.3"
+ resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-1.0.3.tgz#b83571fa4d8c25b82e231b06e3a3055de4ca1a47"
+
+to-object-path@^0.3.0:
+ version "0.3.0"
+ resolved "https://registry.yarnpkg.com/to-object-path/-/to-object-path-0.3.0.tgz#297588b7b0e7e0ac08e04e672f85c1f4999e17af"
+ dependencies:
+ kind-of "^3.0.2"
+
+to-regex-range@^2.1.0:
+ version "2.1.1"
+ resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-2.1.1.tgz#7c80c17b9dfebe599e27367e0d4dd5590141db38"
+ dependencies:
+ is-number "^3.0.0"
+ repeat-string "^1.6.1"
+
+to-regex@^3.0.1, to-regex@^3.0.2:
+ version "3.0.2"
+ resolved "https://registry.yarnpkg.com/to-regex/-/to-regex-3.0.2.tgz#13cfdd9b336552f30b51f33a8ae1b42a7a7599ce"
+ dependencies:
+ define-property "^2.0.2"
+ extend-shallow "^3.0.2"
+ regex-not "^1.0.2"
+ safe-regex "^1.1.0"
+
+topo@2.x.x:
+ version "2.0.2"
+ resolved "https://registry.yarnpkg.com/topo/-/topo-2.0.2.tgz#cd5615752539057c0dc0491a621c3bc6fbe1d182"
+ dependencies:
+ hoek "4.x.x"
+
+touch@^3.1.0:
+ version "3.1.0"
+ resolved "https://registry.yarnpkg.com/touch/-/touch-3.1.0.tgz#fe365f5f75ec9ed4e56825e0bb76d24ab74af83b"
+ dependencies:
+ nopt "~1.0.10"
+
+tough-cookie@>=2.3.3, tough-cookie@^2.3.4, tough-cookie@~2.4.3:
+ version "2.4.3"
+ resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.4.3.tgz#53f36da3f47783b0925afa06ff9f3b165280f781"
+ dependencies:
+ psl "^1.1.24"
+ punycode "^1.4.1"
+
+tr46@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/tr46/-/tr46-1.0.1.tgz#a8b13fd6bfd2489519674ccde55ba3693b706d09"
+ dependencies:
+ punycode "^2.1.0"
+
+trim-right@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/trim-right/-/trim-right-1.0.1.tgz#cb2e1203067e0c8de1f614094b9fe45704ea6003"
+
+tslib@^1.9.0:
+ version "1.9.3"
+ resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.9.3.tgz#d7e4dd79245d85428c4d7e4822a79917954ca286"
+
+tunnel-agent@^0.6.0:
+ version "0.6.0"
+ resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd"
+ dependencies:
+ safe-buffer "^5.0.1"
+
+tweetnacl@^0.14.3, tweetnacl@~0.14.0:
+ version "0.14.5"
+ resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64"
+
+type-check@~0.3.2:
+ version "0.3.2"
+ resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.3.2.tgz#5884cab512cf1d355e3fb784f30804b2b520db72"
+ dependencies:
+ prelude-ls "~1.1.2"
+
+type-is@~1.6.16:
+ version "1.6.16"
+ resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.16.tgz#f89ce341541c672b25ee7ae3c73dee3b2be50194"
+ dependencies:
+ media-typer "0.3.0"
+ mime-types "~2.1.18"
+
+uglify-js@^3.1.4:
+ version "3.4.9"
+ resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.4.9.tgz#af02f180c1207d76432e473ed24a28f4a782bae3"
+ dependencies:
+ commander "~2.17.1"
+ source-map "~0.6.1"
+
+unc-path-regex@^0.1.2:
+ version "0.1.2"
+ resolved "https://registry.yarnpkg.com/unc-path-regex/-/unc-path-regex-0.1.2.tgz#e73dd3d7b0d7c5ed86fbac6b0ae7d8c6a69d50fa"
+
+undefsafe@^2.0.2:
+ version "2.0.2"
+ resolved "https://registry.yarnpkg.com/undefsafe/-/undefsafe-2.0.2.tgz#225f6b9e0337663e0d8e7cfd686fc2836ccace76"
+ dependencies:
+ debug "^2.2.0"
+
+underscore@^1.7.0:
+ version "1.9.1"
+ resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.9.1.tgz#06dce34a0e68a7babc29b365b8e74b8925203961"
+
+union-value@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/union-value/-/union-value-1.0.0.tgz#5c71c34cb5bad5dcebe3ea0cd08207ba5aa1aea4"
+ dependencies:
+ arr-union "^3.1.0"
+ get-value "^2.0.6"
+ is-extendable "^0.1.1"
+ set-value "^0.4.3"
+
+unique-string@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/unique-string/-/unique-string-1.0.0.tgz#9e1057cca851abb93398f8b33ae187b99caec11a"
+ dependencies:
+ crypto-random-string "^1.0.0"
+
+unpipe@1.0.0, unpipe@~1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"
+
+unset-value@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/unset-value/-/unset-value-1.0.0.tgz#8376873f7d2335179ffb1e6fc3a8ed0dfc8ab559"
+ dependencies:
+ has-value "^0.3.1"
+ isobject "^3.0.0"
+
+unzip-response@^2.0.1:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/unzip-response/-/unzip-response-2.0.1.tgz#d2f0f737d16b0615e72a6935ed04214572d56f97"
+
+upath@^1.0.5:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/upath/-/upath-1.1.0.tgz#35256597e46a581db4793d0ce47fa9aebfc9fabd"
+
+update-notifier@^2.3.0:
+ version "2.5.0"
+ resolved "https://registry.yarnpkg.com/update-notifier/-/update-notifier-2.5.0.tgz#d0744593e13f161e406acb1d9408b72cad08aff6"
+ dependencies:
+ boxen "^1.2.1"
+ chalk "^2.0.1"
+ configstore "^3.0.0"
+ import-lazy "^2.1.0"
+ is-ci "^1.0.10"
+ is-installed-globally "^0.1.0"
+ is-npm "^1.0.0"
+ latest-version "^3.0.0"
+ semver-diff "^2.0.0"
+ xdg-basedir "^3.0.0"
+
+uri-js@^4.2.2:
+ version "4.2.2"
+ resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.2.2.tgz#94c540e1ff772956e2299507c010aea6c8838eb0"
+ dependencies:
+ punycode "^2.1.0"
+
+urix@^0.1.0:
+ version "0.1.0"
+ resolved "https://registry.yarnpkg.com/urix/-/urix-0.1.0.tgz#da937f7a62e21fec1fd18d49b35c2935067a6c72"
+
+url-parse-lax@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/url-parse-lax/-/url-parse-lax-1.0.0.tgz#7af8f303645e9bd79a272e7a14ac68bc0609da73"
+ dependencies:
+ prepend-http "^1.0.1"
+
+use@^3.1.0:
+ version "3.1.1"
+ resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f"
+
+util-deprecate@~1.0.1:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
+
+util.promisify@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/util.promisify/-/util.promisify-1.0.0.tgz#440f7165a459c9a16dc145eb8e72f35687097030"
+ dependencies:
+ define-properties "^1.1.2"
+ object.getownpropertydescriptors "^2.0.3"
+
+utils-merge@1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
+
+uuid@^3.0.1, uuid@^3.1.0, uuid@^3.3.2:
+ version "3.3.2"
+ resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.2.tgz#1b4af4955eb3077c501c23872fc6513811587131"
+
+v8flags@^3.1.1:
+ version "3.1.1"
+ resolved "https://registry.yarnpkg.com/v8flags/-/v8flags-3.1.1.tgz#42259a1461c08397e37fe1d4f1cfb59cad85a053"
+ dependencies:
+ homedir-polyfill "^1.0.1"
+
+validate-npm-package-license@^3.0.1:
+ version "3.0.4"
+ resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a"
+ dependencies:
+ spdx-correct "^3.0.0"
+ spdx-expression-parse "^3.0.0"
+
+vary@^1, vary@~1.1.2:
+ version "1.1.2"
+ resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"
+
+verror@1.10.0:
+ version "1.10.0"
+ resolved "https://registry.yarnpkg.com/verror/-/verror-1.10.0.tgz#3a105ca17053af55d6e270c1f8288682e18da400"
+ dependencies:
+ assert-plus "^1.0.0"
+ core-util-is "1.0.2"
+ extsprintf "^1.2.0"
+
+w3c-hr-time@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/w3c-hr-time/-/w3c-hr-time-1.0.1.tgz#82ac2bff63d950ea9e3189a58a65625fedf19045"
+ dependencies:
+ browser-process-hrtime "^0.1.2"
+
+wait-for-postgres@^1.5.3:
+ version "1.5.3"
+ resolved "https://registry.yarnpkg.com/wait-for-postgres/-/wait-for-postgres-1.5.3.tgz#9945e43126ccb9584db170327c9d98844880442d"
+ dependencies:
+ bluebird "^3.5.0"
+ commander "^2.9.0"
+ durations "^3.0.0"
+ joi "^10.6.0"
+ pg "^6.1.2"
+ q "^1.4.1"
+
+walker@~1.0.5:
+ version "1.0.7"
+ resolved "https://registry.yarnpkg.com/walker/-/walker-1.0.7.tgz#2f7f9b8fd10d677262b18a884e28d19618e028fb"
+ dependencies:
+ makeerror "1.0.x"
+
+watch@~0.18.0:
+ version "0.18.0"
+ resolved "https://registry.yarnpkg.com/watch/-/watch-0.18.0.tgz#28095476c6df7c90c963138990c0a5423eb4b986"
+ dependencies:
+ exec-sh "^0.2.0"
+ minimist "^1.2.0"
+
+webidl-conversions@^4.0.2:
+ version "4.0.2"
+ resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz#a855980b1f0b6b359ba1d5d9fb39ae941faa63ad"
+
+whatwg-encoding@^1.0.1, whatwg-encoding@^1.0.3:
+ version "1.0.5"
+ resolved "https://registry.yarnpkg.com/whatwg-encoding/-/whatwg-encoding-1.0.5.tgz#5abacf777c32166a51d085d6b4f3e7d27113ddb0"
+ dependencies:
+ iconv-lite "0.4.24"
+
+whatwg-mimetype@^2.1.0:
+ version "2.2.0"
+ resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-2.2.0.tgz#a3d58ef10b76009b042d03e25591ece89b88d171"
+
+whatwg-url@^6.4.1:
+ version "6.5.0"
+ resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-6.5.0.tgz#f2df02bff176fd65070df74ad5ccbb5a199965a8"
+ dependencies:
+ lodash.sortby "^4.7.0"
+ tr46 "^1.0.1"
+ webidl-conversions "^4.0.2"
+
+whatwg-url@^7.0.0:
+ version "7.0.0"
+ resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-7.0.0.tgz#fde926fa54a599f3adf82dff25a9f7be02dc6edd"
+ dependencies:
+ lodash.sortby "^4.7.0"
+ tr46 "^1.0.1"
+ webidl-conversions "^4.0.2"
+
+which-module@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a"
+
+which@^1.2.10, which@^1.2.12, which@^1.2.14, which@^1.2.9, which@^1.3.0:
+ version "1.3.1"
+ resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a"
+ dependencies:
+ isexe "^2.0.0"
+
+wide-align@^1.1.0:
+ version "1.1.3"
+ resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.3.tgz#ae074e6bdc0c14a431e804e624549c633b000457"
+ dependencies:
+ string-width "^1.0.2 || 2"
+
+widest-line@^2.0.0:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/widest-line/-/widest-line-2.0.1.tgz#7438764730ec7ef4381ce4df82fb98a53142a3fc"
+ dependencies:
+ string-width "^2.1.1"
+
+wordwrap@~0.0.2:
+ version "0.0.3"
+ resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-0.0.3.tgz#a3d5da6cd5c0bc0008d37234bbaf1bed63059107"
+
+wordwrap@~1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb"
+
+wrap-ansi@^2.0.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-2.1.0.tgz#d8fc3d284dd05794fe84973caecdd1cf824fdd85"
+ dependencies:
+ string-width "^1.0.1"
+ strip-ansi "^3.0.1"
+
+wrappy@1:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
+
+write-file-atomic@^2.0.0, write-file-atomic@^2.1.0:
+ version "2.3.0"
+ resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-2.3.0.tgz#1ff61575c2e2a4e8e510d6fa4e243cce183999ab"
+ dependencies:
+ graceful-fs "^4.1.11"
+ imurmurhash "^0.1.4"
+ signal-exit "^3.0.2"
+
+write@^0.2.1:
+ version "0.2.1"
+ resolved "https://registry.yarnpkg.com/write/-/write-0.2.1.tgz#5fc03828e264cea3fe91455476f7a3c566cb0757"
+ dependencies:
+ mkdirp "^0.5.1"
+
+ws@^5.2.0:
+ version "5.2.2"
+ resolved "https://registry.yarnpkg.com/ws/-/ws-5.2.2.tgz#dffef14866b8e8dc9133582514d1befaf96e980f"
+ dependencies:
+ async-limiter "~1.0.0"
+
+xdg-basedir@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-3.0.0.tgz#496b2cc109eca8dbacfe2dc72b603c17c5870ad4"
+
+xml-name-validator@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-3.0.0.tgz#6ae73e06de4d8c6e47f9fb181f78d648ad457c6a"
+
+xtend@^4.0.0:
+ version "4.0.1"
+ resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.1.tgz#a5c6d532be656e23db820efb943a1f04998d63af"
+
+y18n@^3.2.1:
+ version "3.2.1"
+ resolved "https://registry.yarnpkg.com/y18n/-/y18n-3.2.1.tgz#6d15fba884c08679c0d77e88e7759e811e07fa41"
+
+yallist@^2.1.2:
+ version "2.1.2"
+ resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52"
+
+yallist@^3.0.0, yallist@^3.0.2:
+ version "3.0.2"
+ resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.0.2.tgz#8452b4bb7e83c7c188d8041c1a837c773d6d8bb9"
+
+yargs-parser@^9.0.2:
+ version "9.0.2"
+ resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-9.0.2.tgz#9ccf6a43460fe4ed40a9bb68f48d43b8a68cc077"
+ dependencies:
+ camelcase "^4.1.0"
+
+yargs@^11.0.0:
+ version "11.1.0"
+ resolved "https://registry.yarnpkg.com/yargs/-/yargs-11.1.0.tgz#90b869934ed6e871115ea2ff58b03f4724ed2d77"
+ dependencies:
+ cliui "^4.0.0"
+ decamelize "^1.1.1"
+ find-up "^2.1.0"
+ get-caller-file "^1.0.1"
+ os-locale "^2.0.0"
+ require-directory "^2.1.1"
+ require-main-filename "^1.0.1"
+ set-blocking "^2.0.0"
+ string-width "^2.0.0"
+ which-module "^2.0.0"
+ y18n "^3.2.1"
+ yargs-parser "^9.0.2"
+
+zen-observable-ts@^0.8.10:
+ version "0.8.10"
+ resolved "https://registry.yarnpkg.com/zen-observable-ts/-/zen-observable-ts-0.8.10.tgz#18e2ce1c89fe026e9621fd83cc05168228fce829"
+ dependencies:
+ zen-observable "^0.8.0"
+
+zen-observable@^0.8.0:
+ version "0.8.9"
+ resolved "https://registry.yarnpkg.com/zen-observable/-/zen-observable-0.8.9.tgz#0475c760ff0eda046bbdfa4dc3f95d392807ac53"
diff --git a/eq-author-graphql-schema/.github/ISSUE_TEMPLATE.md b/eq-author-graphql-schema/.github/ISSUE_TEMPLATE.md
new file mode 100644
index 0000000000..ad4bd0111c
--- /dev/null
+++ b/eq-author-graphql-schema/.github/ISSUE_TEMPLATE.md
@@ -0,0 +1,9 @@
+### Expected behaviour
+
+### Actual behaviour
+
+### Steps to reproduce the behaviour
+
+### Technical information
+
+### Screenshot
diff --git a/eq-author-graphql-schema/.github/PULL_REQUEST_TEMPLATE.md b/eq-author-graphql-schema/.github/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 0000000000..d76e63e17a
--- /dev/null
+++ b/eq-author-graphql-schema/.github/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,5 @@
+### What is the context of this PR?
+Describe what you have changed and why, link to other PRs or Issues as appropriate.
+
+### How to review
+Describe the steps required to test the changes (include screenshots if appropriate).
diff --git a/eq-author-graphql-schema/.nvmrc b/eq-author-graphql-schema/.nvmrc
new file mode 100644
index 0000000000..f599e28b8a
--- /dev/null
+++ b/eq-author-graphql-schema/.nvmrc
@@ -0,0 +1 @@
+10
diff --git a/eq-author-graphql-schema/.travis.yml b/eq-author-graphql-schema/.travis.yml
new file mode 100644
index 0000000000..b906f5d72e
--- /dev/null
+++ b/eq-author-graphql-schema/.travis.yml
@@ -0,0 +1,16 @@
+language: node_js
+node_js:
+ - "10"
+
+cache:
+ yarn: true
+ directories:
+ - "node_modules"
+
+script:
+ - yarn test
+
+branches:
+ only:
+ - master
+ - /^greenkeeper/.*$/
diff --git a/eq-author-graphql-schema/README.md b/eq-author-graphql-schema/README.md
new file mode 100644
index 0000000000..8d2339b52a
--- /dev/null
+++ b/eq-author-graphql-schema/README.md
@@ -0,0 +1,20 @@
+# eq-author-graphql-schema
+
+[](https://greenkeeper.io/)
+
+GraphQL type definitions and schema for [eq-author](https://github.com/ONSdigital/eq-author).
+
+## Publishing
+
+1. Create new branch
+2. Make any changes
+3. Commit changes
+4. Run `npm version [minor|patch|major]`. This will:
+ 1. Update the package.json version
+ 2. Commit changes to package.json
+ 3. Create a git tag
+ 4. Push changes
+5. Create PR
+6. Merge PR
+7. Pull master
+8. Run `npm publish` (ensure you have the correct credentials)
\ No newline at end of file
diff --git a/eq-author-graphql-schema/index.js b/eq-author-graphql-schema/index.js
new file mode 100644
index 0000000000..6f04f56945
--- /dev/null
+++ b/eq-author-graphql-schema/index.js
@@ -0,0 +1,767 @@
+module.exports = `
+
+scalar Date
+
+scalar JSON
+
+directive @deprecated(reason: String) on INPUT_FIELD_DEFINITION | ARGUMENT_DEFINITION |ENUM_VALUE | FIELD_DEFINITION
+
+type User {
+ name: String!
+}
+
+type QuestionnaireInfo {
+ totalSectionCount: Int!
+}
+
+type Questionnaire {
+ id: ID!
+ title: String
+ description: String
+ theme: Theme
+ legalBasis: LegalBasis
+ navigation: Boolean
+ surveyId: String
+ createdAt: Date
+ createdBy: User!
+ sections: [Section]
+ summary: Boolean
+ questionnaireInfo: QuestionnaireInfo
+ metadata: [Metadata!]!
+}
+
+type Section {
+ id: ID!
+ title: String!
+ alias: String
+ displayName: String!
+ pages: [Page]
+ questionnaire: Questionnaire
+ position: Int!
+ introductionTitle: String
+ introductionContent: String
+ introductionEnabled: Boolean!
+}
+
+interface Page {
+ id: ID!
+ title: String!
+ alias: String
+ displayName: String!
+ description: String
+ pageType: PageType!
+ section: Section
+ position: Int!
+}
+
+type QuestionPage implements Page {
+ id: ID!
+ title: String!
+ alias: String
+ displayName: String!
+ description: String!
+ guidance: String
+ pageType: PageType!
+ answers: [Answer]
+ section: Section
+ position: Int!
+ routingRuleSet: RoutingRuleSet
+}
+
+interface Answer {
+ id: ID!
+ displayName: String!
+ description: String
+ guidance: String
+ qCode: String
+ label: String
+ type: AnswerType!
+ page: QuestionPage
+ properties: JSON
+}
+
+type BasicAnswer implements Answer {
+ id: ID!
+ displayName: String!
+ description: String
+ guidance: String
+ qCode: String
+ label: String
+ secondaryLabel: String
+ type: AnswerType!
+ page: QuestionPage
+ properties: JSON
+ validation: ValidationType
+}
+
+type MultipleChoiceAnswer implements Answer {
+ id: ID!
+ displayName: String!
+ description: String
+ guidance: String
+ qCode: String
+ label: String
+ type: AnswerType!
+ options: [Option]
+ other: OptionWithAnswer
+ mutuallyExclusiveOption: Option
+ page: QuestionPage
+ properties: JSON
+}
+
+type CompositeAnswer implements Answer {
+ id: ID!
+ displayName: String!
+ description: String
+ guidance: String
+ qCode: String
+ label: String
+ type: AnswerType!
+ page: QuestionPage
+ childAnswers: [BasicAnswer]!
+ properties: JSON
+}
+
+type Option {
+ id: ID!
+ displayName: String!
+ label: String
+ description: String
+ value: String
+ qCode: String
+ answer: Answer
+}
+
+type OptionWithAnswer {
+ option: Option!
+ answer: BasicAnswer!
+}
+
+type RoutingRuleSet {
+ id: ID!
+ routingRules: [RoutingRule]
+ questionPage: QuestionPage
+ else: RoutingDestination
+}
+
+type RoutingRule {
+ id: ID!
+ operation: RoutingOperation
+ conditions: [RoutingCondition]
+ goto: RoutingDestination
+}
+
+enum LogicalDestinations {
+ NextPage
+ EndOfQuestionnaire
+}
+
+enum AbsoluteDestinationTypes {
+ Section
+ QuestionPage
+}
+
+union AbsoluteDestinations = QuestionPage | Section
+
+type AbsoluteDestination {
+ absoluteDestination: AbsoluteDestinations!
+}
+
+type LogicalDestination {
+ id: ID!
+ logicalDestination: LogicalDestinations!
+}
+
+union RoutingDestination = AbsoluteDestination | LogicalDestination
+
+type AvailableRoutingDestinations {
+ logicalDestinations: [LogicalDestination]!
+ questionPages: [QuestionPage]!
+ sections: [Section]!
+}
+
+type RoutingCondition {
+ id: ID!
+ comparator: RoutingComparator
+ questionPage: QuestionPage
+ answer: Answer
+ routingValue: RoutingConditionValue
+}
+
+type IDArrayValue {
+ value: [ID]
+}
+
+type NumberValue {
+ id: ID!
+ numberValue: Int
+}
+
+union RoutingConditionValue = IDArrayValue | NumberValue
+
+union ValidationType = NumberValidation | DateValidation
+
+enum ValidationRuleEntityType {
+ Custom
+ PreviousAnswer
+ Metadata
+ Now
+}
+
+type NumberValidation {
+ minValue: MinValueValidationRule!
+ maxValue: MaxValueValidationRule!
+}
+
+type DateValidation {
+ earliestDate: EarliestDateValidationRule!
+ latestDate: LatestDateValidationRule!
+}
+
+interface ValidationRule {
+ id: ID!
+ enabled: Boolean!
+}
+
+type MinValueValidationRule implements ValidationRule {
+ id: ID!
+ enabled: Boolean!
+ inclusive: Boolean!
+ custom: Int
+}
+
+type MaxValueValidationRule implements ValidationRule {
+ id: ID!
+ enabled: Boolean!
+ inclusive: Boolean!
+ custom: Int
+ previousAnswer: BasicAnswer
+ entityType: ValidationRuleEntityType
+}
+
+type EarliestDateValidationRule implements ValidationRule {
+ id: ID!
+ enabled: Boolean!
+ offset: Duration!
+ relativePosition: RelativePosition!
+ custom: Date
+ previousAnswer: BasicAnswer
+ metadata: Metadata
+ entityType: ValidationRuleEntityType
+}
+
+type LatestDateValidationRule implements ValidationRule {
+ id: ID!
+ enabled: Boolean!
+ offset: Duration!
+ relativePosition: RelativePosition!
+ custom: Date
+ previousAnswer: BasicAnswer
+ metadata: Metadata
+ entityType: ValidationRuleEntityType
+}
+
+type Duration {
+ value: Int
+ unit: DurationUnit!
+}
+
+enum DurationUnit {
+ Days
+ Months
+ Years
+}
+
+enum RelativePosition {
+ Before
+ After
+}
+
+enum RoutingOperation {
+ And
+ Or
+}
+
+enum RoutingComparator {
+ Equal
+ NotEqual
+ GreaterThan
+ LessThan
+ GreaterOrEqual
+ LessOrEqual
+}
+
+enum PageType {
+ QuestionPage
+ InterstitialPage
+}
+
+enum AnswerType {
+ Checkbox
+ Currency
+ Date
+ DateRange
+ MonthYearDate
+ Number
+ Percentage
+ Radio
+ TextArea
+ TextField
+ Relationship
+}
+
+enum LegalBasis {
+ Voluntary
+ StatisticsOfTradeAct
+}
+
+enum Theme {
+ default
+ census
+}
+
+type Metadata {
+ id: ID!
+ key: String
+ alias: String
+ type: MetadataType!
+ dateValue: Date
+ regionValue: Region
+ languageValue: Language
+ textValue: String
+ displayName: String!
+}
+
+enum MetadataType {
+ Date
+ Text
+ Region
+ Language
+}
+
+enum Region {
+ GB_ENG
+ GB_GBN
+ GB_NIR
+ GB_SCT
+ GB_WLS
+}
+
+enum Language {
+ en
+ cy
+}
+
+type Query {
+ questionnaires: [Questionnaire]
+ questionnaire(id: ID!): Questionnaire
+ section(id: ID!): Section
+ page(id: ID!): Page
+ questionPage(id: ID!): QuestionPage
+ answer(id: ID!): Answer
+ answers(ids: [ID]!): [Answer]
+ option(id: ID!): Option
+ pagesAffectedByDeletion(pageId: ID!): [Page]!
+ availableRoutingDestinations(pageId: ID!): AvailableRoutingDestinations!
+}
+
+type Mutation {
+ createQuestionnaire(input: CreateQuestionnaireInput!): Questionnaire
+ updateQuestionnaire(input: UpdateQuestionnaireInput!): Questionnaire
+ deleteQuestionnaire(input: DeleteQuestionnaireInput!): Questionnaire
+ undeleteQuestionnaire(input: UndeleteQuestionnaireInput!): Questionnaire
+ duplicateQuestionnaire(input: DuplicateQuestionnaireInput!): Questionnaire
+ createSection(input: CreateSectionInput!): Section
+ updateSection(input: UpdateSectionInput!): Section
+ deleteSection(input: DeleteSectionInput!): Section
+ undeleteSection(input: UndeleteSectionInput!): Section
+ moveSection(input: MoveSectionInput!): Section
+ duplicateSection(input: DuplicateSectionInput!): Section
+ createPage(input: CreatePageInput!): Page
+ updatePage(input: UpdatePageInput!): Page
+ deletePage(input: DeletePageInput!): Page
+ undeletePage(input: UndeletePageInput!): Page
+ movePage(input: MovePageInput!): Page
+ duplicatePage(input: DuplicatePageInput!): Page
+ createQuestionPage(input: CreateQuestionPageInput!): QuestionPage
+ updateQuestionPage(input: UpdateQuestionPageInput!): QuestionPage
+ deleteQuestionPage(input: DeleteQuestionPageInput!): QuestionPage
+ undeleteQuestionPage(input: UndeleteQuestionPageInput!): QuestionPage
+ createAnswer(input: CreateAnswerInput!): Answer
+ updateAnswer(input: UpdateAnswerInput!): Answer
+ deleteAnswer(input: DeleteAnswerInput!): Answer
+ undeleteAnswer(input: UndeleteAnswerInput!): Answer
+ createOption(input: CreateOptionInput!): Option
+ createMutuallyExclusiveOption(input: CreateMutuallyExclusiveOptionInput!): Option
+ updateOption(input: UpdateOptionInput!): Option
+ deleteOption(input: DeleteOptionInput!): Option
+ undeleteOption(input: UndeleteOptionInput!): Option
+ createOther(input: CreateOtherInput!): OptionWithAnswer
+ deleteOther(input: DeleteOtherInput!): OptionWithAnswer
+ createRoutingRuleSet(input: CreateRoutingRuleSetInput!): RoutingRuleSet
+ updateRoutingRuleSet(input: UpdateRoutingRuleSetInput!): RoutingRuleSet
+ deleteRoutingRuleSet(input: DeleteRoutingRuleSetInput!): RoutingRuleSet
+ resetRoutingRuleSetElse(input: ResetRoutingRuleSetElseInput!): RoutingRuleSet
+ createRoutingRule(input: CreateRoutingRuleInput!): RoutingRule
+ updateRoutingRule(input: UpdateRoutingRuleInput!): RoutingRule
+ deleteRoutingRule(input: DeleteRoutingRuleInput!): RoutingRule
+ undeleteRoutingRule(input: UndeleteRoutingRuleInput!): RoutingRule
+ createRoutingCondition(input: CreateRoutingConditionInput!): RoutingCondition
+ updateRoutingCondition(input: UpdateRoutingConditionInput!): RoutingCondition
+ deleteRoutingCondition(input: DeleteRoutingConditionInput!): RoutingCondition
+ toggleConditionOption(input: ToggleConditionOptionInput!): RoutingConditionValue
+ createConditionValue(input: CreateConditionValueInput!): RoutingConditionValue
+ updateConditionValue(input: UpdateConditionValueInput!): RoutingConditionValue
+ toggleValidationRule(input: ToggleValidationRuleInput!): ValidationRule!
+ updateValidationRule(input: UpdateValidationRuleInput!): ValidationRule!
+ createMetadata(input: CreateMetadataInput!): Metadata!
+ updateMetadata(input: UpdateMetadataInput!): Metadata!
+ deleteMetadata(input: DeleteMetadataInput!): Metadata!
+}
+
+input CreateQuestionnaireInput {
+ title: String!
+ description: String
+ theme: String!
+ legalBasis: LegalBasis!
+ navigation: Boolean
+ surveyId: String!
+ summary: Boolean
+ createdBy: String
+}
+
+input UpdateQuestionnaireInput {
+ id: ID!
+ title: String
+ description: String
+ theme: String
+ legalBasis: LegalBasis
+ navigation: Boolean
+ surveyId: String
+ summary: Boolean
+}
+
+input DeleteQuestionnaireInput {
+ id: ID!
+}
+
+input UndeleteQuestionnaireInput {
+ id: ID!
+}
+
+input DuplicateQuestionnaireInput {
+ id: ID!
+ createdBy: String!
+}
+
+input CreateSectionInput {
+ title: String!
+ alias: String
+ questionnaireId: ID!
+ position: Int
+}
+
+input UpdateSectionInput {
+ id: ID!
+ title: String
+ alias: String
+ introductionTitle: String
+ introductionContent: String
+ introductionEnabled: Boolean
+}
+
+input DeleteSectionInput {
+ id: ID!
+}
+
+input UndeleteSectionInput {
+ id: ID!
+}
+
+input DuplicateSectionInput {
+ id: ID!
+ position: Int!
+}
+
+input CreatePageInput {
+ title: String!
+ description: String
+ sectionId: ID!
+ position: Int
+}
+
+input UpdatePageInput {
+ id: ID!
+ title: String!
+ description: String
+}
+
+input DeletePageInput {
+ id: ID!
+}
+
+input UndeletePageInput {
+ id: ID!
+}
+
+input DuplicatePageInput {
+ id: ID!
+ position: Int!
+}
+
+input CreateQuestionPageInput {
+ title: String!
+ alias: String
+ description: String
+ guidance: String
+ sectionId: ID!
+ position: Int
+}
+
+input UpdateQuestionPageInput {
+ id: ID!
+ alias: String
+ title: String
+ description: String
+ guidance: String
+}
+
+input DeleteQuestionPageInput {
+ id: ID!
+}
+
+input UndeleteQuestionPageInput {
+ id: ID!
+}
+
+input CreateAnswerInput {
+ description: String
+ guidance: String
+ label: String
+ secondaryLabel: String
+ qCode: String
+ type: AnswerType!
+ questionPageId: ID!
+}
+
+input UpdateAnswerInput {
+ id: ID!
+ description: String
+ guidance: String
+ label: String
+ secondaryLabel: String
+ qCode: String
+ type: AnswerType
+ properties: JSON
+}
+
+input DeleteAnswerInput {
+ id: ID!
+}
+
+input UndeleteAnswerInput {
+ id: ID!
+}
+
+input CreateOptionInput {
+ label: String
+ description: String
+ value: String
+ qCode: String
+ answerId: ID!
+}
+
+input CreateMutuallyExclusiveOptionInput {
+ label: String
+ description: String
+ value: String
+ qCode: String
+ answerId: ID!
+}
+
+input UpdateOptionInput {
+ id: ID!
+ label: String
+ description: String
+ value: String
+ qCode: String
+}
+
+input DeleteOptionInput {
+ id: ID!
+}
+
+input UndeleteOptionInput {
+ id: ID!
+}
+
+input MovePageInput {
+ id: ID!
+ sectionId: ID!
+ position: Int!
+}
+
+input MoveSectionInput {
+ id: ID!
+ questionnaireId: ID!
+ position: Int!
+}
+
+input CreateOtherInput {
+ parentAnswerId: ID!
+}
+
+input DeleteOtherInput {
+ parentAnswerId: ID!
+}
+
+input CreateRoutingRuleSetInput {
+ questionPageId: ID!
+}
+
+input UpdateRoutingRuleSetInput {
+ id: ID!
+ else: RoutingDestinationInput!
+}
+
+input DeleteRoutingRuleSetInput {
+ id: ID!
+}
+
+input ResetRoutingRuleSetElseInput {
+ id: ID!
+}
+
+input CreateRoutingRuleInput {
+ operation: RoutingOperation!
+ routingRuleSetId: ID!
+}
+
+input UpdateRoutingRuleInput {
+ id: ID!
+ operation: RoutingOperation
+ goto: RoutingDestinationInput
+}
+
+input DeleteRoutingRuleInput {
+ id: ID!
+}
+
+input UndeleteRoutingRuleInput {
+ id: ID!
+}
+
+input CreateRoutingConditionInput {
+ comparator: RoutingComparator!
+ questionPageId: ID!
+ answerId: ID
+ routingRuleId: ID!
+}
+
+input UpdateRoutingConditionInput {
+ id: ID!
+ comparator: RoutingComparator
+ questionPageId: ID!
+ answerId: ID
+}
+
+input DeleteRoutingConditionInput {
+ id: ID!
+}
+
+input ToggleConditionOptionInput {
+ optionId: ID
+ conditionId: ID!
+ checked: Boolean!
+}
+
+input CreateConditionValueInput {
+ conditionId: ID!
+}
+
+input UpdateConditionValueInput {
+ id: ID!
+ customNumber: Int
+}
+
+input LogicalDestinationInput {
+ destinationType: LogicalDestinations!
+}
+
+input AbsoluteDestinationInput {
+ destinationType: AbsoluteDestinationTypes!
+ destinationId: ID!
+}
+
+input RoutingDestinationInput {
+ logicalDestination: LogicalDestinationInput
+ absoluteDestination: AbsoluteDestinationInput
+}
+
+input ToggleValidationRuleInput {
+ id: ID!
+ enabled: Boolean!
+}
+
+input UpdateValidationRuleInput {
+ id: ID!
+ minValueInput: UpdateMinValueInput
+ maxValueInput: UpdateMaxValueInput
+ earliestDateInput: UpdateEarliestDateInput
+ latestDateInput: UpdateLatestDateInput
+}
+
+input UpdateMinValueInput {
+ inclusive: Boolean!
+ custom: Int
+}
+
+input UpdateMaxValueInput {
+ inclusive: Boolean!
+ custom: Int
+ entityType: ValidationRuleEntityType
+ previousAnswer: ID
+}
+
+input UpdateEarliestDateInput {
+ offset: DurationInput!
+ relativePosition: RelativePosition!
+ entityType: ValidationRuleEntityType
+ custom: Date
+ previousAnswer: ID
+ metadata: ID
+}
+
+input UpdateLatestDateInput {
+ offset: DurationInput!
+ relativePosition: RelativePosition!
+ entityType: ValidationRuleEntityType
+ custom: Date
+ previousAnswer: ID
+ metadata: ID
+}
+
+input DurationInput {
+ value: Int
+ unit: DurationUnit!
+}
+
+input CreateMetadataInput {
+ questionnaireId: ID!
+}
+
+input DeleteMetadataInput {
+ id: ID!
+}
+
+input UpdateMetadataInput {
+ id: ID!
+ key: String
+ alias: String
+ type: MetadataType!
+ dateValue: Date
+ regionValue: Region
+ languageValue: Language
+ textValue: String
+}
+`;
diff --git a/eq-author-graphql-schema/package.json b/eq-author-graphql-schema/package.json
new file mode 100644
index 0000000000..632e41d463
--- /dev/null
+++ b/eq-author-graphql-schema/package.json
@@ -0,0 +1,24 @@
+{
+ "name": "eq-author-graphql-schema",
+ "version": "0.40.0",
+ "files": [
+ "index.js",
+ "fragmentTypes.json"
+ ],
+ "description": "The GraphQL schema for the eq-author application.",
+ "main": "index.js",
+ "repository": "git@github.com:ONSdigital/eq-author-graphql-schema.git",
+ "author": "Samiwel Thomas ",
+ "license": "MIT",
+ "dependencies": {},
+ "scripts": {
+ "postversion": "git push origin head && git push --tags",
+ "test": "./scripts/build.js && yarn prepublishOnly ",
+ "prepublishOnly": "./scripts/generateIntrospectionFragmentMatcher.js"
+ },
+ "devDependencies": {
+ "chalk": "^2.3.0",
+ "graphql": "^14.0.2",
+ "graphql-tools": "^4.0.0"
+ }
+}
diff --git a/eq-author-graphql-schema/scripts/build.js b/eq-author-graphql-schema/scripts/build.js
new file mode 100755
index 0000000000..9b288f7f38
--- /dev/null
+++ b/eq-author-graphql-schema/scripts/build.js
@@ -0,0 +1,45 @@
+#!/usr/bin/env node
+
+const chalk = require("chalk");
+const schema = require("../");
+const { buildSchema } = require("graphql");
+const childProcess = require("child_process");
+const fs = require("fs");
+const findBreakingChanges = require("./findBreakingChanges");
+
+const getMasterSchema = () => {
+ childProcess.execSync("git show origin/master:index.js > ./scripts/temp.js");
+ const schema = require("./temp.js");
+ fs.unlinkSync("./scripts/temp.js");
+
+ return schema;
+};
+
+try {
+ buildSchema(schema);
+ console.log(chalk.green("Valid schema"));
+} catch (e) {
+ console.error(chalk.red("Invalid schema:"));
+ console.error(e.message);
+
+ process.exitCode = 1;
+ return;
+}
+
+const oldSchema = buildSchema(getMasterSchema());
+const newSchema = buildSchema(schema);
+const breakages = findBreakingChanges(oldSchema, newSchema);
+
+if (breakages.length === 0) {
+ console.log(chalk.green("Changes are backwards compatible"));
+} else {
+ console.error(chalk.red("Breaking changes found"));
+ console.error("Please deprecate these fields instead of removing:");
+
+ breakages.forEach(breakage => {
+ console.error(` ${breakage.type}: ${breakage.description}`);
+ });
+ console.log();
+
+ process.exitCode = 1;
+}
diff --git a/eq-author-graphql-schema/scripts/findBreakingChanges.js b/eq-author-graphql-schema/scripts/findBreakingChanges.js
new file mode 100644
index 0000000000..adfba621ad
--- /dev/null
+++ b/eq-author-graphql-schema/scripts/findBreakingChanges.js
@@ -0,0 +1,132 @@
+const {
+ buildSchema,
+ findBreakingChanges,
+ isObjectType,
+ isInterfaceType,
+ BreakingChangeType,
+ isInputObjectType,
+ isEnumType
+} = require("graphql");
+
+const tail = ([, ...rest]) => rest;
+const re = re => data => re.exec(data);
+const flow = (...fns) => arg => fns.reduce((acc, fn) => fn(acc), arg);
+const get = prop => obj => obj[prop];
+const startsWith = needle => haystack => haystack.indexOf(needle) === 0;
+
+const parseEnumBreakage = data => {
+ const stripFullStops = /\./g;
+ const words = data.replace(stripFullStops, "").split(" ");
+ return [words[0], words[words.length - 1]];
+};
+
+const isIntrospectionType = flow(
+ get("name"),
+ startsWith("__")
+);
+const parseBreakage = flow(
+ get("description"),
+ re(/^(.*?)\.(.*?) /),
+ tail
+);
+
+const findDeprecatedDirective = directives => {
+ if (directives.length === 0) {
+ return false;
+ } else {
+ const deprecatedDirectives = directives.filter(
+ directive => directive.name.value === "deprecated"
+ );
+ return deprecatedDirectives.length > 0 ? true : false;
+ }
+};
+
+const findDeprecatedFields = schema => {
+ const deprecatedFields = [];
+ const typeMap = schema.getTypeMap();
+
+ Object.keys(typeMap).forEach(typeName => {
+ const type = typeMap[typeName];
+
+ if (
+ !(
+ isObjectType(type) ||
+ isInterfaceType(type) ||
+ isInputObjectType(type) ||
+ isEnumType(type)
+ ) ||
+ isIntrospectionType(type)
+ ) {
+ return;
+ }
+
+ if (isEnumType(type)) {
+ const values = type.getValues();
+ values.map(value => {
+ if (value.isDeprecated) {
+ deprecatedFields.push({
+ field: value.name,
+ type: type.toString()
+ });
+ }
+ });
+ } else {
+ const fields = type.getFields();
+
+ Object.keys(fields).forEach(fieldName => {
+ const field = fields[fieldName];
+ if (
+ field.isDeprecated ||
+ findDeprecatedDirective(field.astNode.directives)
+ ) {
+ deprecatedFields.push({
+ field: fieldName,
+ type: typeName
+ });
+ }
+ if (field.hasOwnProperty("args") && field.args.length > 0) {
+ field.args.map(arg => {
+ if (findDeprecatedDirective(arg.astNode.directives)) {
+ deprecatedFields.push({
+ field: fieldName,
+ type: typeName
+ });
+ }
+ });
+ }
+ });
+ }
+ });
+ return deprecatedFields;
+};
+
+const filterOutDeprecatedFields = (breakages, deprecated) => {
+ return breakages.filter(breakage => {
+ if (
+ [
+ BreakingChangeType.FIELD_REMOVED,
+ BreakingChangeType.ARG_REMOVED,
+ BreakingChangeType.VALUE_REMOVED_FROM_ENUM
+ ].includes(breakage.type)
+ ) {
+ let type, field;
+ if (breakage.type === BreakingChangeType.VALUE_REMOVED_FROM_ENUM) {
+ [field, type] = parseEnumBreakage(get("description")(breakage));
+ } else {
+ [type, field] = parseBreakage(breakage);
+ }
+ const isDeprecatedField = deprecated.some(
+ x => x.type === type && x.field === field
+ );
+
+ return !isDeprecatedField;
+ }
+ return true;
+ });
+};
+
+module.exports = (oldSchema, newSchema) =>
+ filterOutDeprecatedFields(
+ findBreakingChanges(oldSchema, newSchema),
+ findDeprecatedFields(oldSchema)
+ );
diff --git a/eq-author-graphql-schema/scripts/generateIntrospectionFragmentMatcher.js b/eq-author-graphql-schema/scripts/generateIntrospectionFragmentMatcher.js
new file mode 100755
index 0000000000..c8b6fd1752
--- /dev/null
+++ b/eq-author-graphql-schema/scripts/generateIntrospectionFragmentMatcher.js
@@ -0,0 +1,60 @@
+#!/usr/bin/env node
+
+const { makeExecutableSchema } = require("graphql-tools");
+const { graphql } = require("graphql");
+const chalk = require("chalk");
+const schema = require("../");
+const fs = require("fs");
+
+const writeFile = (path, contents) => {
+ return new Promise((resolve, reject) => {
+ fs.writeFile(path, contents, function(err) {
+ if (err) {
+ reject(err);
+ } else {
+ resolve(contents);
+ }
+ });
+ });
+}
+
+const fragmentMatcherQuery = `
+ query Introspection {
+ __schema {
+ types {
+ kind
+ name
+ possibleTypes {
+ name
+ }
+ }
+ }
+ }
+`;
+
+const fetchIntrospectionFragmentMatcher = (typeDefs) => {
+
+ const schema = makeExecutableSchema({ typeDefs, resolverValidationOptions: {requireResolversForResolveType: false} });
+
+ return graphql(schema, fragmentMatcherQuery).then(result => {
+ result.data.__schema.types = result.data.__schema.types.filter(
+ type => type.possibleTypes !== null
+ );
+
+ return result.data;
+ });
+}
+
+const generateIntrospectionFragmentMatcher = (schema, outPath) => {
+ return fetchIntrospectionFragmentMatcher(schema).then(result =>
+ writeFile(outPath, JSON.stringify(result))
+ )
+}
+
+const fileName = "./fragmentTypes.json"
+
+generateIntrospectionFragmentMatcher(schema, fileName).then(res => {
+ console.log(chalk.green("Fragment types file built at " + fileName));
+}).catch(e => {
+ console.error(chalk.red("Fragment types file build failed: "), e);
+})
diff --git a/eq-author-graphql-schema/yarn.lock b/eq-author-graphql-schema/yarn.lock
new file mode 100644
index 0000000000..3ed265073f
--- /dev/null
+++ b/eq-author-graphql-schema/yarn.lock
@@ -0,0 +1,106 @@
+# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
+# yarn lockfile v1
+
+
+ansi-styles@^3.1.0:
+ version "3.2.0"
+ resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.0.tgz#c159b8d5be0f9e5a6f346dab94f16ce022161b88"
+ integrity sha512-NnSOmMEYtVR2JVMIGTzynRkkaxtiq1xnFBcdQD/DnNCYPoEPsVJhM98BDyaoNOQIi7p4okdi3E27eN7GQbsUug==
+ dependencies:
+ color-convert "^1.9.0"
+
+apollo-link@^1.2.3:
+ version "1.2.3"
+ resolved "https://registry.yarnpkg.com/apollo-link/-/apollo-link-1.2.3.tgz#9bd8d5fe1d88d31dc91dae9ecc22474d451fb70d"
+ integrity sha512-iL9yS2OfxYhigme5bpTbmRyC+Htt6tyo2fRMHT3K1XRL/C5IQDDz37OjpPy4ndx7WInSvfSZaaOTKFja9VWqSw==
+ dependencies:
+ apollo-utilities "^1.0.0"
+ zen-observable-ts "^0.8.10"
+
+apollo-utilities@^1.0.0, apollo-utilities@^1.0.1:
+ version "1.0.11"
+ resolved "https://registry.yarnpkg.com/apollo-utilities/-/apollo-utilities-1.0.11.tgz#cd36bfa6e5c04eea2caf0c204a0f38a0ad550802"
+ integrity sha512-SAjRTqcYVHwpct+bcwX3x3zGEQOkNzj3Ri7Iy+vFIozxS8xtdkQqPiML7S6EI9Q2IuimQ7gvuYFHY0HQK0O1AA==
+
+chalk@^2.3.0:
+ version "2.3.0"
+ resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.3.0.tgz#b5ea48efc9c1793dccc9b4767c93914d3f2d52ba"
+ integrity sha512-Az5zJR2CBujap2rqXGaJKaPHyJ0IrUimvYNX+ncCy8PJP4ltOGTrHUIo097ZaL2zMeKYpiCdqDvS6zdrTFok3Q==
+ dependencies:
+ ansi-styles "^3.1.0"
+ escape-string-regexp "^1.0.5"
+ supports-color "^4.0.0"
+
+color-convert@^1.9.0:
+ version "1.9.1"
+ resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.1.tgz#c1261107aeb2f294ebffec9ed9ecad529a6097ed"
+ integrity sha512-mjGanIiwQJskCC18rPR6OmrZ6fm2Lc7PeGFYwCmy5J34wC6F1PzdGL6xeMfmgicfYcNLGuVFA3WzXtIDCQSZxQ==
+ dependencies:
+ color-name "^1.1.1"
+
+color-name@^1.1.1:
+ version "1.1.3"
+ resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25"
+ integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=
+
+deprecated-decorator@^0.1.6:
+ version "0.1.6"
+ resolved "https://registry.yarnpkg.com/deprecated-decorator/-/deprecated-decorator-0.1.6.tgz#00966317b7a12fe92f3cc831f7583af329b86c37"
+ integrity sha1-AJZjF7ehL+kvPMgx91g68ym4bDc=
+
+escape-string-regexp@^1.0.5:
+ version "1.0.5"
+ resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
+ integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=
+
+graphql-tools@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/graphql-tools/-/graphql-tools-4.0.0.tgz#6ea01937c6f947212f83567ba687e97c22fdd2a6"
+ integrity sha512-WokvjkanuZwY4BZBS3SlkDjrjCPu7WlCtLB2i9JiiXembVEkNos3Rl90zf7sJu72zSidGzTXU63iXRO2Fg3TtA==
+ dependencies:
+ apollo-link "^1.2.3"
+ apollo-utilities "^1.0.1"
+ deprecated-decorator "^0.1.6"
+ iterall "^1.1.3"
+ uuid "^3.1.0"
+
+graphql@^14.0.2:
+ version "14.0.2"
+ resolved "https://registry.yarnpkg.com/graphql/-/graphql-14.0.2.tgz#7dded337a4c3fd2d075692323384034b357f5650"
+ integrity sha512-gUC4YYsaiSJT1h40krG3J+USGlwhzNTXSb4IOZljn9ag5Tj+RkoXrWp+Kh7WyE3t1NCfab5kzCuxBIvOMERMXw==
+ dependencies:
+ iterall "^1.2.2"
+
+has-flag@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-2.0.0.tgz#e8207af1cc7b30d446cc70b734b5e8be18f88d51"
+ integrity sha1-6CB68cx7MNRGzHC3NLXovhj4jVE=
+
+iterall@^1.1.3, iterall@^1.2.2:
+ version "1.2.2"
+ resolved "https://registry.yarnpkg.com/iterall/-/iterall-1.2.2.tgz#92d70deb8028e0c39ff3164fdbf4d8b088130cd7"
+ integrity sha512-yynBb1g+RFUPY64fTrFv7nsjRrENBQJaX2UL+2Szc9REFrSNm1rpSXHGzhmAy7a9uv3vlvgBlXnf9RqmPH1/DA==
+
+supports-color@^4.0.0:
+ version "4.5.0"
+ resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-4.5.0.tgz#be7a0de484dec5c5cddf8b3d59125044912f635b"
+ integrity sha1-vnoN5ITexcXN34s9WRJQRJEvY1s=
+ dependencies:
+ has-flag "^2.0.0"
+
+uuid@^3.1.0:
+ version "3.2.1"
+ resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.2.1.tgz#12c528bb9d58d0b9265d9a2f6f0fe8be17ff1f14"
+ integrity sha512-jZnMwlb9Iku/O3smGWvZhauCf6cvvpKi4BKRiliS3cxnI+Gz9j5MEpTz2UFuXiKPJocb7gnsLHwiS05ige5BEA==
+
+zen-observable-ts@^0.8.10:
+ version "0.8.10"
+ resolved "https://registry.yarnpkg.com/zen-observable-ts/-/zen-observable-ts-0.8.10.tgz#18e2ce1c89fe026e9621fd83cc05168228fce829"
+ integrity sha512-5vqMtRggU/2GhePC9OU4sYEWOdvmayp2k3gjPf4F0mXwB3CSbbNznfDUvDJx9O2ZTa1EIXdJhPchQveFKwNXPQ==
+ dependencies:
+ zen-observable "^0.8.0"
+
+zen-observable@^0.8.0:
+ version "0.8.9"
+ resolved "https://registry.yarnpkg.com/zen-observable/-/zen-observable-0.8.9.tgz#0475c760ff0eda046bbdfa4dc3f95d392807ac53"
+ integrity sha512-Y9kPzjGvIZ5jchSlqlCpBW3I82zBBL4z+ulXDRVA1NwsKzjt5kwAi+gOYIy0htNkfuehGZZtP5mRXHRV6TjDWw==
diff --git a/eq-author/.babelrc b/eq-author/.babelrc
new file mode 100644
index 0000000000..cd0de600de
--- /dev/null
+++ b/eq-author/.babelrc
@@ -0,0 +1,25 @@
+{
+ "presets": [["@babel/preset-env", { "modules": false }], "@babel/preset-react"],
+ "plugins": [
+ "@babel/plugin-proposal-class-properties",
+ "@babel/plugin-proposal-object-rest-spread",
+ [
+ "babel-plugin-styled-components",
+ {
+ "ssr": true
+ }
+ ],
+ "babel-plugin-add-react-displayname",
+ "react-hot-loader/babel"
+ ],
+ "env": {
+ "test": {
+ "presets": [
+ ["@babel/preset-env", { "targets": { "node": "current" }}]
+ ],
+ "plugins": [
+ "babel-plugin-require-context-hook"
+ ]
+ }
+ }
+}
diff --git a/eq-author/.codecov.yml b/eq-author/.codecov.yml
new file mode 100644
index 0000000000..f847ecbce8
--- /dev/null
+++ b/eq-author/.codecov.yml
@@ -0,0 +1,17 @@
+coverage:
+ status:
+ project:
+ default:
+ target: 90%
+ threshold: 0.25%
+ base: auto
+
+comment: off
+
+ignore:
+ - "**/*story.js"
+ - "features"
+ - "scripts/*.js"
+ - "src/graphql/*.js"
+ - "tests"
+ - ".eslintrc.js"
diff --git a/eq-author/.dockerignore b/eq-author/.dockerignore
new file mode 100644
index 0000000000..39f56d3674
--- /dev/null
+++ b/eq-author/.dockerignore
@@ -0,0 +1,10 @@
+.git
+Dockerfile
+
+.github
+.storybook
+tests
+cypress
+coverage
+
+node_modules
\ No newline at end of file
diff --git a/eq-author/.env b/eq-author/.env
new file mode 100644
index 0000000000..618f99187f
--- /dev/null
+++ b/eq-author/.env
@@ -0,0 +1,3 @@
+NODE_PATH=src/
+REACT_APP_USE_FULLSTORY="false"
+REACT_APP_USE_SENTRY="false"
diff --git a/eq-author/.env.development b/eq-author/.env.development
new file mode 100644
index 0000000000..9bad9e9cb4
--- /dev/null
+++ b/eq-author/.env.development
@@ -0,0 +1,10 @@
+REACT_APP_BASE_NAME=""
+REACT_APP_API_URL="http://localhost:4000/graphql"
+REACT_APP_LAUNCH_URL="http://localhost:4000/launch"
+REACT_APP_USE_FULLSTORY="false"
+REACT_APP_USE_SENTRY="false"
+
+REACT_APP_ENABLE_AUTH="true"
+REACT_APP_FIREBASE_PROJECT_ID=""
+REACT_APP_FIREBASE_API_KEY=""
+REACT_APP_FIREBASE_MESSAGING_SENDER_ID=""
\ No newline at end of file
diff --git a/eq-author/.env.production b/eq-author/.env.production
new file mode 100644
index 0000000000..d0deccfc8d
--- /dev/null
+++ b/eq-author/.env.production
@@ -0,0 +1,9 @@
+REACT_APP_BASE_NAME="/eq-author"
+REACT_APP_API_URL=""
+REACT_APP_USE_FULLSTORY="false"
+REACT_APP_USE_SENTRY="false"
+
+REACT_APP_ENABLE_AUTH="true"
+REACT_APP_FIREBASE_PROJECT_ID=""
+REACT_APP_FIREBASE_API_KEY=""
+REACT_APP_FIREBASE_MESSAGING_SENDER_ID=""
\ No newline at end of file
diff --git a/eq-author/.env.test b/eq-author/.env.test
new file mode 100644
index 0000000000..7088713f5b
--- /dev/null
+++ b/eq-author/.env.test
@@ -0,0 +1,6 @@
+CYPRESS_BASE_NAME=""
+REACT_APP_BASE_NAME=""
+CYPRESS_baseUrl="http://localhost:13000"
+REACT_APP_API_URL="http://localhost:4000/graphql"
+REACT_APP_LAUNCH_URL="http://localhost:4000/launch"
+REACT_APP_FUNCTIONAL_TEST="true"
\ No newline at end of file
diff --git a/eq-author/.eslintrc.js b/eq-author/.eslintrc.js
new file mode 100644
index 0000000000..ccbca159b8
--- /dev/null
+++ b/eq-author/.eslintrc.js
@@ -0,0 +1,36 @@
+const path = require("path");
+const schema = require("eq-author-graphql-schema");
+
+const config = {
+ resolve: {
+ modules: [
+ path.resolve(__dirname, "src"),
+ path.resolve(__dirname, "node_modules")
+ ]
+ }
+};
+
+module.exports = {
+ extends: ["eslint-config-eq-author", "eslint-config-eq-author/react"],
+ settings: {
+ react: {
+ version: "latest"
+ },
+ "import/resolver": {
+ webpack: {
+ config
+ }
+ }
+ },
+ rules: {
+ "react/jsx-no-bind": [2, { allowArrowFunctions: true }],
+ "graphql/template-strings": [
+ "error",
+ {
+ env: "literal",
+ schemaString: schema
+ }
+ ]
+ },
+ plugins: ["graphql"]
+};
diff --git a/eq-author/.github/ISSUE_TEMPLATE.md b/eq-author/.github/ISSUE_TEMPLATE.md
new file mode 100644
index 0000000000..88c20bd4d4
--- /dev/null
+++ b/eq-author/.github/ISSUE_TEMPLATE.md
@@ -0,0 +1,13 @@
+### Expected behaviour
+
+### Actual behaviour
+
+### Steps to reproduce the behaviour
+
+### Technical information
+
+#### Browser
+
+#### Operating System
+
+### Screenshot
diff --git a/eq-author/.github/PULL_REQUEST_TEMPLATE.md b/eq-author/.github/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 0000000000..d76e63e17a
--- /dev/null
+++ b/eq-author/.github/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,5 @@
+### What is the context of this PR?
+Describe what you have changed and why, link to other PRs or Issues as appropriate.
+
+### How to review
+Describe the steps required to test the changes (include screenshots if appropriate).
diff --git a/eq-author/.gitignore b/eq-author/.gitignore
new file mode 100644
index 0000000000..461c870a4c
--- /dev/null
+++ b/eq-author/.gitignore
@@ -0,0 +1,29 @@
+# See http://help.github.com/ignore-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+
+# testing
+/coverage
+
+# production
+/build
+/build-storybook
+
+# misc
+.DS_Store
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+/storybook-static
+
+# vscode-specific stuff
+.vscode/
+jsconfig.json
+
+graphql.config.json
+public/status.json
+.env.*local
+cypress/videos/
+cypress/screenshots/
\ No newline at end of file
diff --git a/eq-author/.nvmrc b/eq-author/.nvmrc
new file mode 100644
index 0000000000..5debbed219
--- /dev/null
+++ b/eq-author/.nvmrc
@@ -0,0 +1 @@
+lts/carbon
diff --git a/eq-author/.prettierignore b/eq-author/.prettierignore
new file mode 100644
index 0000000000..2ff8622f17
--- /dev/null
+++ b/eq-author/.prettierignore
@@ -0,0 +1 @@
+package.json
\ No newline at end of file
diff --git a/eq-author/.storybook/addons.js b/eq-author/.storybook/addons.js
new file mode 100644
index 0000000000..409a21a8f8
--- /dev/null
+++ b/eq-author/.storybook/addons.js
@@ -0,0 +1,3 @@
+import "@storybook/addon-actions/register";
+import "@storybook/addon-knobs/register";
+import "@storybook/addon-options/register";
diff --git a/eq-author/.storybook/config.js b/eq-author/.storybook/config.js
new file mode 100644
index 0000000000..19fa8284b4
--- /dev/null
+++ b/eq-author/.storybook/config.js
@@ -0,0 +1,33 @@
+import React from "react";
+import { configure, addDecorator } from "@storybook/react";
+import { withOptions } from "@storybook/addon-options";
+import { Provider } from "react-redux";
+import configureStore from "redux/configureStore";
+
+import App from "components/App";
+
+addDecorator(story => {story()} );
+
+addDecorator(story => (
+ {story()}
+));
+
+function requireAll(requireContext) {
+ return requireContext.keys().map(requireContext);
+}
+
+function loadStories() {
+ requireAll(require.context("../src/components", true, /[/.]story\.js$/));
+}
+
+withOptions({
+ name: "My Storybook",
+ goFullScreen: false,
+ showLeftPanel: true,
+ showDownPanel: true,
+ showSearchBox: false,
+ downPanelInRight: true,
+ sortStoriesByKind: true
+});
+
+configure(loadStories, module);
diff --git a/eq-author/.storybook/preview-head.html b/eq-author/.storybook/preview-head.html
new file mode 100644
index 0000000000..a7b839d3bc
--- /dev/null
+++ b/eq-author/.storybook/preview-head.html
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/eq-author/.storybook/storyshots.storytest.js b/eq-author/.storybook/storyshots.storytest.js
new file mode 100644
index 0000000000..6340d31814
--- /dev/null
+++ b/eq-author/.storybook/storyshots.storytest.js
@@ -0,0 +1,16 @@
+import initStoryshots from "@storybook/addon-storyshots";
+import { mount } from "enzyme";
+
+import registerRequireContextHook from "babel-plugin-require-context-hook/register";
+// Needed as storyshots runs outside of webpack https://github.com/storybooks/storybook/issues/2894
+registerRequireContextHook();
+
+function renderOnly({ story, context }) {
+ var storyElement = story.render(context);
+ mount(storyElement);
+}
+
+initStoryshots({
+ framework: "react",
+ test: renderOnly
+});
diff --git a/eq-author/.storybook/webpack.config.js b/eq-author/.storybook/webpack.config.js
new file mode 100644
index 0000000000..8c18696f07
--- /dev/null
+++ b/eq-author/.storybook/webpack.config.js
@@ -0,0 +1,5 @@
+const config = require("../config/webpack.config.dev");
+
+module.exports = {
+ module: config.module
+};
diff --git a/eq-author/.stylelintrc b/eq-author/.stylelintrc
new file mode 100644
index 0000000000..54e5b9586f
--- /dev/null
+++ b/eq-author/.stylelintrc
@@ -0,0 +1,23 @@
+{
+ "processors": ["stylelint-processor-styled-components"],
+ "extends": [
+ "stylelint-config-standard",
+ "stylelint-config-styled-components"
+ ],
+ "rules": {
+ "block-closing-brace-space-after": null,
+ "block-closing-brace-space-before": null,
+ "block-opening-brace-space-after": null,
+ "declaration-colon-newline-after": null,
+ "indentation": null,
+ "property-no-vendor-prefix": null,
+ "selector-type-case": null,
+ "selector-type-no-unknown": null,
+ "no-eol-whitespace": null,
+ "rule-empty-line-before": ["always", {
+ "ignore": ["inside-block"]
+ }],
+ "declaration-empty-line-before": null
+ },
+ "syntax": "scss"
+}
\ No newline at end of file
diff --git a/eq-author/.travis.yml b/eq-author/.travis.yml
new file mode 100644
index 0000000000..6ffaffb771
--- /dev/null
+++ b/eq-author/.travis.yml
@@ -0,0 +1,61 @@
+dist: trusty
+
+sudo: required
+
+services:
+ - docker
+
+language: node_js
+
+node_js:
+ - "8"
+
+cache:
+ yarn: true
+
+addons:
+ chrome: stable
+
+install:
+ - yarn install --frozen-lockfile
+
+before_script:
+ - set -e
+
+script:
+ - yarn lint
+ - yarn coverage
+ - yarn test:storybook
+ - yarn storybook-build
+ - docker build -t onsdigital/eq-author:$TRAVIS_BUILD_NUMBER --build-arg APPLICATION_VERSION=$(git rev-parse HEAD) -f Dockerfile .
+ - yarn test:integration
+ - yarn test:e2e
+
+
+after_success:
+ - docker login -u $DOCKER_USERNAME -p $DOCKER_PASSWORD
+ - export TAG=`if [ "$TRAVIS_PULL_REQUEST_BRANCH" == "" ]; then echo "latest"; else echo $TRAVIS_PULL_REQUEST_BRANCH; fi`
+ - docker tag onsdigital/eq-author:$TRAVIS_BUILD_NUMBER onsdigital/eq-author:$TAG
+ - echo "Pushing with tag [$TAG]"
+ - docker push onsdigital/eq-author:$TAG
+ - bash <(curl -s https://codecov.io/bash) -e TRAVIS_NODE_VERSION
+
+env:
+ global:
+ - NODE_PATH=src/
+ - secure: "g8ycyTABxfgqexweX0CWDVl0ofGG7vJY2/qFcFulttNgNY4UhdqvAgdQooj1tgJjH4i93dKmFYC9l9bmdv36m1N6Ihd08wc1HzTgvdfzmT/0f7wYOdnAdYi5kJH1mPj7vr0fExRZm56LdCJsj9ghV//y/cGKCJc1TmcEQxCSJlVMmuHiB4sSG3cOHMdSBrJ+EDl+LH8bpyRoQHfy3RPHtHU4mZp8pMBkDD1FAnuuZiPNhqtgU5d0D/BfJ3pGj0DxBW7oPe9cj0TWwZJkBs9Wa7F9FNn+U3TBm1CqGhPHtUvsA04IAo+BY/iC9A//exf/1ApvQxL2W3p20VoUwbJBSr7Yu0v6Rn3W34pwsfnLTtCU6TgQbrUoy78K4fgxMf5uNUAQL2hFBfZBY/2oKN9MCQ2RgdWYxvp/SDV6NxfB4Rb8Wawjqiu20BPyjs4lYmStRMuif18e0JFoKZcULy6svpCGMMntDxijPKYle0s8tThc/QR0V+F7gSBQ0Kzk6Vxsa53IFSzSXJ3snLzImEN9EXPiSLKRjStoiRnBZHBE3CozfniOa1D+zc6BINkfdEN3h7gQVm5OXuIVgsbUVPR4snW7odUDFEi+mb+1nx+lUXplU9oSyYtZYjKHTR5xLJJ9C6WQNvP2IAcPgNd7M5Iwe//2O/lmsI2lOF6lFnYfKI0="
+
+
+deploy:
+ local_dir: storybook-static
+ provider: pages
+ skip_cleanup: true
+ github_token: $GITHUB_TOKEN
+ on:
+ branch:
+ - master
+
+branches:
+ only:
+ - master
+ - /^greenkeeper.*$/
\ No newline at end of file
diff --git a/eq-author/Dockerfile b/eq-author/Dockerfile
new file mode 100644
index 0000000000..c5a8028bc4
--- /dev/null
+++ b/eq-author/Dockerfile
@@ -0,0 +1,31 @@
+FROM node:8-alpine as build
+
+WORKDIR /app
+
+ARG APPLICATION_VERSION
+ENV EQ_AUTHOR_VERSION $APPLICATION_VERSION
+
+# Install
+COPY package.json yarn.lock /app/
+RUN yarn install --frozen-lockfile
+
+# Copy build dependencies
+COPY src /app/src
+COPY config /app/config
+COPY public /app/public
+COPY scripts /app/scripts
+COPY .babelrc .eslintrc.js .env .env.production /app/
+
+RUN yarn build
+
+FROM nginx:stable
+
+EXPOSE 3000
+
+COPY nginx /etc/nginx/
+# Log to stdout/stderr
+RUN mkdir -p /etc/nginx/logs && ln -sf /dev/stdout /etc/nginx/logs/access.log && ln -sf /dev/stderr /etc/nginx/logs/error.log
+COPY --from=build /app/build /etc/nginx/html/
+RUN cp /etc/nginx/html/index.html /etc/nginx/html/index.html.tmpl
+
+CMD ["/etc/nginx/docker-entrypoint.sh"]
diff --git a/eq-author/README.md b/eq-author/README.md
new file mode 100644
index 0000000000..11584b75b7
--- /dev/null
+++ b/eq-author/README.md
@@ -0,0 +1,239 @@
+[](https://travis-ci.org/ONSdigital/eq-author)
+
+This project was bootstrapped with [Create React App](https://github.com/facebookincubator/create-react-app).
+
+## Table of Contents
+
+- [Installation](#installation)
+- [Folder Structure](#folder-structure)
+- [Available Scripts](#available-scripts)
+- [Environment Variables](#environment-variables)
+- [Troubleshooting](#troubleshooting)
+
+## Installation
+
+### Prerequisites
+
+- Node.js 7.10.0 or newer
+- [Yarn](https://yarnpkg.com/en/)
+- Google Chrome
+
+### How to install
+
+- Just run `yarn` to install all dependencies.
+
+## Folder Structure
+
+`/.storybook` Config for storybook.
+
+`/config` Webpack config.
+
+`/data` Example Runner JSON schemas.
+
+`/public` Public static assets.
+
+`/scripts` NPM scripts for running the app.
+
+`/src` JavaScript source files.
+
+`/src/actions` Redux action creators.
+
+`/src/components` React components.
+
+`/src/constants` Constants that can be used throughout the application such as theme colours and action names.
+
+`/src/containers` Redux container components.
+
+`/src/helpers` Helper functions, etc.
+
+`/src/layouts` Layout components.
+
+`/src/pages` Page components rendered via a route.
+
+`/src/reducers` Redux reducer functions.
+
+`/src/schema` Schema for Normalizr.
+
+For the project to build, **these files must exist with exact filenames**:
+
+* `public/index.html` is the page template;
+* `src/index.js` is the JavaScript entry point.
+
+You can delete or rename the other files.
+
+## Available Scripts
+
+In the project directory, you can run:
+
+### `yarn start`
+
+Runs the app in the development mode.
+Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
+
+The page will reload if you make edits.
+You will also see any lint errors in the console.
+
+### `yarn lint`
+
+Lints the `src` directory using the rules defined in `.eslintrc`. Run `yarn lint -- --fix` if you want eslint to fix any issues it can.
+
+### `yarn test`
+
+Launches the test runner in the interactive watch mode.
+See the section about [running tests](#running-tests) for more information.
+
+If you would like to collect code coverage run `yarn test -- --coverage`.
+
+### `yarn build`
+
+Builds the app for production to the `build` folder.
+It correctly bundles React in production mode and optimizes the build for the best performance.
+
+The build is minified and the filenames include the hashes.
+Your app is ready to be deployed!
+
+See the section about [deployment](#deployment) for more information.
+
+### `yarn deploy`
+
+Builds (via `yarn build`) and deploys the project to Github Pages.
+
+### `yarn storybook`
+
+Spins up the Storybook development server.
+
+## Environment Variables
+
+### Authentication
+| Name | Description | Required |
+| --- | --- | --- |
+| `REACT_APP_FIREBASE_PROJECT_ID` | Firebase is used for basic authentication this environment and the two below are needed for this. The project ID for your Firebase project. Can be obtained from your Firebase project | Yes If authentication is enabled |
+| `REACT_APP_FIREBASE_API_KEY` | The api key for your Firebase project. Can be obtained from your Firebase project | Yes If authentication is enabled |
+| `REACT_APP_FIREBASE_MESSAGING_SENDER_ID` | The messaging sender ID for your Firebase project. Can be obtained from your Firebase project | Yes If authentication is enabled |
+| `REACT_APP_ENABLE_AUTH` | Used to enable and disable firebase authentication. User can sign in as guest if this is set to false. This is always enabled in the docker images. | Yes |
+
+### Functional
+| Name | Description | Required |
+| --- | --- | --- |
+| `REACT_APP_API_URL` | Set Author API URL | Yes |
+| `REACT_APP_LAUNCH_URL` | Set the launch-a-survey target | No |
+| `PUBLIC_URL` | The public URL inferred if not provided | No |
+| `REACT_APP_BASE_NAME` | Used to build up URL set to "/eq-author" in production | No |
+
+### Testing
+| Name | Description | Required |
+| --- | --- | --- |
+| `CYPRESS_baseUrl` | Set Cypress URL | Yes |
+| `CYPRESS_BASE_NAME` | Not used | No |
+| `REACT_APP_FUNCTIONAL_TEST` | Run functional test switch | No |
+
+### Third party services
+| Name | Description | Required |
+| --- | --- | --- |
+| `REACT_APP_USE_SENTRY` | Use Sentry for error checking | Yes |
+| `REACT_APP_USE_FULLSTORY` | Use fullstory if set to true | No |
+
+### Runtime
+| Name | Description | Required |
+| --- | --- | --- |
+| `HOST` | Set to 0.0.0.0 if not provided | No |
+| `PORT` |The port which express listens on (defaults to `3000`). | No |
+| `HTTPS` | HTTP/HTTPS Switch | No |
+
+### Build configuration
+| Name | Description | Required |
+| --- | --- | --- |
+| `BABEL_ENV` | Sets the environment the code is running in | Yes |
+| `NODE_ENV` | Sets the environment the code is running in | Yes |
+| `NODE_PATH` | Folder path for the code folder structure | Yes |
+| `CI` | Switch that if is set to true will treat warnings as errors | No |
+| `EQ_AUTHOR_VERSION` | The current Author version. This is what gets reported on the /status endpoint | No |
+
+## Authentication
+
+We currently use firebase for basic authentication requirements. The following environment variables are required for firebase:
+
+* `REACT_APP_FIREBASE_PROJECT_ID`
+* `REACT_APP_FIREBASE_API_KEY`
+* `REACT_APP_FIREBASE_MESSAGING_SENDER_ID`
+
+These can either be passed on command line:
+
+```bash
+REACT_APP_FIREBASE_PROJECT_ID=ABC REACT_APP_FIREBASE_API_KEY=DEF REACT_APP_FIREBASE_MESSAGING_SENDER_ID=GHI yarn start
+```
+
+Or they can be added to an `.env.development.local` file in the root of the repo:
+
+```
+REACT_APP_FIREBASE_PROJECT_ID="ABC"
+REACT_APP_FIREBASE_API_KEY="DEF"
+REACT_APP_FIREBASE_MESSAGING_SENDER_ID="GHI"
+```
+
+Note: CLI env vars taken precedence over `.env.development.local` vars. For more information about precedence of config files, see: https://github.com/bkeepers/dotenv#what-other-env-files-can-i-use
+
+### Enabling / disabling authentication
+
+Firebase authentication can be disabled by setting the env var `REACT_APP_ENABLE_AUTH=false`. Disabling firebase authentication allows users to login as a guest.
+
+### Environment variable in different environments
+
+There are two ways we use environment variables in the application:
+1. Build time environment variables. These are values that are known at build time and cannot be changed once the docker image is built. Currently these are only `NODE_ENV` and `REACT_APP_AUTH_ENABLED`. These are referenced in the code as `process.env.{key}`
+1. Runtime configurable variables. These are values that can change for each place we run the app for example in staging we want the API url to be different to production. In the code these values are read using the config object for example `config.{key}`.
+ - Dev - Values are read from the environment.
+ - Docker - Values are read from `window.config` (as defined in `index.html`) and then `process.env`. `index.html` is rewritten in docker to read the available environment variables and pass them to the application every time the docker image starts.
+
+## Testing
+
+### Integration tests
+
+Author's integration testing is run using the Cypress framework and can be run using the following commands provided author is already running with AUTH disabled using the `REACT_APP_ENABLE_AUTH=false` env variable:
+
+* `yarn test:integration`
+
+Launches Cypress on Chrome and automatically runs the default test suite.
+
+* `yarn cypress:open`
+
+Launches Cypress using the Electron framework and allows for choosing which test to run and a more interactive and detailed testing enviroment.
+
+By default the integration tests will be run against `http://localhost:13000` as configured in the [.env configuration](.env.test). It is possible to point Cypress at another environment by overriding the `CYPRESS_baseUrl` environment variable.
+
+e.g. `CYPRESS_baseUrl=http://some-other-environment yarn cypress:open`
+
+### Filename Conventions
+
+Tests are colocated next to the code they are testing. For example, a test for `/src/components/Button/index.js` could be in a file `/src/components/Button/test.js`.
+
+### Command Line Interface
+
+When you run `yarn test`, Jest will launch in the watch mode. Every time you save a file, it will re-run the tests, just like `yarn start` recompiles the code.
+
+The watcher includes an interactive command-line interface with the ability to run all tests, or focus on a search pattern. It is designed this way so that you can keep it open and enjoy fast re-runs.
+
+# Troubleshooting
+
+## Jest crashing
+
+### Problem
+
+Running `yarn test` causes Jest to crash with the following error:
+```
+(FSEvents.framework) FSEventStreamStart: register_with_server: ERROR: f2d_register_rpc() => (null) (-22)
+(FSEvents.framework) FSEventStreamStart: register_with_server: ERROR: f2d_register_rpc() => (null) (-22)
+(FSEvents.framework) FSEventStreamStart: register_with_server: ERROR: f2d_register_rpc() => (null) (-22)
+events.js:160
+ throw er; // Unhandled 'error' event
+ ^
+
+Error: Error watching file for changes: EMFILE
+ at exports._errnoException (util.js:1036:11)
+ at FSEvent.FSWatcher._handle.onchange (fs.js:1406:11)
+```
+
+### Solution
+
+According to [this thread](https://github.com/facebook/jest/issues/1767), install watchman: `brew install watchman`
+
diff --git a/eq-author/config/env.js b/eq-author/config/env.js
new file mode 100644
index 0000000000..53daf5bc46
--- /dev/null
+++ b/eq-author/config/env.js
@@ -0,0 +1,94 @@
+/* eslint-disable import/unambiguous */
+"use strict";
+
+const fs = require("fs");
+const path = require("path");
+const paths = require("./paths");
+
+// Make sure that including paths.js after env.js will read .env variables.
+delete require.cache[require.resolve("./paths")];
+
+const NODE_ENV = process.env.NODE_ENV;
+if (!NODE_ENV) {
+ throw new Error(
+ "The NODE_ENV environment variable is required but was not specified."
+ );
+}
+
+// https://github.com/bkeepers/dotenv#what-other-env-files-can-i-use
+var dotenvFiles = [
+ `${paths.dotenv}.${NODE_ENV}.local`,
+ `${paths.dotenv}.${NODE_ENV}`,
+ // Don't include `.env.local` for `test` environment
+ // since normally you expect tests to produce the same
+ // results for everyone
+ NODE_ENV !== "test" && `${paths.dotenv}.local`,
+ paths.dotenv
+].filter(Boolean);
+
+// Load environment variables from .env* files. Suppress warnings using silent
+// if this file is missing. dotenv will never modify any environment variables
+// that have already been set. Variable expansion is supported in .env files.
+// https://github.com/motdotla/dotenv
+// https://github.com/motdotla/dotenv-expand
+dotenvFiles.forEach(dotenvFile => {
+ if (fs.existsSync(dotenvFile)) {
+ require("dotenv-expand")(
+ require("dotenv").config({
+ path: dotenvFile
+ })
+ );
+ }
+});
+
+// We support resolving modules according to `NODE_PATH`.
+// This lets you use absolute paths in imports inside large monorepos:
+// https://github.com/facebook/create-react-app/issues/253.
+// It works similar to `NODE_PATH` in Node itself:
+// https://nodejs.org/api/modules.html#modules_loading_from_the_global_folders
+// Note that unlike in Node, only *relative* paths from `NODE_PATH` are honored.
+// Otherwise, we risk importing Node.js core modules into an app instead of Webpack shims.
+// https://github.com/facebook/create-react-app/issues/1023#issuecomment-265344421
+// We also resolve them to make sure all tools using them work consistently.
+const appDirectory = fs.realpathSync(process.cwd());
+process.env.NODE_PATH = (process.env.NODE_PATH || "")
+ .split(path.delimiter)
+ .filter(folder => folder && !path.isAbsolute(folder))
+ .map(folder => path.resolve(appDirectory, folder))
+ .join(path.delimiter);
+
+// Grab NODE_ENV and REACT_APP_* environment variables and prepare them to be
+// injected into the application via DefinePlugin in Webpack configuration.
+const REACT_APP = /^REACT_APP_/i;
+
+function getClientEnvironment(publicUrl) {
+ const raw = Object.keys(process.env)
+ .filter(key => REACT_APP.test(key))
+ .reduce(
+ (env, key) => {
+ env[key] = process.env[key];
+ return env;
+ },
+ {
+ // Useful for determining whether we’re running in production mode.
+ // Most importantly, it switches React into the correct mode.
+ NODE_ENV: process.env.NODE_ENV || "development",
+ // Useful for resolving the correct path to static assets in `public`.
+ // For example, .
+ // This should only be used as an escape hatch. Normally you would put
+ // images into the `src` and `import` them in code to get their paths.
+ PUBLIC_URL: publicUrl
+ }
+ );
+ // Stringify all values so we can feed into Webpack DefinePlugin
+ const stringified = {
+ "process.env": Object.keys(raw).reduce((env, key) => {
+ env[key] = JSON.stringify(raw[key]);
+ return env;
+ }, {})
+ };
+
+ return { raw, stringified };
+}
+
+module.exports = getClientEnvironment;
diff --git a/eq-author/config/jest/cssTransform.js b/eq-author/config/jest/cssTransform.js
new file mode 100644
index 0000000000..999a496d01
--- /dev/null
+++ b/eq-author/config/jest/cssTransform.js
@@ -0,0 +1,14 @@
+"use strict";
+
+// This is a custom Jest transformer turning style imports into empty objects.
+// http://facebook.github.io/jest/docs/tutorial-webpack.html
+
+module.exports = {
+ process() {
+ return "module.exports = {};";
+ },
+ getCacheKey() {
+ // The output is always the same.
+ return "cssTransform";
+ }
+};
diff --git a/eq-author/config/jest/fileTransform.js b/eq-author/config/jest/fileTransform.js
new file mode 100644
index 0000000000..8921c4ddfc
--- /dev/null
+++ b/eq-author/config/jest/fileTransform.js
@@ -0,0 +1,12 @@
+"use strict";
+
+const path = require("path");
+
+// This is a custom Jest transformer turning file imports into filenames.
+// http://facebook.github.io/jest/docs/tutorial-webpack.html
+
+module.exports = {
+ process(src, filename) {
+ return `module.exports = ${JSON.stringify(path.basename(filename))};`;
+ }
+};
diff --git a/eq-author/config/jest/svgTransform.js b/eq-author/config/jest/svgTransform.js
new file mode 100644
index 0000000000..7f7e7fdae8
--- /dev/null
+++ b/eq-author/config/jest/svgTransform.js
@@ -0,0 +1,3 @@
+import React from "react";
+
+module.exports = () => ;
diff --git a/eq-author/config/paths.js b/eq-author/config/paths.js
new file mode 100644
index 0000000000..2fda329820
--- /dev/null
+++ b/eq-author/config/paths.js
@@ -0,0 +1,58 @@
+/* eslint-disable import/unambiguous */
+"use strict";
+
+const path = require("path");
+const fs = require("fs");
+const url = require("url");
+
+// Make sure any symlinks in the project folder are resolved:
+// https://github.com/facebook/create-react-app/issues/637
+const appDirectory = fs.realpathSync(process.cwd());
+const resolveApp = relativePath => path.resolve(appDirectory, relativePath);
+
+const envPublicUrl = process.env.PUBLIC_URL;
+
+function ensureSlash(inputPath, needsSlash) {
+ const hasSlash = inputPath.endsWith("/");
+ if (hasSlash && !needsSlash) {
+ return inputPath.substr(0, inputPath.length - 1);
+ } else if (!hasSlash && needsSlash) {
+ return `${inputPath}/`;
+ } else {
+ return inputPath;
+ }
+}
+
+const getPublicUrl = appPackageJson =>
+ envPublicUrl || require(appPackageJson).homepage;
+
+// We use `PUBLIC_URL` environment variable or "homepage" field to infer
+// "public path" at which the app is served.
+// Webpack needs to know it to put the right
+
+
+
+
+
+