diff --git a/src/app/components/form-select/form-select.tsx b/src/app/components/form-select/form-select.tsx index 4d2c985dc..e505718b8 100644 --- a/src/app/components/form-select/form-select.tsx +++ b/src/app/components/form-select/form-select.tsx @@ -11,7 +11,7 @@ export default function FormSelect({ }: { label: string; name: string; - selectAttributes: object; + selectAttributes?: object; options: SelectItem[]; onValueUpdate?: (v: string) => void; }) { diff --git a/src/app/helpers/data.tsx b/src/app/helpers/data.tsx index 45a98d38e..b9574d1d0 100644 --- a/src/app/helpers/data.tsx +++ b/src/app/helpers/data.tsx @@ -32,6 +32,8 @@ export function useRefreshable(getter: () => T) { return React.useReducer(getter, getter()); } +export type SetHandle = ReturnType + // Each time the Set is updated, the handle is refreshed // That way, the Set doesn't have to be rebuilt export function useSet(initialValue:T[]=[]) { diff --git a/src/app/helpers/window-settings.ts b/src/app/helpers/window-settings.ts index ec3d5edab..f63f1e0e5 100644 --- a/src/app/helpers/window-settings.ts +++ b/src/app/helpers/window-settings.ts @@ -4,6 +4,7 @@ export type WindowWithSettings = typeof window & { accountHref: string; gatedContentEndpoint: string; renewalEndpoint: string; + mapboxPK: string; }; }; diff --git a/src/app/models/query-schools.ts b/src/app/models/query-schools.ts index 6eee68364..55918df36 100644 --- a/src/app/models/query-schools.ts +++ b/src/app/models/query-schools.ts @@ -13,6 +13,7 @@ export type SchoolInfo = { testimonial?: string; testimonial_name?: string; testimonial_position?: string; + current_year_savings: number; all_time_savings: number; location: string; total_school_enrollment: string | null; @@ -29,13 +30,13 @@ type Item = { fields: SchoolInfo; }; -type AugmentedInfo = { +export type AugmentedInfo = { pk: string; cityState: string; institutionalPartner: string; institutionType: string; fields: SchoolInfo; - lngLat?: number[]; + lngLat?: [number, number]; testimonial?: { text: string; name?: string; @@ -51,7 +52,7 @@ function augmentInfo(item: Item) { institutionType: item.fields.type, fields: item.fields }; - const lngLat = [Number(item.fields.long), Number(item.fields.lat)]; + const lngLat: [number, number] = [Number(item.fields.long), Number(item.fields.lat)]; if (!(lngLat[0] === 0 && lngLat[1] === 0)) { result.lngLat = lngLat; diff --git a/src/app/pages/separatemap/map-api.js b/src/app/pages/separatemap/map-api.tsx similarity index 51% rename from src/app/pages/separatemap/map-api.js rename to src/app/pages/separatemap/map-api.tsx index e36603f08..da54e770a 100644 --- a/src/app/pages/separatemap/map-api.js +++ b/src/app/pages/separatemap/map-api.tsx @@ -1,31 +1,34 @@ -import mapboxgl from 'mapbox-gl'; +import mapboxgl, { + FilterSpecification, + LngLatBoundsLike, + LngLatLike, + MapOptions +} from 'mapbox-gl'; import mapboxPromise from '~/models/mapbox'; - -const settings = window.SETTINGS; +import settings from '~/helpers/window-settings'; +import {AugmentedInfo} from '~/models/query-schools'; // Set up CSS once, when needed (() => { - const cssEl = Object.assign( - document.createElement('link'), - { - rel: 'stylesheet', - href: 'https://api.tiles.mapbox.com/mapbox-gl-js/v3.9.4/mapbox-gl.css', - type: 'text/css' - } - ); - const firstLink = document.querySelector('head link[rel="stylesheet"]') || + const cssEl = Object.assign(document.createElement('link'), { + rel: 'stylesheet', + href: 'https://api.tiles.mapbox.com/mapbox-gl-js/v3.9.4/mapbox-gl.css', + type: 'text/css' + }); + const firstLink = + document.querySelector('head link[rel="stylesheet"]') || document.querySelector('head title'); - firstLink?.parentNode.insertBefore(cssEl, firstLink.nextSibling); + firstLink?.parentNode?.insertBefore(cssEl, firstLink.nextSibling); })(); -mapboxgl.accessToken = settings.mapboxPK; +mapboxgl.accessToken = settings().mapboxPK; -function hasLngLat({lngLat}) { +function hasLngLat({lngLat}: {lngLat?: unknown}) { return Boolean(lngLat); } -async function createMapboxMap(mapOptions) { +async function createMapboxMap(mapOptions: MapOptions) { const mapbox = await mapboxPromise; return new mapboxgl.Map({ @@ -34,7 +37,7 @@ async function createMapboxMap(mapOptions) { }); } -export default function createMap(options) { +export default function createMap(options: MapOptions) { // This is a promise that yields the MapboxGL map object const loaded = createMapboxMap(options); const initialState = { @@ -42,7 +45,7 @@ export default function createMap(options) { zoom: options.zoom }; - function setBounds(bounds) { + function setBounds(bounds?: LngLatBoundsLike) { loaded.then((map) => { if (bounds) { map.fitBounds(bounds, { @@ -55,7 +58,7 @@ export default function createMap(options) { }); } - function setFilters(filterSpec) { + function setFilters(filterSpec?: FilterSpecification) { loaded.then((map) => { map.setFilter('os-schools', filterSpec); map.setFilter('os-schools-shadow-at-8', filterSpec); @@ -74,9 +77,14 @@ export default function createMap(options) { tooltip.remove(); } - function showPoints(pointList) { - const mappable = ({lngLat: [lng, lat]}) => !(lng === 0 && lat === 0); - const mappableData = pointList.filter(hasLngLat).filter(mappable); + type HasLngLat = AugmentedInfo & Required>; + + function showPoints(pointList: AugmentedInfo[]) { + const mappable = ({lngLat: [lng, lat]}: HasLngLat) => + !(lng === 0 && lat === 0); + const mappableData = ( + pointList.filter(hasLngLat) as HasLngLat[] + ).filter(mappable); tooltip.remove(); if (mappableData.length === 0) { @@ -86,20 +94,24 @@ export default function createMap(options) { (bound, obj) => bound.extend(obj.lngLat), new mapboxgl.LngLatBounds() ); - const filterSpec = ['in', 'id', ...(mappableData.map((obj) => obj.pk))]; + const filterSpec = [ + 'in', + 'id', + ...mappableData.map((obj) => obj.pk) + ]; setFilters(filterSpec); setBounds(bounds); } } - function showTooltip(schoolInfo, flyThere) { + function showTooltip(schoolInfo: AugmentedInfo) { if (!hasLngLat(schoolInfo)) { return; } let html = `
-
@@ -109,32 +121,27 @@ export default function createMap(options) { if (schoolInfo.cityState) { html += `
${schoolInfo.cityState}`; } - tooltip.setLngLat(schoolInfo.lngLat); + tooltip.setLngLat(schoolInfo.lngLat as LngLatLike); tooltip.setHTML(html); loaded.then((map) => { tooltip.addTo(map); }); - if (flyThere) { - setBounds((new mapboxgl.LngLatBounds()).extend(schoolInfo.lngLat)); - } } - loaded.then( - (map) => { - map.on('load', () => map.loaded() && map.resize()); - map.on('mouseenter', 'os-schools', () => { - map.getCanvas().style.cursor = 'pointer'; - }); - map.on('mouseleave', 'os-schools', () => { - map.getCanvas().style.cursor = ''; - }); - map.on('click', (el) => { - if (!el.features && tooltip) { - tooltip.remove(); - } - }); - } - ); + loaded.then((map) => { + map.on('load', () => map.loaded() && map.resize()); + map.on('mouseenter', 'os-schools', () => { + map.getCanvas().style.cursor = 'pointer'; + }); + map.on('mouseleave', 'os-schools', () => { + map.getCanvas().style.cursor = ''; + }); + map.on('click', (el) => { + if (!el.features && tooltip) { + tooltip.remove(); + } + }); + }); return { loaded, diff --git a/src/app/pages/separatemap/map-context.js b/src/app/pages/separatemap/map-context.js deleted file mode 100644 index bd34427aa..000000000 --- a/src/app/pages/separatemap/map-context.js +++ /dev/null @@ -1,76 +0,0 @@ -import React from 'react'; -import buildContext from '~/components/jsx-helpers/build-context'; -import Map from './map-api'; -import {isMobileDisplay} from '~/helpers/device'; -import {queryById} from '~/models/query-schools'; - -function useMap(id) { - const mapZoom = isMobileDisplay() ? 2 : 3; - const map = React.useMemo( - () => new Map({ - container: id, - center: [-95.712891, 37.090240], - zoom: mapZoom, - pitchWithRotate: false, - dragRotate: false, - touchZoomRotate: false - }), - [mapZoom, id] - ); - - React.useEffect(() => { - const container = document.getElementById('mapd'); - const clickHandler = (event) => { - const delegateTarget = event.target.closest('.mapboxgl-popup-content .put-away'); - - if (delegateTarget) { - map.tooltip.remove(); - } - }; - - container.addEventListener('click', clickHandler); - return () => container.removeEventListener('click', clickHandler); - }, [map]); - - return map; -} - -function useSelectedSchool(map) { - const [selectedSchool, setSelectedSchool] = React.useState(); - const selectSchool = React.useCallback( - (id) => queryById(id).then( - (schoolInfo) => setSelectedSchool(schoolInfo) - ), - [] - ); - - React.useEffect( - () => map.loaded.then((glMap) => { - glMap.on( - 'click', - 'os-schools', - (el) => selectSchool(el.features[0].properties.id) - ); - }), - [map.loaded, selectSchool] - ); - - return selectedSchool; -} - -function useContextValue({id}) { - const map = useMap(id); - const selectedSchool = useSelectedSchool(map); - - return { - map, - selectedSchool - }; -} - -const {useContext, ContextProvider} = buildContext({useContextValue}); - -export { - useContext as default, - ContextProvider as MapContextProvider -}; diff --git a/src/app/pages/separatemap/map-context.tsx b/src/app/pages/separatemap/map-context.tsx new file mode 100644 index 000000000..5a7ac239d --- /dev/null +++ b/src/app/pages/separatemap/map-context.tsx @@ -0,0 +1,76 @@ +import React from 'react'; +import buildContext from '~/components/jsx-helpers/build-context'; +import createMap from './map-api'; +import {isMobileDisplay} from '~/helpers/device'; +import {AugmentedInfo, queryById} from '~/models/query-schools'; +import {assertNotNull} from '~/helpers/data'; + +function useMap(id: string) { + const mapZoom = isMobileDisplay() ? 2 : 3; + const map = React.useMemo( + () => + createMap({ + container: id, + center: [-95.712891, 37.09024], + zoom: mapZoom, + pitchWithRotate: false, + dragRotate: false, + touchZoomRotate: false + }), + [mapZoom, id] + ); + + React.useEffect(() => { + const container = assertNotNull(document.getElementById('mapd')); + const clickHandler = (event: MouseEvent) => { + const delegateTarget = (event.target as Element).closest( + '.mapboxgl-popup-content .put-away' + ); + + if (delegateTarget) { + map.tooltip.remove(); + } + }; + + container.addEventListener('click', clickHandler); + return () => container.removeEventListener('click', clickHandler); + }, [map]); + + return map; +} + +type Map = ReturnType; + +function useSelectedSchool(map: Map) { + const [selectedSchool, setSelectedSchool] = + React.useState(null); + const selectSchool = React.useCallback( + (id: string) => + queryById(id).then((schoolInfo) => setSelectedSchool(schoolInfo)), + [] + ); + + React.useEffect(() => { + map.loaded.then((glMap) => { + glMap.on('click', 'os-schools', (el) => + selectSchool(el.features?.[0].properties?.id) + ); + }); + }, [map.loaded, selectSchool]); + + return selectedSchool; +} + +function useContextValue({id}: {id: string}) { + const map = useMap(id); + const selectedSchool = useSelectedSchool(map); + + return { + map, + selectedSchool + }; +} + +const {useContext, ContextProvider} = buildContext({useContextValue}); + +export {useContext as default, ContextProvider as MapContextProvider}; diff --git a/src/app/pages/separatemap/search-box/filters/filters.js b/src/app/pages/separatemap/search-box/filters/filters.js deleted file mode 100644 index 8053a9617..000000000 --- a/src/app/pages/separatemap/search-box/filters/filters.js +++ /dev/null @@ -1,69 +0,0 @@ -import React from 'react'; -import FormSelect from '~/components/form-select/form-select'; -import './filters.scss'; - -function InstitutionSelector({setInstitution}) { - const options = [ - {label: 'Any', value: '', selected: true}, - {label: 'College/University', value: 'College/University'}, - {label: 'Technical/Community College', value: 'Technical/Community College'}, - {label: 'High School', value: 'High School'} - ]; - const onChange = React.useCallback( - (event) => setInstitution(event.target.value), - [setInstitution] - ); - - return ( - - ); -} - -function ForCheckbox({name, label, selected}) { - const onChange = React.useCallback( - (event) => { - const {checked} = event.target; - - if (checked) { - selected.add(name); - } else { - selected.delete(name); - } - }, - [selected, name] - ); - - return ( - - ); -} - -export default function Filters({selected, setInstitution}) { - return ( -
-
- -
- - - -
- ); -} diff --git a/src/app/pages/separatemap/search-box/filters/filters.tsx b/src/app/pages/separatemap/search-box/filters/filters.tsx new file mode 100644 index 000000000..bb18d5d0f --- /dev/null +++ b/src/app/pages/separatemap/search-box/filters/filters.tsx @@ -0,0 +1,90 @@ +import React, {ChangeEvent} from 'react'; +import FormSelect from '~/components/form-select/form-select'; +import type {SetHandle} from '~/helpers/data'; +import './filters.scss'; + +const options = [ + {label: 'Any', value: '', selected: true}, + {label: 'College/University', value: 'College/University'}, + { + label: 'Technical/Community College', + value: 'Technical/Community College' + }, + {label: 'High School', value: 'High School'} +]; + +function InstitutionSelector({ + setInstitution +}: { + setInstitution: React.Dispatch>; +}) { + return ( + + ); +} + +function ForCheckbox({ + name, + label, + selected +}: { + name: string; + label: string; + selected: SetHandle; +}) { + const onChange = React.useCallback( + (event: ChangeEvent) => { + const {checked} = event.target as HTMLInputElement; + + if (checked) { + selected.add(name); + } else { + selected.delete(name); + } + }, + [selected, name] + ); + + return ( + + ); +} + +export default function Filters({ + selected, + setInstitution +}: { + selected: SetHandle; + setInstitution: React.Dispatch>; +}) { + return ( +
+
+ +
+ + + +
+ ); +} diff --git a/src/app/pages/separatemap/search-box/inputs/inputs.js b/src/app/pages/separatemap/search-box/inputs/inputs.js deleted file mode 100644 index b1d4305e6..000000000 --- a/src/app/pages/separatemap/search-box/inputs/inputs.js +++ /dev/null @@ -1,109 +0,0 @@ -import React from 'react'; -import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; -import {faSlidersH} from '@fortawesome/free-solid-svg-icons/faSlidersH'; -import {faTimes} from '@fortawesome/free-solid-svg-icons/faTimes'; -import {faSearch} from '@fortawesome/free-solid-svg-icons/faSearch'; -import {faChevronLeft} from '@fortawesome/free-solid-svg-icons/faChevronLeft'; -import cn from 'classnames'; -import './inputs.scss'; - -function FilterToggleButton({filtersHidden, toggleFilters, children}) { - return ( - - ); -} - -function SearchAndClear({ - placeholder='Search by country, state, city, or institution name', - textValue='', - setTextValue -}) { - const clearIconHiddenFlag = textValue.length < 1; - - function updateOnEnter(event) { - if (event.key === 'Enter') { - setTextValue(event.target.value); - } - } - - return ( -
- -
- ); -} - -function SearchIcon({minimized, toggle}) { - const icon = minimized ? faSearch : faChevronLeft; - - return ( -
toggle()} - > - -
- ); -} - -function InputView({ - className, placeholder, children, - minimized, toggle, searchValue, setSearchValue, filtersHidden, toggleFilters -}) { - return ( -
- - - - {children} - -
- ); -} - -export default function Inputs({ - toggle, minimized, filtersHidden, toggleFilters, searchValue, setSearchValue -}) { - const commonViewProps = { - minimized, toggle, searchValue, setSearchValue, - filtersHidden, toggleFilters - }; - - return ( -
- - Filter by - - -
- ); -} diff --git a/src/app/pages/separatemap/search-box/inputs/inputs.scss b/src/app/pages/separatemap/search-box/inputs/inputs.scss index 3f6318f6a..e28b7be60 100644 --- a/src/app/pages/separatemap/search-box/inputs/inputs.scss +++ b/src/app/pages/separatemap/search-box/inputs/inputs.scss @@ -47,7 +47,10 @@ } .search-clear { - cursor: pointer; + appearance: none; + border: 0; + box-shadow: none; + padding: 0; &[hidden] { display: none; diff --git a/src/app/pages/separatemap/search-box/inputs/inputs.tsx b/src/app/pages/separatemap/search-box/inputs/inputs.tsx new file mode 100644 index 000000000..431767d70 --- /dev/null +++ b/src/app/pages/separatemap/search-box/inputs/inputs.tsx @@ -0,0 +1,152 @@ +import React from 'react'; +import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; +import {faSlidersH} from '@fortawesome/free-solid-svg-icons/faSlidersH'; +import {faTimes} from '@fortawesome/free-solid-svg-icons/faTimes'; +import {faSearch} from '@fortawesome/free-solid-svg-icons/faSearch'; +import {faChevronLeft} from '@fortawesome/free-solid-svg-icons/faChevronLeft'; +import cn from 'classnames'; +import './inputs.scss'; + +type CommonViewProps = { + toggle: () => void; + minimized: boolean; + filtersHidden: boolean; + toggleFilters: () => void; + searchValue: string; + setSearchValue: (s: string) => void; +}; + +function FilterToggleButton({ + filtersHidden, + toggleFilters, + children +}: React.PropsWithChildren< + Pick +>) { + return ( + + ); +} + +function SearchAndClear({ + placeholder, + textValue, + setTextValue +}: { + placeholder: string; + textValue: string; + setTextValue: (s: string) => void; +}) { + const clearIconHiddenFlag = textValue.length < 1; + + function updateOnEnter({ + key, + target + }: React.KeyboardEvent) { + if (key === 'Enter') { + setTextValue((target as HTMLInputElement).value); + } + } + + return ( +
+ + +
+ ); +} + +function SearchIcon({ + minimized, + toggle +}: Pick) { + const icon = minimized ? faSearch : faChevronLeft; + + return ( +
toggle()} + aria-label="toggle search window" + > + +
+ ); +} + +function InputView({ + className, + placeholder, + children, + toggle, + minimized, + filtersHidden, + toggleFilters, + searchValue, + setSearchValue +}: React.PropsWithChildren< + { + className: string; + placeholder: string; + } & CommonViewProps +>) { + return ( +
+ + + + {children} + +
+ ); +} + +export default function Inputs(commonViewProps: CommonViewProps) { + return ( +
+ + Filter by + + +
+ ); +} diff --git a/src/app/pages/separatemap/search-box/result-box/result-box.js b/src/app/pages/separatemap/search-box/result-box/result-box.tsx similarity index 60% rename from src/app/pages/separatemap/search-box/result-box/result-box.js rename to src/app/pages/separatemap/search-box/result-box/result-box.tsx index 93cdbc709..a845f06cb 100644 --- a/src/app/pages/separatemap/search-box/result-box/result-box.js +++ b/src/app/pages/separatemap/search-box/result-box/result-box.tsx @@ -3,6 +3,7 @@ import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; import {faChevronUp} from '@fortawesome/free-solid-svg-icons/faChevronUp'; import {faChevronDown} from '@fortawesome/free-solid-svg-icons/faChevronDown'; import {faQuoteLeft} from '@fortawesome/free-solid-svg-icons/faQuoteLeft'; +import {AugmentedInfo} from '~/models/query-schools'; import './result-box.scss'; const format = new window.Intl.NumberFormat('en-US', { @@ -10,28 +11,32 @@ const format = new window.Intl.NumberFormat('en-US', { currency: 'USD' }).format; -function Testimonial({testimonial}) { +function Testimonial({ + testimonial +}: { + testimonial: Required['testimonial']; +}) { return (
- + {testimonial.text}
{testimonial.name}
- { - testimonial.position && -
{testimonial.position}
- } + {testimonial.position && ( +
{testimonial.position}
+ )}
); } -function SchoolDetails({model}) { - const [ - savingsTotal, savingsThisYear, testimonial - ] = [ +function SchoolDetails({model}: {model: AugmentedInfo}) { + const [savingsTotal, savingsThisYear, testimonial] = [ format(model.fields.all_time_savings), format(model.fields.current_year_savings), model.testimonial @@ -49,9 +54,16 @@ function SchoolDetails({model}) { ); } -export default function ResultBox({model, theOpenOne, setTheOpenOne}) { - const ref = useRef(); - const [name, location] = [model.fields.name, model.cityState]; +export default function ResultBox({ + model, + theOpenOne, + setTheOpenOne +}: { + model: AugmentedInfo; + theOpenOne: AugmentedInfo | null; + setTheOpenOne: (m: AugmentedInfo | null) => void; +}) { + const ref = useRef(null); const isOpen = theOpenOne === model; function toggle() { @@ -60,22 +72,26 @@ export default function ResultBox({model, theOpenOne, setTheOpenOne}) { useLayoutEffect(() => { if (isOpen) { - ref.current.scrollIntoView({block: 'nearest', behavior: 'smooth'}); + ref.current?.scrollIntoView({block: 'nearest', behavior: 'smooth'}); } }); return (
-

{name}

-
{location}
+

{model.fields.name}

+
{model.cityState}
- +
{isOpen && } diff --git a/src/app/pages/separatemap/search-box/results.js b/src/app/pages/separatemap/search-box/results.js deleted file mode 100644 index e067209a4..000000000 --- a/src/app/pages/separatemap/search-box/results.js +++ /dev/null @@ -1,42 +0,0 @@ -import {useState, useEffect} from 'react'; -import querySchools from '~/models/query-schools'; -import {useDataFromPromise} from '~/helpers/page-data-utils'; - -function useSchoolsPromise(searchString, filters, institution) { - const [promise, setPromise] = useState(); - - useEffect(() => { - const filtersArray = Array.from(filters.values()); - const filtersDict = filtersArray.reduce((a, b) => { - a[b] = b; - return a; - }, {}); - - filtersDict['institution-type'] = institution; - const p = querySchools(searchString, filtersDict); - - if (p) { - setPromise(p); - } - }, [searchString, filters, institution]); - - return promise; -} - -// eslint-disable-next-line complexity -export default function useResults(searchString, selectedFilters, institution) { - const nothingSelected = searchString === '' && - Array.from(selectedFilters.values()).length === 0; - const schoolsPromise = useSchoolsPromise(searchString, selectedFilters, institution); - const results = useDataFromPromise(schoolsPromise); - let message = null; - - if (results && !nothingSelected) { - if (results.TOO_MANY) { - message = 'Too many matching results'; - } else if (results.length === 0) { - message = 'No matching results'; - } - } - return [results || [], message]; -} diff --git a/src/app/pages/separatemap/search-box/results.tsx b/src/app/pages/separatemap/search-box/results.tsx new file mode 100644 index 000000000..4cb8a4862 --- /dev/null +++ b/src/app/pages/separatemap/search-box/results.tsx @@ -0,0 +1,61 @@ +import {useState, useEffect} from 'react'; +import querySchools, {AugmentedInfo} from '~/models/query-schools'; +import {useDataFromPromise} from '~/helpers/page-data-utils'; +import type {SetHandle} from '~/helpers/data'; + +type Results = {TOO_MANY: unknown} | AugmentedInfo[]; + +function useSchoolsPromise( + searchString: string, + filters: SetHandle, + institution: string +) { + const [promise, setPromise] = useState | null>(null); + + useEffect(() => { + const filtersArray = Array.from(filters.values()) as string[]; + const filtersDict = filtersArray.reduce( + (a, b) => { + a[b] = b; + return a; + }, + {} as Record + ); + + filtersDict['institution-type'] = institution; + const p = querySchools(searchString, filtersDict); + + if (p) { + setPromise(p); + } + }, [searchString, filters, institution]); + + return promise; +} + +// eslint-disable-next-line complexity +export default function useResults( + searchString: string, + selectedFilters: SetHandle, + institution: string +) { + const nothingSelected = + searchString === '' && + Array.from(selectedFilters.values()).length === 0; + const schoolsPromise = useSchoolsPromise( + searchString, + selectedFilters, + institution + ); + const results = useDataFromPromise(schoolsPromise); + let message = null; + + if (results && !nothingSelected) { + if ('TOO_MANY' in results) { + message = 'Too many matching results'; + } else if (results.length === 0) { + message = 'No matching results'; + } + } + return [results || [], message] as [Results, string]; +} diff --git a/src/app/pages/separatemap/search-box/search-box.js b/src/app/pages/separatemap/search-box/search-box.js deleted file mode 100644 index 270e4aef1..000000000 --- a/src/app/pages/separatemap/search-box/search-box.js +++ /dev/null @@ -1,97 +0,0 @@ -import React, {useState, useEffect} from 'react'; -import cn from 'classnames'; -import {useToggle, useSet} from '~/helpers/data'; -import Inputs from './inputs/inputs'; -import Filters from './filters/filters'; -import ResultBox from './result-box/result-box'; -import useResults from './results'; -import useMapContext from '../map-context'; -import './search-box.scss'; - -/* -When you select school, see if it is in results. -If so, setTheOpenOne to it and scroll to it. -Otherwise, put it in as the sole element of the list -*/ - -function useTheOpenOne({results}) { - const [theOpenOne, setTheOpenOne] = useState(); - const {map, selectedSchool} = useMapContext(); - - useEffect(() => { - if (selectedSchool) { - setTheOpenOne(results.find((r) => r.pk === selectedSchool.pk)); - map.showTooltip(selectedSchool); - } else { - setTheOpenOne(null); - } - }, [map, selectedSchool, results]); - - useEffect(() => { - setTheOpenOne(results.length === 1 ? results[0] : null); - }, [results]); - - useEffect( - () => { - if (theOpenOne) { - map.showPoints([theOpenOne]); - map.showTooltip(theOpenOne); - } else if (results.length > 0) { - map.showPoints(results); - } - }, - [map, theOpenOne, results] - ); - - return [theOpenOne, setTheOpenOne]; -} - -function SearchResults({minimized, results=[]}) { - const {selectedSchool} = useMapContext(); - const showSelectedSchool = Boolean(results.length < 1 && selectedSchool); - const resultsOrSchool = React.useMemo( - () => showSelectedSchool ? [selectedSchool] : results, - [showSelectedSchool, selectedSchool, results] - ); - const resultsHidden = resultsOrSchool.length < 1; - const [theOpenOne, setTheOpenOne] = useTheOpenOne({results}); - - return ( - - ); -} - -export default function SearchBox({minimized, toggle}) { - const [searchValue, setSearchValue] = useState(''); - const [filtersHidden, toggleFilters] = useToggle(true); - const selectedFilters = useSet(); - const [institution, setInstitution] = useState(''); - const [results, searchMessage] = useResults(searchValue, selectedFilters, institution); - - return ( -
-
- -
- { - searchMessage && - - } - - -
- ); -} diff --git a/src/app/pages/separatemap/search-box/search-box.tsx b/src/app/pages/separatemap/search-box/search-box.tsx new file mode 100644 index 000000000..128991e75 --- /dev/null +++ b/src/app/pages/separatemap/search-box/search-box.tsx @@ -0,0 +1,133 @@ +import React, {useState, useEffect} from 'react'; +import cn from 'classnames'; +import {useToggle, useSet} from '~/helpers/data'; +import Inputs from './inputs/inputs'; +import Filters from './filters/filters'; +import ResultBox from './result-box/result-box'; +import useResults from './results'; +import useMapContext from '../map-context'; +import './search-box.scss'; +import {AugmentedInfo} from '~/models/query-schools'; + +/* +When you select school, see if it is in results. +If so, setTheOpenOne to it and scroll to it. +Otherwise, put it in as the sole element of the list +*/ + +function useTheOpenOne({results}: {results: AugmentedInfo[]}) { + const [theOpenOne, setTheOpenOne] = useState(null); + const {map, selectedSchool} = useMapContext(); + + useEffect(() => { + if (selectedSchool) { + setTheOpenOne( + results.find((r) => r.pk === selectedSchool.pk) ?? null + ); + map.showTooltip(selectedSchool); + } else { + setTheOpenOne(null); + } + }, [map, selectedSchool, results]); + + useEffect(() => { + setTheOpenOne(results.length === 1 ? results[0] : null); + }, [results]); + + useEffect(() => { + if (theOpenOne) { + map.showPoints([theOpenOne]); + map.showTooltip(theOpenOne); + } else if (results.length > 0) { + map.showPoints(results); + } + }, [map, theOpenOne, results]); + + return [theOpenOne, setTheOpenOne] as const; +} + +function SearchResults({ + minimized, + results +}: { + minimized: boolean; + results: AugmentedInfo[]; +}) { + const {selectedSchool} = useMapContext(); + const showSelectedSchool = Boolean(results.length < 1 && selectedSchool); + const resultsOrSchool = React.useMemo( + () => + showSelectedSchool ? [selectedSchool as AugmentedInfo] : results, + [showSelectedSchool, selectedSchool, results] + ); + const resultsHidden = resultsOrSchool.length < 1; + const [theOpenOne, setTheOpenOne] = useTheOpenOne({results}); + + return ( + + ); +} + +export default function SearchBox({ + minimized, + toggle +}: { + minimized: boolean; + toggle: () => void; +}) { + const [searchValue, setSearchValue] = useState(''); + const [filtersHidden, toggleFilters] = useToggle(true); + const selectedFilters = useSet(); + const [institution, setInstitution] = useState(''); + const [results, searchMessage] = useResults( + searchValue, + selectedFilters, + institution + ); + + return ( +
+
+ +
+ {searchMessage && ( + + )} + + {results instanceof Array && ( + + )} +
+ ); +} diff --git a/src/app/pages/separatemap/separatemap.js b/src/app/pages/separatemap/separatemap.tsx similarity index 58% rename from src/app/pages/separatemap/separatemap.js rename to src/app/pages/separatemap/separatemap.tsx index 0b4e8dd2b..1c0b6533f 100644 --- a/src/app/pages/separatemap/separatemap.js +++ b/src/app/pages/separatemap/separatemap.tsx @@ -11,9 +11,7 @@ import './separatemap.scss'; function GoBackControl() { return ( - + Close map
@@ -35,24 +33,30 @@ function SearchBoxDiv() { function PopupMessage() { const [popupVisible, togglePopup] = useToggle(true); - return popupVisible && ( -
-
- togglePopup()} - /> -
-
Not seeing your school? Numbers aren't right?
-
-
- Since our books are free, we rely on instructors to - tell us they’re using our books. + return ( + popupVisible && ( +
+
+ togglePopup()} + /> +
+
Not seeing your school? Numbers aren't right?
+ +
+ Since our books are free, we rely on instructors to tell us + they’re using our books. +
-
+ ) ); } @@ -60,7 +64,8 @@ export default function SeparateMap() { useMainModal(); useDocumentHead({ title: 'Institution Map - OpenStax', - description: 'Searchable map of institutions that have adopted OpenStax textbooks' + description: + 'Searchable map of institutions that have adopted OpenStax textbooks' }); return ( diff --git a/test/src/pages/separatemap/filters.test.js b/test/src/pages/separatemap/filters.test.js deleted file mode 100644 index 280652961..000000000 --- a/test/src/pages/separatemap/filters.test.js +++ /dev/null @@ -1,18 +0,0 @@ -import React, {useState} from 'react'; -import {render, screen} from '@testing-library/preact'; -import Filters from '~/pages/separatemap/search-box/filters/filters'; -import {useSet} from '~/helpers/data'; - -// I need the hooks -function WrappedFilters() { - const selectedFilters = useSet(); - const [institution, setInstitution] = useState(''); - - return ( - - ); -} -it('creates', () => { - render(); - expect(screen.getAllByRole('checkbox')).toHaveLength(3); -}); diff --git a/test/src/pages/separatemap/map-api.test.ts b/test/src/pages/separatemap/map-api.test.ts new file mode 100644 index 000000000..98ac50248 --- /dev/null +++ b/test/src/pages/separatemap/map-api.test.ts @@ -0,0 +1,122 @@ +import createMap from '~/pages/separatemap/map-api'; +import {waitFor} from '@testing-library/preact'; +import type {AugmentedInfo} from '~/models/query-schools'; + +const triggers: Record void> = {}; +let mockLoaded: jest.Mock; +let mockResize: jest.Mock; +let mockSetFilter: jest.Mock; + +jest.mock('~/models/mapbox', () => () => console.info('*** LOADED?')); +jest.mock('mapbox-gl', () => { + const functionWithThings = () => ({}); + const mockMap = { + on: (...args: unknown[]) => { + const fn = args.pop() as () => void; + + (args as string[]).forEach((t) => { + triggers[t] = fn; + }); + }, + loaded: (mockLoaded = jest.fn().mockReturnValue(true)), + resize: (mockResize = jest.fn()), + getCanvas: () => ({style: {}}), + setFilter: (mockSetFilter = jest.fn()), + easeTo: jest.fn(), + fitBounds: jest.fn() + }; + + functionWithThings.Map = jest.fn().mockReturnValue(mockMap); + functionWithThings.Popup = jest.fn().mockReturnValue({ + remove: jest.fn() + }); + functionWithThings.LngLatBounds = jest.fn().mockReturnValue({ + extend: jest.fn().mockReturnValue([0, 10]) + }); + + return { + __esModule: true, + default: functionWithThings + }; +}); + +describe('map-api', () => { + it('sets up event handlers', async () => { + const container = document.createElement('div'); + const {tooltip} = createMap({container}); + + await waitFor(() => expect(triggers.load).toBeTruthy()); + triggers.load(); + expect(mockLoaded).toHaveBeenCalled(); + expect(mockResize).toHaveBeenCalled(); + triggers.mouseenter(); + triggers.mouseleave(); + triggers.click({}); + expect(tooltip.remove).toHaveBeenCalled(); + (tooltip.remove as jest.Mock).mockReset(); + triggers.click({features: true}); + expect(tooltip.remove).not.toHaveBeenCalled(); + }); + it('shows tooltip for school', async () => { + const container = document.createElement('div'); + const {loaded, tooltip, showTooltip} = createMap({container}); + const schoolInfo = { + lngLat: [1, 2], + fields: { + name: 'Test University' + }, + cityState: 'Smallville, KS' + } as unknown as AugmentedInfo; + + await loaded; + tooltip.setLngLat = jest.fn(); + tooltip.setHTML = jest.fn(); + tooltip.addTo = jest.fn(); + showTooltip(schoolInfo); + expect(tooltip.setLngLat).toHaveBeenCalledWith(schoolInfo.lngLat); + expect(tooltip.setHTML).toHaveBeenCalledWith( + expect.stringContaining(schoolInfo.cityState) + ); + // Handles empty cityState + schoolInfo.cityState = ''; + showTooltip(schoolInfo); + expect(tooltip.setHTML).toHaveBeenCalledWith( + expect.not.stringMatching('
') + ); + // Does not display if no lngLat + jest.clearAllMocks(); + delete schoolInfo.lngLat; + showTooltip(schoolInfo); + expect(tooltip.setLngLat).not.toHaveBeenCalled(); + expect(tooltip.setHTML).not.toHaveBeenCalled(); + }); + it('shows points of schools', async () => { + const container = document.createElement('div'); + const {loaded, showPoints} = createMap({container}); + const schoolInfo = { + lngLat: [1, 2], + fields: { + name: 'Test University' + }, + cityState: 'Smallville, KS' + } as unknown as AugmentedInfo; + + await loaded; + // Handles empty list (lnglat of 0, 0 gets filtered out) + showPoints([{...schoolInfo, lngLat: [0, 0]}]); + await waitFor(() => + expect(mockSetFilter).toHaveBeenCalledWith('os-schools', undefined) + ); + mockSetFilter.mockClear(); + // Non-empty + showPoints([schoolInfo]); + await waitFor(() => + expect(mockSetFilter).toHaveBeenCalledWith('os-schools', [ + 'in', + 'id', + undefined + ]) + ); + mockSetFilter.mockClear(); + }); +}); diff --git a/test/src/pages/separatemap/result-box.test.js b/test/src/pages/separatemap/result-box.test.js deleted file mode 100644 index 89945c644..000000000 --- a/test/src/pages/separatemap/result-box.test.js +++ /dev/null @@ -1,40 +0,0 @@ -import React from 'react'; -import {render, screen} from '@testing-library/preact'; -import userEvent from '@testing-library/user-event'; -import ResultBox from '~/pages/separatemap/search-box/result-box/result-box'; - -const model = { - cityState: "Pomfret, Maryland", - fields: { - salesforce_id: "0016f00002alpsuAAA", - name: "Maurice J. McDonough High School", - phone: "(301) 934-2944", - website: "https://www.ccboe.com/schools/mcdonough/", - type: "High School" - }, - institutionType: "High School", - institutionalPartner: false, - lngLat: (2) [-77.034, 38.555], - pk: 656595, - testimonial: { - text: 'Good stuff', - name: 'Some Body', - position: 'Chief Example' - } -}; -function Wrapper() { - const [theOpenOne, setTheOpenOne] = React.useState(); - - return ( - - ); -} - -it('opens on click of toggle', async () => { - const user = userEvent.setup(); - - render(); - expect(screen.queryAllByText('savings', {exact: false})).toHaveLength(0); - await user.click(screen.getByRole('switch')); - expect(screen.queryAllByText('savings', {exact: false})).toHaveLength(1); -}); diff --git a/test/src/pages/separatemap/separatemap.test.tsx b/test/src/pages/separatemap/separatemap.test.tsx new file mode 100644 index 000000000..615a9ee10 --- /dev/null +++ b/test/src/pages/separatemap/separatemap.test.tsx @@ -0,0 +1,202 @@ +import React from 'react'; +import {render, screen, waitFor} from '@testing-library/preact'; +import userEvent from '@testing-library/user-event'; +import SeparateMap from '~/pages/separatemap/separatemap'; +import * as DH from '~/helpers/use-document-head'; +import * as MD from '~/helpers/device'; + +const mockMapbox = jest.fn(); +const mockQuerySchools = jest.fn(); +const mockQueryById = jest.fn(); +let mockOn: jest.Mock; +let mockRemove: jest.Mock; + +jest.spyOn(DH, 'default').mockReturnValue(); +const mockIsMobileDisplay = jest + .spyOn(MD, 'isMobileDisplay') + .mockReturnValue(false); + +jest.mock('~/models/mapbox', () => () => mockMapbox()); +jest.mock('mapbox-gl', () => { + const functionWithThings = () => ({}); + + functionWithThings.Map = jest.fn().mockReturnValue({ + on: (mockOn = jest.fn()), + setFilter: jest.fn(), + fitBounds: jest.fn() + }); + functionWithThings.Popup = jest.fn().mockReturnValue({ + remove: (mockRemove = jest.fn()), + setLngLat: jest.fn(), + setHTML: jest.fn(), + addTo: jest.fn() + }); + functionWithThings.LngLatBounds = jest.fn().mockReturnValue({ + extend: jest.fn().mockReturnValue([0, 10]) + }); + + return { + __esModule: true, + default: functionWithThings + }; +}); +jest.mock('~/models/query-schools', () => ({ + __esModule: true, + default: () => mockQuerySchools(), + queryById: () => mockQueryById() +})); +jest.mock('~/helpers/main-class-hooks', () => ({ + __esModule: true, + useMainModal: jest.fn() +})); + +const aSchoolData = { + cityState: 'Pomfret, Maryland', + fields: { + // eslint-disable-next-line camelcase + salesforce_id: '0016f00002alpsuAAA', + name: 'Maurice J. McDonough High School', + phone: '(301) 934-2944', + website: 'https://www.ccboe.com/schools/mcdonough/', + type: 'High School' + }, + institutionType: 'High School', + institutionalPartner: false, + lngLat: [-77.034, 38.555], + pk: '656595', + testimonial: { + text: 'Good stuff', + name: 'Some Body', + position: 'Chief Example' + } +}; + +describe('separatemap', () => { + const user = userEvent.setup(); + + mockMapbox.mockResolvedValue({ + name: 'mock-schools', + style: 'mapbox-style' + }); + mockQuerySchools.mockResolvedValue({TOO_MANY: true}); + + it('renders; popup dismisses', async () => { + render(); + await user.click( + await screen.findByRole('button', {name: 'close popup'}) + ); + expect(screen.queryByRole('button', {name: 'close popup'})).toBeNull(); + }); + it('handles school click', async () => { + mockOn.mockImplementation( + (eType: string, what: string, fn: (el: object) => void) => { + if (eType === 'click' && what === 'os-schools') { + fn({ + features: [ + { + properties: { + id: 1 + } + } + ] + }); + } + } + ); + mockQueryById.mockResolvedValue(null); + // cover mapZoom branch + mockIsMobileDisplay.mockReturnValue(true); + render(); + await waitFor(() => expect(mockQueryById).toHaveBeenCalled()); + + const mapd = document.getElementById('mapd') as HTMLElement; + + mapd.innerHTML = `
+
+ +
+
`; + + await user.click(screen.getByRole('button', {name: 'Test put-away'})); + expect(mockRemove).toHaveBeenCalled(); + mockRemove.mockClear(); + await user.click(mapd); + expect(mockRemove).not.toHaveBeenCalled(); + }); + it('interacts with search box', async () => { + mockQuerySchools.mockResolvedValue([aSchoolData]); + + render(); + const inputs = await screen.findAllByRole('textbox'); + + await user.type(inputs[0], 'Test{enter}'); + await user.click(screen.getByRole('switch')); + }); + it('handles too many search results', async () => { + mockQuerySchools.mockResolvedValue({TOO_MANY: true}); + + render(); + const inputs = await screen.findAllByRole('textbox'); + + await user.type(inputs[0], 'Test{enter}'); + expect(screen.queryByRole('switch')).toBeNull(); + }); + it('handles filter checkboxes; school query returns no promise', async () => { + mockQueryById.mockClear(); + mockQuerySchools.mockReturnValue(null); + render(); + await screen.findAllByRole('textbox'); + await user.click(screen.getByRole('button', {name: 'Filter by'})); + await user.click(screen.getAllByRole('checkbox')[0]); + expect(mockQueryById).toHaveBeenCalledTimes(1); + // Exercises checkbox unselect code in filters.tsx + await user.click(screen.getAllByRole('checkbox')[0]); + // Exercise institution selector + const select = screen.getByRole('combobox'); + + expect(select.textContent).toBe('Any'); + await user.click(select); + await user.click(screen.getByRole('option', {name: 'High School'})); + expect(select.textContent).toBe('High School'); + }); + it('handles school query returning no schools', async () => { + mockQuerySchools.mockResolvedValue([]); + mockQueryById.mockResolvedValue(null); + render(); + const inputs = await screen.findAllByRole('textbox'); + + await user.type(inputs[0], 'Test{enter}'); + expect(screen.queryByRole('switch')).toBeNull(); + }); + it('handles school match and when all schools are filtered out', async () => { + mockQuerySchools.mockResolvedValue([aSchoolData]); + mockQueryById.mockResolvedValue(aSchoolData); + render(); + const inputs = await screen.findAllByRole('textbox'); + + await user.type(inputs[0], 'Test{enter}'); + mockQuerySchools.mockResolvedValue([]); + // select/open/fly-to + await user.click(screen.getByRole('switch')); + await user.type(inputs[0], 'Oops{enter}'); + // unselect/close/fly-out-from + await user.click(screen.getByRole('switch')); + await user.click( + screen.getAllByRole('button', {name: 'clear search'})[0] + ); + // Clear search button goes away when search is cleared + expect(screen.queryByRole('button', {name: 'clear search'})).toBeNull(); + }); + it('toggles search window open/closed', async () => { + render(); + const toggle = screen.getAllByRole('button', { + name: 'toggle search window' + })[0]; + + expect(toggle.getAttribute('aria-pressed')).toBe('false'); + await user.click(toggle); + expect(toggle.getAttribute('aria-pressed')).toBe('true'); + await user.click(toggle); + expect(toggle.getAttribute('aria-pressed')).toBe('false'); + }); +});