diff --git a/designer/client/pages/landing-page/ViewFundForms.tsx b/designer/client/pages/landing-page/ViewFundForms.tsx index 4f48e16d..9abf0617 100644 --- a/designer/client/pages/landing-page/ViewFundForms.tsx +++ b/designer/client/pages/landing-page/ViewFundForms.tsx @@ -13,6 +13,7 @@ type Props = { type State = { configs: { Key: string; DisplayName: string }[]; loading?: boolean; + }; export class ViewFundForms extends Component { @@ -25,30 +26,23 @@ export class ViewFundForms extends Component { }; } - componentDidMount() { - formConfigurationApi.loadConfigurations().then((configs) => { + async componentDidMount() { + try { + const configs = await formConfigurationApi.loadConfigurations(); this.setState({ loading: false, configs, }); - }); + } catch (error) { + logger.error("ViewFundForms componentDidMount", error); + this.setState({ loading: false }); + } } selectForm = async (form) => { try { - const response = await window.fetch("/api/new", { - method: "POST", - body: JSON.stringify({ - selected: {Key: form}, - name: "", - }), - headers: { - Accept: "application/json", - "Content-Type": "application/json", - }, - }); - const responseJson = await response.json(); - this.props.history.push(`/designer/${responseJson.id}`); + // Always go directly to edit the form from Pre-Award API + this.props.history.push(`/designer/${form}`); } catch (e) { logger.error("ChooseExisting", e); } diff --git a/designer/package.json b/designer/package.json index 1b896379..4667ac8c 100644 --- a/designer/package.json +++ b/designer/package.json @@ -10,7 +10,9 @@ "static-content:dist-push": "cp -r ../designer/public/static/images dist/client/assets && cp -r ../designer/public/static/css dist/client/assets", "build": "NODE_ENV=production && NODE_OPTIONS=--openssl-legacy-provider && webpack && yarn run static-content:dist-push", "start:server": "node dist/server.js", - "start:local": "NODE_ENV=development PERSISTENT_BACKEND=preview ts-node-dev --inspect=0.0.0.0:9229 --respawn --transpile-only server/index.ts" + "start:local": "NODE_ENV=development PERSISTENT_BACKEND=preview ts-node-dev --inspect=0.0.0.0:9229 --respawn --transpile-only server/index.ts", + "test": "lab test/cases/server/plugins/*.test.ts -T test/.transform.js -v", + "unit-test": "lab test/cases/server/plugins/*.test.ts -T test/.transform.js -v -S -r console -o stdout -r html -o unit-test.html -I version -l" }, "author": "Communities UK", "license": "SEE LICENSE IN LICENSE", diff --git a/designer/server/config.ts b/designer/server/config.ts index f0b71b31..92a5f68d 100644 --- a/designer/server/config.ts +++ b/designer/server/config.ts @@ -31,6 +31,8 @@ export interface Config { authCookieName: string, sslKey: string, sslCert: string, + + preAwardApiUrl: string, } // server-side storage expiration - defaults to 20 minutes @@ -65,6 +67,8 @@ const schema = joi.object({ authCookieName: joi.string().optional(), sslKey: joi.string().optional(), sslCert: joi.string().optional(), + + preAwardApiUrl: joi.string().default("https://api.communities.gov.localhost:4004"), }); // Build config @@ -90,6 +94,8 @@ const config = { authCookieName: process.env.AUTH_COOKIE_NAME, sslKey: process.env.SSL_KEY, sslCert: process.env.SSL_CERT, + + preAwardApiUrl: process.env.PRE_AWARD_API_URL || "https://api.communities.gov.localhost:4004", }; // Validate config diff --git a/designer/server/lib/preAwardApiClient.ts b/designer/server/lib/preAwardApiClient.ts new file mode 100644 index 00000000..0eef58a5 --- /dev/null +++ b/designer/server/lib/preAwardApiClient.ts @@ -0,0 +1,116 @@ +import config from "../config"; +import Wreck from "@hapi/wreck"; + +interface FormJson { + startPage: string; + pages: any[]; + sections: any[]; + name: string; + version?: number; + conditions?: any[]; + lists?: any[]; + metadata?: any; + fees?: any[]; + outputs?: any[]; + skipSummary?: boolean; + [key: string]: any; +} + +export interface FormData { + name: string; + form_json: FormJson; +} + +export interface FormResponse { + id: string; + name: string; + created_at: string; + updated_at: string; + published_at: string | null; + draft_json: FormJson; + published_json: FormJson; + is_published: boolean; +} + +export class PreAwardApiClient { + private baseUrl: string; + + constructor() { + this.baseUrl = config.preAwardApiUrl; + } + + async createOrUpdateForm(name: string, form_json: FormJson): Promise{ + const payload = { + name: name, + form_json: form_json, + }; + + try{ + const { payload: responseData } = await Wreck.post( + `${this.baseUrl}/forms`, + { + payload: JSON.stringify(payload), + headers: { + 'Content-Type': 'application/json' + } + } + ); + + const parsedData = JSON.parse((responseData as Buffer).toString()); + + return parsedData as FormResponse; + } + catch (error) { + + throw error; + } + } + + async getAllForms(): Promise { + try { + + const { payload: responseData } = await Wreck.get( + `${this.baseUrl}/forms`, + { + headers: { + 'Content-Type': 'application/json' + } + } + ); + + + + + const parsedData = JSON.parse((responseData as Buffer).toString()); + + + + return parsedData as FormResponse[]; + } catch (error) { + + throw error; + } + } + + async getFormDraft(name: string): Promise{ + try{ + const { payload: responseData } = await Wreck.get( + `${this.baseUrl}/forms/${name}/draft`, + { + headers: { + 'Content-Type': 'application/json' + } + } + ); + const parsedData = JSON.parse((responseData as Buffer).toString()); + + return parsedData as FormJson; + } + catch (error) { + + throw error; + } + } +} + +export const preAwardApiClient = new PreAwardApiClient(); diff --git a/designer/server/plugins/DesignerRouteRegister.ts b/designer/server/plugins/DesignerRouteRegister.ts index 2dd284a2..a6d4490f 100644 --- a/designer/server/plugins/DesignerRouteRegister.ts +++ b/designer/server/plugins/DesignerRouteRegister.ts @@ -1,6 +1,8 @@ -import {newConfig, api, app} from "../../../digital-form-builder/designer/server/plugins/routes"; +import {app} from "../../../digital-form-builder/designer/server/plugins/routes"; import {envStore, flagg} from "flagg"; import {putFormWithIdRouteRegister} from "./routes/PutFormWithIdRouteRegister"; +import {registerNewFormWithRunner} from "./routes/newConfig"; +import {getFormWithId, getAllPersistedConfigurations, log} from "./routes/api"; import config from "../config"; import {jwtAuthStrategyName} from "./AuthPlugin"; @@ -83,15 +85,15 @@ export const designerPlugin = { // @ts-ignore app.redirectOldUrlToDesigner.options.auth = jwtAuthStrategyName // @ts-ignore - newConfig.registerNewFormWithRunner.options.auth = jwtAuthStrategyName + registerNewFormWithRunner.options.auth = jwtAuthStrategyName // @ts-ignore - api.getFormWithId.options.auth = jwtAuthStrategyName + getFormWithId.options.auth = jwtAuthStrategyName // @ts-ignore putFormWithIdRouteRegister.options.auth = jwtAuthStrategyName // @ts-ignore - api.getAllPersistedConfigurations.options.auth = jwtAuthStrategyName + getAllPersistedConfigurations.options.auth = jwtAuthStrategyName // @ts-ignore - api.log.options.auth = jwtAuthStrategyName + log.options.auth = jwtAuthStrategyName } server.route(startRoute); @@ -118,6 +120,7 @@ export const designerPlugin = { store: envStore(process.env), definitions: { featureEditPageDuplicateButton: {default: false}, + }, }); @@ -128,11 +131,11 @@ export const designerPlugin = { }, }); - server.route(newConfig.registerNewFormWithRunner); - server.route(api.getFormWithId); + server.route(registerNewFormWithRunner); + server.route(getFormWithId); server.route(putFormWithIdRouteRegister); - server.route(api.getAllPersistedConfigurations); - server.route(api.log); + server.route(getAllPersistedConfigurations); + server.route(log); }, }, }; diff --git a/designer/server/plugins/routes/PutFormWithIdRouteRegister.ts b/designer/server/plugins/routes/PutFormWithIdRouteRegister.ts index 3764b9bc..477fc110 100644 --- a/designer/server/plugins/routes/PutFormWithIdRouteRegister.ts +++ b/designer/server/plugins/routes/PutFormWithIdRouteRegister.ts @@ -1,6 +1,8 @@ import {ServerRoute} from "@hapi/hapi"; import {AdapterSchema} from "@communitiesuk/model"; import {publish} from "../../../../digital-form-builder/designer/server/lib/publish"; +import {preAwardApiClient} from "../../lib/preAwardApiClient"; +import config from "../../config"; export const putFormWithIdRouteRegister: ServerRoute = { @@ -31,6 +33,10 @@ export const putFormWithIdRouteRegister: ServerRoute = { `${id}`, JSON.stringify(value) ); + // Save to Pre-Award API + const formWithName = { ...value, name: id}; + await preAwardApiClient.createOrUpdateForm(id, formWithName); + // Publish to runner for preview await publish(id, value); return h.response({ok: true}).code(204); } catch (err) { diff --git a/designer/server/plugins/routes/api.ts b/designer/server/plugins/routes/api.ts new file mode 100644 index 00000000..9ed966b9 --- /dev/null +++ b/designer/server/plugins/routes/api.ts @@ -0,0 +1,60 @@ +import { api as originalApi } from "../../../../digital-form-builder/designer/server/plugins/routes"; +import { preAwardApiClient } from "../../lib/preAwardApiClient"; +import config from "../../config"; +import { ServerRoute, ResponseObject } from "@hapi/hapi"; + +// Extend the original getFormWithId with Pre-Award API support +export const getFormWithId: ServerRoute = { + ...originalApi.getFormWithId, + options: { + ...originalApi.getFormWithId.options || {}, + handler: async (request, h) => { + const { id } = request.params; + const formJson = await preAwardApiClient.getFormDraft(id); + return h.response(formJson).type("application/json"); + }, + }, +}; + +// Extend the original putFormWithId with Pre-Award API support +export const putFormWithId: ServerRoute = { + ...originalApi.putFormWithId, + options: { + ...originalApi.putFormWithId.options || {}, + handler: async (request, h) => { + const { id } = request.params; + const { Schema } = await import("../../../../digital-form-builder/model/src"); + const { value, error } = Schema.validate(request.payload, { + abortEarly: false, + }); + + if (error) { + throw new Error("Schema validation failed, reason: " + error.message); + } + const formWithName = { ...value, name: id}; + await preAwardApiClient.createOrUpdateForm(id, formWithName); + + return h.response({ ok: true }).code(204); + }, + }, +}; + +// Extend the original getAllPersistedConfigurations with Pre-Award API support +export const getAllPersistedConfigurations: ServerRoute = { + ...originalApi.getAllPersistedConfigurations, + options: { + ...originalApi.getAllPersistedConfigurations.options || {}, + handler: async (request, h): Promise => { + const forms = await preAwardApiClient.getAllForms(); + const response = forms.map(form => ({ + Key: form.name, + DisplayName: form.name, + LastModified: form.updated_at + })); + return h.response(response).type("application/json"); + }, + }, +}; + +// Use original log route as-is +export const log = originalApi.log; diff --git a/designer/server/plugins/routes/newConfig.ts b/designer/server/plugins/routes/newConfig.ts new file mode 100644 index 00000000..a0477cf5 --- /dev/null +++ b/designer/server/plugins/routes/newConfig.ts @@ -0,0 +1,42 @@ +import { newConfig as originalNewConfig } from "../../../../digital-form-builder/designer/server/plugins/routes"; +import { preAwardApiClient } from "../../lib/preAwardApiClient"; +import config from "../../config"; +import { ServerRoute } from "@hapi/hapi"; +import { HapiRequest } from "../../../../digital-form-builder/designer/server/types"; +import { nanoid } from "nanoid"; +import newFormJson from "../../../../digital-form-builder/designer/new-form.json"; + +// Extend the original registerNewFormWithRunner with Pre-Award API support +export const registerNewFormWithRunner: ServerRoute = { + ...originalNewConfig.registerNewFormWithRunner, + options: { + ...originalNewConfig.registerNewFormWithRunner.options, + handler: async (request: HapiRequest, h) => { + const { selected, name } = request.payload as any; + + if (name && name !== "" && !name.match(/^[a-zA-Z0-9 _-]+$/)) { + return h + .response("Form name should not contain special characters") + .type("application/json") + .code(400); + } + + const newName = name === "" ? nanoid(10) : name; + + if (selected.Key === "New") { + const formWithName = { ...newFormJson, name: newName }; + await preAwardApiClient.createOrUpdateForm(newName, formWithName); + } else { + const existingForm = await preAwardApiClient.getFormDraft(selected.Key); + const formWithNewName = { ...existingForm, name: newName }; + await preAwardApiClient.createOrUpdateForm(newName, formWithNewName); + } + + const response = JSON.stringify({ + id: `${newName}`, + previewUrl: config.previewUrl, + }); + return h.response(response).type("application/json").code(200); + }, + }, +}; diff --git a/designer/test/.transform.js b/designer/test/.transform.js new file mode 100644 index 00000000..b153b443 --- /dev/null +++ b/designer/test/.transform.js @@ -0,0 +1,17 @@ +const Babel = require("@babel/core"); + +module.exports = [ + { + ext: ".ts", + transform: (content, filename) => { + const result = Babel.transformSync(content, { + filename, + presets: [ + ["@babel/preset-env", { targets: { node: "current" } }], + "@babel/preset-typescript" + ] + }); + return result.code; + } + } +]; \ No newline at end of file diff --git a/designer/test/cases/server/plugins/api.routes.test.ts b/designer/test/cases/server/plugins/api.routes.test.ts new file mode 100644 index 00000000..6a3f81be --- /dev/null +++ b/designer/test/cases/server/plugins/api.routes.test.ts @@ -0,0 +1,73 @@ +const Code = require("@hapi/code"); +const Lab = require("@hapi/lab"); +const sinon = require("sinon"); + +const { expect } = Code; +const lab = Lab.script(); +exports.lab = lab; +const { suite, test, beforeEach, afterEach } = lab; + +suite("API Routes with Pre-Award Integration", () => { + let mockPreAwardClient; + + beforeEach(() => { + mockPreAwardClient = { + getAllForms: sinon.stub(), + getFormDraft: sinon.stub(), + createOrUpdateForm: sinon.stub() + }; + }); + + afterEach(() => { + sinon.restore(); + }); + + suite("getAllPersistedConfigurations", () => { + test("should retrieve all forms from Pre-Award API", async () => { + const mockForms = [ + { name: "form-1", updated_at: "2025-01-01" }, + { name: "form-2", updated_at: "2025-01-02" } + ]; + mockPreAwardClient.getAllForms.resolves(mockForms); + + const forms = await mockPreAwardClient.getAllForms(); + const response = forms.map(form => ({ + Key: form.name, + DisplayName: form.name, + LastModified: form.updated_at + })); + + expect(response).to.have.length(2); + expect(response[0].DisplayName).to.equal("form-1"); + }); + + test("should handle empty forms list", async () => { + mockPreAwardClient.getAllForms.resolves([]); + const result = await mockPreAwardClient.getAllForms(); + expect(result).to.equal([]); + }); + }); + + suite("getFormWithId", () => { + test("should retrieve specific form from Pre-Award API", async () => { + const mockForm = { + id: "test-form", + name: "Test Form" + }; + mockPreAwardClient.getFormDraft.resolves(mockForm); + + const result = await mockPreAwardClient.getFormDraft("test-form"); + expect(result).to.equal(mockForm); + }); + }); + + suite("putFormWithId", () => { + test("should save form to Pre-Award API", async () => { + const mockResponse = { statusCode: 200 }; + mockPreAwardClient.createOrUpdateForm.resolves(mockResponse); + + const result = await mockPreAwardClient.createOrUpdateForm("test-form", {}); + expect(result.statusCode).to.equal(200); + }); + }); +}); diff --git a/designer/test/cases/server/plugins/config.test.ts b/designer/test/cases/server/plugins/config.test.ts new file mode 100644 index 00000000..a0047e79 --- /dev/null +++ b/designer/test/cases/server/plugins/config.test.ts @@ -0,0 +1,104 @@ +const Code = require("@hapi/code"); +const Lab = require("@hapi/lab"); + +const { expect } = Code; +const lab = Lab.script(); +exports.lab = lab; +const { suite, test, beforeEach, afterEach } = lab; + +suite("Configuration Management", () => { + let originalEnv; + + beforeEach(() => { + originalEnv = { + PRE_AWARD_API_URL: process.env.PRE_AWARD_API_URL + }; + }); + + afterEach(() => { + if (originalEnv.PRE_AWARD_API_URL !== undefined) { + process.env.PRE_AWARD_API_URL = originalEnv.PRE_AWARD_API_URL; + } else { + delete process.env.PRE_AWARD_API_URL; + } + }); + + suite("Pre-Award API URL Configuration", () => { + test("should use provided PRE_AWARD_API_URL", () => { + const testUrl = "https://api.example.com"; + process.env.PRE_AWARD_API_URL = testUrl; + + const preAwardApiUrl = process.env.PRE_AWARD_API_URL; + + expect(preAwardApiUrl).to.equal(testUrl); + }); + + test("should handle localhost URLs", () => { + const localhostUrl = "http://localhost:3001"; + process.env.PRE_AWARD_API_URL = localhostUrl; + + const preAwardApiUrl = process.env.PRE_AWARD_API_URL; + + expect(preAwardApiUrl).to.equal(localhostUrl); + }); + + test("should handle URLs with paths", () => { + const urlWithPath = "https://api.example.com/v1/forms"; + process.env.PRE_AWARD_API_URL = urlWithPath; + + const preAwardApiUrl = process.env.PRE_AWARD_API_URL; + + expect(preAwardApiUrl).to.equal(urlWithPath); + }); + + test("should use default when PRE_AWARD_API_URL is empty", () => { + process.env.PRE_AWARD_API_URL = ""; + + const preAwardApiUrl = process.env.PRE_AWARD_API_URL || "https://api.communities.gov.localhost:4004"; + + expect(preAwardApiUrl).to.equal("https://api.communities.gov.localhost:4004"); + }); + + test("should use default when PRE_AWARD_API_URL is undefined", () => { + delete process.env.PRE_AWARD_API_URL; + + const preAwardApiUrl = process.env.PRE_AWARD_API_URL || "https://api.communities.gov.localhost:4004"; + + expect(preAwardApiUrl).to.equal("https://api.communities.gov.localhost:4004"); + }); + }); + + suite("Configuration Validation", () => { + test("should validate API URL format", () => { + const validUrls = [ + "https://api.example.com", + "http://localhost:3001", + "https://api.communities.gov.localhost:4004" + ]; + + validUrls.forEach(url => { + process.env.PRE_AWARD_API_URL = url; + const config = { + preAwardApiUrl: process.env.PRE_AWARD_API_URL + }; + + expect(config.preAwardApiUrl).to.equal(url); + expect(config.preAwardApiUrl).to.match(/^https?:\/\/.+/); + }); + }); + + test("should handle configuration with all required fields", () => { + process.env.PRE_AWARD_API_URL = "https://api.example.com"; + + const config = { + preAwardApiUrl: process.env.PRE_AWARD_API_URL, + env: "development", + port: 3000 + }; + + expect(config.preAwardApiUrl).to.equal("https://api.example.com"); + expect(config.env).to.equal("development"); + expect(config.port).to.equal(3000); + }); + }); +}); \ No newline at end of file diff --git a/designer/test/cases/server/plugins/preAwardApiClient.test.ts b/designer/test/cases/server/plugins/preAwardApiClient.test.ts new file mode 100644 index 00000000..86a5572d --- /dev/null +++ b/designer/test/cases/server/plugins/preAwardApiClient.test.ts @@ -0,0 +1,137 @@ +const Code = require("@hapi/code"); +const Lab = require("@hapi/lab"); +const sinon = require("sinon"); + +const { expect } = Code; +const lab = Lab.script(); +exports.lab = lab; +const { suite, test, beforeEach, afterEach } = lab; + +class MockPreAwardApiClient { + public baseUrl: string; + public wreck: any; + + constructor(baseUrl: string, wreckClient: any) { + this.baseUrl = baseUrl; + this.wreck = wreckClient; + } + + async createOrUpdateForm(name: string, formData: any): Promise { + const url = `${this.baseUrl}/forms/${name}`; + return await this.wreck.post(url, { + payload: JSON.stringify(formData), + headers: { "Content-Type": "application/json" } + }); + } + + async getAllForms(): Promise { + const url = `${this.baseUrl}/forms`; + const response = await this.wreck.get(url); + return JSON.parse((response.payload).toString()); + } + + async getFormDraft(formId: string): Promise { + const url = `${this.baseUrl}/forms/${formId}`; + const response = await this.wreck.get(url); + return JSON.parse((response.payload).toString()); + } +} + +suite("PreAwardApiClient", () => { + let client; + let wreckStub; + + beforeEach(() => { + wreckStub = { + post: sinon.stub(), + get: sinon.stub(), + }; + client = new MockPreAwardApiClient("http://test-api.com", wreckStub); + }); + + afterEach(() => { + sinon.restore(); + }); + + suite("createOrUpdateForm", () => { + test("should successfully create a new form", async () => { + const mockResponse = { statusCode: 201 }; + wreckStub.post.resolves(mockResponse); + + const formData = { + name: "Test Form", + configuration: { pages: [] } + }; + const result = await client.createOrUpdateForm("new-form-id", formData); + + expect(wreckStub.post.calledOnce).to.be.true(); + expect(result).to.equal(mockResponse); + }); + + test("should handle network errors", async () => { + const networkError = new Error("Network timeout"); + wreckStub.post.rejects(networkError); + + try { + await client.createOrUpdateForm("test-id", {}); + expect.fail("Should have thrown a network error"); + } catch (err: any) { + expect(err.message).to.equal("Network timeout"); + } + }); + }); + + suite("getAllForms", () => { + test("should successfully retrieve all forms", async () => { + const mockForms = [ + { id: "form-1", name: "Application Form" }, + { id: "form-2", name: "Feedback Form" } + ]; + const mockBuffer = Buffer.from(JSON.stringify(mockForms)); + wreckStub.get.resolves({ payload: mockBuffer }); + + const result = await client.getAllForms(); + + expect(result).to.equal(mockForms); + expect(result.length).to.equal(2); + }); + + test("should handle empty forms list", async () => { + const mockBuffer = Buffer.from(JSON.stringify([])); + wreckStub.get.resolves({ payload: mockBuffer }); + + const result = await client.getAllForms(); + + expect(result).to.equal([]); + }); + }); + + suite("getFormDraft", () => { + test("should successfully retrieve a specific form", async () => { + const mockForm = { + id: "test-form", + name: "Test Form", + configuration: { pages: [] } + }; + const mockBuffer = Buffer.from(JSON.stringify(mockForm)); + wreckStub.get.resolves({ payload: mockBuffer }); + + const result = await client.getFormDraft("test-form"); + + expect(result).to.equal(mockForm); + }); + + test("should handle form not found", async () => { + const notFoundError = new Error("Form not found"); + (notFoundError as any).statusCode = 404; + wreckStub.get.rejects(notFoundError); + + try { + await client.getFormDraft("non-existent-form"); + expect.fail("Should have thrown a not found error"); + } catch (err: any) { + expect(err.message).to.equal("Form not found"); + } + }); + }); +}); \ No newline at end of file