diff --git a/api/helpers/getFileExtension.ts b/api/helpers/getFileExtension.ts index 5698953..7e55e70 100644 --- a/api/helpers/getFileExtension.ts +++ b/api/helpers/getFileExtension.ts @@ -3,7 +3,7 @@ import { handleApiError } from '@common/helpers/handleApiError' export function getFileExtension(fileName: string): string { try { const result = /\.([^.]+)$/.exec(fileName) - if (result === null) { + if (result === null || result[1] === undefined) { throw new Error(`Impossible to extract the file extension from "${fileName}" file name."`) } diff --git a/api/helpers/getFileNameFromFilePath.ts b/api/helpers/getFileNameFromFilePath.ts index ae22525..9cdb30e 100644 --- a/api/helpers/getFileNameFromFilePath.ts +++ b/api/helpers/getFileNameFromFilePath.ts @@ -3,7 +3,7 @@ import { handleApiError } from '@common/helpers/handleApiError' export function getFileNameFromFilePath(filePath: string): string { try { const result = /[/\\]([^/\\]+)$/.exec(filePath) - if (result === null) { + if (result === null || result[1] === undefined) { throw new Error(`Impossible to extract the file name from "${filePath}" file path."`) } diff --git a/api/libs/StaticServer.ts b/api/libs/StaticServer.ts index 8ede71f..6a53906 100644 --- a/api/libs/StaticServer.ts +++ b/api/libs/StaticServer.ts @@ -21,15 +21,15 @@ enum StaticServerType { const CACHE: { type?: StaticServerType -} = { - type: undefined, -} +} = {} export class StaticServer { #type?: StaticServerType constructor() { - this.#type = CACHE.type + if (CACHE.type !== undefined) { + this.#type = CACHE.type + } } public async upload(filePath: string, mimeType: MimeType): Promise { diff --git a/api/middlewares/withAuth/handleAuth.ts b/api/middlewares/withAuth/handleAuth.ts index 7c61a1b..9882510 100644 --- a/api/middlewares/withAuth/handleAuth.ts +++ b/api/middlewares/withAuth/handleAuth.ts @@ -65,6 +65,9 @@ export async function handleAuth( // Disposable tokens used for internal front-end private API requests. const oneTimeToken = Array.isArray(req.query.oneTimeToken) ? req.query.oneTimeToken[0] : req.query.oneTimeToken + if (oneTimeToken === undefined) { + throw new ApiError(`Unauthorized.`, 401, true) + } const maybeOneTimeToken = await prisma.oneTimeToken.findUnique({ where: { diff --git a/app/atoms/CardTitle.tsx b/app/atoms/CardTitle.tsx index f508e43..4a2334c 100644 --- a/app/atoms/CardTitle.tsx +++ b/app/atoms/CardTitle.tsx @@ -1,7 +1,7 @@ import styled from 'styled-components' export const CardTitle = styled.h2<{ - accent?: string + accent?: 'danger' | 'info' | 'primary' | 'secondary' | 'success' | 'warning' isFirst?: boolean withBottomMargin?: boolean }>` diff --git a/app/helpers/__tests__/getRandomId.test.ts b/app/helpers/__tests__/getRandomId.test.ts index 01d7b70..6775a85 100644 --- a/app/helpers/__tests__/getRandomId.test.ts +++ b/app/helpers/__tests__/getRandomId.test.ts @@ -6,7 +6,7 @@ import crypto from 'crypto' // TODO Repleace this polyfill once this is fixed: https://github.com/jsdom/jsdom/issues/1612. window.crypto = { - getRandomValues: buffer => crypto.randomFillSync(buffer), + getRandomValues: (buffer: any) => crypto.randomFillSync(buffer), } as any // eslint-disable-next-line import/first diff --git a/app/hocs/withCustomFormik.ts b/app/hocs/withCustomFormik.ts new file mode 100644 index 0000000..9030f66 --- /dev/null +++ b/app/hocs/withCustomFormik.ts @@ -0,0 +1,29 @@ +import { withFormik } from 'formik' + +import type { CompositeComponent, FormikErrors } from 'formik' +import type { ObjectSchema } from 'yup' + +export type FormProps = { + initialValues: Partial + onSubmit: (values: Partial) => Promise> +} + +export function withCustomFormik( + FormComponent: CompositeComponent>, + validationSchema: ObjectSchema, +) { + return withFormik, Partial>({ + handleSubmit: async (values, { props: { onSubmit }, setErrors, setSubmitting }) => { + const errors = await onSubmit(values) + if (errors !== undefined) { + setErrors(errors) + } + + setSubmitting(false) + }, + + mapPropsToValues: ({ initialValues }) => initialValues, + + validationSchema, + })(FormComponent) +} diff --git a/app/libs/SurveyEditorManager/index.ts b/app/libs/SurveyEditorManager/index.ts index 9148ccb..488fe81 100644 --- a/app/libs/SurveyEditorManager/index.ts +++ b/app/libs/SurveyEditorManager/index.ts @@ -22,7 +22,12 @@ export class SurveyEditorManager { return null } - return this.#blocks[this.#focusedBlockIndex] + const block = this.#blocks[this.#focusedBlockIndex] + if (block === undefined) { + return null + } + + return block } public get focusedBlockIndex(): number { @@ -44,11 +49,26 @@ export class SurveyEditorManager { this.#blocks = [] this.#focusedBlockIndex = -1 - Object.getOwnPropertyNames(Object.getPrototypeOf(this)).forEach(key => { - if (this[key] instanceof Function && key !== 'constructor') { - this[key] = this[key].bind(this) - } - }) + this.appendNewBlockAt = this.appendNewBlockAt.bind(this) + this.appendNewBlockToFocusedBlock = this.appendNewBlockToFocusedBlock.bind(this) + this.findBlockIndexById = this.findBlockIndexById.bind(this) + this.focusNextBlock = this.focusNextBlock.bind(this) + this.focusPreviousBlock = this.focusPreviousBlock.bind(this) + this.getQuestionInputTypeAt = this.getQuestionInputTypeAt.bind(this) + this.initializeBlocks = this.initializeBlocks.bind(this) + this.removeBlockAt = this.removeBlockAt.bind(this) + this.removeFocusedBlock = this.removeFocusedBlock.bind(this) + this.setBlockPropsAt = this.setBlockPropsAt.bind(this) + this.setBlockTypeAt = this.appendNewBlockAt.bind(this) + this.setBlockValueAt = this.appendNewBlockAt.bind(this) + this.setFocusAt = this.setFocusAt.bind(this) + this.setFocusedBlockProps = this.setFocusedBlockProps.bind(this) + this.setFocusedBlockType = this.setFocusedBlockType.bind(this) + this.setFocusedBlockValue = this.setFocusedBlockValue.bind(this) + this.setIfSelectedThenShowQuestionIdsAt = this.setIfSelectedThenShowQuestionIdsAt.bind(this) + this.toggleBlockObligationAt = this.toggleBlockObligationAt.bind(this) + this.toggleBlockVisibilityAt = this.toggleBlockVisibilityAt.bind(this) + this.unsetFocus = this.unsetFocus.bind(this) this.initializeBlocks(initialBlocks) } @@ -62,10 +82,12 @@ export class SurveyEditorManager { return } + const oldBlock = this.blocks[index] + const newBlock = { data: { - pageIndex: index === -1 ? 0 : this.blocks[index].data.pageIndex, - pageRankIndex: index === -1 ? 0 : this.blocks[index].data.pageRankIndex + 1, + pageIndex: index === -1 || oldBlock === undefined ? 0 : oldBlock.data.pageIndex, + pageRankIndex: index === -1 || oldBlock === undefined ? 0 : oldBlock.data.pageRankIndex + 1, }, id: cuid(), type, @@ -132,14 +154,13 @@ export class SurveyEditorManager { // eslint-disable-next-line no-plusplus while (++nextBlockIndex < blocksLength) { const nextBlock = this.blocks[nextBlockIndex] + if (nextBlock === undefined || isQuestionBlock(nextBlock)) { + break + } if (isInputBlock(nextBlock)) { return nextBlock.type } - - if (isQuestionBlock(nextBlock)) { - break - } } throw new Error(`This survey question block has no related input block.`) diff --git a/app/organisms/GlobalSettingsForm.tsx b/app/organisms/GlobalSettingsForm.tsx new file mode 100644 index 0000000..2570476 --- /dev/null +++ b/app/organisms/GlobalSettingsForm.tsx @@ -0,0 +1,154 @@ +import { CardTitle } from '@app/atoms/CardTitle' +import { withCustomFormik } from '@app/hocs/withCustomFormik' +import { useLocalization } from '@app/hooks/useLocalization' +import { Form } from '@app/molecules/Form' +import { GlobalVariableKey } from '@common/constants' +import { Card, Field } from '@singularity/core' +import { Form as FormikForm } from 'formik' +import { useMemo } from 'react' +import { useIntl } from 'react-intl' +import * as Yup from 'yup' + +import type { FormProps } from '@app/hocs/withCustomFormik' + +export type GlobalSettingsFormValues = Record + +function GlobalSettingsFormComponent() { + const intl = useIntl() + + return ( + + + + + + + + + + {intl.formatMessage({ + defaultMessage: 'S3-compatible Static Assets Server', + description: '[Settings Form] S3 subtitle.', + id: 'SETTINGS_FORM__S3_SUBTITLE', + })} + + + + + + + + + + + + + + + + + + + + + + + + + + + {intl.formatMessage({ + defaultMessage: 'Update', + description: '[Settings Form] Form update button label.', + id: 'SETTINGS_FORM__UPDATE_BUTTON_LABEL', + })} + + + + + ) +} + +export function GlobalSettingsForm(props: FormProps) { + const localization = useLocalization() + // const intl = useIntl() + + const formSchema = useMemo( + () => + Yup.object().shape({ + [GlobalVariableKey.BASE_URL]: Yup.string().trim().nullable().url("This URL doesn't look right."), + [GlobalVariableKey.S3_ACCESS_KEY]: Yup.string().trim().nullable(), + [GlobalVariableKey.S3_BUCKET]: Yup.string().trim().nullable(), + [GlobalVariableKey.S3_ENDPOINT]: Yup.string().trim().nullable(), + [GlobalVariableKey.S3_PORT]: Yup.number().nullable(), + [GlobalVariableKey.S3_SECRET_KEY]: Yup.string().trim().nullable(), + [GlobalVariableKey.S3_URL]: Yup.string().trim().nullable().url("This URL doesn't look right."), + }), + [localization.locale], + ) + + const WrappedComponent = useMemo( + () => withCustomFormik(GlobalSettingsFormComponent, formSchema), + [localization.locale], + ) + + return +} diff --git a/common/helpers/handleApiEndpointError.ts b/common/helpers/handleApiEndpointError.ts index ceb63ea..9ad9061 100644 --- a/common/helpers/handleApiEndpointError.ts +++ b/common/helpers/handleApiEndpointError.ts @@ -12,7 +12,12 @@ import type { NextApiResponse } from 'next' * If is not set to `true`, the handler will rethrow a SilentError * after sending the response to avoid any subsequent code to be run. */ -export function handleApiEndpointError(error: any, path: string, res: NextApiResponse, isLast: boolean = false): never { +export function handleApiEndpointError( + error: unknown, + path: string, + res: NextApiResponse, + isLast: boolean = false, +): never { if (isLast && error instanceof SilentError) { return undefined as never } diff --git a/common/helpers/handleApiError.ts b/common/helpers/handleApiError.ts index a049c80..ab220ef 100644 --- a/common/helpers/handleApiError.ts +++ b/common/helpers/handleApiError.ts @@ -14,7 +14,7 @@ import { handleError } from './handleError' * * ⚠️ Don't use that within SSR pages! */ -export function handleApiError(error: any, path: string): never { +export function handleApiError(error: unknown, path: string): never { if (!(error instanceof ApiError)) { handleError(error, path) } diff --git a/common/helpers/handleFatalError.ts b/common/helpers/handleFatalError.ts index 866be01..0468d87 100644 --- a/common/helpers/handleFatalError.ts +++ b/common/helpers/handleFatalError.ts @@ -3,7 +3,7 @@ import { handleError } from './handleError' /** * Exit process (with an error code) after handling any passed error. */ -export function handleFatalError(error: any, path: string): never { +export function handleFatalError(error: unknown, path: string): never { handleError(error, path) process.exit(1) diff --git a/pages/_document.tsx b/pages/_document.tsx index 6377d44..570b0b1 100644 --- a/pages/_document.tsx +++ b/pages/_document.tsx @@ -1,8 +1,10 @@ import Document from 'next/document' import { ServerStyleSheet } from 'styled-components' +import type { DocumentContext } from 'next/document' + export default class TellMeDocument extends Document { - static async getInitialProps(ctx) { + static override async getInitialProps(ctx: DocumentContext) { const sheet = new ServerStyleSheet() const originalRenderPage = ctx.renderPage diff --git a/pages/settings.tsx b/pages/settings.tsx index 45c9ab4..3f80163 100644 --- a/pages/settings.tsx +++ b/pages/settings.tsx @@ -1,36 +1,25 @@ import { AdminHeader } from '@app/atoms/AdminHeader' -import { CardTitle } from '@app/atoms/CardTitle' import { Title } from '@app/atoms/Title' import { useApi } from '@app/hooks/useApi' import { useIsMounted } from '@app/hooks/useIsMounted' -import { Form } from '@app/molecules/Form' import { Loader } from '@app/molecules/Loader' import { AdminBox } from '@app/organisms/AdminBox' -import { GlobalVariableKey } from '@common/constants' +import { GlobalSettingsForm } from '@app/organisms/GlobalSettingsForm' import { GlobalVariable } from '@prisma/client' -import { Card, Field } from '@singularity/core' -import { useEffect, useState } from 'react' +import * as R from 'ramda' +import { useCallback, useEffect, useState } from 'react' import { useIntl } from 'react-intl' -import * as Yup from 'yup' -const FormSchema = Yup.object().shape({ - [GlobalVariableKey.BASE_URL]: Yup.string().trim().nullable().url("This URL doesn't look right."), - [GlobalVariableKey.S3_ACCESS_KEY]: Yup.string().trim().nullable(), - [GlobalVariableKey.S3_BUCKET]: Yup.string().trim().nullable(), - [GlobalVariableKey.S3_ENDPOINT]: Yup.string().trim().nullable(), - [GlobalVariableKey.S3_PORT]: Yup.number().nullable(), - [GlobalVariableKey.S3_SECRET_KEY]: Yup.string().trim().nullable(), - [GlobalVariableKey.S3_URL]: Yup.string().trim().nullable().url("This URL doesn't look right."), -}) +import type { GlobalSettingsFormValues } from '@app/organisms/GlobalSettingsForm' export default function SettingsPage() { const api = useApi() const [isLoading, setIsLoading] = useState(true) - const [initialValues, setInitialValues] = useState>>({}) + const [initialValues, setInitialValues] = useState>({}) const intl = useIntl() const isMounted = useIsMounted() - const load = async () => { + const load = useCallback(async () => { const maybeBody = await api.get(`global-variables`) if (maybeBody === null || maybeBody.hasError) { return @@ -43,14 +32,10 @@ export default function SettingsPage() { setInitialValues(globalVariablesAsInitialValues) setIsLoading(false) } - } - - useEffect(() => { - load() }, []) - const update = async (values, { setSubmitting }) => { - const globalVariableKeys = Object.keys(values) + const update = useCallback(async (values: Partial) => { + const globalVariableKeys = R.keys(values) await Promise.all( globalVariableKeys.map(async globalVariableKey => { @@ -65,8 +50,12 @@ export default function SettingsPage() { }), ) - setSubmitting(false) - } + return undefined + }, []) + + useEffect(() => { + load() + }, []) if (isLoading) { return ( @@ -88,113 +77,7 @@ export default function SettingsPage() { -
- - - - - - - - - {intl.formatMessage({ - defaultMessage: 'S3-compatible Static Assets Server', - description: '[Settings Form] S3 subtitle.', - id: 'SETTINGS_FORM__S3_SUBTITLE', - })} - - - - - - - - - - - - - - - - - - - - - - - - - - - {intl.formatMessage({ - defaultMessage: 'Update', - description: '[Settings Form] Form update button label.', - id: 'SETTINGS_FORM__UPDATE_BUTTON_LABEL', - })} - - - -
+ ) } diff --git a/tsconfig.json b/tsconfig.json index 3cca711..fde71d0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,15 +1,33 @@ { "extends": "@ivangabriele/tsconfig-next", "compilerOptions": { + "allowUnreachableCode": false, + "allowUnusedLabels": false, + "alwaysStrict": true, "baseUrl": ".", + "exactOptionalPropertyTypes": true, "incremental": true, "isolatedModules": true, + "noFallthroughCasesInSwitch": true, + "noImplicitAny": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noImplicitThis": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, "paths": { "@api/*": ["api/*"], "@app/*": ["app/*"], "@common/*": ["common/*"], "@schemas/*": ["schemas/*"] - } + }, + "strict": true, + "strictBindCallApply": true, + "strictFunctionTypes": true, + "strictNullChecks": true, + "strictPropertyInitialization": true, + "useUnknownInCatchVariables": true }, "include": ["**/*.ts", "**/*.tsx", "next-env.d.ts", "next.config.cjs"], "exclude": [".next/*", "config/*", "e2e/*", "node_modules/*", "scripts/*"]