diff --git a/.env.example b/.env.example index fbdd5ecd11..759d790a91 100644 --- a/.env.example +++ b/.env.example @@ -32,6 +32,8 @@ FILE_API_KEY_NEXUS=👻 FILE_API_KEY_BARNET=👻 FILE_API_KEY_LAMBETH=👻 FILE_API_KEY_SOUTHWARK=👻 +FILE_API_KEY_EPSOM_EWELL=👻 +FILE_API_KEY_MEDWAY=👻 # Editor EDITOR_URL_EXT=http://localhost:3000 diff --git a/.gitignore b/.gitignore index 147f72b877..b95f376eae 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,8 @@ hasura.planx.uk/.env.test /playwright-report/ /playwright/.cache/ api.planx.uk/tmp/ +.python-version +__pycache__ # Ignore certificate files **/*.chain diff --git a/api.planx.uk/.env.test.example b/api.planx.uk/.env.test.example index ec234e54f9..bdc74b375e 100644 --- a/api.planx.uk/.env.test.example +++ b/api.planx.uk/.env.test.example @@ -22,6 +22,8 @@ FILE_API_KEY_NEXUS=👻 FILE_API_KEY_BARNET=👻 FILE_API_KEY_LAMBETH=👻 FILE_API_KEY_SOUTHWARK=👻 +FILE_API_KEY_EPSOM_EWELL=👻 +FILE_API_KEY_MEDWAY=👻 # Editor EDITOR_URL_EXT=example.com diff --git a/api.planx.uk/modules/auth/middleware.ts b/api.planx.uk/modules/auth/middleware.ts index 9bd5d63ea7..5b4cdfc9d3 100644 --- a/api.planx.uk/modules/auth/middleware.ts +++ b/api.planx.uk/modules/auth/middleware.ts @@ -98,6 +98,14 @@ export const useFilePermission: RequestHandler = (req, _res, next): void => { isEqual( req.headers["api-key"] as string, process.env.FILE_API_KEY_SOUTHWARK!, + ) || + isEqual( + req.headers["api-key"] as string, + process.env.FILE_API_KEY_MEDWAY!, + ) || + isEqual( + req.headers["api-key"] as string, + process.env.FILE_API_KEY_EPSOM_EWELL!, ); if (!isAuthenticated) return next({ status: 401, message: "Unauthorised" }); return next(); diff --git a/api.planx.uk/modules/sendEmail/docs.yaml b/api.planx.uk/modules/sendEmail/docs.yaml index 1aeb4c1ba5..e2a489f61d 100644 --- a/api.planx.uk/modules/sendEmail/docs.yaml +++ b/api.planx.uk/modules/sendEmail/docs.yaml @@ -55,6 +55,8 @@ paths: post: tags: [send email] summary: Send an email + security: + - hasuraAuth: [] parameters: - name: template in: path diff --git a/doc/how-to/aws/how-to-setup-aws-s3-submissions.md b/doc/how-to/aws/how-to-setup-aws-s3-submissions.md index 998f963a72..43c2949824 100644 --- a/doc/how-to/aws/how-to-setup-aws-s3-submissions.md +++ b/doc/how-to/aws/how-to-setup-aws-s3-submissions.md @@ -12,7 +12,7 @@ Once a council has confirmed they're cleared from their IT dept to use this meth 2. Create 2x tokens for sending secure requests to the Power Automate webhook and add both encrypted values to: - `team_integrations.production_power_automate_api_key` & `team_integrations.staging_power_automate_api_key` via the production Hasura console - - See `how-to-generate-a-secret` for how to properly generate tokens and encrypt values + - See `how-to-generate-a-secret` and `how-to-add-a-team-secret` for how to properly generate tokens and encrypt values 3. Create 2x tokens for downloading files from the PlanX S3 Bucket and add values to: - Root `.env.example` & `.env`, API's `.env.test` & `.env.test.example` as `FILE_API_KEY_{TEAM_SLUG}` @@ -20,7 +20,9 @@ Once a council has confirmed they're cleared from their IT dept to use this meth - Root `docker-compose.yml` - API's `modules/auth/middleware.ts` function `isAuthenticated` - Pulumi's `infrastructure/application/index.ts` list of `apiService` "environment" variables - - Run `pulumi config set file-api-key-{team_slug} --stack {stack}` 2x for each staging & production stacks - - Encrypt the values using _our_ encrypt scripts (again see `how-to-generate-a-secret`) and add to `team_integrations.production_file_api_key` & `team_integrations.staging_file_api_key` via the production Hasura console. Please note these values are _not_ currently read, but suitable for a potential future refactor (just a bit tricky because file API keys are issued to a mix of _teams_ and _systems_ (eg BOPS & Idox)). + - Run `pulumi config set file-api-key-{team_slug} {your-new-secret} --secret --stack {stack}` once for each staging & production stacks, making sure that the secret you used for the root `.env` is the STAGING secret. + - Encrypt the values using _our_ encrypt scripts (see [`how-to-add-a-team-secret.md`](https://github.com/theopensystemslab/planx-new/blob/main/doc/how-to/secrets/how-to-add-a-team-secret.md)) and add to `team_integrations.production_file_api_key` & `team_integrations.staging_file_api_key` via the production Hasura console. Please note these values are _not_ currently read, but suitable for a potential future refactor (just a bit tricky because file API keys are issued to a mix of _teams_ and _systems_ (eg BOPS & Idox)). -4. Securely share tokens back to council contact via onetimesecret or similar +4. Securely share all four raw tokens back to council contact via onetimesecret or similar: +- The two Power Automate API keys +- The two file API keys diff --git a/doc/how-to/secrets/how-to-add-a-team-secret.md b/doc/how-to/secrets/how-to-add-a-team-secret.md index dfed00ba3f..ab1b13555a 100644 --- a/doc/how-to/secrets/how-to-add-a-team-secret.md +++ b/doc/how-to/secrets/how-to-add-a-team-secret.md @@ -17,6 +17,7 @@ This guide will demonstrate how to - > [!NOTE] > The `stack_name` should be either `production` or `staging`, depending on which environment the secret is for (e.g. `staging_govpay_secret`). +> I.e. the staging encryption key needs to be used for staging secrets (even though they need to get set on the production database) ### Encrypt the secret 1. In `/scripts/encrypt`, run the encryption script using the encryption key and raw secret that you obtained in the previous steps: `pnpm encrypt `. diff --git a/doc/how-to/secrets/how-to-generate-a-secret.md b/doc/how-to/secrets/how-to-generate-a-secret.md index afe1abea4a..01839a36f3 100644 --- a/doc/how-to/secrets/how-to-generate-a-secret.md +++ b/doc/how-to/secrets/how-to-generate-a-secret.md @@ -5,7 +5,7 @@ 2. Secrets must meet the following criteria - * Have a minimum length of 32 characters * All characters should be alphanumeric -3. Follow [the current process for adding secrets](https://github.com/theopensystemslab/planx-new/blob/main/doc/how-to/how-to-add-a-secret.md) to the application +3. Follow [the current process for adding secrets](https://github.com/theopensystemslab/planx-new/blob/main/doc/how-to/secrets/how-to-add-a-secret.md) to the application ## Principles - Staging and Production environments should not share secrets diff --git a/docker-compose.yml b/docker-compose.yml index e846bfee9d..b045c55b3d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -127,6 +127,8 @@ services: FILE_API_KEY_BARNET: ${FILE_API_KEY_BARNET} FILE_API_KEY_LAMBETH: ${FILE_API_KEY_LAMBETH} FILE_API_KEY_SOUTHWARK: ${FILE_API_KEY_SOUTHWARK} + FILE_API_KEY_EPSOM_EWELL: ${FILE_API_KEY_EPSOM_EWELL} + FILE_API_KEY_MEDWAY: ${FILE_API_KEY_MEDWAY} FILE_API_KEY_NEXUS: ${FILE_API_KEY_NEXUS} FILE_API_KEY: ${FILE_API_KEY} GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID} diff --git a/editor.planx.uk/src/@planx/components/Checklist/Editor/Options.tsx b/editor.planx.uk/src/@planx/components/Checklist/Editor/Options.tsx index 0080d90a92..388c021dac 100644 --- a/editor.planx.uk/src/@planx/components/Checklist/Editor/Options.tsx +++ b/editor.planx.uk/src/@planx/components/Checklist/Editor/Options.tsx @@ -14,7 +14,7 @@ import InputRow from "ui/shared/InputRow"; import { Option } from "../../shared"; import type { Group } from "../model"; -import { OptionEditor } from "./OptionsEditor"; +import ChecklistOptionsEditor from "./OptionsEditor"; export const Options: React.FC<{ formik: FormikHookReturn }> = ({ formik }) => { return ( @@ -70,7 +70,7 @@ export const Options: React.FC<{ formik: FormikHookReturn }> = ({ formik }) => { }) as Option } newValueLabel="add new option" - Editor={OptionEditor} + Editor={ChecklistOptionsEditor} editorExtraProps={{ groupIndex, showValueField: !!formik.values.fn, @@ -142,7 +142,7 @@ export const Options: React.FC<{ formik: FormikHookReturn }> = ({ formik }) => { }, }) as Option } - Editor={OptionEditor} + Editor={ChecklistOptionsEditor} editorExtraProps={{ showValueField: !!formik.values.fn }} /> )} diff --git a/editor.planx.uk/src/@planx/components/Checklist/Editor/OptionsEditor.tsx b/editor.planx.uk/src/@planx/components/Checklist/Editor/OptionsEditor.tsx index 67bff66212..bb4dfb6e41 100644 --- a/editor.planx.uk/src/@planx/components/Checklist/Editor/OptionsEditor.tsx +++ b/editor.planx.uk/src/@planx/components/Checklist/Editor/OptionsEditor.tsx @@ -1,104 +1,46 @@ +import { + BaseOptionsEditor, + BaseOptionsEditorProps, +} from "@planx/components/shared/BaseOptionsEditor"; import React from "react"; -import ImgInput from "ui/editor/ImgInput/ImgInput"; import SimpleMenu from "ui/editor/SimpleMenu"; -import Input from "ui/shared/Input/Input"; -import InputRow from "ui/shared/InputRow"; -import InputRowItem from "ui/shared/InputRowItem"; -import { FlagsSelect } from "../../shared/FlagsSelect"; -import { OptionEditorProps } from "../types"; +export type ChecklistOptionsEditorProps = BaseOptionsEditorProps & { + index: number; + groupIndex?: number; + groups?: Array; + onMoveToGroup?: (itemIndex: number, groupIndex: number) => void; + showValueField?: boolean; +}; -export const OptionEditor: React.FC = (props) => { +const ChecklistOptionsEditor: React.FC = ({ + value, + onChange, + showValueField = false, + groups, + onMoveToGroup, + index, +}) => { return ( -
- - {props.value.id ? ( - - ) : null} - - { - props.onChange({ - ...props.value, - data: { - ...props.value.data, - text: ev.target.value, - }, - }); - }} - placeholder="Option" - /> - - - { - props.onChange({ - ...props.value, - data: { - ...props.value.data, - img, - }, - }); - }} + + {typeof index !== "undefined" && groups && onMoveToGroup && ( + ({ + label: `Move to ${group || `group ${groupIndex}`}`, + onClick: () => { + if (onMoveToGroup && typeof index === "number") + onMoveToGroup(index, groupIndex); + }, + disabled: groupIndex === groupIndex, + }))} /> - - {typeof props.index !== "undefined" && - props.groups && - props.onMoveToGroup && ( - ({ - label: `Move to ${group || `group ${groupIndex}`}`, - onClick: () => { - props.onMoveToGroup && - typeof props.index === "number" && - props.onMoveToGroup(props.index, groupIndex); - }, - disabled: groupIndex === props.groupIndex, - }))} - /> - )} - - - {props.showValueField && ( - - { - props.onChange({ - ...props.value, - data: { - ...props.value.data, - val: ev.target.value, - }, - }); - }} - /> - )} - - { - props.onChange({ - ...props.value, - data: { - ...props.value.data, - flag: ev, - }, - }); - }} - /> -
+ ); }; + +export default ChecklistOptionsEditor; diff --git a/editor.planx.uk/src/@planx/components/Checklist/Public/tests/Public.test.tsx b/editor.planx.uk/src/@planx/components/Checklist/Public/tests/Public.test.tsx index f77d90ae1b..c7a6804537 100644 --- a/editor.planx.uk/src/@planx/components/Checklist/Public/tests/Public.test.tsx +++ b/editor.planx.uk/src/@planx/components/Checklist/Public/tests/Public.test.tsx @@ -22,7 +22,7 @@ describe("Checklist Component - Grouped Layout", () => { text="home type?" handleSubmit={handleSubmit} groupedOptions={groupedOptions} - />, + /> ); await user.click(screen.getByText("Section 1")); @@ -46,7 +46,7 @@ describe("Checklist Component - Grouped Layout", () => { handleSubmit={handleSubmit} previouslySubmittedData={{ answers: ["S1_Option1", "S3_Option1"] }} groupedOptions={groupedOptions} - />, + /> ); expect(screen.getByTestId("group-0-expanded")).toBeTruthy(); @@ -67,7 +67,7 @@ describe("Checklist Component - Grouped Layout", () => { description="" text="home type?" groupedOptions={groupedOptions} - />, + /> ); const results = await axe(container); expect(results).toHaveNoViolations(); @@ -83,7 +83,7 @@ describe("Checklist Component - Grouped Layout", () => { text="home type?" handleSubmit={handleSubmit} groupedOptions={groupedOptions} - />, + /> ); const [section1Button, section2Button, section3Button] = screen.getAllByRole("button"); @@ -140,7 +140,7 @@ describe("Checklist Component - Basic & Images Layout", () => { text="home type?" handleSubmit={handleSubmit} options={options[type]} - />, + /> ); expect(screen.getByRole("heading")).toHaveTextContent("home type?"); @@ -177,7 +177,7 @@ describe("Checklist Component - Basic & Images Layout", () => { handleSubmit={handleSubmit} previouslySubmittedData={{ answers: ["flat_id", "house_id"] }} options={options[type]} - />, + /> ); await user.click(screen.getByTestId("continue-button")); @@ -194,7 +194,7 @@ describe("Checklist Component - Basic & Images Layout", () => { description="" text="home type?" options={options[type]} - />, + /> ); const results = await axe(container); expect(results).toHaveNoViolations(); @@ -210,7 +210,7 @@ describe("Checklist Component - Basic & Images Layout", () => { text="home type?" handleSubmit={handleSubmit} options={options[type]} - />, + /> ); await user.tab(); diff --git a/editor.planx.uk/src/@planx/components/Checklist/types.ts b/editor.planx.uk/src/@planx/components/Checklist/types.ts index cdebd204ad..76ef9d47bd 100644 --- a/editor.planx.uk/src/@planx/components/Checklist/types.ts +++ b/editor.planx.uk/src/@planx/components/Checklist/types.ts @@ -17,14 +17,5 @@ export interface ChecklistProps extends Checklist { } & BaseNodeData; }; } -export interface OptionEditorProps { - index: number; - value: Option; - onChange: (newVal: Option) => void; - groupIndex?: number; - groups?: Array; - onMoveToGroup?: (itemIndex: number, groupIndex: number) => void; - showValueField?: boolean; -} export type Props = PublicProps; diff --git a/editor.planx.uk/src/@planx/components/Feedback/components/FaceBox.tsx b/editor.planx.uk/src/@planx/components/Feedback/components/FaceBox.tsx index 32f50cfd86..12a89180e2 100644 --- a/editor.planx.uk/src/@planx/components/Feedback/components/FaceBox.tsx +++ b/editor.planx.uk/src/@planx/components/Feedback/components/FaceBox.tsx @@ -28,7 +28,7 @@ export const FaceBox = ({ px: 0, width: "100%", textTransform: "none", - [`&.${toggleButtonClasses.selected}`]: { + [`&.${toggleButtonClasses.selected} > div`]: { borderColor: (theme) => theme.palette.primary.dark, background: (theme) => theme.palette.background.paper, }, diff --git a/editor.planx.uk/src/@planx/components/List/Editor.tsx b/editor.planx.uk/src/@planx/components/List/Editor.tsx index 72b01dc3b7..339c08ca9c 100644 --- a/editor.planx.uk/src/@planx/components/List/Editor.tsx +++ b/editor.planx.uk/src/@planx/components/List/Editor.tsx @@ -17,6 +17,9 @@ import { ICONS } from "../shared/icons"; import { EditorProps } from "../shared/types"; import { List, parseContent, validationSchema } from "./model"; import { ProposedAdvertisements } from "./schemas/Adverts"; +import { ExistingBuildingsCIL } from "./schemas/CIL/ExistingCIL"; +import { MezzanineCIL } from "./schemas/CIL/MezzanineCIL"; +import { UnoccupiedBuildingsCIL } from "./schemas/CIL/UnoccupiedCIL"; import { NonResidentialFloorspace } from "./schemas/Floorspace"; import { BuildingDetailsGLA } from "./schemas/GLA/BuildingDetails"; import { CommunalSpaceGLA } from "./schemas/GLA/CommunalSpace"; @@ -69,6 +72,9 @@ export const SCHEMAS = [ { name: "Proposed advertisements", schema: ProposedAdvertisements }, { name: "Parking details", schema: Parking }, { name: "Parking details (GLA)", schema: ParkingGLA }, + { name: "Existing buildings (CIL)", schema: ExistingBuildingsCIL }, + { name: "Unoccupied buildings (CIL)", schema: UnoccupiedBuildingsCIL }, + { name: "Mezzanine floors (CIL)", schema: MezzanineCIL }, { name: "Trees", schema: Trees }, { name: "Trees (Map first)", schema: TreesMapFirst }, ]; diff --git a/editor.planx.uk/src/@planx/components/List/schemas/CIL/ExistingCIL.ts b/editor.planx.uk/src/@planx/components/List/schemas/CIL/ExistingCIL.ts new file mode 100644 index 0000000000..3f69e83302 --- /dev/null +++ b/editor.planx.uk/src/@planx/components/List/schemas/CIL/ExistingCIL.ts @@ -0,0 +1,65 @@ +import { Schema } from "@planx/components/shared/Schema/model"; +import { TextInputType } from "@planx/components/TextInput/model"; + +export const ExistingBuildingsCIL: Schema = { + type: "Existing building or part of building", + fields: [ + { + type: "text", + data: { + title: "Describe the existing building or part", + fn: "descriptionExisting", + type: TextInputType.Short, + }, + }, + { + type: "number", + data: { + title: "How much of its floorspace will be retained?", + units: "m²", + fn: "area.retained", + allowNegatives: false, + }, + }, + { + type: "text", + data: { + title: "What will the retained floorspace be used for?", + description: "This can be identical to its current use.", + fn: "descriptionProposed", + type: TextInputType.Short, + }, + }, + { + type: "number", + data: { + title: "How much of its floorspace will be lost?", + units: "m²", + fn: "area.loss", + allowNegatives: false, + }, + }, + { + type: "question", + data: { + title: + "Has the building or part been lawfully occupied for 6 continuous months in the past 36 months?", + fn: "continuousOccupation", + options: [ + { id: "true", data: { text: "Yes", val: "true" } }, + { id: "false", data: { text: "No", val: "false" } }, + ], + }, + }, + { + type: "text", + data: { + title: "When was it last occupied for its lawful use?", + description: "Please enter a date or whether it is still in use.", + fn: "lastOccupation", + type: TextInputType.Short, + }, + }, + ], + min: 1, +} as const; diff --git a/editor.planx.uk/src/@planx/components/List/schemas/CIL/MezzanineCIL.ts b/editor.planx.uk/src/@planx/components/List/schemas/CIL/MezzanineCIL.ts new file mode 100644 index 0000000000..3c216892d6 --- /dev/null +++ b/editor.planx.uk/src/@planx/components/List/schemas/CIL/MezzanineCIL.ts @@ -0,0 +1,27 @@ +import { Schema } from "@planx/components/shared/Schema/model"; +import { TextInputType } from "@planx/components/TextInput/model"; + +export const MezzanineCIL: Schema = { + type: "New mezzanine floor", + fields: [ + { + type: "text", + data: { + title: "Describe the use of the mezzanine", + fn: "description", + type: TextInputType.Short, + }, + }, + { + type: "number", + data: { + title: + "What will be the Gross Internal Floor Area (GIA) of the mezzanine?", + units: "m²", + fn: "area", + allowNegatives: false, + }, + }, + ], + min: 1, +} as const; diff --git a/editor.planx.uk/src/@planx/components/List/schemas/CIL/UnoccupiedCIL.ts b/editor.planx.uk/src/@planx/components/List/schemas/CIL/UnoccupiedCIL.ts new file mode 100644 index 0000000000..83126000b9 --- /dev/null +++ b/editor.planx.uk/src/@planx/components/List/schemas/CIL/UnoccupiedCIL.ts @@ -0,0 +1,44 @@ +import { Schema } from "@planx/components/shared/Schema/model"; +import { TextInputType } from "@planx/components/TextInput/model"; + +export const UnoccupiedBuildingsCIL: Schema = { + type: "Building not meant for occupation or temporarily permitted", + fields: [ + { + type: "text", + data: { + title: "Describe the existing building", + fn: "descriptionExisting", + type: TextInputType.Short, + }, + }, + { + type: "number", + data: { + title: "How much of its floorspace will be retained?", + units: "m²", + fn: "area.retained", + allowNegatives: false, + }, + }, + { + type: "text", + data: { + title: "What will the retained floorspace be used for?", + description: "This can be identical to its current use.", + fn: "descriptionProposed", + type: TextInputType.Short, + }, + }, + { + type: "number", + data: { + title: "How much of its floorspace will be lost?", + units: "m²", + fn: "area.loss", + allowNegatives: false, + }, + }, + ], + min: 1, +} as const; diff --git a/editor.planx.uk/src/@planx/components/Question/Editor.tsx b/editor.planx.uk/src/@planx/components/Question/Editor.tsx index e60f851311..bf61ce94ed 100644 --- a/editor.planx.uk/src/@planx/components/Question/Editor.tsx +++ b/editor.planx.uk/src/@planx/components/Question/Editor.tsx @@ -10,14 +10,13 @@ import ModalSectionContent from "ui/editor/ModalSectionContent"; import RichTextInput from "ui/editor/RichTextInput/RichTextInput"; import Input from "ui/shared/Input/Input"; import InputRow from "ui/shared/InputRow"; -import InputRowItem from "ui/shared/InputRowItem"; import { Switch } from "ui/shared/Switch"; import { InternalNotes } from "../../../ui/editor/InternalNotes"; import { MoreInformation } from "../../../ui/editor/MoreInformation/MoreInformation"; import { BaseNodeData, Option, parseBaseNodeData } from "../shared"; -import { FlagsSelect } from "../shared/FlagsSelect"; import { ICONS } from "../shared/icons"; +import QuestionOptionsEditor from "./OptionsEditor"; interface Props { node: { @@ -34,100 +33,6 @@ interface Props { handleSubmit?: Function; } -const OptionEditor: React.FC<{ - value: Option; - onChange: (newVal: Option) => void; - showValueField?: boolean; -}> = (props) => ( -
- - {props.value.id && ( - - )} - - { - props.onChange({ - ...props.value, - data: { - ...props.value.data, - text: ev.target.value, - }, - }); - }} - placeholder="Option" - /> - - { - props.onChange({ - ...props.value, - data: { - ...props.value.data, - img, - }, - }); - }} - /> - - - { - props.onChange({ - ...props.value, - data: { - ...props.value.data, - description: ev.target.value, - }, - }); - }} - /> - - {props.showValueField && ( - - { - props.onChange({ - ...props.value, - data: { - ...props.value.data, - val: ev.target.value, - }, - }); - }} - /> - - )} - { - props.onChange({ - ...props.value, - data: { - ...props.value.data, - flag: ev, - }, - }); - }} - /> -
-); - export const Question: React.FC = (props) => { const type = TYPES.Question; @@ -246,7 +151,7 @@ export const Question: React.FC = (props) => { }, }) as Option } - Editor={OptionEditor} + Editor={QuestionOptionsEditor} editorExtraProps={{ showValueField: !!formik.values.fn }} /> diff --git a/editor.planx.uk/src/@planx/components/Question/OptionsEditor.tsx b/editor.planx.uk/src/@planx/components/Question/OptionsEditor.tsx new file mode 100644 index 0000000000..d9e0c4792b --- /dev/null +++ b/editor.planx.uk/src/@planx/components/Question/OptionsEditor.tsx @@ -0,0 +1,23 @@ +import React from "react"; + +import { + BaseOptionsEditor, + BaseOptionsEditorProps, +} from "../shared/BaseOptionsEditor"; + +const QuestionOptionsEditor: React.FC = ({ + value, + onChange, + showValueField = false, +}) => { + return ( + + ); +}; + +export default QuestionOptionsEditor; diff --git a/editor.planx.uk/src/@planx/components/shared/BaseOptionsEditor.tsx b/editor.planx.uk/src/@planx/components/shared/BaseOptionsEditor.tsx new file mode 100644 index 0000000000..702c843868 --- /dev/null +++ b/editor.planx.uk/src/@planx/components/shared/BaseOptionsEditor.tsx @@ -0,0 +1,109 @@ +import React, { ReactNode } from "react"; +import ImgInput from "ui/editor/ImgInput/ImgInput"; +import Input from "ui/shared/Input/Input"; +import InputRow from "ui/shared/InputRow"; +import InputRowItem from "ui/shared/InputRowItem"; + +import { Option } from "."; +import { FlagsSelect } from "./FlagsSelect"; + +export interface BaseOptionsEditorProps { + value: Option; + showValueField?: boolean; + showDescriptionField?: boolean; + onChange: (newVal: Option) => void; + children?: ReactNode; +} + +export const BaseOptionsEditor: React.FC = (props) => ( +
+ + {props.value.id && ( + + )} + + { + props.onChange({ + ...props.value, + data: { + ...props.value.data, + text: ev.target.value, + }, + }); + }} + placeholder="Option" + /> + + { + props.onChange({ + ...props.value, + data: { + ...props.value.data, + img, + }, + }); + }} + /> + {props.children} + + {props.showDescriptionField && ( + + + props.onChange({ + ...props.value, + data: { + ...props.value.data, + description: ev.target.value, + }, + }) + } + /> + + )} + {props.showValueField && ( + + { + props.onChange({ + ...props.value, + data: { + ...props.value.data, + val: ev.target.value, + }, + }); + }} + /> + + )} + { + props.onChange({ + ...props.value, + data: { + ...props.value.data, + flag: ev, + }, + }); + }} + /> +
+); diff --git a/editor.planx.uk/src/pages/Team.tsx b/editor.planx.uk/src/pages/Team.tsx index ad0c8b9d0b..37c10d6d37 100644 --- a/editor.planx.uk/src/pages/Team.tsx +++ b/editor.planx.uk/src/pages/Team.tsx @@ -258,59 +258,57 @@ const FlowItem: React.FC = ({ }; const GetStarted: React.FC<{ flows: FlowSummary[] }> = ({ flows }) => ( - ({ - mt: 4, - backgroundColor: theme.palette.background.paper, - borderRadius: "8px", - display: "flex", - flexDirection: "column", + ({ + mt: 4, + backgroundColor: theme.palette.background.paper, + borderRadius: "8px", + display: "flex", + flexDirection: "column", alignItems: "center", - gap: 2, - padding: 2 - })}> - No services found - Get started by creating your first service - + gap: 2, + padding: 2, + })} + > + No services found + Get started by creating your first service + -) +); const AddFlowButton: React.FC<{ flows: FlowSummary[] }> = ({ flows }) => { const { navigate } = useNavigation(); - const { teamId, createFlow, teamSlug } = useStore() + const { teamId, createFlow, teamSlug } = useStore(); const addFlow = async () => { const newFlowName = prompt("Service name"); if (!newFlowName) return; const newFlowSlug = slugify(newFlowName); - const duplicateFlowName = flows?.find( - (flow) => flow.slug === newFlowSlug, - ); + const duplicateFlowName = flows?.find((flow) => flow.slug === newFlowSlug); if (duplicateFlowName) { alert( `The flow "${newFlowName}" already exists. Enter a unique flow name to continue`, ); + return; } const newId = await createFlow(teamId, newFlowSlug, newFlowName); navigate(`/${teamSlug}/${newId}`); - } + }; - return( - - Add a new service - - ) -} + return Add a new service; +}; const Team: React.FC = () => { - const [{ id: teamId, slug }, canUserEditTeam, getFlows] = useStore((state) => [state.getTeam(), state.canUserEditTeam, state.getFlows ]); + const [{ id: teamId, slug }, canUserEditTeam, getFlows] = useStore( + (state) => [state.getTeam(), state.canUserEditTeam, state.getFlows], + ); const [flows, setFlows] = useState(null); const fetchFlows = useCallback(() => { - getFlows(teamId) - .then((flows) => { + getFlows(teamId).then((flows) => { // Copy the array and sort by most recently edited desc using last associated operation.createdAt, not flow.updatedAt const sortedFlows = flows.toSorted((a, b) => b.operations[0]["createdAt"].localeCompare( @@ -325,7 +323,7 @@ const Team: React.FC = () => { fetchFlows(); }, [fetchFlows]); - const teamHasFlows = flows && Boolean(flows.length) + const teamHasFlows = flows && Boolean(flows.length); const showAddFlowButton = teamHasFlows && canUserEditTeam(slug); return ( @@ -349,15 +347,9 @@ const Team: React.FC = () => { Services - {canUserEditTeam(slug) ? ( - - ) : ( - - )} + {canUserEditTeam(slug) ? : } - {showAddFlowButton && ( - - )} + {showAddFlowButton && } {teamHasFlows && ( @@ -373,9 +365,9 @@ const Team: React.FC = () => { }} /> ))} - ) - } - { flows && !flows.length && } + + )} + {flows && !flows.length && } ); }; diff --git a/infrastructure/application/Pulumi.production.yaml b/infrastructure/application/Pulumi.production.yaml index ba75a36fc7..6aba6b28c2 100644 --- a/infrastructure/application/Pulumi.production.yaml +++ b/infrastructure/application/Pulumi.production.yaml @@ -10,8 +10,12 @@ config: secure: AAABAGyTfLujGho+V0tEhFXQRET5FjYK6txyaFTB3gY/VaKzq8yNlocJTAM5nt8mBhF6T+AeQD2GxW63 application:file-api-key-barnet: secure: AAABANMl+fVFsRVGXvJV/aLManXO+TldXVDhp5QH6KGWJoG7O9Ket63zIW1iOiinINWJ2I5OizI= + application:file-api-key-epsom-ewell: + secure: AAABANvwhiVRBq8NH7ZqcToUzYn4X+KfC5Wm8WjWUKXT5TuVXqC6zHhVVKFBbmdtKjC4j5M4+bWsLiFO9dO0MLobxLpK7YCE application:file-api-key-lambeth: secure: AAABAMNhdCTlFx3fZH/nO71ildypZB2JR5NixlQCENsS1VqwdiOX17q/Gi1UFrCQi2qaY2sZFG4= + application:file-api-key-medway: + secure: AAABABXSnk3j06JukiZ2u/me+pnRjHWAm+rf9FW9bSmzMfr9SWSKyN4XlltOYv1iZKujgI9hGIGiVR40+uYjGjy7BwNqOFxI application:file-api-key-nexus: secure: AAABAB2cv4GAf8RqN1hHbRbO68p8o4kLJYWsip9BoPdobrNtQB787M3s+gJnKKl9DfyXRHOXHGc= application:file-api-key-southwark: @@ -25,6 +29,8 @@ config: secure: AAABAHfDtVpAD8w32yINWTjgvuRQixWXYFf3/rEcyh59/pRSz+J4ZYCXNq5jqBiIXM2emB+7zOY= application:hasura-cpu: "512" application:hasura-memory: "2048" + application:hasura-proxy-cpu: "512" + application:hasura-proxy-memory: "2048" application:hasura-planx-api-key: secure: AAABAExsXFL7HabeK0Z1oSUJzI2NqVqEmKJ1ojYXyX4Hi8Sbt1Ht9QJc/Yn3cPBAB2r32HKa4HtqqLmfGjS+04lFB/I= application:idox-nexus-client: diff --git a/infrastructure/application/Pulumi.staging.yaml b/infrastructure/application/Pulumi.staging.yaml index be082e9ca8..49df40ad99 100644 --- a/infrastructure/application/Pulumi.staging.yaml +++ b/infrastructure/application/Pulumi.staging.yaml @@ -11,8 +11,12 @@ config: secure: AAABAN0LjLOgxCkr5ZqQLn6FkZPcrPlvNG4fbNZ02W2qC1VVYVee/3aToZQuXuokVwnIPNbbe2w= application:file-api-key-barnet: secure: AAABAFpZq81zy3CKFXUgi9oEGIGp7LDVD3TNlYkZD4liX0bxOrmMJYdDpMmyGt4aGARF63nEUmo= + application:file-api-key-epsom-ewell: + secure: AAABAD1/nlJ2EOEglLiiNsOLbOd3KWCONhNhJAIdZQVnrSRsNIzX2luszOreQf20EYl8AZ4L1TiheqUHSt22e5z1FiLWoCtY application:file-api-key-lambeth: secure: AAABALQTeIf/uScxASJkhmoPRhewQT94Guad4iJ7GRk0DcND8wDUG0eNxDU4+XwUQZqCnL2DP+E= + application:file-api-key-medway: + secure: AAABABCpSTmTDJU81pG7U57Igr4OtBbX6VuqRooq9Ipzoq1peTenbNQYBWacZn6lyg7ceLAgbiHQgT7LqX2tKN1QwON/BSQh application:file-api-key-nexus: secure: AAABAJFgaBoTWNmZyXDkGRngwU8KpOt6CeBLxGBgBG0JFMsKK7rWT39TsjJ9pL1wZaBoT0YZhCg= application:file-api-key-southwark: @@ -26,6 +30,8 @@ config: secure: AAABAHsoh7ZNkr6ep3xXsUZpp/JIjshBX+tJ0KOFgGnJ4wxR0oIcB6VewVDuwSyFJRVix72YahM= application:hasura-cpu: "512" application:hasura-memory: "2048" + application:hasura-proxy-cpu: "512" + application:hasura-proxy-memory: "2048" application:hasura-planx-api-key: secure: AAABANHLs3ItPxkteh0chwMP2bKuHO3ovuRLi4FsIrCqerzXVIaTLFDqNR+4KBTeMPz4cnF5tCTwsrJv9GruZdXU+lg= application:idox-nexus-client: @@ -36,6 +42,8 @@ config: secure: AAABACbmLC4176IBxX5iL64/nycSXEsCYSQ0hTb7t2OCVlWUc627Vr/EpBhcqPrw9q+0z8UOvRJG5/c/DflZxfPxyJRUVNu+ application:mapbox-access-token: secure: AAABAMWf2zVq5/mKCLynpgzAidNsnbUEBpb47n7MRWp2xzRgwaf3kzOvnZax9N04ZScQqU6I5/tEKTBAbSb8MIBJ8mU2iTZbPg8FD6wYsetRyftm1K39KBsIl9aS7fXvZFOG7BsC4qMDEhlDkH8gbV2HTev3VvvRUe3lzVhjNGNHqQ== + application:metabase-api-key: + secure: AAABAFf+hW09AWupsY6adrPAJHCkrTMeRX7/gaUHLYXi3QS77MVelPp9K0L4zyUL8u7zDanjuE9G/bfIlcVXLwiLLKAJkt5to9knKJqTXg== application:metabase-encryption-secret-key: secure: AAABAGmfVICD8sR+IE6mHC8BNUY1WQXGCbv5F3C1fSgA+1ADiRem3GNrwY0YRZociRYuPIo3MIRS0aIg44jt10SBCE0ik58wHamcKA== application:metabasePgPassword: @@ -66,5 +74,3 @@ config: certificates:cloudflare-zone-id: dc27ac531ff8862559ed9ab5016c4953 cloudflare:apiToken: secure: AAABABWhDm+7RstbxLXd1D8CcxkylHS6UKMqk4kOaY7Y0E7FJS4bZfvyGs0nks80hl3vjENH4eDuFbUgA82/sA4SmDlfpNXr - application:metabase-api-key: - secure: AAABAFf+hW09AWupsY6adrPAJHCkrTMeRX7/gaUHLYXi3QS77MVelPp9K0L4zyUL8u7zDanjuE9G/bfIlcVXLwiLLKAJkt5to9knKJqTXg== diff --git a/infrastructure/application/index.ts b/infrastructure/application/index.ts index c0cd37a8a4..a4754a1ab5 100644 --- a/infrastructure/application/index.ts +++ b/infrastructure/application/index.ts @@ -94,7 +94,17 @@ const CUSTOM_DOMAINS: CustomDomains = export = async () => { const DOMAIN: string = await certificates.requireOutputValue("domain"); - const repo = new awsx.ecr.Repository("repo"); + const repo = new awsx.ecr.Repository("repo", { + lifeCyclePolicyArgs: { + rules: [ + { + description: "Keep last 100 images", + maximumNumberOfImages: 100, + selection: "any", + }, + ], + }, + }); const vpc = awsx.ec2.Vpc.fromExistingIds("vpc", { vpcId: networking.requireOutput("vpcId"), @@ -359,6 +369,14 @@ export = async () => { name: "FILE_API_KEY_SOUTHWARK", value: config.requireSecret("file-api-key-southwark"), }, + { + name: "FILE_API_KEY_EPSOM_EWELL", + value: config.requireSecret("file-api-key-epsom-ewell"), + }, + { + name: "FILE_API_KEY_MEDWAY", + value: config.requireSecret("file-api-key-medway"), + }, { name: "GOOGLE_CLIENT_ID", value: config.require("google-client-id"), diff --git a/infrastructure/application/services/hasura.ts b/infrastructure/application/services/hasura.ts index c27698d872..c4b3f63028 100644 --- a/infrastructure/application/services/hasura.ts +++ b/infrastructure/application/services/hasura.ts @@ -59,7 +59,8 @@ export const createHasuraService = async ({ containers: { hasuraProxy: { image: repo.buildAndPushImage("../../hasura.planx.uk/proxy"), - memory: 1024 /*MB*/, + cpu: config.requireNumber("hasura-proxy-cpu"), + memory: config.requireNumber("hasura-proxy-memory"), portMappings: [hasuraListenerHttp], environment: [ { name: "HASURA_PROXY_PORT", value: String(HASURA_PROXY_PORT) }, @@ -97,6 +98,7 @@ export const createHasuraService = async ({ name: "HASURA_GRAPHQL_DATABASE_URL", value: dbRootUrl, }, + { name: "HASURA_GRAPHQL_MIGRATIONS_SERVER_TIMEOUT", value: "300" }, { name: "HASURA_PLANX_API_URL", value: `https://api.${DOMAIN}`, diff --git a/infrastructure/data/index.ts b/infrastructure/data/index.ts index 78fe9270bd..7046441910 100644 --- a/infrastructure/data/index.ts +++ b/infrastructure/data/index.ts @@ -16,7 +16,7 @@ const db = new aws.rds.Instance("app", { // $ aws rds describe-db-engine-versions --default-only --engine postgres engineVersion: "12.17", // Available instance types: https://aws.amazon.com/rds/instance-types/ - instanceClass: env === "production" ? "db.t3.medium" : "db.t3.micro", + instanceClass: env === "production" ? "db.t3.medium" : "db.t3.small", allocatedStorage: env === "production" ? 100 : 20, allowMajorVersionUpgrade: true, dbSubnetGroupName: networking.requireOutput("subnetId"),