Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(Search): Add text and categories search #190

Merged
merged 41 commits into from
Aug 15, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
6123ad7
fix(history): Fall back to home in bo back button when no history
vogelino Aug 3, 2023
5159f4f
Merge branch 'staging' into refactor/better-back-button
vogelino Aug 3, 2023
b5b78dd
Merge branch 'staging' into refactor/better-back-button
vogelino Aug 3, 2023
5bfaebf
fix(Links): Encode the back url into the query params to avoid loss o…
vogelino Aug 3, 2023
170eaca
chore(Button): Fix types of query in Button
vogelino Aug 3, 2023
d666aa4
refactor(Map): Scroll to previously clicked facility on back button c…
vogelino Aug 3, 2023
621b267
refactor(map): Cast ScrollBehavior type because typesrcipt does under…
vogelino Aug 3, 2023
de6c8a6
Merge branch 'staging' into fix/avoid-filters-reset
vogelino Aug 7, 2023
b7391cc
fix(UrlStateContext): Use router push with shallow statt window replace
vogelino Aug 7, 2023
e3b3c46
Merge branch 'fix/local-missing-map-markers' into fix/avoid-filters-r…
vogelino Aug 7, 2023
3e7ce23
feat(TextSearch): Add Text Search UI
vogelino Aug 8, 2023
20e4ed0
feat(useActiveIdsBySearchTerm): Filter data by text or category
vogelino Aug 8, 2023
f291cb6
feat(ActiveFiltersList): Show the text search and categories in the l…
vogelino Aug 8, 2023
c17aa3b
refactor(useActiveIdsBySearchTerm): Show all items for first two cate…
vogelino Aug 9, 2023
b12cdc7
refactor(FiltersList): Add loading indication when loading search res…
vogelino Aug 9, 2023
ac96662
feat(TextSearch): Add clear and submit buttons on text search
vogelino Aug 9, 2023
29052af
refactor(UrlStateContext): Limit text search length
vogelino Aug 9, 2023
6e9b1cb
refactor(MapHeader): Change active class and text of sidebar button
vogelino Aug 9, 2023
5f89dbf
Merge branch 'staging' into feat/full-text-search-frontend
vogelino Aug 9, 2023
e6f8764
refactor(MapHeader): Fix styles related to merging the address search…
vogelino Aug 9, 2023
703694c
fix(TextSearch): Remove class that misaligns buttons on text field
vogelino Aug 9, 2023
9cb3cca
fix(UrlStateContext): Make sure that the query string isn't lost when…
vogelino Aug 9, 2023
36962f7
fix(UrlStateContext): use ?? instead of pipe to avoid falsy values to…
vogelino Aug 9, 2023
a118c11
fix(UrlStateContext): Use window pushState instead of router push to …
vogelino Aug 10, 2023
3fc919f
refactor(UrlStateContext): Simplify initial default function
vogelino Aug 10, 2023
58d8920
Merge branch 'refactor/remember-scroll-position' into feat/full-text-…
vogelino Aug 10, 2023
186c89e
Merge branch 'fix/avoid-filters-reset' into feat/full-text-search-fro…
vogelino Aug 10, 2023
75e9cae
fix(UrlStateContext): Ensure the query is sent with every link
vogelino Aug 10, 2023
e3bb85e
fix(UrlStateContext): The text search is now clearable
vogelino Aug 10, 2023
7ad2dbc
fix(UrlStateContext): always update state to avoid old values
vogelino Aug 10, 2023
c2392fa
refactor(sofortige-hilfe): Remove log statement
vogelino Aug 10, 2023
254dbaf
refactor(UrlStateContext): Cleanup the url when empty values
vogelino Aug 10, 2023
4a9f6a6
refactor(UrlStateContext): Enforce cleaning of falsy values from url
vogelino Aug 10, 2023
e461666
fix(UrlStateContext): Avoid resetting everything when no categories s…
vogelino Aug 10, 2023
7d1008c
fix(UrlStateContext): Fix missing filters on page change when no cate…
vogelino Aug 10, 2023
e14a4fd
fix(facilityFilterUtil): Remove encodeURIComponent
vogelino Aug 10, 2023
a071c3d
feat(typos): Fix typos in function names
vogelino Aug 14, 2023
162d555
refactor(UrlStateContext): Attempt to simplify the UrlStateContext
vogelino Aug 14, 2023
115fbd7
fix(TextsContext): Fix text key name case change
vogelino Aug 14, 2023
9710845
fix(TextSearch): Use changed key Text in TextSearch
vogelino Aug 14, 2023
92ecbdb
fix(UrlStateContext): Fix typo in comment
vogelino Aug 15, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions next.config.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
scrollRestoration: true,
// swcMinify: false,
}

Expand Down
389 changes: 139 additions & 250 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,15 @@
},
"dependencies": {
"@headlessui/react": "1.7.8",
"@tailwindcss/container-queries": "0.1.1",
"date-fns": "2.29.3",
"maplibre-gl": "2.4.0",
"next": "13.4.12",
"react": "18.2.0",
"react-dom": "18.2.0",
"sanitize-html": "2.7.3",
"swiper": "8.4.5",
"swr": "2.2.0",
"use-debounce": "9.0.2"
},
"devDependencies": {
Expand Down Expand Up @@ -62,4 +64,4 @@
"tsconfig-paths-webpack-plugin": "3.5.2",
"typescript": "4.9.5"
}
}
}
100 changes: 100 additions & 0 deletions pages/api/search.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { TableRowType } from '@common/types/gristData'
import { NextApiRequest, NextApiResponse } from 'next'
import path from 'path'
import fs from 'fs/promises'
import { existsSync } from 'fs'

const searchData = ({
data,
query,
filters,
}: {
data: TableRowType[]
query: string
filters: string[]
}): TableRowType[] => {
if (filters.length === 0) return []
if (!query && filters.length === 5) return data
return data.filter((item) => {
// check if item.fields.Typ is in the filters array
if (filters.length > 0 && !filters.includes(item.fields.Typ)) {
return false
}

// Return true if there is no query
if (!query) return true

// Convert each field value into a string and join them together.
// const str = Object.values(item.fields)
// .map((value) => String(value))
// .join(' ')
// only filter specific fields
const str = [
item.fields.Einrichtung,
item.fields.Schlagworte.join(' '),
item.fields.Zielgruppen,
item.fields.Trager,
item.fields.Kategorie,
item.fields.Sprachen,
item.fields.Uber_uns,
item.fields.Strasse,
item.fields.Hausnummer,
item.fields.Zusatz,
item.fields.PLZ,
item.fields.Bezirk,
item.fields.Stadtteil,
item.fields.Telefonnummer,
item.fields.EMail,
item.fields.Typ,
].join(' ')

// Return true if the keyword exists in str.
return str.toLowerCase().includes(query.toLowerCase())
})
}
const handler = async (
req: NextApiRequest,
res: NextApiResponse
): Promise<void> => {
const url = new URL(req.url ?? '', 'http://localhost')

// Get all query parameters as an object
const params = Object.fromEntries(url.searchParams.entries())
// check if the q parameter exists

if (typeof params.query !== 'string') {
return res
.status(400)
.json({ result: null, error: 'Missing query parameter "query"' })
}

// the paramters filters is a comma sparated list. Create an an array from it
// If there is no filter applied we still get an string of 0 length. Which gets split into [""] an array with one empty string
// to prevent our filter function from failing we replace this with an empty array
const filters = params.filters.length === 0 ? [] : params.filters.split(',')

const filePath = path.resolve(process.cwd(), './data/records.json')
// check if the file ath te path exists
if (!existsSync(filePath)) {
return res
.status(500)
.json({ result: null, error: 'data/records.json File not found' })
}

try {
const content = await fs.readFile(filePath, 'utf8')
const data = JSON.parse(content) as TableRowType[]
const result = searchData({ data, query: params.query, filters })
return res.status(200).json({ params, result, total: data.length })
} catch (error: unknown) {
if (error instanceof Error) {
return res.status(500).json({ result: null, error: error.message })
} else {
return res
.status(500)
.json({ result: null, error: new Error('unknown error') })
}
}
}

export default handler
12 changes: 7 additions & 5 deletions pages/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,29 @@ import classNames from '@lib/classNames'
import { WelcomeScreen } from '@components/WelcomeScreen'
import { WelcomeFilters } from '@components/WelcomeFilters'
import { useEffect, useState } from 'react'
import { GristLabelType, TableRowType } from '@common/types/gristData'
import { GristLabelType } from '@common/types/gristData'
import { useIsMobile } from '@lib/hooks/useIsMobile'
import { LegalFooter } from '@components/LegalFooter'
import { Page } from '@common/types/nextPage'
import { LabelsProvider } from '@lib/LabelsContext'
import { loadData } from '@lib/loadData'
import { Footer } from '@components/Footer'
import { RecordsWithOnlyLabelsType } from '@lib/hooks/useFilteredFacilitiesCount'

export const getStaticProps: GetStaticProps = async () => {
const { texts, labels, records } = await loadData()
const recordsWithOnlyLabels = records.map(
(records) => records.fields.Schlagworte
)
const recordsWithOnlyLabels = records.map((record) => [
record.id,
record.fields.Schlagworte,
])
return {
props: { texts, recordsWithOnlyLabels, labels },
revalidate: 120,
}
}

interface HomePropsType {
recordsWithOnlyLabels: TableRowType['fields']['Schlagworte'][]
recordsWithOnlyLabels: RecordsWithOnlyLabelsType[]
labels: GristLabelType[]
}

Expand Down
11 changes: 9 additions & 2 deletions pages/info.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,21 @@ export const getStaticProps: GetStaticProps = async () => {
}

const Info: Page = () => {
const { back } = useRouter()
const { query } = useRouter()

const { back, ...restQuery } = query
return (
<div>
<Head>
<title>Info - HILF-MIR Berlin</title>
</Head>
<div className="min-h-screen mx-auto max-w-xl">
<BackButton onClick={() => void back()} />
<BackButton
href={{
pathname: typeof back === 'string' ? back : '/',
query: restQuery,
}}
/>
<div
className={classNames('p-5 md:p-8 flex flex-col gap-8 md:pt-[5vmin]')}
>
Expand Down
60 changes: 32 additions & 28 deletions pages/map.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ import { useDistanceToUser } from '@lib/hooks/useDistanceToUser'
import { useUserGeolocation } from '@lib/hooks/useUserGeolocation'
import { useFiltersWithActiveProp } from '@lib/hooks/useFiltersWithActiveProp'
import { getFilteredFacilities } from '@lib/facilityFilterUtil'
import { Button } from '@components/Button'
import { FacilityListItem } from '@components/FacilityListItem'
import { useActiveIdsBySearchTerm } from '@lib/hooks/useActiveIdsBySearchTerm'
import ActiveFiltersList from '@components/ActiveFiltersList'

export const getStaticProps: GetStaticProps = async () => {
const { texts, labels, records } = await loadData()
Expand All @@ -41,12 +42,13 @@ interface MapProps {
const MapPage: Page<MapProps> = ({ records: originalRecords }) => {
const [urlState] = useUrlState()
const texts = useTexts()
const { isFallback } = useRouter()
const { query, isFallback } = useRouter()
const { getDistanceToUser } = useDistanceToUser()
const { useGeolocation } = useUserGeolocation()
const labels = useFiltersWithActiveProp()
const [filteredRecords, setFilteredRecords] =
useState<MinimalRecordType[]>(originalRecords)
const activeIdsBySearchTerm = useActiveIdsBySearchTerm()

const sortByTagsCount = useCallback(
(a: MinimalRecordType, b: MinimalRecordType) => {
Expand Down Expand Up @@ -101,13 +103,27 @@ const MapPage: Page<MapProps> = ({ records: originalRecords }) => {
[getDistanceToUser, useGeolocation, defaultSort, sortByTagsCount]
)

const tagsKey = urlState.tags?.join('-') || ''
useEffect(() => {
const filteredRecords = getFilteredFacilities({
facilities: originalRecords,
labels,
activeIdsBySearchTerm: activeIdsBySearchTerm.ids,
})
return setFilteredRecords(sortFacilities(filteredRecords))
}, [urlState.tags?.join('-')])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tagsKey, activeIdsBySearchTerm.key, activeIdsBySearchTerm.isLoading])

useEffect(() => {
if (!query.back || typeof query.back !== 'string') return
const prevItemId = query.back.replace('/', '')
if (!prevItemId) return
const listEl = document.getElementById(`facility-${prevItemId}`)
if (!listEl) return
listEl.scrollIntoView({ behavior: 'instant' as ScrollBehavior })
listEl?.focus()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])

return (
<>
Expand All @@ -118,35 +134,23 @@ const MapPage: Page<MapProps> = ({ records: originalRecords }) => {
: `${texts.resultPageTitle} – ${texts.siteTitle}`}
</title>
</Head>
{labels.filter((label) => label.isActive).length > 0 && (
<ActiveFiltersList />

{filteredRecords.length === 0 && (
<div className="p-5 border-b border-gray-20 bg-gray-10 bg-opacity-25">
<p className="text-sm font-bold">{texts.resultPageIntro}</p>
<ul className="mt-2 md:mt-3 flex flex-wrap gap-1 md:gap-2">
{labels
.filter((label) => label.isActive)
.map((label) => (
<Button
key={label.id}
tag="button"
disabled={true}
scheme="primary"
size="extrasmall"
className="!bg-primary !text-white !cursor-default flex gap-x-1 items-center"
>
{label.fields.text}
</Button>
))}
</ul>
<p className="text-sm font-bold">{texts.noResults}</p>
</div>
)}

<ul className="pb-28">
{filteredRecords.map((record) => (
<li key={record.id}>
<FacilityListItem facility={record} />
</li>
))}
</ul>
{filteredRecords.length > 0 && (
<ul className="pb-28">
{filteredRecords.map((record) => (
<li key={record.id}>
<FacilityListItem facility={record} />
</li>
))}
</ul>
)}
</>
)
}
Expand Down
9 changes: 7 additions & 2 deletions pages/sofortige-hilfe.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ const Home: NextPage = () => {
const [selectedNeighborhood, setSelectedNeighborhood] = useState(
neigborhoodKeys[0]
)
const { back } = useRouter()
const { query } = useRouter()

const isEmergencyTime = useIsEmergencyTime()

Expand All @@ -51,7 +51,12 @@ const Home: NextPage = () => {
</title>
</Head>
<div className="min-h-screen mx-auto max-w-xl">
<BackButton onClick={() => void back()} />
<BackButton
href={{
pathname: typeof query.back === 'string' ? query.back : '/',
query,
}}
/>
<div className="p-5 md:p-8 flex flex-col gap-8 md:pt-[5vmin]">
<h1 className="relative pr-16">
{texts.directHelpButtonText}
Expand Down
2 changes: 2 additions & 0 deletions scripts/createFakeGristData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ const fakeData: Omit<TableRowType, 'id'>[] = Array.from(Array(300)).map(() => {
Weitere_Offnungszeiten: '',
Wichtige_Hinweise: '',
Zusatz: '',
Typ: 'Beratung',
Kategorie: 'KJPD',
} as TableRowType['fields'],
}
})
Expand Down
2 changes: 2 additions & 0 deletions src/common/types/gristData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,5 +57,7 @@ export interface TableRowType extends Record<string, unknown> {
lat: number
/** The facility's longitude */
long: number
Kategorie: string
Typ: 'Beratung' | 'Klinik' | 'Amt' | 'Online' | 'Selbsthilfe'
}
}
59 changes: 59 additions & 0 deletions src/components/ActiveFiltersList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { useTexts } from '@lib/TextsContext'
import { useFiltersWithActiveProp } from '@lib/hooks/useFiltersWithActiveProp'
import React from 'react'
import { getCategoriesTexts } from './TextSearch'
import {
urlSearchCategoriesToStateSearchCategories,
useUrlState,
} from '@lib/UrlStateContext'
import { GristLabelType } from '@common/types/gristData'
import { Button } from './Button'

function ActiveFiltersList(): JSX.Element | null {
const texts = useTexts()
const [urlState] = useUrlState()
const labels = useFiltersWithActiveProp()
const categoriesTexts = getCategoriesTexts(texts)
const categories = urlSearchCategoriesToStateSearchCategories(
urlState.qCategories
)

const categoryFilters = Object.entries(categories)
.filter(([, isActive]) => isActive)
.map(([key]) => ({
id: key,
fields: {
text: categoriesTexts[key as keyof typeof categoriesTexts],
},
}))

const allFilters = [
...(urlState.q ? [{ id: 'search', fields: { text: urlState.q } }] : []),
...categoryFilters,
...labels.filter((label) => label.isActive),
] as GristLabelType[]

if (allFilters.length === 0) return null

return (
<div className="p-5 border-b border-gray-20 bg-gray-10 bg-opacity-25">
<p className="text-sm font-bold">{texts.resultPageIntro}</p>
<ul className="mt-2 md:mt-3 flex flex-wrap gap-1 md:gap-2">
{allFilters.map((label) => (
<Button
key={label.id}
tag="button"
disabled={true}
scheme="primary"
size="extrasmall"
className="!bg-primary !text-white !cursor-default flex gap-x-1 items-center"
>
{label.fields.text}
</Button>
))}
</ul>
</div>
)
}

export default ActiveFiltersList
Loading
Loading