diff --git a/api.planx.uk/modules/analytics/metabase/shared/client.test.ts b/api.planx.uk/modules/analytics/metabase/shared/client.test.ts index 43d9c7a3f9..564909ef6a 100644 --- a/api.planx.uk/modules/analytics/metabase/shared/client.test.ts +++ b/api.planx.uk/modules/analytics/metabase/shared/client.test.ts @@ -1 +1,115 @@ -test.todo("should test configuration and errors"); +import axios from "axios"; +import { + validateConfig, + createMetabaseClient, + MetabaseError, +} from "./client.js"; +import nock from "nock"; + +const axiosCreateSpy = vi.spyOn(axios, "create"); + +describe("Metabase client", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + test("returns configured client", async () => { + const client = createMetabaseClient(); + expect(client.defaults.baseURL).toBe(process.env.METABASE_URL_EXT); + expect(client.defaults.headers["X-API-Key"]).toBe( + process.env.METABASE_API_KEY, + ); + expect(client.defaults.headers["Content-Type"]).toBe("application/json"); + expect(client.defaults.timeout).toBe(30_000); + }); + + describe("validates configuration", () => { + test("throws error when URL_EXT is missing", () => { + vi.stubEnv("METABASE_URL_EXT", undefined); + expect(() => validateConfig()).toThrow( + "Missing environment variable 'METABASE_URL_EXT'", + ); + }); + + test("throws error when API_KEY is missing", () => { + vi.stubEnv("METABASE_API_KEY", undefined); + expect(() => validateConfig()).toThrow( + "Missing environment variable 'METABASE_API_KEY'", + ); + }); + + test("returns valid config object", () => { + const config = validateConfig(); + expect(config).toMatchObject({ + baseURL: process.env.METABASE_URL_EXT, + apiKey: process.env.METABASE_API_KEY, + timeout: 30_000, + retries: 3, + }); + }); + }); + + describe("Error handling", () => { + test("retries then succeeds on 5xx errors", async () => { + const metabaseScope = nock(process.env.METABASE_URL_EXT!); + + metabaseScope + .get("/test") + .reply(500, { message: "Internal Server Error" }) + .get("/test") + .reply(200, { data: "success" }); + + const client = createMetabaseClient(); + const response = await client.get("/test"); + + expect(response.data).toEqual({ data: "success" }); + expect(metabaseScope.isDone()).toBe(true); + }); + + test("throws an error if all requests fail", async () => { + const metabaseScope = nock(process.env.METABASE_URL_EXT!); + + metabaseScope + .get("/test") + .times(4) + .reply(500, { message: "Internal Server Error" }); + + const client = createMetabaseClient(); + + try { + await client.get("/test"); + expect.fail("Should have thrown an error"); + } catch (error) { + expect(error).toBeInstanceOf(MetabaseError); + expect((error as MetabaseError).statusCode).toBe(500); + expect(metabaseScope.isDone()).toBe(true); + } + }); + + test("does not retry on non-5xx errors", async () => { + const metabaseScope = nock(process.env.METABASE_URL_EXT!); + + metabaseScope.get("/test").once().reply(200, { data: "success" }); + + const client = createMetabaseClient(); + const response = await client.get("/test"); + + expect(response.data).toEqual({ data: "success" }); + + // All expected requests were made + expect(metabaseScope.isDone()).toBe(true); + + // No pending mocks left + expect(metabaseScope.pendingMocks()).toHaveLength(0); + + // Double check that no other requests were intercepted + const requestCount = metabaseScope.activeMocks().length; + expect(requestCount).toBe(0); + }); + }); +}); diff --git a/api.planx.uk/modules/analytics/metabase/shared/client.ts b/api.planx.uk/modules/analytics/metabase/shared/client.ts index e69de29bb2..17c4b7b07c 100644 --- a/api.planx.uk/modules/analytics/metabase/shared/client.ts +++ b/api.planx.uk/modules/analytics/metabase/shared/client.ts @@ -0,0 +1,129 @@ +import axios from "axios"; +import type { + AxiosInstance, + AxiosError, + InternalAxiosRequestConfig, +} from "axios"; + +// Custom error class for Metabase-specific errors +export class MetabaseError extends Error { + constructor( + message: string, + public statusCode?: number, + public response?: unknown, + ) { + super(message); + this.name = "MetabaseError"; + } +} + +interface MetabaseConfig { + baseURL: string; + apiKey: string; + timeout?: number; + retries?: number; +} + +// Validate environment variables +export const validateConfig = (): MetabaseConfig => { + const baseURL = process.env.METABASE_URL_EXT; + const apiKey = process.env.METABASE_API_KEY; + + const METABASE_TIMEOUT = 30_000; + const METABASE_MAX_RETRIES = 3; + + assert(baseURL, "Missing environment variable 'METABASE_URL_EXT'"); + assert(apiKey, "Missing environment variable 'METABASE_API_KEY'"); + + return { + baseURL, + apiKey, + timeout: METABASE_TIMEOUT, + retries: METABASE_MAX_RETRIES, + }; +}; + +// Extended request config to include retry count +interface ExtendedAxiosRequestConfig extends InternalAxiosRequestConfig { + retryCount?: number; +} + +// Create and configure Axios instance +export const createMetabaseClient = (): AxiosInstance => { + const config = validateConfig(); + + const client = axios.create({ + baseURL: config.baseURL, + headers: { + "X-API-Key": config.apiKey, + "Content-Type": "application/json", + }, + timeout: config.timeout, + }); + + client.interceptors.response.use( + (response) => { + return response; + }, + async (error: AxiosError) => { + const originalRequest = error.config as ExtendedAxiosRequestConfig; + + if (!originalRequest) { + throw new MetabaseError("No request config available"); + } + + // Initialise retry count if not present + if (typeof originalRequest.retryCount === "undefined") { + originalRequest.retryCount = 0; + } + + // Handle retry logic + if (error.response) { + // Retry on 5xx errors + if ( + error.response.status >= 500 && + originalRequest.retryCount < (config.retries ?? 3) + ) { + originalRequest.retryCount++; + return client.request(originalRequest); + } + + // Transform error response + const errorMessage = + typeof error.response.data === "object" && + error.response.data !== null && + "message" in error.response.data + ? String(error.response.data.message) + : "Metabase request failed"; + + throw new MetabaseError( + errorMessage, + error.response.status, + error.response.data, + ); + } + + // Handle network errors + if (error.request) { + throw new MetabaseError( + "Network error occurred", + undefined, + error.request, + ); + } + + // Handle other errors + throw new MetabaseError(error.message); + }, + ); + + return client; +}; + +// // Export both client and instance with delayed instantiation for test purposes +// export let metabaseClient: AxiosInstance; + +// export const initializeMetabaseClient = () => { +// metabaseClient = createMetabaseClient(); +// return metabaseClient; +// }; diff --git a/api.planx.uk/modules/flows/validate/helpers.ts b/api.planx.uk/modules/flows/validate/helpers.ts index 577949bbb2..cbc4c3770e 100644 --- a/api.planx.uk/modules/flows/validate/helpers.ts +++ b/api.planx.uk/modules/flows/validate/helpers.ts @@ -22,14 +22,12 @@ export const hasComponentType = ( const nodeIds = Object.entries(flowGraph).filter( (entry): entry is [string, Node] => isComponentType(entry, type), ); + if (fn) { - nodeIds - ?.filter(([_nodeId, nodeData]) => nodeData?.data?.fn === fn) - ?.map(([nodeId, _nodeData]) => nodeId); - } else { - nodeIds?.map(([nodeId, _nodeData]) => nodeId); + return nodeIds.some(([, nodeData]) => nodeData?.data?.fn === fn); } - return Boolean(nodeIds?.length); + + return Boolean(nodeIds.length); }; export const numberOfComponentType = ( diff --git a/api.planx.uk/modules/flows/validate/validate.test.ts b/api.planx.uk/modules/flows/validate/validate.test.ts index 302b50fd14..46aa4a0833 100644 --- a/api.planx.uk/modules/flows/validate/validate.test.ts +++ b/api.planx.uk/modules/flows/validate/validate.test.ts @@ -419,15 +419,9 @@ describe("invite to pay validation on diff", () => { }); it("does not update if invite to pay is enabled, but there is not a Checklist that sets `proposal.projectType`", async () => { - const { - Checklist: _Checklist, - ChecklistOptionOne: _ChecklistOptionOne, - ChecklistOptionTwo: _ChecklistOptionTwo, - ...invalidatedFlow - } = flowWithInviteToPay; - invalidatedFlow["_root"].edges?.splice( - invalidatedFlow["_root"].edges?.indexOf("Checklist"), - ); + const invalidatedFlow = flowWithInviteToPay; + // Remove proposal.projectType, set incorrect variable + invalidatedFlow!.Checklist!.data!.fn = "some.other.variable"; queryMock.mockQuery({ name: "GetFlowData", diff --git a/doc/how-to/how-to-setup-aws-s3-submissions.md b/doc/how-to/aws/how-to-setup-aws-s3-submissions.md similarity index 100% rename from doc/how-to/how-to-setup-aws-s3-submissions.md rename to doc/how-to/aws/how-to-setup-aws-s3-submissions.md diff --git a/doc/how-to/how-to-setup-aws-sso-credentials.md b/doc/how-to/aws/how-to-setup-aws-sso-credentials.md similarity index 100% rename from doc/how-to/how-to-setup-aws-sso-credentials.md rename to doc/how-to/aws/how-to-setup-aws-sso-credentials.md diff --git a/doc/how-to/how-to-add-fields-to-search.md b/doc/how-to/how-to-add-fields-to-search.md new file mode 100644 index 0000000000..fb6c12062f --- /dev/null +++ b/doc/how-to/how-to-add-fields-to-search.md @@ -0,0 +1,90 @@ +# How to add fields to the Editor search index + +## Overview + +This guide outlines the process of adding new searchable fields to the PlanX Editors' frontend search functionality, which uses Fuse.js for indexing and searching. This is a required step with adding a new component to PlanX, or adding new fields to an existing component. + +## Background + +- Search is currently implemented in the frontend using Fuse.js +- Only certain fields are searchable: + - Text fields (simple text) + - Rich text fields (HTML) + - Data values (e.g. `data.fn`) + +## Process + +### 1. Update facets configuration + +Location: `src/pages/FlowEditor/components/Sidebar/Search/facets.ts` + +#### Guidelines: +- Use key paths to the new fields (e.g. `data.myNewField`) +- Wrap rich text fields with `richTextField()` helper - this strips HTML tags +- Add data fields to `DATA_FACETS` +- Add text fields to `ALL_FACETS` +- Avoid adding duplicate values already held in `ALL_FACETS` (e.g., `data.title`, `data.description`) + +#### Example: + +```ts +// facets.ts + +const myNewComponent: SearchFacets = [ + richTextField("data.myRichTextField"), + "data.myPlainTextField" +]; + +export const ALL_FACETS: SearchFacets = [ + ...otherComponents, + ...myNewComponent, + ...DATA_FACETS, +]; +``` + +### 2. Assign display values + +Location: `src/pages/FlowEditor/components/Sidebar/Search/SearchResultCard/getDisplayDetailsForResult.tsx` + +#### Add key formatters: + +```ts +// getDisplayDetailsForResult.tsx + +const keyFormatters: KeyMap = { + ...existingFormatters, + "data.myNewField": { + getDisplayKey: () => "My New Field", + }, +}; +``` + +### 3. Update tests + +Locations: +- `src/pages/FlowEditor/components/Sidebar/Search/SearchResultCard/allFacets.test.ts` +- `src/pages/FlowEditor/components/Sidebar/Search/SearchResultCard/dataFacets.test.ts` + +#### Test steps: +1. Add new component to `mockFlow` +2. Create mock search result + - Example: `mockMyComponentResult: SearchResult` + +### Debugging tip + +A search result can be easily logged to the console from `SearchResultCard`. Simply search for one of your new fields, and click on the card. + +```ts +// SearchResultCard/index.tsx + +const handleClick = () => { + const url = getURLForNode(result.item.id); + // Temporarily disable navigation + // navigate(url); + + // Log the full search result to console + console.log({ result }); +}; +``` + +For reference, [please see this PR](https://github.com/theopensystemslab/planx-new/pull/4015) which added the text fields for the `Feedback` component to the search index. \ No newline at end of file diff --git a/doc/how-to/how-to-debug-failed-submissions.md b/doc/how-to/how-to-debug-failed-submissions.md new file mode 100644 index 0000000000..6a6da64d33 --- /dev/null +++ b/doc/how-to/how-to-debug-failed-submissions.md @@ -0,0 +1,99 @@ +# How to debug failed submissions + +This guide will explain how to troubleshoot failed application submissions. + +If a live application submission fails it is important to investigate this urgently, and aim for same-day re-submission if possible. Luckily this doesn't happen very often! + +More information on how to re-submit applications can be found in [./how-to-resubmit-an-application.md](./how-to-resubmit-an-application.md). + +## Finding out that a submission has failed + +### Via `#planx-errors` + +Normally we find out that a submission has failed through a notification in the `#planx-errors` Slack channel, and these are usually easy to spot as they are longer than the normal errors. + +They also start with `Error: Sending to failed`. + +E.g. + +```json +> New production error in planx (error severity) +> +Error: Sending to BOPS v2 failed (southwark - a-5e5510n-1d-a-535510n1d): Error: Invalid DigitalPlanning payload for session a-5e5510n-1d-a-535510n1d. Errors: [ { "instancePath": "/data/applicant/ownership/owners/0", "schemaPath": "#/required", "keyword": "required", "params": { "missingProperty": "noticeGiven" }, "message": "must have required property 'noticeGiven'" }, { "instancePath": "/data/applicant/ownership/owners/0", "schemaPath": "#/required", "keyword": "required", "params": { "missingProperty": "noNoticeReason" }, "message": "must have required property 'noNoticeReason'" }, { "instancePath": "/data/applicant/ownership/ow… +> Show more +> Occurred in file:///api/dist/modules/send/bops/bops.js:98 + +``` + +### Via `#planx-notifications` + +There are normally three notifications per Lawful Development Certificate (LDC) submission in the `#planx-notifications` Slack channel, so a tell-tale sign of an error is if there are fewer messages. + +For example, along with the production error above, for this submission there were only these two messages (the BOPS submission was missing): + +``` +6:02PM New GOV Pay payment cj14av1r8hd8vdqp5e2tn5gto1 with status success [southwark] +6:03PM New Uniform submission a-5e5510n-1d-a-535510n1d [Southwark] +``` + +When we successfully re-submitted the application, we got this third notification: + +``` +11:06AM New BOPS submission 24-00242-LDCP [https://southwark.bops.services/api/v2/planning_applications] +``` + +### Feedback from users + +Ideally we never want to get in the situation where an end-user notices the error before we do. But we may be informed about a failed submission via Slack channels shared with Local Planning Authorities (LPAs), such as the Open Digital Planning (ODP) Slack workspace. + +## Investigating the error + +We have several admin API endpoints that exist to help debug submission issues. These can be viewed in the [Swagger docs](https://api.editor.planx.uk/docs/#/admin). + +If it is not obvious from the error notification, you can follow these steps to investigate further: + +#### Inspect the whole error message + +1. Get the `sessionId` from the error notification. +2. In a browser, hit `api.editor.planx.uk/admin/session/{sessions-id}/digital-planning-application`. This should output the full error message. + +#### Inspect the erroring payload +3. To get the schema payload that caused this error, add a `skipValidation` query param to the `digital-planning-application` URL, i.e.`?skipValidation=true` + +#### Compare the payload with the schema +1. There are useful online tools which can help find the validation issue, such as `json-schema.app`. +2. Load the current schema URL (e.g. `https://theopensystemslab.github.io/digital-planning-data-schemas/v0.7.1/schemas/application.json` ), and you can compare the erroring payload directly with the schema via the window on the right in the same tool. + +> [!TIP] +> The schema URL can be found at the bottom of the payload: +> +> `{ ..."schema": "https://theopensystemslab.github.io/digital-planning-data-schemas/v0.7.1/schemas/application.json" }` + +#### Read the code +1. The flow passport is mapped to the schema in this file: https://github.com/theopensystemslab/planx-core/blob/005ef823e68eaf9f7d7d5a2c793a6a729e0b6475/src/export/digitalPlanning/model.ts + +#### Inspect the Hasura record for the submission +1. Use the admin `/summary` endpoint. This will output the hasura record for the submission, including the entire passport. + +#### Go through the flow that the submission came from +1. Trace the journey that the user took based on their passport variables, and find out if/where there is a mismatch between content and schema. + +> [!TIP] +> It might be useful to toggle the data fields for the flow to help match the passport answers with the questions. + +> [!WARNING] +> Note that the published flow that the applicant used might be different from the current version of the editor flow! + +## Fixing the payload + +If the solution involves fixing the submission payload before resubmitting, you can do this as follows: + +1. Go to the production hasura console and filter the `lowcal_sessions` table with the `session_id`. +2. Make a copy of the contents of the `data` field (it is a good idea to store this temporarily in your code editor in case multiple changes are needed). +3. Fix the payload (e.g. this might mean inserting a new key-value pair in the JSON) and validate the payload with a tool like [jsonlint.com](jsonlint.com). +4. Replace the data field in Hasura with the corrected and validated payload. +5. Save the data, then rerun the admin endpoints from earlier (without the `skipValidation` param) to check that there are no longer any errors. +6. If all is good, resubmit the application - follow the instructions in [./how-to-resubmit-an-application.md](./how-to-resubmit-an-application.md). + +> [!CAUTION] +> Production data should only be edited when pairing with another OSL developer! diff --git a/doc/how-to/how-to-resubmit-an-application.md b/doc/how-to/how-to-resubmit-an-application.md index 033cce136f..95ba599d7b 100644 --- a/doc/how-to/how-to-resubmit-an-application.md +++ b/doc/how-to/how-to-resubmit-an-application.md @@ -27,13 +27,15 @@ In the near future, this process should be replaced by a re-submission API which * `bops_application.response.message` should be changed from `"Application created"` to `"FAILED"` 6. In Insomnia (or HTTP client of choice), prepare to re-submit the payload - - * API Endpoint - `https://api.editor.planx.uk/${destination}/${localAuthority}` - * For example, https://api.editor.planx.uk/uniform/aylesbury-vale + * API Endpoint - `https://api.editor.planx.uk/create-send-events/${sessionId}` + * For example, https://api.editor.planx.uk/create-send-events/a535510n-1d-a535510n-1d * HTTP Method - POST * Headers - `{ "Authorization": ${HASURA_PLANX_API_KEY} }` * This secret can be found via Pulumi or your `.env` file + +This can also be done directly from the [Swagger docs](https://api.editor.planx.uk/docs/). -> ⚠️ *Uniform instances do not neatly map to local authorities. Please take care to ensure that the value for `localAuthority` is taken from `uniform_applications.destination` when re-submitting to Uniform* +> ⚠️ *Uniform instances do not neatly map to local authorities. Please take care to ensure that the value for `localAuthority` in the payload is taken from `uniform_applications.destination` when re-submitting to Uniform* 7. Prepare the payload copied from Step 3. Ensure the structure is correct, see example - @@ -47,9 +49,9 @@ In the near future, this process should be replaced by a re-submission API which 8. Send! ✉️ -9. Check response message from BoPS / Uniform to confirm success or failure. Rectify any issues if possible (e.g. malfored payload) +9. Check response message from BoPS / Uniform to confirm success or failure (e.g. you can check the submissions log in the PlanX Editor). Rectify any issues if possible (e.g. malformed payload) -10. Notify partners on `#planx-notifications` channel of the re-submissions +10. Notify partners on `#planx-notifications` channel of the re-submissions. It is useful to link missing submissions to their counterpart payment logs, if applicable (reply in thread). ## Process if the application has been paid for, but there are no submission attempts recorded diff --git a/doc/how-to/how-to-add-allow-list-variables.md b/doc/how-to/metabase/how-to-add-allow-list-variables.md similarity index 100% rename from doc/how-to/how-to-add-allow-list-variables.md rename to doc/how-to/metabase/how-to-add-allow-list-variables.md diff --git a/doc/how-to/how-to-grant-metabase-permissions.md b/doc/how-to/metabase/how-to-grant-metabase-permissions.md similarity index 100% rename from doc/how-to/how-to-grant-metabase-permissions.md rename to doc/how-to/metabase/how-to-grant-metabase-permissions.md diff --git a/doc/how-to/how-to-setup-metabase-for-a-new-team-or-service.md b/doc/how-to/metabase/how-to-setup-metabase-for-a-new-team-or-service.md similarity index 100% rename from doc/how-to/how-to-setup-metabase-for-a-new-team-or-service.md rename to doc/how-to/metabase/how-to-setup-metabase-for-a-new-team-or-service.md diff --git a/doc/how-to/images/setup-metabase/default_values_filtered.png b/doc/how-to/metabase/images/setup-metabase/default_values_filtered.png similarity index 100% rename from doc/how-to/images/setup-metabase/default_values_filtered.png rename to doc/how-to/metabase/images/setup-metabase/default_values_filtered.png diff --git a/doc/how-to/images/setup-metabase/duplicate_a_dashboard.png b/doc/how-to/metabase/images/setup-metabase/duplicate_a_dashboard.png similarity index 100% rename from doc/how-to/images/setup-metabase/duplicate_a_dashboard.png rename to doc/how-to/metabase/images/setup-metabase/duplicate_a_dashboard.png diff --git a/doc/how-to/images/setup-metabase/edit_flow_in_hasura.png b/doc/how-to/metabase/images/setup-metabase/edit_flow_in_hasura.png similarity index 100% rename from doc/how-to/images/setup-metabase/edit_flow_in_hasura.png rename to doc/how-to/metabase/images/setup-metabase/edit_flow_in_hasura.png diff --git a/doc/how-to/images/setup-metabase/enable_sharing.png b/doc/how-to/metabase/images/setup-metabase/enable_sharing.png similarity index 100% rename from doc/how-to/images/setup-metabase/enable_sharing.png rename to doc/how-to/metabase/images/setup-metabase/enable_sharing.png diff --git a/doc/how-to/images/setup-metabase/new_collection.png b/doc/how-to/metabase/images/setup-metabase/new_collection.png similarity index 100% rename from doc/how-to/images/setup-metabase/new_collection.png rename to doc/how-to/metabase/images/setup-metabase/new_collection.png diff --git a/doc/how-to/images/setup-metabase/only_duplicate_dashboard.png b/doc/how-to/metabase/images/setup-metabase/only_duplicate_dashboard.png similarity index 100% rename from doc/how-to/images/setup-metabase/only_duplicate_dashboard.png rename to doc/how-to/metabase/images/setup-metabase/only_duplicate_dashboard.png diff --git a/doc/how-to/images/setup-metabase/service_slug_default_value.png b/doc/how-to/metabase/images/setup-metabase/service_slug_default_value.png similarity index 100% rename from doc/how-to/images/setup-metabase/service_slug_default_value.png rename to doc/how-to/metabase/images/setup-metabase/service_slug_default_value.png diff --git a/doc/how-to/images/setup-metabase/share_with_team.png b/doc/how-to/metabase/images/setup-metabase/share_with_team.png similarity index 100% rename from doc/how-to/images/setup-metabase/share_with_team.png rename to doc/how-to/metabase/images/setup-metabase/share_with_team.png diff --git a/doc/how-to/images/setup-metabase/team_slug_default_value.png b/doc/how-to/metabase/images/setup-metabase/team_slug_default_value.png similarity index 100% rename from doc/how-to/images/setup-metabase/team_slug_default_value.png rename to doc/how-to/metabase/images/setup-metabase/team_slug_default_value.png diff --git a/doc/how-to/images/setup-metabase/templates.png b/doc/how-to/metabase/images/setup-metabase/templates.png similarity index 100% rename from doc/how-to/images/setup-metabase/templates.png rename to doc/how-to/metabase/images/setup-metabase/templates.png diff --git a/doc/how-to/images/setup-metabase/update_analytics_link.png b/doc/how-to/metabase/images/setup-metabase/update_analytics_link.png similarity index 100% rename from doc/how-to/images/setup-metabase/update_analytics_link.png rename to doc/how-to/metabase/images/setup-metabase/update_analytics_link.png diff --git a/doc/how-to/how-to-add-a-secret.md b/doc/how-to/secrets/how-to-add-a-secret.md similarity index 100% rename from doc/how-to/how-to-add-a-secret.md rename to doc/how-to/secrets/how-to-add-a-secret.md diff --git a/doc/how-to/how-to-add-a-team-secret.md b/doc/how-to/secrets/how-to-add-a-team-secret.md similarity index 100% rename from doc/how-to/how-to-add-a-team-secret.md rename to doc/how-to/secrets/how-to-add-a-team-secret.md diff --git a/doc/how-to/how-to-generate-a-secret.md b/doc/how-to/secrets/how-to-generate-a-secret.md similarity index 100% rename from doc/how-to/how-to-generate-a-secret.md rename to doc/how-to/secrets/how-to-generate-a-secret.md diff --git a/e2e/tests/ui-driven/src/create-flow-with-geospatial.spec.ts b/e2e/tests/ui-driven/src/create-flow-with-geospatial.spec.ts index 991dcd0bc5..38cb922659 100644 --- a/e2e/tests/ui-driven/src/create-flow-with-geospatial.spec.ts +++ b/e2e/tests/ui-driven/src/create-flow-with-geospatial.spec.ts @@ -1,5 +1,4 @@ import { expect, test } from "@playwright/test"; -import type { Context } from "./helpers/context"; import { contextDefaults, setUpTestContext, @@ -9,27 +8,30 @@ import { getTeamPage } from "./helpers/getPage"; import { createAuthenticatedSession } from "./helpers/globalHelpers"; import { answerFindProperty, clickContinue } from "./helpers/userActions"; import { PlaywrightEditor } from "./pages/Editor"; +import { + navigateToService, + publishService, + turnServiceOnline, +} from "./helpers/navigateAndPublish"; +import { TestContext } from "./helpers/types"; +import { serviceProps } from "./helpers/serviceData"; test.describe("Flow creation, publish and preview", () => { - let context: Context = { + let context: TestContext = { ...contextDefaults, }; - const serviceProps = { - name: "A Test Service", - slug: "a-test-service", - }; test.beforeAll(async () => { try { context = await setUpTestContext(context); } catch (error) { - await tearDownTestContext(context); + await tearDownTestContext(); throw error; } }); test.afterAll(async () => { - await tearDownTestContext(context); + await tearDownTestContext(); }); test("Create a flow", async ({ browser }) => { @@ -44,9 +46,6 @@ test.describe("Flow creation, publish and preview", () => { page.on("dialog", (dialog) => dialog.accept(serviceProps.name)); await editor.addNewService(); - // update context to allow flow to be torn down - context.flow = { ...serviceProps }; - await editor.createFindProperty(); await expect(editor.nodeList).toContainText(["Find property"]); await editor.createInternalPortal(); @@ -78,24 +77,16 @@ test.describe("Flow creation, publish and preview", () => { userId: context.user!.id!, }); // publish flow - await page.goto(`/${context.team.slug}/${serviceProps.slug}`); - page.getByRole("button", { name: "CHECK FOR CHANGES TO PUBLISH" }).click(); - page.getByRole("button", { name: "PUBLISH", exact: true }).click(); + await navigateToService(page, serviceProps.slug); + await publishService(page); let previewLink = page.getByRole("link", { name: "Open published service", }); await expect(previewLink).toBeVisible(); - await page.goto(`/${context.team.slug}/${serviceProps.slug}`); - - // Toggle flow online - page.locator('[aria-label="Service settings"]').click(); - page.getByLabel("Offline").click(); - page.getByRole("button", { name: "Save", disabled: false }).click(); - await expect( - page.getByText("Service settings updated successfully"), - ).toBeVisible(); + await navigateToService(page, serviceProps.slug); + await turnServiceOnline(page); // Exit back to main Editor page page.locator('[aria-label="Editor"]').click(); diff --git a/e2e/tests/ui-driven/src/create-flow.spec.ts b/e2e/tests/ui-driven/src/create-flow.spec.ts index 94c6bee879..f681ad01de 100644 --- a/e2e/tests/ui-driven/src/create-flow.spec.ts +++ b/e2e/tests/ui-driven/src/create-flow.spec.ts @@ -1,5 +1,4 @@ import { Browser, expect, test } from "@playwright/test"; -import type { Context } from "./helpers/context"; import { contextDefaults, setUpTestContext, @@ -19,9 +18,20 @@ import { clickContinue, } from "./helpers/userActions"; import { PlaywrightEditor } from "./pages/Editor"; +import { createExternalPortal } from "./helpers/addComponent"; +import { + navigateToService, + publishService, + turnServiceOnline, +} from "./helpers/navigateAndPublish"; +import { TestContext } from "./helpers/types"; +import { + externalPortalFlowData, + externalPortalServiceProps, +} from "./helpers/serviceData"; test.describe("Flow creation, publish and preview", () => { - let context: Context = { + let context: TestContext = { ...contextDefaults, }; const serviceProps = { @@ -34,13 +44,13 @@ test.describe("Flow creation, publish and preview", () => { context = await setUpTestContext(context); } catch (error) { // ensure proper teardown if setup fails - await tearDownTestContext(context); + await tearDownTestContext(); throw error; } }); test.afterAll(async () => { - await tearDownTestContext(context); + await tearDownTestContext(); }); test("Create a flow", async ({ browser }) => { @@ -120,10 +130,8 @@ test.describe("Flow creation, publish and preview", () => { userId: context.user!.id!, }); - await page.goto(`/${context.team.slug}/${serviceProps.slug}`); - - page.getByRole("button", { name: "CHECK FOR CHANGES TO PUBLISH" }).click(); - page.getByRole("button", { name: "PUBLISH", exact: true }).click(); + await navigateToService(page, serviceProps.slug); + await publishService(page); const previewLink = page.getByRole("link", { name: "Open published service", @@ -156,17 +164,8 @@ test.describe("Flow creation, publish and preview", () => { userId: context.user!.id!, }); - await page.goto(`/${context.team.slug}/${serviceProps.slug}`); - - // Open flow settings - page.locator('[aria-label="Service settings"]').click(); - - // Toggle flow online - page.getByLabel("Offline").click(); - page.getByRole("button", { name: "Save", disabled: false }).click(); - await expect( - page.getByText("Service settings updated successfully"), - ).toBeVisible(); + await navigateToService(page, serviceProps.slug); + await turnServiceOnline(page); // Exit back to main Editor page page.locator('[aria-label="Editor"]').click(); @@ -177,6 +176,56 @@ test.describe("Flow creation, publish and preview", () => { await expect(previewLink).toBeVisible(); }); + test("Can add an external portal", async ({ + browser, + }: { + browser: Browser; + }) => { + const page = await createAuthenticatedSession({ + browser, + userId: context.user!.id!, + }); + + await page.goto(`/${context.team.slug}`); + + const editor = new PlaywrightEditor(page); + + page.on("dialog", (dialog) => + dialog.accept(externalPortalServiceProps.name), + ); + await editor.addNewService(); + + // update context to allow new flow to be torn down + context.externalPortalFlow = { ...externalPortalServiceProps }; + + const { title, answers } = externalPortalFlowData; + + await editor.createQuestionWithOptions(title, answers); + + await expect(editor.nodeList).toContainText([ + title, + answers[0], + answers[1], + ]); + + // We are publishing the Ext Portal service and turning it online + await publishService(page); + await turnServiceOnline(page); + + // We switch back to the original service + await navigateToService(page, serviceProps.slug); + + // Add our ext portal to the middle of the service + await createExternalPortal(page, page.locator("li:nth-child(6)")); + + await expect( + page.getByRole("link", { name: "E2E/an-external-portal-service" }), + ).toBeVisible(); + + // publish the changes we've made to the original service + await publishService(page); + }); + test("Can preview a published flow", async ({ browser, }: { @@ -187,6 +236,12 @@ test.describe("Flow creation, publish and preview", () => { userId: context.user!.id!, }); + await page.goto(`/${context.team.slug}/${serviceProps.slug}`); + + await expect( + page.getByRole("link", { name: "E2E/an-external-portal-service" }), + ).toBeVisible(); + await page.goto( `/${context.team.slug}/${serviceProps.slug}/published?analytics=false`, ); @@ -213,6 +268,13 @@ test.describe("Flow creation, publish and preview", () => { }); await clickContinue({ page }); + await answerQuestion({ + page, + title: externalPortalFlowData.title, + answer: externalPortalFlowData.answers[0], + }); + await clickContinue({ page }); + await answerTextInput(page, { expectedQuestion: "Tell us about your trees.", answer: "My trees are lovely", diff --git a/e2e/tests/ui-driven/src/helpers/addComponent.ts b/e2e/tests/ui-driven/src/helpers/addComponent.ts index 003567d4db..61c0f8ca4e 100644 --- a/e2e/tests/ui-driven/src/helpers/addComponent.ts +++ b/e2e/tests/ui-driven/src/helpers/addComponent.ts @@ -1,5 +1,7 @@ import { ComponentType } from "@opensystemslab/planx-core/types"; import { expect, Locator, Page } from "@playwright/test"; +import { contextDefaults } from "./context"; +import { externalPortalServiceProps } from "./serviceData"; const createBaseComponent = async ( page: Page, @@ -26,7 +28,7 @@ const createBaseComponent = async ( await page.getByPlaceholder("Notice").fill(title || ""); break; case ComponentType.Checklist: - await page.getByPlaceholder("Text").fill(title || "text"); + await page.getByPlaceholder("Text").fill(title || ""); if (options) { await createComponentOptions(options, "add new option", page); } @@ -122,6 +124,13 @@ const createBaseComponent = async ( case ComponentType.InternalPortal: await page.getByPlaceholder("Portal name").fill(title || ""); break; + case ComponentType.ExternalPortal: + await page + .getByTestId("flowId") + .selectOption( + `${contextDefaults.team.slug}/${externalPortalServiceProps.slug}`, + ); + break; default: throw new Error(`Unsupported type: ${type}`); } @@ -394,3 +403,10 @@ export const createInternalPortal = async ( export const createFeedback = async (page: Page, locatingNode: Locator) => { await createBaseComponent(page, locatingNode, ComponentType.Feedback); }; + +export const createExternalPortal = async ( + page: Page, + locatingNode: Locator, +) => { + await createBaseComponent(page, locatingNode, ComponentType.ExternalPortal); +}; diff --git a/e2e/tests/ui-driven/src/helpers/context.ts b/e2e/tests/ui-driven/src/helpers/context.ts index c94783189e..792a99a1ed 100644 --- a/e2e/tests/ui-driven/src/helpers/context.ts +++ b/e2e/tests/ui-driven/src/helpers/context.ts @@ -2,31 +2,11 @@ import { CoreDomainClient } from "@opensystemslab/planx-core"; import { GraphQLClient, gql } from "graphql-request"; import { sign } from "jsonwebtoken"; import assert from "node:assert"; -import { log } from "./globalHelpers"; +import { TestContext } from "./types"; -type NewTeam = Parameters[0]; - -export interface Context { - user: { - id?: number; - firstName: string; - lastName: string; - email: string; - isPlatformAdmin: boolean; - }; - team: { id?: number } & NewTeam; - flow?: { - id?: string; - publishedId?: number; - slug: string; - name: string; - data?: object; - }; - sessionIds?: string[]; -} - -export const contextDefaults: Context = { +export const contextDefaults: TestContext = { user: { + id: 0, firstName: "Test", lastName: "Test", email: "simulate-delivered@notifications.service.gov.uk", @@ -46,13 +26,20 @@ export const contextDefaults: Context = { }, }; +const $admin = getCoreDomainClient(); + export async function setUpTestContext( - initialContext: Context, -): Promise { - const $admin = getCoreDomainClient(); - const context: Context = { ...initialContext }; + initialContext: TestContext, +): Promise { + const context: TestContext = { ...initialContext }; if (context.user) { - context.user.id = await $admin.user.create(context.user); + const { firstName, lastName, email, isPlatformAdmin } = context.user; + context.user.id = await $admin.user.create({ + firstName, + lastName, + email, + isPlatformAdmin, + }); } if (context.team) { context.team.id = await $admin.team.create({ @@ -65,9 +52,10 @@ export async function setUpTestContext( }); } if ( - context.flow?.slug && - context.flow?.data && - context.flow?.name && + context.flow && + context.flow.slug && + context.flow.data && + context.flow.name && context.team?.id && context.user?.id ) { @@ -75,13 +63,13 @@ export async function setUpTestContext( slug: context.flow.slug, name: context.flow.name, teamId: context.team.id, - data: context.flow!.data!, + data: context.flow.data, status: "online", }); context.flow.publishedId = await $admin.flow.publish({ flow: { id: context.flow.id, - data: context.flow!.data!, + data: context.flow.data, }, publisherId: context.user!.id!, }); @@ -91,19 +79,10 @@ export async function setUpTestContext( return context; } -export async function tearDownTestContext(context: Context) { - const adminGQLClient = getGraphQLClient(); - if (context.flow) { - await deleteSession(adminGQLClient, context); - await deletePublishedFlow(adminGQLClient, context); - await deleteFlow(adminGQLClient, context); - } - if (context.user) { - await deleteUser(adminGQLClient, context); - } - if (context.team) { - await deleteTeam(adminGQLClient, context); - } +export async function tearDownTestContext() { + await $admin.flow._destroyAll(); + await $admin.user._destroyAll(); + await $admin.team._destroyAll(); } export function generateAuthenticationToken(userId: string) { @@ -152,7 +131,7 @@ export async function findSessionId( id } }`, - { slug: context.flow?.slug }, + { slug: context.flow.slug }, ); if (!flowResponse.flows.length || !flowResponse.flows[0].id) { return; @@ -179,162 +158,10 @@ export async function findSessionId( } } -async function deleteSession(adminGQLClient: GraphQLClient, context) { - if (context.sessionIds) { - for (const sessionId of context.sessionIds) { - await adminGQLClient.request( - `mutation DeleteTestSession( $sessionId: uuid!) { - delete_lowcal_sessions_by_pk(id: $sessionId) { - id - } - }`, - { sessionId }, - ); - } - } - const sessionId = await findSessionId(adminGQLClient, context); - if (sessionId) { - log(`deleting session id: ${sessionId}`); - await adminGQLClient.request( - `mutation DeleteTestSession( $sessionId: uuid!) { - delete_lowcal_sessions_by_pk(id: $sessionId) { - id - } - }`, - { sessionId }, - ); - } -} - -async function deletePublishedFlow( - adminGQLClient: GraphQLClient, - context: Context, +async function setupGovPaySecret( + $admin: CoreDomainClient, + context: TestContext, ) { - if (context.flow?.publishedId) { - log(`deleting published flow ${context.flow?.publishedId}`); - await adminGQLClient.request( - `mutation DeleteTestPublishedFlow( $publishedFlowId: Int!) { - delete_published_flows_by_pk(id: $publishedFlowId) { - id - } - }`, - { publishedFlowId: context.flow?.publishedId }, - ); - } -} - -async function deleteFlow(adminGQLClient: GraphQLClient, context: Context) { - if (context.flow?.id) { - log(`deleting flow ${context.flow?.id}`); - await adminGQLClient.request( - `mutation DeleteTestFlow($flowId: uuid!) { - delete_flows_by_pk(id: $flowId) { - id - } - }`, - { flowId: context.flow?.id }, - ); - } else if (context.flow?.slug) { - // try deleting via slug (when cleaning up from a previously failed test) - const response: { flows: { id: string }[] } = await adminGQLClient.request( - `query GetFlowBySlug($slug: String!) { - flows(where: {slug: {_eq: $slug}}) { - id - } - }`, - { slug: context.flow?.slug }, - ); - if (response.flows.length && response.flows[0].id) { - log( - `deleting flow ${context.flow?.slug} flowId: ${response.flows[0].id}`, - ); - await adminGQLClient.request( - `mutation DeleteTestFlow( $flowId: uuid!) { - delete_flows_by_pk(id: $flowId) { - id - } - }`, - { flowId: response.flows[0].id }, - ); - } - } -} - -async function deleteUser(adminGQLClient: GraphQLClient, context: Context) { - if (context.user?.id) { - log(`deleting user ${context.user?.id}`); - await adminGQLClient.request( - `mutation DeleteTestUser($userId: Int!) { - delete_users_by_pk(id: $userId) { - id - } - }`, - { userId: context.user?.id }, - ); - } else if (context.user?.email) { - // try deleting via email (when cleaning up from a previously failed test) - const response: { users: { id: number }[] } = await adminGQLClient.request( - `query GetUserByEmail($email: String!) { - users(where: {email: {_eq: $email}}) { - id - } - }`, - { email: context.user?.email }, - ); - if (response.users.length && response.users[0].id) { - log( - `deleting user ${context.user?.email} userId: ${response.users[0].id}`, - ); - await adminGQLClient.request( - `mutation DeleteTestUser($userId: Int!) { - delete_users_by_pk(id: $userId) { - id - } - }`, - { userId: response.users[0].id }, - ); - } - } -} - -async function deleteTeam(adminGQLClient: GraphQLClient, context: Context) { - if (context.team?.id) { - log(`deleting team ${context.team?.id}`); - await adminGQLClient.request( - `mutation DeleteTestTeam( $teamId: Int!) { - delete_teams_by_pk(id: $teamId) { - id - } - }`, - { teamId: context.team?.id }, - ); - } else if (context.team?.slug) { - // try deleting via slug (when cleaning up from a previously failed test) - const response: { teams: { id: number }[] } = await adminGQLClient.request( - `query GetTeamBySlug( $slug: String!) { - teams(where: {slug: {_eq: $slug}}) { - id - } - }`, - { slug: context.team?.slug }, - ); - if (response.teams.length && response.teams[0].id) { - log( - `deleting team ${context.team?.slug} teamId: ${response.teams[0].id}`, - ); - await adminGQLClient.request( - `mutation DeleteTestTeam( $teamId: Int!) { - delete_teams_by_pk(id: $teamId) { - id - } - }`, - { teamId: response.teams[0].id }, - ); - } - } -} - -async function setupGovPaySecret($admin: CoreDomainClient, context: Context) { try { await $admin.client.request( gql` diff --git a/e2e/tests/ui-driven/src/helpers/globalHelpers.ts b/e2e/tests/ui-driven/src/helpers/globalHelpers.ts index 29e00f7e64..f580449673 100644 --- a/e2e/tests/ui-driven/src/helpers/globalHelpers.ts +++ b/e2e/tests/ui-driven/src/helpers/globalHelpers.ts @@ -1,8 +1,8 @@ import { FlowGraph } from "@opensystemslab/planx-core/types"; import type { Browser, Page, Request } from "@playwright/test"; import { gql } from "graphql-request"; -import type { Context } from "./context"; import { generateAuthenticationToken, getGraphQLClient } from "./context"; +import { TestContext } from "./types"; // Test card numbers to be used in gov.uk sandbox environment // reference: https://docs.payments.service.gov.uk/testing_govuk_pay/#if-you-39-re-using-a-test-39-sandbox-39-account @@ -87,7 +87,7 @@ export async function getSessionId(page: Page): Promise { return sessionId; } -export async function addSessionToContext(page: Page, context: Context) { +export async function addSessionToContext(page: Page, context: TestContext) { const sessionId = await getSessionId(page); context.sessionIds!.push(sessionId); return sessionId; @@ -95,7 +95,7 @@ export async function addSessionToContext(page: Page, context: Context) { export async function waitForPaymentResponse( page: Page, - context: Context, + context: TestContext, ): Promise<{ paymentId: string; state?: { status: string } }> { const { payment_id: paymentId, state } = await page .waitForResponse((response) => { @@ -110,11 +110,11 @@ export async function modifyFlow({ context, modifiedFlow, }: { - context: Context; + context: TestContext; modifiedFlow: FlowGraph; }) { const adminGQLClient = getGraphQLClient(); - if (!context.flow?.id || !context.user?.id) { + if (!context.flow?.slug || !context.user?.id) { throw new Error("context must have a flow and user"); } await adminGQLClient.request( @@ -132,7 +132,7 @@ export async function modifyFlow({ } `, { - flowId: context.flow!.id, + flowId: context.flow?.id, userId: context.user!.id, data: modifiedFlow, }, diff --git a/e2e/tests/ui-driven/src/helpers/navigateAndPublish.ts b/e2e/tests/ui-driven/src/helpers/navigateAndPublish.ts new file mode 100644 index 0000000000..d5d66138fa --- /dev/null +++ b/e2e/tests/ui-driven/src/helpers/navigateAndPublish.ts @@ -0,0 +1,26 @@ +import { expect, Page } from "@playwright/test"; +import { contextDefaults } from "./context"; + +export const navigateToService = async (page: Page, slug: string) => { + await page.goto(`/${contextDefaults.team.slug}/${slug}`); + + await expect(page.getByRole("link", { name: slug })).toBeVisible(); +}; + +export const publishService = async (page: Page) => { + page.getByRole("button", { name: "CHECK FOR CHANGES TO PUBLISH" }).click(); + await expect( + page.getByRole("heading", { name: "Check for changes to publish" }), + ).toBeVisible(); + page.getByRole("button", { name: "PUBLISH", exact: true }).click(); +}; + +export const turnServiceOnline = async (page: Page) => { + page.locator('[aria-label="Service settings"]').click(); + page.getByLabel("Offline").click(); + + page.getByRole("button", { name: "Save", disabled: false }).click(); + await expect( + page.getByText("Service settings updated successfully"), + ).toBeVisible(); +}; diff --git a/e2e/tests/ui-driven/src/helpers/serviceData.ts b/e2e/tests/ui-driven/src/helpers/serviceData.ts new file mode 100644 index 0000000000..b1fda6a053 --- /dev/null +++ b/e2e/tests/ui-driven/src/helpers/serviceData.ts @@ -0,0 +1,14 @@ +export const serviceProps = { + name: "A Test Service", + slug: "a-test-service", +}; + +export const externalPortalServiceProps = { + name: "An External Portal Service", + slug: "an-external-portal-service", +}; + +export const externalPortalFlowData = { + title: "Is this an External Portal?", + answers: ["It is an external portal", "No it is not an External Portal"], +}; diff --git a/e2e/tests/ui-driven/src/helpers/types.ts b/e2e/tests/ui-driven/src/helpers/types.ts new file mode 100644 index 0000000000..25c3bff286 --- /dev/null +++ b/e2e/tests/ui-driven/src/helpers/types.ts @@ -0,0 +1,23 @@ +import { CoreDomainClient } from "@opensystemslab/planx-core"; +import { User } from "@opensystemslab/planx-core/dist/types"; + +type NewTeam = Parameters[0]; + +export interface Flow { + id?: string; + publishedId?: number; + slug: string; + name: string; + data?: object; +} + +export interface TestContext { + user: Pick< + User, + "firstName" | "lastName" | "email" | "isPlatformAdmin" | "id" + >; + team: { id?: number } & NewTeam; + flow?: Flow; + externalPortalFlow?: Flow; + sessionIds?: string[]; +} diff --git a/e2e/tests/ui-driven/src/helpers/userActions.ts b/e2e/tests/ui-driven/src/helpers/userActions.ts index 7b2c92709a..35d24d5ae9 100644 --- a/e2e/tests/ui-driven/src/helpers/userActions.ts +++ b/e2e/tests/ui-driven/src/helpers/userActions.ts @@ -1,16 +1,16 @@ import type { Locator, Page } from "@playwright/test"; import { expect } from "@playwright/test"; import { setupOSMockResponse } from "../mocks/osPlacesResponse"; -import type { Context } from "./context"; import { findSessionId, getGraphQLClient } from "./context"; import { TEST_EMAIL, log, waitForDebugLog } from "./globalHelpers"; +import { TestContext } from "./types"; export async function saveSession({ page, context, }: { page: Page; - context: Context; + context: TestContext; }): Promise { const pageResponsePromise = page.waitForResponse((response) => { return response.url().includes("/send-email/save"); @@ -31,7 +31,7 @@ export async function returnToSession({ shouldContinue = true, }: { page: Page; - context: Context; + context: TestContext; sessionId: string; shouldContinue?: boolean; }) { @@ -81,7 +81,7 @@ export async function fillInEmail({ context, }: { page: Page; - context: Context; + context: TestContext; }) { await page.locator("#email").fill(context.user.email); await page.locator("#confirmEmail").fill(context.user.email); diff --git a/e2e/tests/ui-driven/src/invite-to-pay/agent.spec.ts b/e2e/tests/ui-driven/src/invite-to-pay/agent.spec.ts index 23d94b2609..b0c889b288 100644 --- a/e2e/tests/ui-driven/src/invite-to-pay/agent.spec.ts +++ b/e2e/tests/ui-driven/src/invite-to-pay/agent.spec.ts @@ -1,6 +1,5 @@ import { BrowserContext, Page, expect, test } from "@playwright/test"; import { - Context, contextDefaults, getGraphQLClient, setUpTestContext, @@ -20,8 +19,9 @@ import { navigateToPayComponent, } from "./helpers"; import { mockPaymentRequest, modifiedInviteToPayFlow } from "./mocks"; +import { TestContext } from "../helpers/types"; -let context: Context = { +let context: TestContext = { ...contextDefaults, flow: { slug: "invite-to-pay-test", @@ -39,12 +39,12 @@ test.describe("Agent journey @regression", async () => { context = await setUpTestContext(context); } catch (e) { // ensure proper teardown if setup fails - await tearDownTestContext(context); + await tearDownTestContext(); throw e; } }); - test.afterAll(async () => await tearDownTestContext(context)); + test.afterAll(async () => await tearDownTestContext()); test("agent can send a payment request", async ({ page }) => { await navigateToPayComponent(page, context); @@ -106,8 +106,7 @@ test.describe("Agent journey @regression", async () => { const sessionId = await makePaymentRequest({ page: firstPage, context }); // Resume session - const resumeLink = `/${context.team!.slug!}/${context.flow! - .slug!}/published?analytics=false&sessionId=${sessionId}`; + const resumeLink = `/${context.team!.slug!}/${context.flow?.slug}/published?analytics=false&sessionId=${sessionId}`; const secondPage = await browserContext.newPage(); await secondPage.goto(resumeLink); await expect( @@ -118,11 +117,11 @@ test.describe("Agent journey @regression", async () => { await secondPage.getByLabel("Email address").fill(context.user.email); await secondPage.getByTestId("continue-button").click(); - await expect( - secondPage.getByRole("heading", { - name: "Sorry, you can't make changes to this application", - }), - ).toBeVisible(); + const errorHeader = secondPage.getByRole("heading", { + name: "Sorry, you can't make changes to this application", + }); + + await expect(errorHeader).toBeVisible(); await expect(secondPage.getByTestId("continue-button")).toBeHidden(); }); @@ -136,8 +135,7 @@ test.describe("Agent journey @regression", async () => { await modifyFlow({ context, modifiedFlow: modifiedInviteToPayFlow }); // Navigate to resume session link - const resumeLink = `/${context.team!.slug!}/${context.flow! - .slug!}/published?analytics=false&sessionId=${sessionId}`; + const resumeLink = `/${context.team!.slug!}/${context.flow?.slug}/published?analytics=false&sessionId=${sessionId}`; const secondPage = await browserContext.newPage(); await secondPage.goto(resumeLink); await expect( diff --git a/e2e/tests/ui-driven/src/invite-to-pay/helpers.ts b/e2e/tests/ui-driven/src/invite-to-pay/helpers.ts index 10347d7a81..b7a21b667d 100644 --- a/e2e/tests/ui-driven/src/invite-to-pay/helpers.ts +++ b/e2e/tests/ui-driven/src/invite-to-pay/helpers.ts @@ -1,7 +1,6 @@ import { PaymentRequest } from "@opensystemslab/planx-core/dist/types"; -import type { Page } from "@playwright/test"; +import { expect, type Page } from "@playwright/test"; import { GraphQLClient, gql } from "graphql-request"; -import { Context } from "../helpers/context"; import { TEST_EMAIL, addSessionToContext, log } from "../helpers/globalHelpers"; import { answerChecklist, @@ -9,13 +8,15 @@ import { answerFindProperty, fillInEmail, } from "../helpers/userActions"; +import { TestContext } from "../helpers/types"; /** * Navigates to pay component whilst completing the minimum requirements for an Invite to Pay flow */ -export async function navigateToPayComponent(page: Page, context: Context) { - const previewURL = `/${context.team!.slug!}/${context.flow! - .slug!}/published?analytics=false`; +export async function navigateToPayComponent(page: Page, context: TestContext) { + const previewURL = `/${context.team!.slug!}/${ + context.flow?.slug + }/published?analytics=false`; await page.goto(previewURL); await fillInEmail({ page, context }); @@ -84,7 +85,7 @@ export async function makePaymentRequest({ context, }: { page: Page; - context: Context; + context: TestContext; }) { await navigateToPayComponent(page, context); const sessionId = await addSessionToContext(page, context); @@ -95,6 +96,6 @@ export async function makePaymentRequest({ await answerInviteToPayForm(page); page.getByRole("button", { name: "Send invitation to pay" }).click(); await page.waitForResponse((res) => res.url().includes("invite-to-pay")); - + await expect(page.getByText("Error generating payment")).toBeHidden(); return sessionId; } diff --git a/e2e/tests/ui-driven/src/invite-to-pay/nominee.spec.ts b/e2e/tests/ui-driven/src/invite-to-pay/nominee.spec.ts index bd78120699..97631a2ff1 100644 --- a/e2e/tests/ui-driven/src/invite-to-pay/nominee.spec.ts +++ b/e2e/tests/ui-driven/src/invite-to-pay/nominee.spec.ts @@ -3,7 +3,6 @@ import { APIRequestContext, Page, expect, test } from "@playwright/test"; import { GraphQLClient, gql } from "graphql-request"; import { v4 as uuidV4 } from "uuid"; import { - Context, contextDefaults, getGraphQLClient, setUpTestContext, @@ -14,8 +13,9 @@ import { fillGovUkCardDetails } from "../helpers/userActions"; import inviteToPayFlow from "../mocks/flows/invite-to-pay-flow"; import { getPaymentRequestBySessionId } from "./helpers"; import { mockPaymentRequestDetails, mockSessionData } from "./mocks"; +import { TestContext } from "../helpers/types"; -let context: Context = { +let context: TestContext = { ...contextDefaults, flow: { slug: "invite-to-pay-test", @@ -35,13 +35,13 @@ test.describe("Nominee journey @regression", async () => { context = await setUpTestContext(context); } catch (e) { // ensure proper teardown if setup fails - await tearDownTestContext(context); + await tearDownTestContext(); throw e; } }); test.afterAll(async () => { - await tearDownTestContext(context); + await tearDownTestContext(); }); test("responding to a valid payment request", async ({ page, request }) => { @@ -82,8 +82,9 @@ test.describe("Nominee journey @regression", async () => { }); test("navigating to a URL with an invalid ID", async ({ page }) => { - const invalidPaymentRequestURL = `/${context.team!.slug!}/${context.flow! - .slug!}/pay?analytics=false&paymentRequestId=INVALID-ID`; + const invalidPaymentRequestURL = `/${context.team!.slug!}/${ + context.flow?.slug + }/pay?analytics=false&paymentRequestId=INVALID-ID`; await page.goto(invalidPaymentRequestURL); await page.waitForLoadState("networkidle"); @@ -91,8 +92,7 @@ test.describe("Nominee journey @regression", async () => { }); test("navigating to a URL without a paymentRequestId", async ({ page }) => { - const invalidPaymentRequestURL = `/${context.team!.slug!}/${context.flow! - .slug!}/pay?analytics=false`; + const invalidPaymentRequestURL = `/${context.team!.slug!}/${context.flow?.slug}/pay?analytics=false`; await page.goto(invalidPaymentRequestURL); await page.waitForLoadState("networkidle"); @@ -126,8 +126,7 @@ async function navigateToPaymentRequestPage( paymentRequest: PaymentRequest, page: Page, ) { - const paymentRequestURL = `/${context.team!.slug!}/${context.flow! - .slug!}/pay?analytics=false&paymentRequestId=${paymentRequest.id}`; + const paymentRequestURL = `/${context.team!.slug!}/${context.flow?.slug}/pay?analytics=false&paymentRequestId=${paymentRequest.id}`; await page.goto(paymentRequestURL); await page.waitForLoadState("networkidle"); } @@ -149,7 +148,7 @@ async function createSession({ client, sessionId, }: { - context: Context; + context: TestContext; client: GraphQLClient; sessionId: string; }) { diff --git a/e2e/tests/ui-driven/src/login.spec.ts b/e2e/tests/ui-driven/src/login.spec.ts index 23bfbecf83..083c1897de 100644 --- a/e2e/tests/ui-driven/src/login.spec.ts +++ b/e2e/tests/ui-driven/src/login.spec.ts @@ -1,14 +1,14 @@ import { expect, test } from "@playwright/test"; -import type { Context } from "./helpers/context"; import { contextDefaults, setUpTestContext, tearDownTestContext, } from "./helpers/context"; import { createAuthenticatedSession } from "./helpers/globalHelpers"; +import { TestContext } from "./helpers/types"; test.describe("Login", () => { - let context: Context = { + let context: TestContext = { ...contextDefaults, }; @@ -17,13 +17,13 @@ test.describe("Login", () => { context = await setUpTestContext(context); } catch (error) { // ensure proper teardown if setup fails - await tearDownTestContext(context); + await tearDownTestContext(); throw error; } }); test.afterAll(async () => { - await tearDownTestContext(context); + await tearDownTestContext(); }); test("setting a cookie bypasses login", async ({ browser }) => { diff --git a/e2e/tests/ui-driven/src/pages/Editor.ts b/e2e/tests/ui-driven/src/pages/Editor.ts index bb581c1386..d19ae67146 100644 --- a/e2e/tests/ui-driven/src/pages/Editor.ts +++ b/e2e/tests/ui-driven/src/pages/Editor.ts @@ -9,6 +9,7 @@ import { createContent, createDateInput, createDrawBoundary, + createExternalPortal, createFeedback, createFileUpload, createFilter, @@ -73,6 +74,13 @@ export class PlaywrightEditor { ).toBeVisible(); } + async createQuestionWithOptions(title: string, answers: string[]) { + await createQuestionWithOptions(this.page, this.firstNode, title, answers); + await expect( + this.page.locator("a").filter({ hasText: title }), + ).toBeVisible(); + } + async createNoticeOnEachBranch() { // Add a notice to the "Yes" path await createNotice( @@ -242,6 +250,10 @@ export class PlaywrightEditor { await this.page.locator('button[form="modal"][type="submit"]').click(); } + async createExternalPortal() { + await createExternalPortal(this.page, this.getNextNode()); + } + async createFeedback() { await createFeedback(this.page, this.getNextNode()); } diff --git a/e2e/tests/ui-driven/src/pay.spec.ts b/e2e/tests/ui-driven/src/pay.spec.ts index edf63ffdb1..e33509d03d 100644 --- a/e2e/tests/ui-driven/src/pay.spec.ts +++ b/e2e/tests/ui-driven/src/pay.spec.ts @@ -2,7 +2,6 @@ import type { SessionData } from "@opensystemslab/planx-core/types"; import type { Page } from "@playwright/test"; import { expect, test } from "@playwright/test"; import { GraphQLClient, gql } from "graphql-request"; -import type { Context } from "./helpers/context"; import { contextDefaults, getGraphQLClient, @@ -17,18 +16,19 @@ import { } from "./helpers/globalHelpers"; import { fillGovUkCardDetails, submitCardDetails } from "./helpers/userActions"; import payFlow from "./mocks/flows/pay-flow.json"; +import { TestContext } from "./helpers/types"; -let context: Context = { +let context: TestContext = { ...contextDefaults, flow: { slug: "pay-test", name: "Pay test", data: payFlow, }, + sessionIds: [], // used to collect and clean up sessions }; -const previewURL = `/${context.team!.slug!}/${context.flow! - .slug!}/published?analytics=false`; +const previewURL = `/${context.team!.slug!}/${context.flow?.slug}/published?analytics=false`; const payButtonText = "Pay now using GOV.UK Pay"; @@ -40,13 +40,13 @@ test.describe("Gov Pay integration @regression", async () => { context = await setUpTestContext(context); } catch (e) { // ensure proper teardown if setup fails - await tearDownTestContext(context); + await tearDownTestContext(); throw e; } }); test.afterAll(async () => { - await tearDownTestContext(context); + await tearDownTestContext(); }); test("a successful payment", async ({ page }) => { diff --git a/e2e/tests/ui-driven/src/refresh-page.spec.ts b/e2e/tests/ui-driven/src/refresh-page.spec.ts index 4edd79c190..744696fe77 100644 --- a/e2e/tests/ui-driven/src/refresh-page.spec.ts +++ b/e2e/tests/ui-driven/src/refresh-page.spec.ts @@ -1,5 +1,4 @@ import { expect, test } from "@playwright/test"; -import type { Context } from "./helpers/context"; import { contextDefaults, setUpTestContext, @@ -9,9 +8,10 @@ import { createAuthenticatedSession, isGetUserRequest, } from "./helpers/globalHelpers"; +import { TestContext } from "./helpers/types"; test.describe("Refresh page", () => { - let context: Context = { + let context: TestContext = { ...contextDefaults, }; @@ -20,13 +20,13 @@ test.describe("Refresh page", () => { context = await setUpTestContext(context); } catch (error) { // ensure proper teardown if setup fails - await tearDownTestContext(context); + await tearDownTestContext(); throw error; } }); test.afterAll(async () => { - await tearDownTestContext(context); + await tearDownTestContext(); }); test("user data persists on page refresh @regression", async ({ diff --git a/e2e/tests/ui-driven/src/save-and-return.spec.ts b/e2e/tests/ui-driven/src/save-and-return.spec.ts index c2d01b954e..d84cdda237 100644 --- a/e2e/tests/ui-driven/src/save-and-return.spec.ts +++ b/e2e/tests/ui-driven/src/save-and-return.spec.ts @@ -1,5 +1,4 @@ import { expect, test } from "@playwright/test"; -import type { Context } from "./helpers/context"; import { contextDefaults, setUpTestContext, @@ -18,9 +17,10 @@ import { modifiedSimpleSendFlow, simpleSendFlow, } from "./mocks/flows/save-and-return-flows"; +import { TestContext } from "./helpers/types"; test.describe("Save and return", () => { - let context: Context = { + let context: TestContext = { ...contextDefaults, flow: { slug: "e2e-save-and-return-test-flow", @@ -34,13 +34,13 @@ test.describe("Save and return", () => { try { context = await setUpTestContext(context); } catch (e) { - await tearDownTestContext(context); + await tearDownTestContext(); throw e; } }); test.afterAll(async () => { - await tearDownTestContext(context); + await tearDownTestContext(); }); test.describe("email", () => { diff --git a/e2e/tests/ui-driven/src/sections.spec.ts b/e2e/tests/ui-driven/src/sections.spec.ts index 0aca98272b..b321506214 100644 --- a/e2e/tests/ui-driven/src/sections.spec.ts +++ b/e2e/tests/ui-driven/src/sections.spec.ts @@ -1,7 +1,6 @@ import type { FlowGraph } from "@opensystemslab/planx-core/types"; import { expect, test } from "@playwright/test"; import { gql } from "graphql-request"; -import type { Context } from "./helpers/context"; import { contextDefaults, getGraphQLClient, @@ -21,6 +20,7 @@ import { saveSession, } from "./helpers/userActions"; import { flow, updatedQuestionAnswers } from "./mocks/flows/sections-flow"; +import { TestContext } from "./helpers/types"; // TODO: move this type to planx-core // also defined in editor.planx.uk/src/types.ts @@ -34,7 +34,7 @@ export enum SectionStatus { } test.describe("Section statuses", () => { - let context: Context = { + let context: TestContext = { ...contextDefaults, flow: { slug: "sections-test-flow", @@ -47,7 +47,7 @@ test.describe("Section statuses", () => { try { context = await setUpTestContext(context); } catch (e) { - await tearDownTestContext(context); + await tearDownTestContext(); throw e; } }); @@ -58,7 +58,7 @@ test.describe("Section statuses", () => { }); test.afterAll(async () => { - await tearDownTestContext(context); + await tearDownTestContext(); }); test.describe("a straight-through journey", () => { @@ -531,7 +531,7 @@ async function modifyFlow({ context, flowData, }: { - context: Context; + context: TestContext; flowData: FlowGraph; }) { const adminGQLClient = getGraphQLClient(); @@ -553,7 +553,7 @@ async function modifyFlow({ } `, { - flowId: context.flow!.id, + flowId: context.flow?.id, userId: context.user!.id, data: flowData, }, diff --git a/editor.planx.uk/docs/adding-a-new-component.md b/editor.planx.uk/docs/adding-a-new-component.md index d04fa57a10..121a03930c 100644 --- a/editor.planx.uk/docs/adding-a-new-component.md +++ b/editor.planx.uk/docs/adding-a-new-component.md @@ -122,6 +122,10 @@ import SetValue from "@planx/components/SetValue/Editor"; set: SetValueComponent, ``` +6. Add text fields to search index + +Please see detailed guide here - https://github.com/theopensystemslab/planx-new/blob/main/doc/how-to/how-to-add-fields-to-search.md + ## Preview environment & integrations 1. `src/pages/Preview/Node.tsx` diff --git a/editor.planx.uk/src/pages/FlowEditor/components/Flow/components/Checklist.tsx b/editor.planx.uk/src/pages/FlowEditor/components/Flow/components/Checklist.tsx index b56db2325c..c808450e69 100644 --- a/editor.planx.uk/src/pages/FlowEditor/components/Flow/components/Checklist.tsx +++ b/editor.planx.uk/src/pages/FlowEditor/components/Flow/components/Checklist.tsx @@ -1,3 +1,4 @@ +import Help from "@mui/icons-material/Help"; import Box from "@mui/material/Box"; import { ComponentType as TYPES, @@ -25,10 +26,11 @@ type Props = { } & NodeTags; const Checklist: React.FC = React.memo((props) => { - const [isClone, childNodes, copyNode] = useStore((state) => [ + const [isClone, childNodes, copyNode, showHelpText] = useStore((state) => [ state.isClone, state.childNodesOf(props.id), state.copyNode, + state.showHelpText, ]); const parent = getParentId(props.parent); @@ -76,6 +78,9 @@ const Checklist: React.FC = React.memo((props) => { const Icon = ICONS[props.type]; + const hasHelpText = + props.data?.policyRef || props.data?.info || props.data?.howMeasured; + return ( <>