diff --git a/.eslintignore b/.eslintignore index 8a9c18a68..fe58c834c 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,7 +1,7 @@ explorer-old !.storybook -src/api-client -!src/api-client/withApollo.tsx +src/api-client/graphql.tsx +src/api-client/page.tsx src/api/db src/api/directives src/@types/resolvers-types.ts diff --git a/next.config.js b/next.config.js index 88f946763..22b13b91a 100644 --- a/next.config.js +++ b/next.config.js @@ -3,13 +3,6 @@ **/ const moduleExports = { reactStrictMode: true, - typescript: { - // !! WARN !! - // Dangerously allow production builds to successfully complete even if - // your project has type errors. - // !! WARN !! - ignoreBuildErrors: true, - }, transpilePackages: [ '@react-leaflet/core', 'react-leaflet', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 610b6e82f..6ae55e407 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2737,6 +2737,9 @@ packages: '@repeaterjs/repeater@3.0.5': resolution: {integrity: sha512-l3YHBLAol6d/IKnB9LhpD0cEZWAoe3eFKUyTYWmFmCO2Q/WOckxLQAUyMZWwZV2M/m3+4vgRoaolFqaII82/TA==} + '@repeaterjs/repeater@3.0.6': + resolution: {integrity: sha512-Javneu5lsuhwNCryN+pXH93VPQ8g0dBX7wItHFgYiwQmzE1sVdg5tWHiOgHywzL2W21XQopa7IwIEnNbmeUJYA==} + '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} @@ -3715,8 +3718,8 @@ packages: resolution: {integrity: sha512-YZA+N3JcW1eh2QRi7o/ij+M07M0dqID73ltgsOEMRyEc2UYVDbyomaih+CWCEZqBIDHw4KMDveXvv4SBZ4TLIw==} engines: {node: '>=16.0.0'} - '@whatwg-node/server@0.7.5': - resolution: {integrity: sha512-xTDJdPqr/wULxW3mGXQXD92SRXUm6jwQxqIvyHG17dykRTd21HuCaS2ggBn5lSAM/sYjjrT+OYv3fXbtS4+Mjw==} + '@whatwg-node/server@0.7.7': + resolution: {integrity: sha512-aHURgNDFm/48WVV3vhTMfnEKCYwYgdaRdRhZsQZx4UVFjGGkGay7Ys0+AYu9QT/jpoImv2oONkstoTMUprDofg==} '@wry/caches@1.0.1': resolution: {integrity: sha512-bXuaUNLVVkD20wcGBWRyo7j9N3TxePEWFZj2Y+r9OoUzfqmavM84+mFykRicNsBqatba5JLay1t48wxaXaWnlA==} @@ -5076,10 +5079,6 @@ packages: peerDependencies: react: '>=16.12.0' - dset@3.1.2: - resolution: {integrity: sha512-g/M9sqy3oHe477Ar4voQxWtaPIFw1jTdKZuomOjhCcBx9nHUNn0pu6NopuFFrTh/TRZIKEj+76vLWFu9BNKk+Q==} - engines: {node: '>=4'} - dset@3.1.4: resolution: {integrity: sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==} engines: {node: '>=4'} @@ -7081,10 +7080,6 @@ packages: resolution: {integrity: sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==} engines: {node: '>=8'} - lru-cache@10.2.0: - resolution: {integrity: sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==} - engines: {node: 14 || >=16.14} - lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} @@ -11141,11 +11136,11 @@ snapshots: '@envelop/core@3.0.6': dependencies: '@envelop/types': 3.0.2 - tslib: 2.5.0 + tslib: 2.7.0 '@envelop/types@3.0.2': dependencies: - tslib: 2.5.0 + tslib: 2.7.0 '@envelop/validation-cache@5.1.3(@envelop/core@3.0.6)(graphql@16.9.0)': dependencies: @@ -11153,7 +11148,7 @@ snapshots: graphql: 16.9.0 hash-it: 6.0.0 lru-cache: 6.0.0 - tslib: 2.5.0 + tslib: 2.7.0 '@esbuild/android-arm64@0.18.20': optional: true @@ -11692,7 +11687,7 @@ snapshots: '@graphql-tools/executor-http@0.1.10(@types/node@20.14.8)(graphql@16.9.0)': dependencies: '@graphql-tools/utils': 9.2.1(graphql@16.9.0) - '@repeaterjs/repeater': 3.0.5 + '@repeaterjs/repeater': 3.0.6 '@whatwg-node/fetch': 0.8.8 dset: 3.1.4 extract-files: 11.0.0 @@ -11746,14 +11741,14 @@ snapshots: '@graphql-typed-document-node/core': 3.2.0(graphql@16.9.0) '@repeaterjs/repeater': 3.0.4 graphql: 16.9.0 - tslib: 2.5.0 + tslib: 2.7.0 value-or-promise: 1.0.12 '@graphql-tools/executor@0.0.20(graphql@16.9.0)': dependencies: '@graphql-tools/utils': 9.2.1(graphql@16.9.0) '@graphql-typed-document-node/core': 3.2.0(graphql@16.9.0) - '@repeaterjs/repeater': 3.0.5 + '@repeaterjs/repeater': 3.0.6 graphql: 16.9.0 tslib: 2.7.0 value-or-promise: 1.0.12 @@ -11762,7 +11757,7 @@ snapshots: dependencies: '@graphql-tools/utils': 10.5.4(graphql@16.9.0) '@graphql-typed-document-node/core': 3.2.0(graphql@16.9.0) - '@repeaterjs/repeater': 3.0.5 + '@repeaterjs/repeater': 3.0.6 graphql: 16.9.0 tslib: 2.7.0 value-or-promise: 1.0.12 @@ -11982,7 +11977,7 @@ snapshots: '@graphql-tools/merge': 8.4.2(graphql@16.9.0) '@graphql-tools/utils': 9.2.1(graphql@16.9.0) graphql: 16.9.0 - tslib: 2.5.0 + tslib: 2.7.0 value-or-promise: 1.0.12 '@graphql-tools/url-loader@7.17.18(@types/node@20.14.8)(graphql@16.9.0)': @@ -12072,19 +12067,19 @@ snapshots: '@graphql-yoga/logger@0.0.1': dependencies: - tslib: 2.5.0 + tslib: 2.7.0 '@graphql-yoga/subscription@3.1.0': dependencies: '@graphql-yoga/typed-event-target': 1.0.0 - '@repeaterjs/repeater': 3.0.4 + '@repeaterjs/repeater': 3.0.6 '@whatwg-node/events': 0.0.2 - tslib: 2.5.0 + tslib: 2.7.0 '@graphql-yoga/typed-event-target@1.0.0': dependencies: - '@repeaterjs/repeater': 3.0.4 - tslib: 2.5.0 + '@repeaterjs/repeater': 3.0.6 + tslib: 2.7.0 '@hapi/hoek@9.3.0': {} @@ -12973,6 +12968,8 @@ snapshots: '@repeaterjs/repeater@3.0.5': {} + '@repeaterjs/repeater@3.0.6': {} + '@rtsao/scc@1.1.0': {} '@rushstack/eslint-patch@1.10.4': {} @@ -14659,10 +14656,10 @@ snapshots: fast-querystring: 1.1.2 tslib: 2.7.0 - '@whatwg-node/server@0.7.5': + '@whatwg-node/server@0.7.7': dependencies: '@whatwg-node/fetch': 0.8.8 - tslib: 2.5.0 + tslib: 2.7.0 '@wry/caches@1.0.1': dependencies: @@ -14898,7 +14895,7 @@ snapshots: aria-hidden@1.2.3: dependencies: - tslib: 2.5.3 + tslib: 2.7.0 aria-query@5.1.3: dependencies: @@ -15465,7 +15462,7 @@ snapshots: camel-case@4.1.2: dependencies: pascal-case: 3.1.2 - tslib: 2.6.2 + tslib: 2.6.3 camelcase@5.3.1: {} @@ -15476,7 +15473,7 @@ snapshots: capital-case@1.0.4: dependencies: no-case: 3.0.4 - tslib: 2.6.2 + tslib: 2.6.3 upper-case-first: 2.0.2 case-sensitive-paths-webpack-plugin@2.4.0: {} @@ -15791,7 +15788,7 @@ snapshots: constant-case@3.0.4: dependencies: no-case: 3.0.4 - tslib: 2.6.2 + tslib: 2.6.3 upper-case: 2.0.2 constants-browserify@1.0.0: {} @@ -16291,7 +16288,7 @@ snapshots: dot-case@3.0.4: dependencies: no-case: 3.0.4 - tslib: 2.6.2 + tslib: 2.6.3 dotenv-cli@7.4.2: dependencies: @@ -16313,8 +16310,6 @@ snapshots: react-is: 17.0.2 tslib: 2.5.0 - dset@3.1.2: {} - dset@3.1.4: {} duplexer@0.1.2: {} @@ -17565,11 +17560,11 @@ snapshots: '@graphql-yoga/logger': 0.0.1 '@graphql-yoga/subscription': 3.1.0 '@whatwg-node/fetch': 0.8.8 - '@whatwg-node/server': 0.7.5 - dset: 3.1.2 + '@whatwg-node/server': 0.7.7 + dset: 3.1.4 graphql: 16.9.0 lru-cache: 7.18.3 - tslib: 2.5.0 + tslib: 2.7.0 graphql@16.9.0: {} @@ -17646,7 +17641,7 @@ snapshots: header-case@2.0.4: dependencies: capital-case: 1.0.4 - tslib: 2.6.2 + tslib: 2.6.3 hexoid@1.0.0: {} @@ -18934,8 +18929,6 @@ snapshots: lowercase-keys@2.0.0: {} - lru-cache@10.2.0: {} - lru-cache@10.4.3: {} lru-cache@4.0.2: @@ -19463,7 +19456,7 @@ snapshots: no-case@3.0.4: dependencies: lower-case: 2.0.2 - tslib: 2.6.2 + tslib: 2.6.3 node-abi@3.56.0: dependencies: @@ -19780,7 +19773,7 @@ snapshots: param-case@3.0.4: dependencies: dot-case: 3.0.4 - tslib: 2.6.2 + tslib: 2.6.3 parent-module@1.0.1: dependencies: @@ -19822,7 +19815,7 @@ snapshots: pascal-case@3.1.2: dependencies: no-case: 3.0.4 - tslib: 2.6.2 + tslib: 2.6.3 path-browserify@1.0.1: {} @@ -19833,7 +19826,7 @@ snapshots: path-case@3.0.4: dependencies: dot-case: 3.0.4 - tslib: 2.6.2 + tslib: 2.6.3 path-exists@3.0.0: {} @@ -19859,7 +19852,7 @@ snapshots: path-scurry@1.10.1: dependencies: - lru-cache: 10.2.0 + lru-cache: 10.4.3 minipass: 7.0.4 path-scurry@1.11.1: @@ -20335,7 +20328,7 @@ snapshots: dependencies: react: 18.3.1 react-style-singleton: 2.2.1(@types/react@18.3.8)(react@18.3.1) - tslib: 2.5.3 + tslib: 2.7.0 optionalDependencies: '@types/react': 18.3.8 @@ -20344,7 +20337,7 @@ snapshots: react: 18.3.1 react-remove-scroll-bar: 2.3.4(@types/react@18.3.8)(react@18.3.1) react-style-singleton: 2.2.1(@types/react@18.3.8)(react@18.3.1) - tslib: 2.5.3 + tslib: 2.7.0 use-callback-ref: 1.3.0(@types/react@18.3.8)(react@18.3.1) use-sidecar: 1.1.2(@types/react@18.3.8)(react@18.3.1) optionalDependencies: @@ -20355,7 +20348,7 @@ snapshots: get-nonce: 1.0.1 invariant: 2.2.4 react: 18.3.1 - tslib: 2.5.3 + tslib: 2.7.0 optionalDependencies: '@types/react': 18.3.8 @@ -20760,7 +20753,7 @@ snapshots: sentence-case@3.0.4: dependencies: no-case: 3.0.4 - tslib: 2.6.2 + tslib: 2.6.3 upper-case-first: 2.0.2 serialize-javascript@6.0.2: @@ -20895,7 +20888,7 @@ snapshots: snake-case@3.0.4: dependencies: dot-case: 3.0.4 - tslib: 2.6.2 + tslib: 2.6.3 snyk-config@5.1.0: dependencies: @@ -21796,7 +21789,7 @@ snapshots: use-callback-ref@1.3.0(@types/react@18.3.8)(react@18.3.1): dependencies: react: 18.3.1 - tslib: 2.5.3 + tslib: 2.7.0 optionalDependencies: '@types/react': 18.3.8 @@ -21810,7 +21803,7 @@ snapshots: dependencies: detect-node-es: 1.1.0 react: 18.3.1 - tslib: 2.5.3 + tslib: 2.7.0 optionalDependencies: '@types/react': 18.3.8 diff --git a/src/api-client/withApollo.tsx b/src/api-client/withApollo.tsx index f420c5ef0..1cf0608dd 100644 --- a/src/api-client/withApollo.tsx +++ b/src/api-client/withApollo.tsx @@ -1,19 +1,22 @@ import { NextPage } from 'next'; import { - ApolloClient, - NormalizedCacheObject, - InMemoryCache, ApolloProvider, createHttpLink, + InMemoryCache, + ApolloClient, + type ApolloLink, + type NormalizedCacheObject, } from '@apollo/client'; +import type { SchemaLink } from '@apollo/client/link/schema'; + import authDirective from '../api/directives/authDirective'; let apolloClient: ApolloClient | undefined; -function createApolloClient() { - let terminatingLink; +function createApolloClient(): ApolloClient { + let terminatingLink: SchemaLink | ApolloLink; if (typeof window === 'undefined') { // eslint-disable-next-line @typescript-eslint/no-var-requires const { SchemaLink } = require('@apollo/client/link/schema'); @@ -55,7 +58,10 @@ function createApolloClient() { }); } -export function getApolloClient() { +type GetApolloClient = ( + ...args: unknown[] +) => ApolloClient; +export const getApolloClient: GetApolloClient = () => { const _apolloClient = apolloClient ?? createApolloClient(); // For SSG and SSR always create a new Apollo Client @@ -65,13 +71,14 @@ export function getApolloClient() { if (!apolloClient) apolloClient = _apolloClient; return _apolloClient; -} +}; -export const withApollo = (Comp: NextPage) => - function ApolloWrapper(props: object) { - return ( - - - - ); - }; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function withApollo>(Component: T): T { + const ApolloWrapper = (props: React.ComponentProps) => ( + + + + ); + return ApolloWrapper as T; +} diff --git a/src/api/graphql/helpers.ts b/src/api/graphql/helpers.ts index 035376479..24e76991e 100644 --- a/src/api/graphql/helpers.ts +++ b/src/api/graphql/helpers.ts @@ -7,7 +7,7 @@ import { ReportInput } from '../../@types/resolvers-types'; export const suggestLooId = async ( nickname: string, coordinates: number[], - updatedAt: Date + updatedAt: Date, ): Promise => { const input = JSON.stringify({ coords: coordinates, @@ -19,7 +19,9 @@ export const suggestLooId = async ( }; export const postgresLooToGraphQL = ( - loo: toilets & { areas?: Partial } + loo: toilets & { + areas?: Partial; + }, ): Loo => ({ id: loo.id.toString(), geohash: loo.geohash, @@ -40,7 +42,9 @@ export const postgresLooToGraphQL = ( children: loo.children, createdAt: loo.created_at, location: { + // @ts-expect-error -- We know that coordinates are there, but the JsonValue types don't. lat: loo.location?.coordinates[1] ?? 0, + // @ts-expect-error -- We know that coordinates are there, but the JsonValue types don't. lng: loo.location?.coordinates[0] ?? 0, }, removalReason: loo.removal_reason, @@ -71,7 +75,7 @@ type ToiletsExcludingComputed = Omit< export const postgresUpsertLooQuery = ( id: string | undefined, data: Partial, - location?: { lat: number; lng: number } + location?: { lat: number; lng: number }, ): ToiletUpsertReport => { return { where: { id }, @@ -89,7 +93,7 @@ export const postgresUpsertLooQuery = ( export const postgresUpsertLooQueryFromReport = async ( id: string | undefined, report: ReportInput, - nickname: string + nickname: string, ): Promise => { const operationTime = new Date(); @@ -98,7 +102,7 @@ export const postgresUpsertLooQueryFromReport = async ( submitId = await suggestLooId( nickname, [report.location.lng, report.location.lat], - operationTime + operationTime, ); } diff --git a/src/api/graphql/resolvers.ts b/src/api/graphql/resolvers.ts index 923b0808c..d75dc0e6d 100644 --- a/src/api/graphql/resolvers.ts +++ b/src/api/graphql/resolvers.ts @@ -24,6 +24,7 @@ import { postgresUpsertLooQueryFromReport, } from './helpers'; import { toilets } from '@prisma/client'; +import { UserProfile } from '@auth0/nextjs-auth0'; const resolvers: Resolvers = { Query: { @@ -176,8 +177,6 @@ const resolvers: Resolvers = { activeToiletsInAreas.map((area) => [area.name, area._count.toilets]), ); - console.log(activeAreas); - const removedAreas = Object.fromEntries( removedToiletsInAreas.map((area) => [area.name, area._count.toilets]), ); @@ -254,18 +253,22 @@ const resolvers: Resolvers = { contributor: 'Anonymous', id: reportId.toString(), isSystemReport: contributor.endsWith('-location'), + // @ts-expect-error -- We know that coordinates are there, but the JsonValue types don't. location: diff.location?.coordinates ? { + // @ts-expect-error -- We know that coordinates are there, but the JsonValue types don't. lat: diff.location?.coordinates[1], + // @ts-expect-error -- We know that coordinates are there, but the JsonValue types don't. lng: diff.location?.coordinates[0], } : undefined, }; }; - const filtered = auditRecords.map((v) => + const filtered = auditRecords.map((auditEntry) => // TODO: use zod to validate the shape of the record. - postgresAuditRecordToGraphQLReport(v.id, v.record), + // @ts-expect-error -- We expect this until we use zod to validate the shape of the record. + postgresAuditRecordToGraphQLReport(auditEntry.id, auditEntry.record), ); // Order by report creation time. @@ -294,7 +297,8 @@ const resolvers: Resolvers = { submitReport: async (_parent, args, { prisma, user }) => { try { // Convert the submitted report to a format that can be saved to the database. - const nickname = user[process.env.AUTH0_PROFILE_KEY]?.nickname; + const nickname = (user[process.env.AUTH0_PROFILE_KEY] as UserProfile) + ?.nickname; const postgresLoo = await postgresUpsertLooQueryFromReport( args.report.edit, args.report, @@ -302,7 +306,7 @@ const resolvers: Resolvers = { ); const result = await upsertLoo(prisma, postgresLoo); - console.log(result); + return { code: '200', success: true, @@ -320,7 +324,8 @@ const resolvers: Resolvers = { }, submitRemovalReport: async (_parent, args, { prisma, user }) => { try { - const nickname = user[process.env.AUTH0_PROFILE_KEY].nickname; + const nickname = (user[process.env.AUTH0_PROFILE_KEY] as UserProfile) + ?.nickname; const result = await removeLoo(prisma, args.report, nickname); return { @@ -356,6 +361,7 @@ const resolvers: Resolvers = { }, }, DateTime: GraphQLDateTime, + // @ts-expect-error -- There's a problem with enum types in the resolvers. SortOrder: { NEWEST_FIRST: { updatedAt: 'desc' }, OLDEST_FIRST: { updatedAt: 'asc' }, diff --git a/src/components/CodeViewer/CodeViewer.tsx b/src/components/CodeViewer/CodeViewer.tsx index 962214b37..cc36bd54a 100644 --- a/src/components/CodeViewer/CodeViewer.tsx +++ b/src/components/CodeViewer/CodeViewer.tsx @@ -1,72 +1,94 @@ -import Editor, { EditorProps } from '@monaco-editor/react'; -import { +import React, { Dispatch, MutableRefObject, SetStateAction, useEffect, useRef, } from 'react'; +import Editor, { OnMount } from '@monaco-editor/react'; +import * as monaco from 'monaco-editor'; import Box from '../Box'; +// Define custom editor options by extending Monaco's options if needed export type MonacoEditorOptions = { stopRenderingLineAfter: number; -}; +} & Partial; -export type MonacoEditorA = MutableRefObject; -export type MonacoEditorB = MutableRefObject; -export type MonacoTextModal = any; +// Define types for the editor and monaco instances +export type MonacoEditorRef = + MutableRefObject; +export type MonacoInstanceRef = MutableRefObject; +export type MonacoTextModel = monaco.editor.ITextModel; +// Callback type for initialization export type MonacoOnInitializePane = ( - monacoEditorRef: MonacoEditorA, - editorRef: MonacoEditorB, - model: MonacoTextModal + monacoEditorRef: MonacoInstanceRef, + editorRef: MonacoEditorRef, + model: MonacoTextModel, ) => void; +// Component props type export type CodeViewerProps = { code: string; setCode?: Dispatch>; - editorOptions?: MonacoEditorOptions; - onInitializePane: MonacoOnInitializePane; }; -const CodeViewer = (props: CodeViewerProps): JSX.Element => { - const { code, setCode, editorOptions, onInitializePane } = props; - - const monacoEditorRef = useRef(null); - const editorRef = useRef(null); +const CodeViewer: React.FC = ({ + code, + setCode, + editorOptions, + onInitializePane, +}) => { + // Refs to store Monaco editor instances + const monacoInstanceRef = useRef(null); + const editorRef = useRef(null); + // Effect to initialize the pane when the editor and models are available useEffect(() => { - if (monacoEditorRef?.current) { - const model: any = monacoEditorRef.current.getModels(); + if (monacoInstanceRef.current && editorRef.current) { + const models = monacoInstanceRef.current.getModels(); - if (model?.length > 0) { - onInitializePane(monacoEditorRef, editorRef, model); + if (models.length > 0) { + onInitializePane(monacoInstanceRef, editorRef, models[0]); } } - }); + }, [onInitializePane]); + + // Handler for editor mount event + const handleMount: OnMount = (editor, monacoInstance) => { + monacoInstanceRef.current = monacoInstance.editor; + editorRef.current = editor; + }; + + // Handler for content change in the editor + const handleChange = (value: string | undefined) => { + if (setCode && value !== undefined) { + setCode(value); + } + }; + + // Merge custom options with default Monaco options + const mergedOptions = { + fontSize: 15, + minimap: { enabled: false }, + lineNumbers: 'off', + stopRenderingLineAfter: editorOptions?.stopRenderingLineAfter, + ...editorOptions, // Allow overriding with additional options + } satisfies monaco.editor.IStandaloneEditorConstructionOptions; return ( - { - if (setCode) setCode(value); - }} - onMount={(editor, monaco) => { - monacoEditorRef.current = monaco.editor; - editorRef.current = editor; - }} - options={{ - ...(editorOptions ?? {}), - fontSize: 15, - minimap: { enabled: false }, - lineNumbers: 'off', - }} - theme="vs-dark" - value={code} - css={{}} - /> + + + ); }; diff --git a/src/components/EntryForm.tsx b/src/components/EntryForm.tsx index c6dfc688f..6f8742d90 100644 --- a/src/components/EntryForm.tsx +++ b/src/components/EntryForm.tsx @@ -312,8 +312,10 @@ const EntryForm = ({ title, loo, children, ...props }) => { // map geometry data to expected structure // eslint-disable-next-line functional/immutable-data transformed.location = { - lat: data.geometry.coordinates[0], - lng: data.geometry.coordinates[1], + // @ts-expect-error -- data isn't typed and we're assuming it has a geometry property here. + lat: data.geometry?.coordinates[0], + // @ts-expect-error -- data isn't typed and we're assuming it has a geometry property here. + lng: data.geometry?.coordinates[1], }; // remove payment details if the isFree field value has changed and is now @@ -466,6 +468,7 @@ const EntryForm = ({ title, loo, children, ...props }) => {
{
{
{ href={ 'https://vercel.com/?utm_source=public-convenience-ltd&utm_campaign=oss' } - passHref - legacyBehavior + target="_blank" + rel="noopener noreferrer" > - {/* eslint-disable-next-line jsx-a11y/anchor-is-valid */} - - Powered by Vercel - + Powered by Vercel diff --git a/src/components/Header/hooks.tsx b/src/components/Header/hooks.tsx index 294e22d92..dd1676e72 100644 --- a/src/components/Header/hooks.tsx +++ b/src/components/Header/hooks.tsx @@ -21,6 +21,7 @@ export const useFeedbackPopover = () => { const FeedbackPopover = () => ( ` - border: 1px solid ${theme.colors.primary}; - border-radius: ${theme.radii[4]}px; - padding: ${theme.space[2]}px 3rem; - padding-left: 3rem; - width: 100%; -` + border: 1px solid ${theme.colors.primary}; + border-radius: ${theme.radii[4]}px; + padding: ${theme.space[2]}px 3rem; + padding-left: 3rem; + width: 100%; + `, ); -const LocationSearch = ({ onSelectedItemChange }) => { +interface LocationSearchProps { + onSelectedItemChange: (coords: { lat: number; lng: number }) => void; +} + +const LocationSearch: React.FC = ({ + onSelectedItemChange, +}) => { const [query, setQuery] = React.useState(''); const theme = useTheme(); - const inputRef = useRef(null); + const inputRef = useRef(null); const { places, getPlaceLatLng } = useNominatimAutocomplete(query); - const handleSelectedItemChange = async ({ selectedItem }) => { + const handleSelectedItemChange = async ({ + selectedItem, + }: UseComboboxStateChangeOptions) => { if (!selectedItem) { return; } - const { lat, lng } = await getPlaceLatLng(selectedItem); + + const { lat, lng } = getPlaceLatLng(selectedItem); onSelectedItemChange({ lat, lng }); - // Remove focus from the input box, ensuring that the dropdown closes on mobile. - inputRef.current.blur(); + // Remove focus from the input box to close the dropdown on mobile. + inputRef.current?.blur(); }; const stateReducer = ( - state: { isOpen: unknown; selectedItem: unknown; inputValue: unknown }, - actionAndChanges: { type: unknown; changes: unknown } + state: UseComboboxState, + actionAndChanges: UseComboboxStateChangeOptions, ) => { switch (actionAndChanges.type) { case useCombobox.stateChangeTypes.InputBlur: - // Prevents reset on blur to fix results being closed when iOS keyboard is hidden + // Prevent reset on blur to keep results open when iOS keyboard is hidden return { ...actionAndChanges.changes, isOpen: state.isOpen, }; case useCombobox.stateChangeTypes.FunctionOpenMenu: - // Always clear the input when opening the menu + // Clear the input when opening the menu return { ...actionAndChanges.changes, inputValue: '', @@ -78,11 +91,11 @@ const LocationSearch = ({ onSelectedItemChange }) => { getComboboxProps, highlightedIndex, getItemProps, - } = useCombobox({ + } = useCombobox({ id: 'search-results', items: places, - onInputValueChange: ({ inputValue }) => setQuery(inputValue), - itemToString: (item: { label: string }) => (item ? item.label : ''), + onInputValueChange: ({ inputValue }) => setQuery(inputValue || ''), + itemToString: (item) => (item ? item.label : ''), onSelectedItemChange: handleSelectedItemChange, stateReducer, }); @@ -116,10 +129,9 @@ const LocationSearch = ({ onSelectedItemChange }) => { {...getInputProps({ ref: inputRef, onFocus: () => { - if (isOpen) { - return; + if (!isOpen) { + openMenu(); } - openMenu(); }, })} /> @@ -156,7 +168,7 @@ const LocationSearch = ({ onSelectedItemChange }) => { {isOpen && ( <> {places.length - ? places.map((item, index: number) => ( + ? places.map((item, index) => ( { - const [places, setPlaces] = React.useState([]); +type NominatimResult = { + place_id: string; + display_name: string; + lat: string; + lon: string; +}; + +export type Place = { + id: string; + label: string; + location: { + lat: number; + lng: number; + }; +}; - const fetchHandler = async (input) => { +type Coordinates = { + lat: number; + lng: number; +}; + +const useNominatimAutocomplete = (input: string) => { + const [places, setPlaces] = React.useState([]); + + const fetchHandler = async (input: string) => { try { - const fetchUrl = `https://nominatim.openstreetmap.org/search?q=${input}&countrycodes=gb&limit=5&format=json`; + const params = new URLSearchParams({ + q: input, + countrycodes: 'gb', + limit: '5', + format: 'json', + }); + + const fetchUrl = `https://nominatim.openstreetmap.org/search?${params.toString()}`; const response = await fetch(fetchUrl); - const results = await response.json(); + const results: NominatimResult[] = await response.json(); if (!results) { return; } - const locationResults = results.map( - (item: { - place_id: unknown; - display_name: unknown; - lat: unknown; - lon: unknown; - }) => ({ - id: item.place_id, - label: item.display_name, - location: { - lat: item.lat, - lng: item.lon, - }, - }) - ); + const locationResults: Place[] = results.map((item) => ({ + id: item.place_id, + label: item.display_name, + location: { + lat: parseFloat(item.lat), + lng: parseFloat(item.lon), + }, + })); setPlaces(locationResults); } catch (e: unknown) { @@ -39,7 +60,7 @@ const useNominatimAutocomplete = (input: string | unknown[]) => { const debouncedFetchHandler = React.useMemo( () => debounce(fetchHandler, 300), - [] + [], ); // Fetch places when input changes @@ -59,12 +80,12 @@ const useNominatimAutocomplete = (input: string | unknown[]) => { return; } setPlaces([]); - }, [input, setPlaces]); + }, [input]); - const getPlaceLatLng = ({ location }) => { + const getPlaceLatLng = (place: Place): Coordinates => { return { - lat: parseFloat(location.lat), - lng: parseFloat(location.lng), + lat: place.location.lat, + lng: place.location.lng, }; }; diff --git a/src/components/LocationSearch/usePlacesAutocomplete.ts b/src/components/LocationSearch/usePlacesAutocomplete.ts deleted file mode 100644 index 27c6a50f2..000000000 --- a/src/components/LocationSearch/usePlacesAutocomplete.ts +++ /dev/null @@ -1,140 +0,0 @@ -import React from 'react'; -import debounce from 'lodash/debounce'; - -const usePlacesAutocompleteService = () => { - const autocompleteService = React.useRef(); - - React.useEffect(() => { - if (!window['google'] || !window['google'].maps.places) { - return; - } - - if (autocompleteService.current) { - return; - } - - // eslint-disable-next-line functional/immutable-data - autocompleteService.current = new window[ - 'google' - ].maps.places.AutocompleteService(); - }); - - return autocompleteService; -}; - -// Session token batches autocomplete results together to reduce Google Maps API costs -// https://developers.google.com/maps/documentation/javascript/reference/places-autocomplete-service#AutocompleteSessionToken -const usePlacesSessionToken = () => { - const [token, setToken] = React.useState(null); - - const reset = () => { - setToken(new window['google'].maps.places.AutocompleteSessionToken()); - }; - - React.useEffect(() => { - if (!window['google'] || !window['google'].maps.places) { - return; - } - - if (token) { - return; - } - - reset(); - }); - - return [token, reset]; -}; - -const usePlacesAutocomplete = (input: string | unknown[]) => { - const autocompleteService = usePlacesAutocompleteService(); - - const [sessionToken, resetSessionToken] = usePlacesSessionToken(); - - const [places, setPlaces] = React.useState([]); - - const fetchPlaces = debounce((input) => { - const onFetchCompleted = (places: unknown[]) => { - if (!places) { - return; - } - - const locationResults = places.map( - (item: { - id: unknown; - place_id: unknown; - structured_formatting: { - main_text: unknown; - secondary_text: unknown; - }; - }) => ({ - id: item.id, - placeId: item.place_id, - label: `${item.structured_formatting.main_text}, ${item.structured_formatting.secondary_text}`, - }) - ); - - setPlaces(locationResults); - }; - - autocompleteService.current.getPlacePredictions( - { input, types: ['geocode'], sessionToken }, - onFetchCompleted - ); - }, 300); - - // Fetch places when input changes - React.useEffect(() => { - if (input.length < 3) { - return; - } - - fetchPlaces(input); - }, [input, fetchPlaces]); - - // Clear places when input is cleared - React.useEffect(() => { - if (input) { - return; - } - - setPlaces([]); - }, [input, setPlaces]); - - const getPlaceLatLng = ({ placeId }) => { - // PlacesService expects an HTML (normally a map) element - // https://developers.google.com/maps/documentation/javascript/reference/places-service#library - const placesService = new window['google'].maps.places.PlacesService( - document.createElement('div') - ); - - const OK = window['google'].maps.places.PlacesServiceStatus.OK; - - return new Promise((resolve, reject) => { - placesService.getDetails( - { placeId, sessionToken }, - ( - result: { geometry: { location: { lat: unknown; lng: unknown } } }, - status - ) => { - if (status !== OK) { - reject(status); - return; - } - - // Create a new session token when session has completed - // https://developers.google.com/maps/documentation/javascript/reference/places-autocomplete-service#AutocompleteSessionToken - resetSessionToken(); - - const { lat, lng } = result.geometry.location; - - resolve({ lat: lat(), lng: lng() }); - } - ); - }); - }; - - return { places, getPlaceLatLng }; -}; - -export default usePlacesAutocomplete; diff --git a/src/components/LooMap/LooMap.tsx b/src/components/LooMap/LooMap.tsx index d869fa7ad..2d0c3a479 100644 --- a/src/components/LooMap/LooMap.tsx +++ b/src/components/LooMap/LooMap.tsx @@ -71,7 +71,6 @@ const LooMap: React.FC = ({ }) => { const [mapState, setMapState] = useMapState(); - // const [hydratedToilets, setHydratedToilets] = useState([]); const [announcement, setAnnouncement] = React.useState(null); const [intersectingToilets, setIntersectingToilets] = useState([]); const plausible = usePlausible(); @@ -79,16 +78,6 @@ const LooMap: React.FC = ({ const [renderAccessibilityOverlays, setRenderAccessibilityOverlays] = useState(showAccessibilityOverlay); - // Load a reference to the leaflet map into application state so components that aren't below in the tree can access. - // const setMap = useCallback( - // (map: Map) => { - // if (map !== null) { - // setMapState({ map }); - // } - // }, - // [setMapState] - // ); - const mapRef = React.createRef(); useEffect(() => { diff --git a/src/components/LooMap/LooMapLoader.tsx b/src/components/LooMap/LooMapLoader.tsx index 0efbc509f..dfa533558 100644 --- a/src/components/LooMap/LooMapLoader.tsx +++ b/src/components/LooMap/LooMapLoader.tsx @@ -5,12 +5,12 @@ import { useMapState } from '../MapState'; import PageLoading from '../PageLoading'; import { LooMapProps } from './LooMap'; -export const LooMapLoader = dynamic(() => import('./LooMap'), { - loading: PageLoading, +export const LooMapLoader = dynamic(() => import('./LooMap'), { + loading: () => , ssr: false, }); -const LooMap = (props: LooMapProps) => { +const LooMap = (props?: Partial) => { const [mapState] = useMapState(); const [loaded, setLoaded] = useState(false); useEffect(() => { diff --git a/src/components/LooMap/Markers.tsx b/src/components/LooMap/Markers.tsx index 5365c9f61..0fd932876 100644 --- a/src/components/LooMap/Markers.tsx +++ b/src/components/LooMap/Markers.tsx @@ -55,33 +55,12 @@ const Markers = () => { boundingBoxWest, ]); - // TODO:fix this - // const throttledPrefetch = useMemo(() => { - // return _.throttle((id) => { - // // router.prefetch(id); - // }, 1000); - // }, [router]); - - // const prefetchVisibleToilets = useCallback(() => { - // const visibleMarkers = document.getElementsByClassName('toilet-marker'); - // for (const marker of visibleMarkers) { - // if (marker instanceof HTMLElement) { - // const toiletId = marker.dataset?.toiletid; - // throttledPrefetch(`/loos/${toiletId}`); - // } - // } - // }, [throttledPrefetch]); - useEffect(() => { setMapState({ ...mapState, currentlyLoadedGeohashes: geohashesToLoad, }); - // if (!userInteractingWithMap) { - // prefetchVisibleToilets(); - // } - // eslint-disable-next-line react-hooks/exhaustive-deps }, [geohashesToLoad, userInteractingWithMap]); @@ -219,6 +198,7 @@ const MarkerGroup: React.FC<{ const childCount = cluster.getChildCount(); const children = cluster.getAllChildMarkers(); const containedIds = children + // @ts-expect-error -- trust me, toiletId is there .map((child) => child.getIcon().options?.toiletId) .join(','); diff --git a/src/components/LooMap/ToiletMarkerIcon.tsx b/src/components/LooMap/ToiletMarkerIcon.tsx index 4580a2246..7d0031b08 100644 --- a/src/components/LooMap/ToiletMarkerIcon.tsx +++ b/src/components/LooMap/ToiletMarkerIcon.tsx @@ -1,13 +1,20 @@ import L from 'leaflet'; -const ICON_DIMENSIONS = [22, 34]; -const LARGE_ICON_DIMENSSIONS = ICON_DIMENSIONS.map((i) => i * 1.5); -const getIconAnchor = (dimensions: number[]) => [ - dimensions[0] / 2, - dimensions[1], +const ICON_DIMENSIONS: [number, number] = [22, 34] as const; +const LARGE_ICON_DIMENSIONS: [number, number] = ICON_DIMENSIONS.map( + (i) => i * 1.5, +) as [number, number]; + +const getIconAnchor = ([width, height]: [number, number]): [number, number] => [ + width / 2, + height, ]; -const getSVGHTML = ({ isHighlighted = false }) => { +interface GetSVGHTMLParams { + isHighlighted?: boolean; +} + +const getSVGHTML = ({ isHighlighted = false }: GetSVGHTMLParams): string => { return ` @@ -20,32 +27,46 @@ const getSVGHTML = ({ isHighlighted = false }) => { `; }; -const ToiletMarkerIcon = ({ isHighlighted = false, toiletId = undefined }) => - new (L.DivIcon.extend({ - options: { +interface ToiletMarkerIconParams { + isHighlighted?: boolean; + toiletId?: string; +} + +interface ToiletMarkerIconOptions extends L.DivIconOptions { + highlight?: boolean; + toiletId?: string; +} + +class ToiletMarkerDivIcon extends L.DivIcon { + options: ToiletMarkerIconOptions; + + constructor({ isHighlighted = false, toiletId }: ToiletMarkerIconParams) { + super(); + + // Merge custom options with default DivIcon options + this.options = { + ...this.options, highlight: isHighlighted, toiletId, - }, - - initialize: function () { - // eslint-disable-next-line functional/immutable-data - this.options = { - ...this.options, - iconSize: isHighlighted ? LARGE_ICON_DIMENSSIONS : ICON_DIMENSIONS, - iconAnchor: isHighlighted - ? getIconAnchor(LARGE_ICON_DIMENSSIONS) - : getIconAnchor(ICON_DIMENSIONS), - html: ` -
- ${getSVGHTML({ toiletId, isHighlighted })} + }> + ${getSVGHTML({ isHighlighted })}
`, - }; + }; - L.Util.setOptions(this, { toiletId, isHighlighted }); - }, - }))(); + L.Util.setOptions(this, { toiletId, isHighlighted }); + } +} + +const ToiletMarkerIcon = (params: ToiletMarkerIconParams): L.DivIcon => { + return new ToiletMarkerDivIcon(params); +}; export default ToiletMarkerIcon; diff --git a/src/components/LooMap/useLocateMapControl.ts b/src/components/LooMap/useLocateMapControl.ts index d3bb5e018..379562468 100644 --- a/src/components/LooMap/useLocateMapControl.ts +++ b/src/components/LooMap/useLocateMapControl.ts @@ -1,17 +1,28 @@ import React from 'react'; -import L, { LatLngLiteral, Map } from 'leaflet'; +import L, { Map, LocationEvent, LayerGroup, LatLngExpression } from 'leaflet'; import { fitMapBoundsToUserLocationNeighbouringTiles } from '../../lib/loo'; -const LocationMarker = L.Marker.extend({ - initialize: function (latlng, options) { +interface LocationMarkerOptions extends L.MarkerOptions { + color?: string; + fillColor?: string; + fillOpacity?: number; + opacity?: number; + weight?: number; + radius?: number; +} + +class LocationMarker extends L.Marker { + options: LocationMarkerOptions; + + constructor(latlng: LatLngExpression, options: LocationMarkerOptions) { + super(latlng, options); L.Util.setOptions(this, options); - // eslint-disable-next-line functional/immutable-data - this._latlng = latlng; this.createIcon(); - }, + } - _getIcon: function (options: { radius: number; weight: number }, style) { - const { radius, weight } = options; + #_getIcon(options: LocationMarkerOptions, style: string): L.DivIcon { + const radius = options.radius ?? 0; + const weight = options.weight ?? 0; const realRadius = radius + weight; const diameter = realRadius * 2; const svg = ``; @@ -21,12 +32,12 @@ const LocationMarker = L.Marker.extend({ html: svg, iconSize: [diameter, diameter], }); - }, + } - createIcon: function () { + private createIcon() { let style = ''; - const styleOptions = { + const styleOptions: { [key: string]: string } = { color: 'stroke', weight: 'stroke-width', fillColor: 'fill', @@ -34,22 +45,20 @@ const LocationMarker = L.Marker.extend({ opacity: 'opacity', }; - // convert style options to css string Object.entries(styleOptions).forEach(([option, property]) => { - if (this.options[option]) { - style = style + `${property}: ${this.options[option]};`; + const value = this.options[option as keyof LocationMarkerOptions]; + if (value !== undefined) { + style += `${property}: ${value};`; } }); - const icon = this._getIcon(this.options, style); + const icon = this.#_getIcon(this.options, style); this.setIcon(icon); - }, -}); + } +} interface UseLocateMapControlProps { - onLocationFound: ( - event: { latitude: number; longitude: number } | LatLngLiteral - ) => void; + onLocationFound: (event: { latitude: number; longitude: number }) => void; onStopLocation: () => void; map: Map; } @@ -65,21 +74,23 @@ const useLocateMapControl = ({ onStopLocation, map, }: UseLocateMapControlProps): UseLocateMapControl => { - const layerRef = React.useRef(null); + const layerRef = React.useRef(null); + const [isActive, setIsActive] = React.useState(false); + React.useEffect(() => { - if (typeof map !== 'undefined') { - // eslint-disable-next-line functional/immutable-data + if (map) { layerRef.current = new L.LayerGroup(); layerRef.current.addTo(map); } }, [map]); const handleLocationFound = React.useCallback( - (event: { accuracy: number; latlng: LatLngLiteral }) => { + (event: LocationEvent) => { const radius = event.accuracy || 0; const latlng = event.latlng; - L.circle(latlng, radius, { + L.circle(latlng, { + radius, color: '#136AEC', fillColor: '#136AEC', fillOpacity: 0.15, @@ -95,35 +106,35 @@ const useLocateMapControl = ({ radius: 9, }).addTo(layerRef.current); - //find neighbouring tiles of the user's location and set the map bounds to fit them + // Find neighbouring tiles of the user's location and set the map bounds to fit them fitMapBoundsToUserLocationNeighbouringTiles(latlng, map); setIsActive(true); - onLocationFound(event); + onLocationFound({ latitude: latlng.lat, longitude: latlng.lng }); }, - [onLocationFound, map] + [onLocationFound, map], ); React.useEffect(() => { - if (typeof map !== 'undefined') { + if (map) { map.on('locationfound', handleLocationFound); - return () => map.off('locationfound', handleLocationFound); + return () => { + map.off('locationfound', handleLocationFound); + }; } }, [map, handleLocationFound]); - const [isActive, setIsActive] = React.useState(false); - const startLocate = React.useCallback(() => { if (isActive) { map.stopLocate(); - layerRef.current.clearLayers(); + layerRef.current?.clearLayers(); } map.locate({ setView: true }); }, [isActive, map]); const stopLocate = React.useCallback(() => { map.stopLocate(); - layerRef.current.clearLayers(); + layerRef.current?.clearLayers(); setIsActive(false); onStopLocation(); }, [map, onStopLocation]); diff --git a/src/components/Sidebar/Sidebar.tsx b/src/components/Sidebar/Sidebar.tsx index 547fbeb1e..101d71bef 100644 --- a/src/components/Sidebar/Sidebar.tsx +++ b/src/components/Sidebar/Sidebar.tsx @@ -54,19 +54,6 @@ const Sidebar = () => { const [isFiltersExpanded, setIsFiltersExpanded] = useState(false); const filterToggleRef = useRef(null); - // Show the filters panel on first pageload if there is a filter set. - // TODO: Disabled for now until decided how this behaviour should work. - // useEffect(() => { - // if (mapState.appliedFilters) { - // const isThereAFilterSet = Object.values(mapState.appliedFilters).some( - // (filterState) => filterState - // ); - // console.log(mapState.appliedFilters); - - // setIsFilterExpanded(isThereAFilterSet); - // } - // }, [mapState.appliedFilters]); - const filterPanel = useMemo( () => (isFilterExpanded || isFiltersExpanded) && ( @@ -214,6 +201,7 @@ const Sidebar = () => { { setMapState({ ...mapState, appliedFilters: undefined }) @@ -262,6 +251,7 @@ const Sidebar = () => { + {/* @ts-expect-error -- Generic box component can't handle these props */} @@ -278,6 +268,7 @@ const Sidebar = () => { { - const fromLatLng = L.latLng(from.lat, from.lng); + const fromLatLng = global.L.latLng(from.lat, from.lng); - const toLatLng = L.latLng(to.lat, to.lng); + const toLatLng = global.L.latLng(to.lat, to.lng); const metersToLoo = fromLatLng.distanceTo(toLatLng); const distance = @@ -96,7 +82,7 @@ const ToiletDetailsPanel: React.FC = ({ if (verificationReportState.error) { console.error( 'There was a problem submitting the verification report.', - verificationReportState.error + verificationReportState.error, ); } }, [verificationReportState.error]); @@ -126,7 +112,7 @@ const ToiletDetailsPanel: React.FC = ({ } } }, - [isExpanded, navigateAway] + [isExpanded, navigateAway], ); React.useEffect(() => { @@ -251,7 +237,7 @@ const ToiletDetailsPanel: React.FC = ({ verificationReportState.loading, verifiedOrUpdated, verifiedOrUpdatedDate, - ] + ], ); if (isExpanded) { @@ -287,6 +273,7 @@ const ToiletDetailsPanel: React.FC = ({ setIsExpanded(false)} diff --git a/src/design-system/components/Button/Button.tsx b/src/design-system/components/Button/Button.tsx index 4dee1f2f7..a5a9903e2 100644 --- a/src/design-system/components/Button/Button.tsx +++ b/src/design-system/components/Button/Button.tsx @@ -5,25 +5,34 @@ import { forwardRef } from 'react'; const Button = forwardRef((props, ref) => { if (props.htmlElement === 'a') { + // Remove htmlElement from props + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { htmlElement, ...rest } = props; + return ( {props.children} ); } + + // Remove htmlElement from props + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { htmlElement, ...rest } = props; + return ( diff --git a/src/lib/openingTimes.ts b/src/lib/openingTimes.ts index 5622672ce..e1a89f8a4 100644 --- a/src/lib/openingTimes.ts +++ b/src/lib/openingTimes.ts @@ -50,11 +50,11 @@ function getDateFromTime(timeRangeString: string, weekdayToCheck: number) { return setISODay( set(new Date(), { - hours: hours, - minutes: minutes, + hours: parseInt(hours, 10), + minutes: parseInt(minutes, 10), seconds: 0, }), - weekday + weekday, ); } diff --git a/src/pages/_app.page.tsx b/src/pages/_app.page.tsx index 8bc1dd979..b45d36df4 100644 --- a/src/pages/_app.page.tsx +++ b/src/pages/_app.page.tsx @@ -20,7 +20,7 @@ const App = (props) => { ) : undefined, - [key] + [key], ); return ( diff --git a/src/pages/api/index.page.ts b/src/pages/api/index.page.ts index 3d82ab0fa..4dc2d15d0 100644 --- a/src/pages/api/index.page.ts +++ b/src/pages/api/index.page.ts @@ -1,12 +1,10 @@ -import jwt, { VerifyOptions } from 'jsonwebtoken'; -import jwksClient from 'jwks-rsa'; import { getSession } from '@auth0/nextjs-auth0'; - import Cors from 'cors'; -import authDirective from '../../api/directives/authDirective'; -import schema from '../../api-client/schema'; import { createYoga } from 'graphql-yoga'; - +import jwt, { VerifyOptions } from 'jsonwebtoken'; +import jwksClient from 'jwks-rsa'; +import schema from '../../api-client/schema'; +import authDirective from '../../api/directives/authDirective'; import { context } from '../../api/graphql/context'; const client = jwksClient({ @@ -32,6 +30,7 @@ const finalSchema = schema(authDirective); export const server = createYoga({ graphqlEndpoint: '/api', schema: finalSchema, + // @ts-expect-error -- req and res are there. context: async ({ req, res }) => { const revalidate = req.headers.referer?.indexOf('message=') > -1; let user = null; diff --git a/src/pages/explorer/loos/[id]/index.page.tsx b/src/pages/explorer/loos/[id]/index.page.tsx index 2194893b2..20fe58493 100644 --- a/src/pages/explorer/loos/[id]/index.page.tsx +++ b/src/pages/explorer/loos/[id]/index.page.tsx @@ -1,33 +1,6 @@ -import React, { useEffect, useState } from 'react'; -import Head from 'next/head'; -import Box from '../../../../components/Box'; -import VisuallyHidden from '../../../../components/VisuallyHidden'; -import { useMapState } from '../../../../components/MapState'; -import config from '../../../../config'; -import { withApollo } from '../../../../api-client/withApollo'; -import { GetServerSideProps } from 'next'; -import { - ssrFindLooById, - ssrLooReportHistory, -} from '../../../../api-client/page'; -import { useRouter } from 'next/router'; -import Notification from '../../../../components/Notification'; -import NotFound from '../../../404.page'; -import { css } from '@emotion/react'; -import type { - FindLooByIdQuery, - LooReportFragmentFragment, - LooReportHistoryQuery, -} from '../../../../api-client/graphql'; +// Import Statements import { ApolloError } from '@apollo/client'; -import Container from '../../../../components/Container'; -import Text from '../../../../components/Text'; -import Spacer from '../../../../components/Spacer'; -import LooMapLoader from '../../../../components/LooMap/LooMapLoader'; -import CodeViewer, { - MonacoOnInitializePane, -} from '../../../../components/CodeViewer/CodeViewer'; -import Link from 'next/link'; +import { css } from '@emotion/react'; import { Timeline, TimelineConnector, @@ -39,128 +12,302 @@ import { timelineOppositeContentClasses, } from '@mui/lab'; import { ThemeProvider, createTheme } from '@mui/material/styles'; +import { getISODay } from 'date-fns'; import isEqual from 'lodash/isEqual'; +import { GetServerSideProps, GetStaticPaths } from 'next'; +import Head from 'next/head'; +import Link from 'next/link'; +import { useRouter } from 'next/router'; +import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; + +// GraphQL Types and Queries +import type { + FindLooByIdQuery, + LooReportFragmentFragment, + LooReportHistoryQuery, +} from '../../../../api-client/graphql'; +import { + ssrFindLooById, + ssrLooReportHistory, +} from '../../../../api-client/page'; +import { withApollo } from '../../../../api-client/withApollo'; + +// Custom Components +import Box from '../../../../components/Box'; +import CodeViewer, { + MonacoOnInitializePane, +} from '../../../../components/CodeViewer/CodeViewer'; +import Container from '../../../../components/Container'; +import LooMapLoader from '../../../../components/LooMap/LooMapLoader'; +import { useMapState } from '../../../../components/MapState'; +import Notification from '../../../../components/Notification'; +import Spacer from '../../../../components/Spacer'; +import Text from '../../../../components/Text'; +import VisuallyHidden from '../../../../components/VisuallyHidden'; +import NotFound from '../../../404.page'; + +// Utilities and Constants +import config from '../../../../config'; import { WEEKDAYS, getTimeRangeLabel } from '../../../../lib/openingTimes'; -import { getISODay } from 'date-fns'; import theme from '../../../../theme'; -type CustomLooByIdComp = React.FC<{ +// Type Definitions + +/** + * Props for the LooPage component. + */ +interface LooPageProps { looData?: FindLooByIdQuery; reportData?: LooReportHistoryQuery; error?: ApolloError; notFound?: boolean; -}>; +} + +/** + * Props for the TimelineEntry component. + */ +interface TimelineEntryProps { + report: LooReportFragmentFragment; +} + +/** + * Props for the TimelineEntry component. + */ +const TimelineEntry: FC = ({ report }) => { + return ( + + + + + + + {Object.entries(report) + .filter( + ([key]) => + key !== 'id' && key !== 'createdAt' && key !== '__typename', + ) + .map(([key, value]) => ( + + + + + ))} + +
+ Key + + Value +
{key}{diffValueMapping(key, value)}
+ ); +}; -const LooPage: CustomLooByIdComp = (props) => { - const [mapState, setMapState] = useMapState(); +/** + * Utility function to map report values based on their keys. + */ +const diffValueMapping = (key: string, value: unknown) => { + if (key === 'location') { + const location = value as { lat: number; lng: number }; + return ( + + + {location?.lat}, {location?.lng} + + + ); + } - const router = useRouter(); - const { message } = router.query; + if (key === 'openingTimes') { + if (!Array.isArray(value)) { + return {JSON.stringify(value)}; + } - const [firstLoad, setFirstLoad] = useState(true); + const openingTimes = value as Array; + const todayWeekdayIndex = getISODay(new Date()) - 1; // getISODay returns 1 (Monday) to 7 (Sunday) + return ( +
    + {openingTimes?.map((timeRange, i) => ( + + {WEEKDAYS[i]} + {getTimeRangeLabel(timeRange)} + + ))} +
+ ); + } - useEffect(() => { - setFirstLoad(false); - }, []); + return {JSON.stringify(value)}; +}; - const looCentre = props?.looData?.loo; - useEffect( - function setInitialMapCentre() { - if ( - looCentre && - firstLoad && - mapState?.locationServices?.isActive !== true - ) { - setMapState({ center: looCentre?.location, focus: looCentre }); - } - }, - [ - firstLoad, - looCentre, - mapState?.locationServices?.isActive, - message, - router.isReady, - setMapState, - ], - ); +/** + * LooPage Component + */ +const LooPage: FC = ({ looData, reportData, notFound }) => { + const [mapState, setMapState] = useMapState(); + const router = useRouter(); + const { message } = router.query; - // Find the diff between the current and previous report - const reportHistory = props?.reportData?.reportsForLoo; + const [firstLoad, setFirstLoad] = useState(true); + const looCentre = looData?.loo; const [reportDiffHistory, setReportDiffHistory] = useState< LooReportFragmentFragment[] >([]); + // Effect to handle first load useEffect(() => { - if (reportHistory) { - const squashedSystemReports = reportHistory.reduce( - (accumulatedReports, currentReport, i) => { - const nextReport = reportHistory[i + 1]; - - // If we're on a system report, skip it. - if (currentReport?.isSystemReport) { - return accumulatedReports; - } + setFirstLoad(false); + }, []); - // If the next report is a system report, merge it with the current report - if (nextReport?.isSystemReport) { - return [ - ...accumulatedReports, - { - ...currentReport, - location: nextReport.location, - geohash: nextReport.geohash, - }, - ]; - } + // Effect to set initial map center + useEffect(() => { + if ( + looCentre && + firstLoad && + mapState?.locationServices?.isActive !== true + ) { + setMapState({ center: looCentre.location, focus: looCentre }); + } + }, [ + firstLoad, + looCentre, + mapState?.locationServices?.isActive, + message, + router.isReady, + setMapState, + ]); + + // Effect to compute report differences + useEffect(() => { + if (reportData?.reportsForLoo) { + const squashedSystemReports = reportData.reportsForLoo.reduce< + LooReportFragmentFragment[] + >((accumulatedReports, currentReport, i, reports) => { + const nextReport = reports[i + 1]; + + if (currentReport?.isSystemReport) { + return accumulatedReports; + } - // Otherwise, just return the current report. - return [...accumulatedReports, currentReport]; - }, - [], - ); + if (nextReport?.isSystemReport) { + return [ + ...accumulatedReports, + { + ...currentReport, + location: nextReport.location, + geohash: nextReport.geohash, + }, + ]; + } + + return [...accumulatedReports, currentReport]; + }, []); const diffHistory = squashedSystemReports.map((report, i) => { if (i === 0) { return report; } - const constructedReport = { ...report }; + + const constructedReport: LooReportFragmentFragment = { ...report }; const prevReport = squashedSystemReports[i - 1]; - // Find out what's changed, only keep those items. - // Skip `createdAt` and `updatedAt`, we don't want to remove these. const skipList = ['createdAt', 'updatedAt']; - for (const key in report) { + + Object.keys(report).forEach((key) => { if ( isEqual(report[key], prevReport[key]) && !skipList.includes(key) ) { delete constructedReport[key]; } - } + }); + return constructedReport; }); setReportDiffHistory(diffHistory); } - }, [reportHistory]); + }, [reportData]); const pageTitle = config.getTitle('Home'); - if (props?.notFound) { + // Handler for Monaco Editor Initialization + const onInitializePane: MonacoOnInitializePane = useCallback( + (_, editorRef) => { + if (editorRef.current) { + editorRef.current.setScrollTop(1); + editorRef.current.setPosition({ + lineNumber: 2, + column: 0, + }); + editorRef.current.focus(); + } + }, + [], + ); + + // Memoized Report History to prevent unnecessary computations + const renderedReportHistory = useMemo( + () => + reportDiffHistory.map((report) => ( + + + {new Date(report.createdAt).toLocaleDateString()} + + + + + + +
    + +
+
+
+ )), + [reportDiffHistory], + ); + + // If data fetching resulted in not found + if (notFound) { return ( <> {pageTitle} - @@ -168,8 +315,8 @@ const LooPage: CustomLooByIdComp = (props) => { my={4} mx="auto" css={css` - max-width: 360px; /* fallback */ - max-width: fit-content; + max-width: 360px; + width: 100%; `} > @@ -183,20 +330,6 @@ const LooPage: CustomLooByIdComp = (props) => { ); } - const onInitializePane: MonacoOnInitializePane = ( - monacoEditorRef, - editorRef, - model, - ) => { - editorRef.current.setScrollTop(1); - editorRef.current.setPosition({ - lineNumber: 2, - column: 0, - }); - editorRef.current.focus(); - monacoEditorRef.current.setModelMarkers(model[0], 'owner', null); - }; - return ( @@ -214,24 +347,28 @@ const LooPage: CustomLooByIdComp = (props) => {
+ + {/* Name Section */}

Name:

{looCentre?.name ?? 'Unnamed toilet'} - - {!looCentre?.name && ( + {!looCentre?.name && ( + (Add a name) - )} - + + )}
+ + {/* Area Section */}

Area:

@@ -239,6 +376,8 @@ const LooPage: CustomLooByIdComp = (props) => { {looCentre?.area[0]?.name} {looCentre?.area[0]?.type}
+ + {/* Location Data Section */}

Location data:

@@ -250,11 +389,15 @@ const LooPage: CustomLooByIdComp = (props) => { Lng: {looCentre?.location?.lng}
+ - + + {/* Map and Code Viewer Section */} + + {/* Map Section */} { Toilet Location{' '} - - (See it on the map) - + (See it on the map) {looCentre && ( { )} + + {/* Code Viewer Section */} { + /> + + + {/* Timeline Section */} @@ -316,23 +461,7 @@ const LooPage: CustomLooByIdComp = (props) => { }, }} > - {reportDiffHistory.map((report) => ( - // Only show non system location update reports for now. - - - {new Date(report?.createdAt).toLocaleDateString()} - - - - - - -
    - -
-
-
- ))} + {renderedReportHistory}
@@ -341,86 +470,6 @@ const LooPage: CustomLooByIdComp = (props) => { ); }; -const diffValueMapping = (key: string, value: unknown) => { - if (key === 'location') { - return ( - - - {value?.lat}, {value?.lng} - - - ); - } - - if (key === 'openingTimes') { - const todayWeekdayIndex = getISODay(new Date()); - return ( -
    - {value?.map((timeRange: unknown[], i) => ( - - {WEEKDAYS[i]} - {getTimeRangeLabel(timeRange)} - - ))} -
- ); - } - - switch (key) { - default: - return {JSON.stringify(value)}; - } -}; - -const TimelineEntry = ({ report }: { report: LooReportFragmentFragment }) => { - return ( - - - - - - - {Object.entries(report) - .filter(([key]) => key !== 'id' && key !== 'createdAt') - .map(([key, value]) => ( - - - - - ))} - -
- Key - - Value -
{key}{diffValueMapping(key, value)}
- ); -}; - export const getStaticProps: GetServerSideProps = async ({ params, req }) => { try { const looDetailsResponse = await ssrFindLooById.getServerPage( @@ -448,6 +497,7 @@ export const getStaticProps: GetServerSideProps = async ({ params, req }) => { return { props: { notFound: true, + revalidate: 60, }, }; } @@ -456,21 +506,25 @@ export const getStaticProps: GetServerSideProps = async ({ params, req }) => { looData: looDetailsResponse.props.data, reportData: reportHistoryResponse.props.data, }, + revalidate: 60, }; } catch { return { props: { notFound: true, + revalidate: 60, }, }; } }; -export const getStaticPaths = async () => { +// Static Paths (Fallback to 'blocking' for dynamic routes) +export const getStaticPaths: GetStaticPaths = async () => { return { paths: [], fallback: 'blocking', }; }; +// Exporting the Component with Apollo HOC export default withApollo(LooPage); diff --git a/src/pages/explorer/search.page.tsx b/src/pages/explorer/search.page.tsx index 692d4a840..590d9809d 100644 --- a/src/pages/explorer/search.page.tsx +++ b/src/pages/explorer/search.page.tsx @@ -1,21 +1,21 @@ import * as React from 'react'; -import { withApollo } from '../../api-client/withApollo'; +import { css } from '@emotion/react'; +import styled from '@emotion/styled'; +import { TablePagination } from '@mui/base'; +import Link from 'next/link'; +import { useRouter } from 'next/router'; import { LooFilter, SortOrder, useAreasQuery, useSearchLoosQuery, } from '../../api-client/graphql'; -import Spacer from '../../components/Spacer'; +import { withApollo } from '../../api-client/withApollo'; import Box from '../../components/Box'; -import styled from '@emotion/styled'; -import { useRouter } from 'next/router'; +import Spacer from '../../components/Spacer'; import Button from '../../design-system/components/Button'; -import { css } from '@emotion/react'; import theme from '../../theme'; -import Link from 'next/link'; -import { TablePagination } from '@mui/base'; const OptionLabel = styled('label')({ display: 'flex', @@ -53,11 +53,11 @@ const UnstyledTable = () => { return { ...removeEmpty(router.query), // Defaults - page: router.query?.page ? parseInt(router.query?.page, 10) : 0, + page: parseInt((router.query?.page as string) ?? '0', 10), rowsPerPage: router.query?.rowsPerPage - ? parseInt(router.query?.rowsPerPage, 10) + ? parseInt(router.query?.rowsPerPage as string, 10) : 25, - text: router.query?.text ?? '', + text: (router.query?.text as string) ?? '', }; }, [router]); diff --git a/tsconfig.json b/tsconfig.json index a45bb6f13..06a183ea0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "es5", + "target": "ES2015", "lib": ["dom", "dom.iterable", "esnext", "ES2015"], "allowJs": true, "skipLibCheck": true, @@ -15,9 +15,13 @@ "jsxImportSource": "@emotion/react", "incremental": true, "downlevelIteration": true, - "types": ["cypress"], + "types": ["cypress", "@testing-library/cypress"], "isolatedModules": true }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "next.config.js"], - "exclude": ["node_modules", "src/@types/resolvers-types.ts"] + "exclude": [ + "node_modules", + "src/@types/resolvers-types.ts", + "scripts/areaToDatabase/*" + ] }