diff --git a/docs/features/code-based/CUSTOM_SERVICES.md b/docs/features/code-based/CUSTOM_SERVICES.md index c388a7fc3..e776a70f1 100644 --- a/docs/features/code-based/CUSTOM_SERVICES.md +++ b/docs/features/code-based/CUSTOM_SERVICES.md @@ -44,3 +44,55 @@ This method is invoked for every page request. Only when the `formMetadata` indicates that the definition has changed is a call to `getFormDefinition` is made. The response from this can be quite big as it contains the entire form definition. + +## Loading forms from files + +To create a `formsService` from form config files that live on disk, you can use the `FileFormService` class. +Form definition config files can be either `.json` or `.yaml`. + +Once created and files have been loaded using the `addForm` method, +call the `toFormsService` method to return a `FormService` compliant interface which can be passed in to the `services` setting of the [plugin options](/forms-engine-plugin/PLUGIN_OPTIONS.md). + +```javascript +import { FileFormService } from '@defra/forms-engine-plugin/file-form-service.js' + +// Create shared form metadata +const now = new Date() +const user = { id: 'user', displayName: 'Username' } +const author = { createdAt: now, createdBy: user, updatedAt: now, updatedBy: user } +const metadata = { + organisation: 'Defra', + teamName: 'Team name', + teamEmail: 'team@defra.gov.uk', + submissionGuidance: "Thanks for your submission, we'll be in touch", + notificationEmail: 'email@domain.com', + ...author, + live: author +} + +// Instantiate the file loader form service +const loader = new FileFormService() + +// Add a Json form +await loader.addForm( + 'src/definitions/example-form.json', { + ...metadata, + id: '95e92559-968d-44ae-8666-2b1ad3dffd31', + title: 'Example Json', + slug: 'example-json' + } +) + +// Add a Yaml form +await loader.addForm( + 'src/definitions/example-form.yaml', { + ...metadata, + id: '641aeafd-13dd-40fa-9186-001703800efb', + title: 'Example Yaml', + slug: 'example-yaml' + } +) + +// Get the forms service +const formsService = loader.toFormsService() +``` diff --git a/package-lock.json b/package-lock.json index 70a5863b6..f934eed49 100644 --- a/package-lock.json +++ b/package-lock.json @@ -52,7 +52,8 @@ "pino": "^9.6.0", "pino-pretty": "^13.0.0", "proxy-agent": "^6.5.0", - "resolve": "^1.22.10" + "resolve": "^1.22.10", + "yaml": "^2.7.1" }, "devDependencies": { "@babel/cli": "^7.26.4", @@ -11599,6 +11600,19 @@ "node": ">=18" } }, + "node_modules/lint-staged/node_modules/yaml": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.6.1.tgz", + "integrity": "sha512-7r0XPzioN/Q9kXBro/XPnA6kznR73DHq+GXh5ON7ZozRO6aMjbmiBuKste2wslTFkC5d1dw0GooOCepZXJ2SAg==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/liquidjs": { "version": "10.21.0", "resolved": "https://registry.npmjs.org/liquidjs/-/liquidjs-10.21.0.tgz", @@ -16847,10 +16861,10 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, "node_modules/yaml": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.6.1.tgz", - "integrity": "sha512-7r0XPzioN/Q9kXBro/XPnA6kznR73DHq+GXh5ON7ZozRO6aMjbmiBuKste2wslTFkC5d1dw0GooOCepZXJ2SAg==", - "dev": true, + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.1.tgz", + "integrity": "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==", + "license": "ISC", "bin": { "yaml": "bin.mjs" }, diff --git a/package.json b/package.json index db9ae0684..207dd2550 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "./file-upload.min.js": "./.public/javascripts/file-upload.min.js", "./file-upload.min.js.map": "./.public/javascripts/file-upload.min.js.map", "./application.min.css": "./.public/stylesheets/application.min.css", + "./file-form-service.js": "./.server/server/utils/file-form-service.js", "./controllers/*": "./.server/server/plugins/engine/pageControllers/*", "./services/*": "./.server/server/plugins/engine/services/*", "./package.json": "./package.json" @@ -103,7 +104,8 @@ "pino": "^9.6.0", "pino-pretty": "^13.0.0", "proxy-agent": "^6.5.0", - "resolve": "^1.22.10" + "resolve": "^1.22.10", + "yaml": "^2.7.1" }, "devDependencies": { "@babel/cli": "^7.26.4", diff --git a/src/server/forms/test.yaml b/src/server/forms/test.yaml new file mode 100644 index 000000000..2f948b665 --- /dev/null +++ b/src/server/forms/test.yaml @@ -0,0 +1,363 @@ +--- +startPage: "/start" +pages: +- title: Start + path: "/start" + components: [] + next: + - path: "/uk-passport" + controller: StartPageController +- path: "/uk-passport" + components: + - type: YesNoField + name: ukPassport + title: Do you have a UK passport? + options: + required: true + schema: {} + section: checkBeforeYouStart + next: + - path: "/how-many-people" + - path: "/no-uk-passport" + condition: doesntHaveUKPassport + title: Do you have a UK passport? +- path: "/no-uk-passport" + title: You're not eligible for this service + components: + - type: Html + name: html + title: Html + content: >- +

+ If you still think you're eligible please contact the Foreign and Commonwealth Office. +

+ options: {} + schema: {} + next: [] +- path: "/how-many-people" + section: applicantDetails + components: + - options: + classes: govuk-input--width-10 + required: true + type: SelectField + name: numberOfApplicants + title: How many applicants are there? + list: numberOfApplicants + next: + - path: "/applicant-one" + title: How many applicants are there? +- path: "/applicant-one" + title: Applicant 1 + section: applicantOneDetails + components: + - type: Html + name: html + title: Html + content:

Provide the details as they appear on your passport.

+ options: {} + schema: {} + - type: TextField + name: firstName + title: First name + options: + required: true + schema: {} + - options: + required: false + optionalText: false + type: TextField + name: middleName + title: Middle name + hint: If you have a middle name on your passport you must include it here + schema: {} + - type: TextField + name: lastName + title: Surname + options: + required: true + schema: {} + next: + - path: "/applicant-one-address" +- path: "/applicant-one-address" + section: applicantOneDetails + components: + - type: UkAddressField + name: address + title: Address + options: + required: true + schema: {} + next: + - path: "/applicant-two" + condition: moreThanOneApplicant + - path: "/contact-details" + title: Address +- path: "/applicant-two" + title: Applicant 2 + section: applicantTwoDetails + components: + - type: Html + name: html + title: Html + content:

Provide the details as they appear on your passport.

+ options: {} + schema: {} + - type: TextField + name: firstName + title: First name + options: + required: true + schema: {} + - options: + required: false + optionalText: false + type: TextField + name: middleName + title: Middle name + hint: If you have a middle name on your passport you must include it here + schema: {} + - type: TextField + name: lastName + title: Surname + options: + required: true + schema: {} + next: + - path: "/applicant-two-address" +- path: "/applicant-two-address" + section: applicantTwoDetails + components: + - type: UkAddressField + name: address + title: Address + options: + required: true + schema: {} + next: + - path: "/applicant-three" + condition: moreThanTwoApplicants + - path: "/contact-details" + title: Address +- path: "/applicant-three" + title: Applicant 3 + section: applicantThreeDetails + components: + - type: Html + name: html + title: Html + content:

Provide the details as they appear on your passport.

+ options: {} + schema: {} + - type: TextField + name: firstName + title: First name + options: + required: true + schema: {} + - options: + required: false + optionalText: false + type: TextField + name: middleName + title: Middle name + hint: If you have a middle name on your passport you must include it here + schema: {} + - type: TextField + name: lastName + title: Surname + options: + required: true + schema: {} + next: + - path: "/applicant-three-address" +- path: "/applicant-three-address" + section: applicantThreeDetails + components: + - type: UkAddressField + name: address + title: Address + options: + required: true + schema: {} + next: + - path: "/applicant-four" + condition: moreThanThreeApplicants + - path: "/contact-details" + title: Address +- path: "/applicant-four" + title: Applicant 4 + section: applicantFourDetails + components: + - type: Html + name: html + title: Html + content:

Provide the details as they appear on your passport.

+ options: {} + schema: {} + - type: TextField + name: firstName + title: First name + options: + required: true + schema: {} + - options: + required: false + optionalText: false + type: TextField + name: middleName + title: Middle name + hint: If you have a middle name on your passport you must include it here + schema: {} + - type: TextField + name: lastName + title: Surname + options: + required: true + schema: {} + next: + - path: "/applicant-four-address" +- path: "/applicant-four-address" + section: applicantFourDetails + components: + - type: UkAddressField + name: address + title: Address + options: + required: true + schema: {} + next: + - path: "/contact-details" + title: Address +- path: "/contact-details" + section: applicantDetails + components: + - type: TelephoneNumberField + name: phoneNumber + title: Phone number + hint: If you haven't got a UK phone number, include country code + options: + required: true + schema: {} + - type: EmailAddressField + name: emailAddress + title: Your email address + options: + required: true + schema: {} + next: + - path: "/summary" + title: Applicant contact details +- path: "/summary" + controller: SummaryPageController + title: Summary + components: [] + next: [] +lists: +- name: numberOfApplicants + title: Number of people + type: number + items: + - text: '1' + value: 1 + description: '' + condition: '' + - text: '2' + value: 2 + description: '' + condition: '' + - text: '3' + value: 3 + description: '' + condition: '' + - text: '4' + value: 4 + description: '' + condition: '' +sections: +- name: checkBeforeYouStart + title: Check before you start +- name: applicantDetails + title: Applicant details +- name: applicantOneDetails + title: Applicant 1 +- name: applicantTwoDetails + title: Applicant 2 +- name: applicantThreeDetails + title: Applicant 3 +- name: applicantFourDetails + title: Applicant 4 +phaseBanner: {} +declaration:

All the answers you have provided are true to the + best of your knowledge.

+conditions: +- name: hasUKPassport + displayName: hasUKPassport + value: + name: hasUKPassport + conditions: + - field: + name: checkBeforeYouStart.ukPassport + type: YesNoField + display: Do you have a UK passport? + operator: is + value: + type: Value + value: 'true' + display: 'true' +- name: doesntHaveUKPassport + displayName: doesntHaveUKPassport + value: + name: doesntHaveUKPassport + conditions: + - field: + name: checkBeforeYouStart.ukPassport + type: YesNoField + display: Do you have a UK passport? + operator: is + value: + type: Value + value: 'false' + display: 'false' +- name: moreThanOneApplicant + displayName: moreThanOneApplicant + value: + name: moreThanOneApplicant + conditions: + - field: + name: applicantDetails.numberOfApplicants + type: SelectField + display: How many applicants are there? + operator: is more than + value: + type: Value + value: '1' + display: '1' +- name: moreThanTwoApplicants + displayName: moreThanTwoApplicants + value: + name: moreThanTwoApplicants + conditions: + - field: + name: applicantDetails.numberOfApplicants + type: SelectField + display: How many applicants are there? + operator: is more than + value: + type: Value + value: '2' + display: '2' +- name: moreThanThreeApplicants + displayName: moreThanThreeApplicants + value: + name: moreThanThreeApplicants + conditions: + - field: + name: applicantDetails.numberOfApplicants + type: SelectField + display: How many applicants are there? + operator: is more than + value: + type: Value + value: '3' + display: '3' diff --git a/src/server/utils/file-form-service.js b/src/server/utils/file-form-service.js new file mode 100644 index 000000000..f8306bc4d --- /dev/null +++ b/src/server/utils/file-form-service.js @@ -0,0 +1,144 @@ +import fs from 'fs/promises' +import path from 'node:path' + +import YAML from 'yaml' + +/** + * FileFormService class + */ +export class FileFormService { + /** + * The map of form metadatas by slug + * @type {Map} + */ + #metadata = new Map() + + /** + * The map of form definitions by id + * @type {Map} + */ + #definition = new Map() + + /** + * Add form from a file + * @param {string} filepath - the file path + * @param {FormMetadata} metadata - the metadata to use for this form + * @returns {Promise} + */ + async addForm(filepath, metadata) { + const definition = await this.readForm(filepath) + + this.#metadata.set(metadata.slug, metadata) + this.#definition.set(metadata.id, definition) + + return definition + } + + /** + * Read the form definition from file + * @param {string} filepath - the file path + * @returns {Promise} + */ + async readForm(filepath) { + const ext = path.extname(filepath).toLowerCase() + + switch (ext) { + case '.json': + return this.readJsonForm(filepath) + case '.yaml': + return this.readYamlForm(filepath) + default: + throw new Error(`Invalid file extension '${ext}'`) + } + } + + /** + * Read the form definition from a json file + * @param {string} filepath - the file path + * @returns {Promise} + */ + async readJsonForm(filepath) { + /** + * @type {FormDefinition} + */ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const definition = JSON.parse(await fs.readFile(filepath, 'utf8')) + + return definition + } + + /** + * Read the form definition from a yaml file + * @param {string} filepath - the file path + * @returns {Promise} + */ + async readYamlForm(filepath) { + /** + * @type {FormDefinition} + */ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const definition = YAML.parse(await fs.readFile(filepath, 'utf8')) + + return definition + } + + /** + * Get the form metadata by slug + * @param {string} slug - the form slug + * @returns {FormMetadata} + */ + getFormMetadata(slug) { + const metadata = this.#metadata.get(slug) + + if (!metadata) { + throw new Error(`Form metadata '${slug}' not found`) + } + + return metadata + } + + /** + * Get the form defintion by id + * @param {string} id - the form id + * @returns {FormDefinition} + */ + getFormDefinition(id) { + const definition = this.#definition.get(id) + + if (!definition) { + throw new Error(`Form definition '${id}' not found`) + } + + return definition + } + + /** + * Returns a FormsService compliant interface + * @returns {import('~/src/server/types.js').FormsService} + */ + toFormsService() { + return { + /** + * Get the form metadata by slug + * @param {string} slug + * @returns {Promise} + */ + getFormMetadata: (slug) => { + return Promise.resolve(this.getFormMetadata(slug)) + }, + + /** + * Get the form defintion by id + * @param {string} id + * @returns {Promise} + */ + getFormDefinition: (id) => { + return Promise.resolve(this.getFormDefinition(id)) + } + } + } +} + +/** + * @import { FormMetadata, FormDefinition } from '@defra/forms-model' + */ diff --git a/src/server/utils/file-form-service.test.js b/src/server/utils/file-form-service.test.js new file mode 100644 index 000000000..68ca7a080 --- /dev/null +++ b/src/server/utils/file-form-service.test.js @@ -0,0 +1,79 @@ +import { FormStatus } from '~/src/server/routes/types.js' +import { FileFormService } from '~/src/server/utils/file-form-service.js' + +// Create the metadata which is shared for all forms +const now = new Date() +const user = { id: 'user', displayName: 'Username' } +const author = { + createdAt: now, + createdBy: user, + updatedAt: now, + updatedBy: user +} + +const metadata = { + id: '95e92559-968d-44ae-8666-2b1ad3dffd31', + slug: 'example-form', + title: 'Example form', + organisation: 'Defra', + teamName: 'Team name', + teamEmail: 'team@defra.gov.uk', + submissionGuidance: "Thanks for your submission, we'll be in touch", + notificationEmail: 'email@domain.com', + ...author, + live: author +} + +describe('File Form Service', () => { + it('should load JSON files from disk', async () => { + const loader = new FileFormService() + + const definition = await loader.addForm( + 'src/server/forms/test.json', + metadata + ) + + const formsService = loader.toFormsService() + expect(await formsService.getFormMetadata(metadata.slug)).toBe(metadata) + expect( + await formsService.getFormDefinition(metadata.id, FormStatus.Draft) + ).toBe(definition) + + expect(() => loader.getFormMetadata('invalid-slug')).toThrow( + "Form metadata 'invalid-slug' not found" + ) + expect(() => loader.getFormDefinition('invalid-id')).toThrow( + "Form definition 'invalid-id' not found" + ) + }) + + it('should load YAML files from disk', async () => { + const loader = new FileFormService() + + const definition = await loader.addForm( + 'src/server/forms/test.yaml', + metadata + ) + + const formsService = loader.toFormsService() + expect(await formsService.getFormMetadata(metadata.slug)).toBe(metadata) + expect( + await formsService.getFormDefinition(metadata.id, FormStatus.Draft) + ).toBe(definition) + + expect(() => loader.getFormMetadata('invalid-slug')).toThrow( + "Form metadata 'invalid-slug' not found" + ) + expect(() => loader.getFormDefinition('invalid-id')).toThrow( + "Form definition 'invalid-id' not found" + ) + }) + + it("should throw if the file isn't JSON or YAML", async () => { + const loader = new FileFormService() + + await expect( + loader.addForm('src/server/forms/test.txt', metadata) + ).rejects.toThrow("Invalid file extension '.txt'") + }) +})