diff --git a/__tests__/unit/utils/io-ts.test.ts b/__tests__/unit/utils/io-ts.test.ts new file mode 100644 index 00000000..60122e4a --- /dev/null +++ b/__tests__/unit/utils/io-ts.test.ts @@ -0,0 +1,26 @@ +import { fromEnum, decodeWithDefault } from '../../../libs/utils/io-ts'; +import * as E from 'fp-ts/lib/Either'; + +enum Foo { + FOO = 'foo', + BAR = 'bar', +} + +const FooSchema = fromEnum('Foo', Foo); + +test('fromEnum works', () => { + expect(FooSchema).toBeTruthy(); + expect(FooSchema.name).toBe('Foo'); + expect(FooSchema.is(Foo.FOO)).toBeTruthy(); + expect(FooSchema.is(Foo.BAR)).toBeTruthy(); + expect(FooSchema.is(null)).toBeFalsy(); + expect(FooSchema.is(true)).toBeFalsy(); + expect(FooSchema.encode(Foo.FOO)).toBe(Foo.FOO); + expect(E.isRight(FooSchema.decode(Foo.FOO))).toBeTruthy(); + expect(E.isLeft(FooSchema.decode(null))).toBeTruthy(); +}); + +test('decodeWithDefault works', () => { + expect(decodeWithDefault(E.right(Foo.FOO), Foo.BAR)).toBe(Foo.FOO); + expect(decodeWithDefault(E.left(Foo.FOO), Foo.BAR)).toBe(Foo.BAR); +}); diff --git a/components/images.tsx b/components/images.tsx index 1437d4ee..b0257370 100644 --- a/components/images.tsx +++ b/components/images.tsx @@ -11,6 +11,8 @@ import useIsMounted from '../hooks/useIsMounted'; import useWindowDimensions from '../hooks/usewindowdimension'; import { ImageApi, ImageLicenseValues, ImageNoSourceApi, SpeciesApi, TaxonCodeValues } from '../libs/api/apitypes'; import { hasProp } from '../libs/utils/util'; +import NoImage from '../public/images/noimage.jpg'; +import NoImageHost from '../public/images/noimagehost.jpg'; // type guard for dealing with possible Images without Source data. If this happens there is an upstream // programming error so we will fail fast and hard. @@ -50,11 +52,7 @@ const Images = ({ sp }: Props): JSX.Element => { return species.images.length < 1 ? ( <div className="p-2"> <Image - src={ - species.taxoncode === TaxonCodeValues.GALL - ? '../public/images/noimage.jpg' - : '../public/images/noimagehost.jpg' - } + src={species.taxoncode === TaxonCodeValues.GALL ? NoImage : NoImageHost} alt={`missing image of ${species.name}`} className="img-fluid d-block" /> diff --git a/components/seealso.tsx b/components/seealso.tsx index 4cdbe142..cb45fa02 100644 --- a/components/seealso.tsx +++ b/components/seealso.tsx @@ -1,6 +1,10 @@ import React from 'react'; import { Col, Row } from 'react-bootstrap'; import Image from 'next/image.js'; +import iNatLogo from '../public/images/inatlogo-small.png'; +import BugGuideLogo from '../public/images/bugguide-small.png'; +import GScholarLogo from '../public/images/gscholar-small.png'; +import BHLLogo from '../public/images/bhllogo.png'; // we allow species names to contain subspecies of the form 'Genus species subspecies' and for gallformers // sexual generation info 'Genus species (sexgen)'. For external linking we want to only link to the main species. @@ -53,7 +57,7 @@ const SeeAlso = ({ name, undescribed }: Props): JSX.Element => { rel="noreferrer" aria-label="Search for more information about this species on iNaturalist." > - <Image src="../public/images/inatlogo-small.png" alt="iNaturalist logo" /> + <Image src={iNatLogo} alt="iNaturalist logo" /> </a> </Col> <Col xs={12} md={6} lg={3} className="align-self-center"> @@ -63,7 +67,7 @@ const SeeAlso = ({ name, undescribed }: Props): JSX.Element => { rel="noreferrer" aria-label="Search for more information about this species on BugGuide." > - <Image src="../public/images/bugguide-small.png" alt="BugGuide logo" /> + <Image src={BugGuideLogo} alt="BugGuide logo" /> </a> </Col> <Col xs={12} md={6} lg={3} className="align-self-center"> @@ -73,7 +77,7 @@ const SeeAlso = ({ name, undescribed }: Props): JSX.Element => { rel="noreferrer" aria-label="Search for more information about this species on Google Scholar." > - <Image src="../public/images/gscholar-small.png" alt="Google Scholar logo" /> + <Image src={GScholarLogo} alt="Google Scholar logo" /> </a> </Col> <Col xs={12} md={6} lg={3} className="align-self-center"> @@ -83,7 +87,7 @@ const SeeAlso = ({ name, undescribed }: Props): JSX.Element => { rel="noreferrer" aria-label="Search for more information about this species at the Biodiversity Heritage Library." > - <Image src="../public/images/bhllogo.png" alt="Biodiversity Heritage Library logo" /> + <Image src={BHLLogo} alt="Biodiversity Heritage Library logo" /> </a> </Col> </Row> @@ -103,7 +107,7 @@ const SeeAlso = ({ name, undescribed }: Props): JSX.Element => { rel="noreferrer" aria-label="Search for more information about this species on iNaturalist." > - <Image src="../public/images/inatlogo-small.png" alt="iNaturalist logo" /> + <Image src={iNatLogo} alt="iNaturalist logo" /> </a> </Col> </Row> diff --git a/jest.config.js b/jest.config.js index 30b4dc4f..ffc2b905 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,5 +1,4 @@ -// eslint-disable-next-line @typescript-eslint/no-var-requires -const nextJest = require('next/jest'); +import nextJest from 'next/jest.js'; const createJestConfig = nextJest({ dir: './', }); @@ -16,4 +15,4 @@ const customJestConfig = { moduleDirectories: ['node_modules', '<rootDir>/'], testEnvironment: 'jest-environment-jsdom', }; -module.exports = createJestConfig(customJestConfig); +export default createJestConfig(customJestConfig); diff --git a/layouts/footer.tsx b/layouts/footer.tsx index 011a42ca..8e003265 100644 --- a/layouts/footer.tsx +++ b/layouts/footer.tsx @@ -18,7 +18,7 @@ const Footer = (): JSX.Element => { }; return ( - <Navbar expand="sm" variant="dark" collapseOnSelect className="navbar-footer container-fluid px-4"> + <Navbar expand="sm" variant="dark" collapseOnSelect className="navbar-footer fixed-bottom container-fluid px-4"> <Navbar.Collapse> {mounted && session && ( <> diff --git a/libs/api/apipage.ts b/libs/api/apipage.ts index 37d22907..7a589cd9 100644 --- a/libs/api/apipage.ts +++ b/libs/api/apipage.ts @@ -107,7 +107,7 @@ export async function apiSearchEndpoint<T>( dbSearch: (s: string) => TE.TaskEither<Error, T[]>, ) { const errMsg = (q: string) => (): TE.TaskEither<Err, unknown> => { - return TE.left({ status: 400, msg: `Failed to provide the ${q} d as a query param.` }); + return TE.left({ status: 400, msg: `Failed to provide a value for ${q} as a query param. e.g., ?q=Andricus` }); }; return await pipe( diff --git a/libs/api/apitypes.ts b/libs/api/apitypes.ts index f12421f2..cfcb5408 100644 --- a/libs/api/apitypes.ts +++ b/libs/api/apitypes.ts @@ -7,7 +7,7 @@ import * as O from 'fp-ts/lib/Option'; import { Option } from 'fp-ts/lib/Option'; import * as t from 'io-ts'; import * as tt from 'io-ts-types'; -import { fromEnum } from '../utils/io-ts.ts'; +import { decodeWithDefault, fromEnum } from '../utils/io-ts.ts'; export type Deletable = { delete?: boolean; @@ -545,7 +545,7 @@ export enum FilterFieldTypeValue { export const FilterFieldTypeSchema = fromEnum<FilterFieldTypeValue>('FilterFieldTypeValue', FilterFieldTypeValue); // export type FilterFieldType = t.TypeOf<typeof FilterFieldTypeSchema>; export const asFilterType = (possibleFilterType?: string | null): FilterFieldTypeValue => - FilterFieldTypeValue[possibleFilterType?.toUpperCase() as keyof typeof FilterFieldTypeValue]; + decodeWithDefault(FilterFieldTypeSchema.decode(possibleFilterType), FilterFieldTypeValue.ALIGNMENTS); export const FilterFieldWithTypeSchema = t.intersection([FilterFieldSchema, t.type({ fieldType: FilterFieldTypeSchema })]); diff --git a/libs/db/gall.ts b/libs/db/gall.ts index 79a62286..b88948de 100644 --- a/libs/db/gall.ts +++ b/libs/db/gall.ts @@ -54,7 +54,7 @@ import { } from '../api/apitypes'; import { SMALL, deleteImagesBySpeciesId, makePath } from '../images/images'; import { defaultSource } from '../pages/renderhelpers'; -import { unsafeDecode } from '../utils/io-ts.ts'; +import { decodeWithDefault } from '../utils/io-ts.ts'; import { logger } from '../utils/logger.ts'; import { ExtractTFromPromise } from '../utils/types'; import { handleError, optionalWith } from '../utils/util'; @@ -220,7 +220,7 @@ export const getGalls = ( name: g.species.name, datacomplete: g.species.datacomplete, speciessource: g.species.speciessource, - taxoncode: unsafeDecode(TaxonCodeSchema.decode(g.species.taxoncode)), + taxoncode: decodeWithDefault(TaxonCodeSchema.decode(g.species.taxoncode), TaxonCodeValues.GALL), description: O.fromNullable(d), abundance: optionalWith(g.species.abundance, adaptAbundance), gall_id: g.gall_id, diff --git a/libs/db/images.ts b/libs/db/images.ts index 850ed272..d60889da 100644 --- a/libs/db/images.ts +++ b/libs/db/images.ts @@ -3,7 +3,7 @@ import { constant, pipe } from 'fp-ts/lib/function'; import * as O from 'fp-ts/lib/Option'; import * as TE from 'fp-ts/lib/TaskEither'; import { TaskEither } from 'fp-ts/lib/TaskEither'; -import { ImageApi, ImageLicenseValues, ImageNoSourceApi } from '../api/apitypes'; +import { ImageApi, ImageLicenseValues, ImageLicenseValuesSchema, ImageNoSourceApi } from '../api/apitypes'; import { createOtherSizes, deleteImagesByPaths, @@ -19,6 +19,7 @@ import { ExtractTFromPromise } from '../utils/types'; import { handleError } from '../utils/util'; import db from './db'; import { connectIfNotNull } from './utils'; +import { decodeWithDefault } from '../utils/io-ts'; export const addImages = (images: ImageApi[]): TaskEither<Error, ImageApi[]> => { // N.B. - the default will also be false for new images, only later can it be changed. So we do not need to worry about @@ -144,7 +145,7 @@ export const adaptImage = <T extends ImageWithSource>(img: T): ImageApi => ({ xlarge: makePath(img.path, XLARGE), original: makePath(img.path, ORIGINAL), source: O.fromNullable(img.source), - license: ImageLicenseValues[img.license as keyof typeof ImageLicenseValues], + license: decodeWithDefault(ImageLicenseValuesSchema.decode(img.license), ImageLicenseValues.NONE), }); export const adaptImageNoSource = <T extends image>(img: T): ImageNoSourceApi => ({ diff --git a/libs/db/taxonomy.ts b/libs/db/taxonomy.ts index bd9bd078..dfb55010 100644 --- a/libs/db/taxonomy.ts +++ b/libs/db/taxonomy.ts @@ -20,6 +20,7 @@ import { TaxonCodeValues, TaxonomyEntry, TaxonomyType, + TaxonomyTypeSchema, TaxonomyTypeValues, TaxonomyUpsertFields, } from '../api/apitypes'; @@ -28,6 +29,7 @@ import { ExtractTFromPromise } from '../utils/types'; import { handleError } from '../utils/util'; import db from './db'; import { extractId } from './utils'; +import { decodeWithDefault } from '../utils/io-ts.ts'; export type TaxonomyTree = taxonomy & { parent: taxonomy | null; @@ -68,7 +70,7 @@ const toTaxonomyEntry = (dbTax: DBTaxonomyWithParent): TaxonomyEntry => { id: dbTax.id, description: dbTax.description == null ? '' : dbTax.description, name: dbTax.name, - type: TaxonomyTypeValues[dbTax.type.toUpperCase() as keyof typeof TaxonomyTypeValues], + type: decodeWithDefault(TaxonomyTypeSchema.decode(dbTax.type), TaxonomyTypeValues.GENUS), parent: pipe(dbTax.parent, O.fromNullable, O.map(toTaxonomyEntry)), }; }; diff --git a/libs/utils/io-ts.ts b/libs/utils/io-ts.ts index 7af82838..8a92bcbc 100644 --- a/libs/utils/io-ts.ts +++ b/libs/utils/io-ts.ts @@ -1,33 +1,39 @@ // stuff for making io-ts nicer import * as E from 'fp-ts/lib/Either'; -import { identity, pipe } from 'fp-ts/lib/function'; -import t, { Type } from 'io-ts'; +import { pipe } from 'fp-ts/lib/function'; +import * as t from 'io-ts'; // From: https://github.com/gcanti/io-ts/issues/216#issuecomment-599020040 /** - * this utility function can be used to turn a TypeScript enum into a io-ts codec. + * this utility function can be used to turn a TypeScript enum into an io-ts codec. */ -export function fromEnum<EnumType>(enumName: string, theEnum: Record<string, string | number>) { +export function fromEnum<EnumType extends string>( + enumName: string, + theEnum: Record<string, EnumType>, +): t.Type<EnumType, EnumType, unknown> { const isEnumValue = (input: unknown): input is EnumType => Object.values<unknown>(theEnum).includes(input); - return new Type<EnumType>( + return new t.Type<EnumType>( enumName, isEnumValue, (input, context) => (isEnumValue(input) ? t.success(input) : t.failure(input, context)), - identity, + t.identity, ); } /** - * Takes the result of a decode (an Either) and unpacks the value. If there is any error it will get logged to - * the Console and will then return and empty object cast to the T typw. I know, janky. - * */ -export function unsafeDecode<T, E>(e: E.Either<E, T>): T { + * + * @param e an Either + * @param defaultValue the default value to return if isLeft(e) === true + * @returns if isRight(e) then the value in e, otherwise defaultValue with a console.error logged + */ +export function decodeWithDefault<E, T>(e: E.Either<E, T>, defaultValue: T): T { return pipe( e, E.match((err) => { - console.error(err); - return {} as T; - }, identity), + console.error(`Failed to decode value from Either "${e}". Got error below`); + console.dir(err); + return defaultValue; + }, t.identity), ); } diff --git a/next.config.mjs b/next.config.mjs index 755a62f8..59969cd7 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -9,7 +9,16 @@ export default (phase) => { BUILD_ID: buildid, }, images: { - domains: ['static.gallformers.org', 'dhz6u1p7t6okk.cloudfront.net'], + remotePatterns: [ + { + protocol: 'https', + hostname: 'static.gallformers.org', + }, + { + protocol: 'https', + hostname: 'dhz6u1p7t6okk.cloudfront.net', + }, + ], }, generateBuildId: async () => { //TODO convert this to the latest git hash diff --git a/pages/404.tsx b/pages/404.tsx index ee178a90..84d163f9 100644 --- a/pages/404.tsx +++ b/pages/404.tsx @@ -2,6 +2,7 @@ import Image from 'next/image.js'; import Link from 'next/link'; import React from 'react'; import { Container, Row, Col } from 'react-bootstrap'; +import Scale from '../public/images/scale.jpg'; export default function FourOhFour(): JSX.Element { return ( @@ -20,7 +21,7 @@ export default function FourOhFour(): JSX.Element { </Row> <Row className="p-1 justify-content-md-center"> <a href="https://www.inaturalist.org/observations/58767231" target="_blank" rel="noreferrer"> - <Image src="../public/images/scale.jpg" alt="A scale insect not a gall." /> + <Image src={Scale} alt="A scale insect not a gall." /> </a> </Row> <Row className="p-3"> diff --git a/pages/_app.tsx b/pages/_app.tsx index 159470be..c542d93a 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -31,7 +31,7 @@ function Gallformers({ Component, pageProps }: AppProps): JSX.Element { </Col> </Row> <Row> - <Col className="ms-5 me-5 p-2"> + <Col className="m-3 mb-5 p-2"> <ConfirmationServiceProvider> <Component {...pageProps} /> </ConfirmationServiceProvider> diff --git a/pages/about.tsx b/pages/about.tsx index 929da6dd..27d7850b 100644 --- a/pages/about.tsx +++ b/pages/about.tsx @@ -5,6 +5,7 @@ import React from 'react'; import { Accordion, Card, Col, Row } from 'react-bootstrap'; import { getCurrentStats, Stat } from '../libs/db/stats.ts'; import { mightFailWithArray } from '../libs/utils/util'; +import GallMeMaybe from '../public/images/gallmemaybe.jpg'; type Props = { stats: Stat[]; @@ -210,13 +211,7 @@ const About = ({ stats, genTime }: Props): JSX.Element => { <Accordion.Header>Dare You Click?</Accordion.Header> <Accordion.Body> <Card.Body className="d-flex justify-content-center"> - <Image - src="../public/images/gallmemaybe.jpg" - alt="Gall Me Maybe" - width="300" - height="532" - layout="fixed" - /> + <Image src={GallMeMaybe} alt="Gall Me Maybe" width="300" height="532" /> </Card.Body> </Accordion.Body> </Accordion.Item> diff --git a/pages/api/gall/index.ts b/pages/api/gall/index.ts index 100edc3d..48588277 100644 --- a/pages/api/gall/index.ts +++ b/pages/api/gall/index.ts @@ -25,7 +25,7 @@ export default async (req: NextApiRequest, res: NextApiResponse): Promise<void> TE.fold(sendErrorResponse(res), sendSuccessResponse(res)), )(); } else if (params && O.isSome(params['q'])) { - apiSearchEndpoint(req, res, searchGalls); + await apiSearchEndpoint(req, res, searchGalls); } else if (params && O.isSome(params['name'])) { await pipe( params['name'], diff --git a/tsconfig.json b/tsconfig.json index 35882a50..08eada7b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -35,7 +35,7 @@ "next-env.d.ts", "**/*.ts", "**/*.tsx" - ], +, "jest.config.js" ], "exclude": [ "node_modules" ]