feat: add profile types filtering to new map

This commit is contained in:
Ben Furber
2024-09-04 10:54:31 +01:00
committed by benfurber
parent d551b2b0ff
commit 590f9e3dcc
19 changed files with 506 additions and 69 deletions

View File

@@ -0,0 +1,16 @@
import { CardButton } from './CardButton'
import type { Meta, StoryFn } from '@storybook/react'
export default {
title: 'Components/CardButton',
component: CardButton,
} as Meta<typeof CardButton>
export const Basic: StoryFn<typeof CardButton> = () => (
<div style={{ width: '300px' }}>
<CardButton>
<div style={{ padding: '20px' }}>Basic Implementation</div>
</CardButton>
</div>
)

View File

@@ -0,0 +1,43 @@
import { Card } from 'theme-ui'
import type { BoxProps, ThemeUIStyleObject } from 'theme-ui'
export interface IProps extends BoxProps {
children: React.ReactNode
extraSx?: ThemeUIStyleObject | undefined
}
export const CardButton = (props: IProps) => {
const { children, extraSx } = props
return (
<Card
sx={{
alignItems: 'center',
alignContent: 'center',
marginTop: '2px',
borderRadius: 2,
padding: 0,
transition: 'borderBottom 0.2s, transform 0.2s',
'&:hover': {
animationSpeed: '0.3s',
cursor: 'pointer',
marginTop: '0',
borderBottom: '4px solid',
transform: 'translateY(-2px)',
borderColor: 'black',
},
'&:active': {
transform: 'translateY(1px)',
borderBottom: '3px solid',
borderColor: 'grey',
transition: 'borderBottom 0.2s, transform 0.2s, borderColor 0.2s',
},
...extraSx,
}}
{...props}
>
{children}
</Card>
)
}

View File

@@ -30,9 +30,7 @@ export const CardList = (props: IProps) => {
const isListEmpty = displayItems.length === 0
const hasListLoaded = list
const results = `${displayItems.length} ${
displayItems.length == 1 ? 'result' : 'results'
}`
const results = `${displayItems.length} result${displayItems.length == 1 ? '' : 's'} in view`
return (
<Flex
@@ -40,6 +38,7 @@ export const CardList = (props: IProps) => {
sx={{
flexDirection: 'column',
gap: 2,
padding: 2,
}}
>
{!hasListLoaded && <Loader />}
@@ -49,6 +48,7 @@ export const CardList = (props: IProps) => {
sx={{
justifyContent: 'space-between',
paddingX: 2,
paddingTop: 2,
fontSize: 2,
}}
>

View File

@@ -1,5 +1,6 @@
import { Card, Flex } from 'theme-ui'
import { Flex } from 'theme-ui'
import { CardButton } from '../CardButton/CardButton'
import { InternalLink } from '../InternalLink/InternalLink'
import { CardDetailsFallback } from './CardDetailsFallback'
import { CardDetailsMemberProfile } from './CardDetailsMemberProfile'
@@ -26,30 +27,7 @@ export const CardListItem = ({ item }: IProps) => {
padding: 2,
}}
>
<Card
sx={{
marginTop: '2px',
borderRadius: 2,
padding: 0,
'&:hover': {
animationSpeed: '0.3s',
cursor: 'pointer',
marginTop: '0',
borderBottom: '4px solid',
transform: 'translateY(-2px)',
transition: 'borderBottom 0.2s, transform 0.2s',
borderColor: 'black',
},
'&:active': {
transform: 'translateY(1px)',
borderBottom: '3px solid',
borderColor: 'grey',
transition: 'borderBottom 0.2s, transform 0.2s, borderColor 0.2s',
},
alignItems: 'stretch',
alignContent: 'stretch',
}}
>
<CardButton>
<Flex sx={{ gap: 2, alignItems: 'stretch', alignContent: 'stretch' }}>
{isMember && <CardDetailsMemberProfile creator={creator} />}
@@ -59,7 +37,7 @@ export const CardListItem = ({ item }: IProps) => {
{!creator && <CardDetailsFallback item={item} />}
</Flex>
</Card>
</CardButton>
</InternalLink>
)
}

View File

@@ -86,7 +86,7 @@ export const DonationRequest = (props: IProps) => {
<Flex
sx={{
backgroundColor: 'offwhite',
backgroundColor: 'offWhite',
borderTop: '2px solid',
flexDirection: ['column', 'row'],
padding: 2,

View File

@@ -0,0 +1,107 @@
import { useState } from 'react'
import { FilterList } from './FilterList'
import type { Meta, StoryFn } from '@storybook/react'
import type { ProfileTypeName } from 'oa-shared'
export default {
title: 'Components/FilterList',
component: FilterList,
} as Meta<typeof FilterList>
const availableFilters = [
{
label: 'Workspace',
type: 'workspace' as ProfileTypeName,
},
{
label: 'Machine Builder',
type: 'machine-builder' as ProfileTypeName,
},
{
label: 'Collection Point',
type: 'collection-point' as ProfileTypeName,
},
{
label: 'Want to get started',
type: 'member' as ProfileTypeName,
},
]
export const Basic: StoryFn<typeof FilterList> = () => {
const [activeFilters, setActiveFilters] = useState<string[]>([])
const onFilterChange = (label: string) => {
const filter = label.toLowerCase()
const isFilterPresent = !!activeFilters.find(
(existing) => existing === filter,
)
if (isFilterPresent) {
return setActiveFilters(activeFilters.filter((f) => f !== filter))
}
return setActiveFilters((existing) => [...existing, filter])
}
return (
<div style={{ maxWidth: '500px' }}>
<FilterList
activeFilters={activeFilters}
availableFilters={availableFilters}
onFilterChange={onFilterChange}
/>
</div>
)
}
export const OnlyOne: StoryFn<typeof FilterList> = () => {
const [activeFilters, setActiveFilters] = useState<string[]>([])
const onFilterChange = (label: string) => {
const filter = label.toLowerCase()
const isFilterPresent = !!activeFilters.find(
(existing) => existing === filter,
)
if (isFilterPresent) {
return setActiveFilters(activeFilters.filter((f) => f !== filter))
}
return setActiveFilters((existing) => [...existing, filter])
}
return (
<div style={{ maxWidth: '500px' }}>
<FilterList
activeFilters={activeFilters}
availableFilters={[availableFilters[0]]}
onFilterChange={onFilterChange}
/>
(Shouldn't see anything, only renders for two or more)
</div>
)
}
export const OnlyTwo: StoryFn<typeof FilterList> = () => {
const [activeFilters, setActiveFilters] = useState<string[]>([])
const onFilterChange = (label: string) => {
const filter = label.toLowerCase()
const isFilterPresent = !!activeFilters.find(
(existing) => existing === filter,
)
if (isFilterPresent) {
return setActiveFilters(activeFilters.filter((f) => f !== filter))
}
return setActiveFilters((existing) => [...existing, filter])
}
return (
<div style={{ maxWidth: '500px' }}>
<FilterList
activeFilters={activeFilters}
availableFilters={[availableFilters[0], availableFilters[1]]}
onFilterChange={onFilterChange}
/>
(No buttons rendered)
</div>
)
}

View File

@@ -0,0 +1,157 @@
import { useEffect, useRef, useState } from 'react'
import { Flex, Text } from 'theme-ui'
import { Button } from '../Button/Button'
import { CardButton } from '../CardButton/CardButton'
import { MemberBadge } from '../MemberBadge/MemberBadge'
import type { ProfileTypeName } from 'oa-shared'
type FilterOption = {
label: string
type: ProfileTypeName
}
export interface IProps {
activeFilters: string[]
availableFilters: FilterOption[]
onFilterChange: (label: string) => void
}
export const FilterList = (props: IProps) => {
const elementRef = useRef<HTMLDivElement>(null)
const [disableLeftArrow, setDisableLeftArrow] = useState<boolean>(true)
const [disableRightArrow, setDisableRightArrow] = useState<boolean>(false)
const { activeFilters, availableFilters, onFilterChange } = props
if (!availableFilters || availableFilters.length < 2) {
return null
}
const handleHorizantalScroll = (step: number) => {
const distance = 121
const element = elementRef.current
const speed = 10
let scrollAmount = 0
const slideTimer = setInterval(() => {
if (element) {
element.scrollLeft += step
scrollAmount += Math.abs(step)
if (scrollAmount >= distance) {
clearInterval(slideTimer)
}
const { scrollLeft, scrollWidth, clientWidth } = element
switch (scrollLeft + clientWidth) {
case clientWidth:
setDisableLeftArrow(true)
// scrollWidth && setDisableRightArrow(true)
break
case scrollWidth:
setDisableRightArrow(true)
break
default:
setDisableLeftArrow(false)
setDisableRightArrow(false)
break
}
}
}, speed)
}
useEffect(() => {
handleHorizantalScroll(0)
}, [])
return (
<Flex
sx={{
flexDirection: 'column',
position: 'relative',
}}
>
<Flex
as="ul"
data-cy="FilterList"
ref={elementRef}
sx={{
listStyle: 'none',
flexWrap: 'nowrap',
overflow: 'hidden',
paddingY: 2,
paddingX: 4,
gap: 2,
zIndex: 1,
top: '-80px',
height: 'auto',
}}
>
{availableFilters.map(({ label, type }, index) => {
const active = activeFilters.find((filter) => filter === type)
return (
<CardButton
as="li"
data-cy={`MapListFilter${active ? '-active' : ''}`}
key={index}
onClick={() => onFilterChange(type)}
extraSx={{
backgroundColor: 'offWhite',
padding: 1,
textAlign: 'center',
width: '130px',
minWidth: '130px',
height: '75px',
flexDirection: 'column',
...(active
? {
borderColor: 'green',
':hover': { borderColor: 'green' },
}
: {
borderColor: 'offWhite',
':hover': { borderColor: 'offWhite' },
}),
}}
>
<MemberBadge size={30} profileType={type} />
<br />
<Text variant="quiet" sx={{ fontSize: 1 }}>
{label}
</Text>
</CardButton>
)
})}
</Flex>
{availableFilters.length > 3 && (
<Flex
sx={{
justifyContent: 'space-between',
zIndex: 2,
paddingX: 2,
position: 'relative',
top: '-65px',
height: 0,
}}
>
<Button
onClick={() => handleHorizantalScroll(-10)}
icon="chevron-left"
disabled={disableLeftArrow}
sx={{ borderRadius: 99 }}
showIconOnly
small
/>
<Button
onClick={() => handleHorizantalScroll(10)}
icon="chevron-right"
disabled={disableRightArrow}
sx={{ borderRadius: 99, zIndex: 2 }}
showIconOnly
small
/>
</Flex>
)}
</Flex>
)
}

View File

@@ -214,7 +214,7 @@ export const ImageGallery = (props: ImageGalleryProps) => {
height: 67,
objectFit: props.allowPortrait ? 'contain' : 'cover',
borderRadius: 1,
border: '1px solid offwhite',
border: '1px solid offWhite',
}}
crossOrigin=""
/>

View File

@@ -21,6 +21,10 @@ export const MemberBadge = (props: Props) => {
const profileType = props.profileType || 'member'
const badgeSize = size ? size : MINIMUM_SIZE
const title =
profileType.charAt(0).toUpperCase() +
profileType.slice(1).replace(/-/g, ' ')
return (
<Image
loading="lazy"
@@ -29,6 +33,7 @@ export const MemberBadge = (props: Props) => {
sx={{ width: badgeSize, borderRadius: '50%', ...sx }}
width={badgeSize}
height={badgeSize}
title={title}
src={
(badgeSize > MINIMUM_SIZE && !useLowDetailVersion
? theme.badges[profileType]?.normal

View File

@@ -4,6 +4,7 @@ export { BlockedRoute } from './BlockedRoute/BlockedRoute'
export { Breadcrumbs } from './Breadcrumbs/Breadcrumbs'
export { Button } from './Button/Button'
export { ButtonShowReplies } from './ButtonShowReplies/ButtonShowReplies'
export { CardButton } from './CardButton/CardButton'
export { CardList } from './CardList/CardList'
export { CardListItem } from './CardListItem/CardListItem'
export { Category } from './Category/Category'
@@ -29,6 +30,7 @@ export { FieldTextarea } from './FieldTextarea/FieldTextarea'
export { Guidelines } from './Guidelines/Guidelines'
export { FlagIcon, FlagIconHowTos, FlagIconEvents } from './FlagIcon/FlagIcon'
export { FollowButton } from './FollowButton/FollowButton'
export { FilterList } from './FilterList/FilterList'
export { GlobalStyles } from './GlobalStyles/GlobalStyles'
export { HeroBanner } from './HeroBanner/HeroBanner'
export { Icon } from './Icon/Icon'

View File

@@ -1,4 +1,5 @@
const userId = 'davehakkens'
const profileTypesCount = 5
describe('[Map]', () => {
it('[Shows expected pins]', () => {
@@ -26,7 +27,17 @@ describe('[Map]', () => {
cy.step('New map shows the cards')
cy.get('[data-cy="welome-header"]').should('be.visible')
cy.get('[data-cy="CardList-desktop"]').should('be.visible')
cy.get('[data-cy="list-results"]').contains('51 results')
cy.get('[data-cy="list-results"]').contains('51 results in view')
cy.step('Map filters can be used')
cy.get('[data-cy=FilterList]')
.first()
.children()
.should('have.length', profileTypesCount)
cy.get('[data-cy=MapListFilter]').first().click()
cy.get('[data-cy="list-results"]').contains('6 results in view')
cy.get('[data-cy=MapListFilter-active]').first().click()
cy.get('[data-cy="list-results"]').contains('51 results in view')
cy.step('As the user moves in the list updates')
for (let i = 0; i < 10; i++) {

View File

@@ -13,7 +13,7 @@ export const commonStyles = {
},
colors: {
white: 'white',
offwhite: '#ececec',
offWhite: '#ececec',
black: '#1b1b1b',
softyellow: '#f5ede2',
blue: '#83ceeb',

View File

@@ -87,7 +87,7 @@ export interface ThemeWithName {
background: string
silver: string
softgrey: string
offwhite: string
offWhite: string
lightgrey: string
darkGrey: string
subscribed: string

View File

@@ -61,7 +61,7 @@ export const ImageConverter = (props: IProps) => {
}}
sx={{
border: '1px solid ',
borderColor: 'offwhite',
borderColor: 'offWhite',
borderRadius: 1,
}}
id="preview"

View File

@@ -24,6 +24,7 @@ const asOptions = (mapPins, items: Array<IMapGrouping>): FilterGroupOption[] =>
: item.type.split(' ')
const value = item.subType ? item.subType : item.type
const profileType = transformSpecialistWorkspaceTypeToWorkspace(value)
return {
label: item.displayName,
@@ -33,10 +34,7 @@ const asOptions = (mapPins, items: Array<IMapGrouping>): FilterGroupOption[] =>
(item.type as string) === 'verified' ? (
<Image src={VerifiedBadgeIcon} width={ICON_SIZE} />
) : (
<MemberBadge
size={ICON_SIZE}
profileType={transformSpecialistWorkspaceTypeToWorkspace(value)}
/>
<MemberBadge size={ICON_SIZE} profileType={profileType} />
),
}
})

View File

@@ -1,9 +1,10 @@
import { useState } from 'react'
import { Button, CardList, Map } from 'oa-components'
import { Box, Flex, Heading } from 'theme-ui'
import { useEffect, useMemo, useState } from 'react'
import { Button, Map } from 'oa-components'
import { Box, Flex } from 'theme-ui'
import { Clusters } from './Cluster'
import { latLongFilter } from './latLongFilter'
import { MapWithListHeader } from './MapWithListHeader'
import { Popup } from './Popup'
import type { LatLngExpression } from 'leaflet'
@@ -11,6 +12,33 @@ import type { Map as MapType } from 'react-leaflet'
import type { ILatLng } from 'shared/models'
import type { IMapPin } from 'src/models/maps.models'
const allFilters = [
{
label: 'Workspace',
type: 'workspace',
},
{
label: 'Machine Builder',
type: 'machine-builder',
},
{
label: 'Community Point',
type: 'community-builder',
},
{
label: 'Collection Point',
type: 'collection-point',
},
{
label: 'Space',
type: 'space',
},
{
label: 'Want to get started',
type: 'member',
},
]
interface IProps {
activePin: IMapPin | null
center: ILatLng
@@ -18,14 +46,12 @@ interface IProps {
notification?: string
pins: IMapPin[]
zoom: number
onPinClicked: (pin: IMapPin) => void
onBlur: () => void
onPinClicked: (pin: IMapPin) => void
setZoom: (arg: number) => void
}
export const MapWithList = (props: IProps) => {
const [filteredPins, setFilteredPins] = useState<IMapPin[] | null>(null)
const [showMobileList, setShowMobileList] = useState<boolean>(false)
const {
activePin,
center,
@@ -38,15 +64,52 @@ export const MapWithList = (props: IProps) => {
setZoom,
} = props
const [activePinFilters, setActivePinFilters] = useState<string[]>([])
const [allPinsInView, setAllPinsInView] = useState<IMapPin[]>(pins)
const [filteredPins, setFilteredPins] = useState<IMapPin[] | null>(
pins || null,
)
const [showMobileList, setShowMobileList] = useState<boolean>(false)
const availableFilters = useMemo(() => {
const pinTypes = pins.map(({ creator }) => creator?.profileType)
const filtersNeeded = [...new Set(pinTypes)]
return allFilters.filter(({ type }) =>
filtersNeeded.some((filter) => filter === type),
)
}, [pins])
useEffect(() => {
if (activePinFilters.length === 0) {
return setFilteredPins(allPinsInView)
}
const filteredPins = allPinsInView.filter((pin) =>
activePinFilters.includes(pin.type),
)
setFilteredPins(filteredPins)
}, [activePinFilters, allPinsInView])
const handleLocationFilter = () => {
if (mapRef.current) {
const boundaries = mapRef.current.leafletElement.getBounds()
// Map.getBounds() is wrongly typed
const results = latLongFilter(boundaries as any, pins)
setFilteredPins(results)
setAllPinsInView(results)
}
}
const onFilterChange = (label: string) => {
const filter = label.toLowerCase()
const isFilterPresent = !!activePinFilters.find(
(pinFilter) => pinFilter === filter,
)
if (isFilterPresent) {
return setActivePinFilters((pins) => pins.filter((pin) => pin !== filter))
}
return setActivePinFilters((pins) => [...pins, filter])
}
const isViewportGreaterThanTablet = window.innerWidth > 1024
const mapCenter: LatLngExpression = center ? [center.lat, center.lng] : [0, 0]
const mapZoom = center ? zoom : 2
@@ -67,14 +130,16 @@ export const MapWithList = (props: IProps) => {
background: 'white',
flex: 1,
overflow: 'scroll',
padding: 2,
}}
>
<Heading data-cy="welome-header" sx={{ padding: 2 }}>
Welcome to our world!{' '}
{pins && `${pins.length} members (and counting...)`}
</Heading>
<CardList dataCy="desktop" list={pins} filteredList={filteredPins} />
<MapWithListHeader
pins={pins}
activePinFilters={activePinFilters}
availableFilters={availableFilters}
onFilterChange={onFilterChange}
filteredPins={filteredPins}
viewport="desktop"
/>
</Box>
{/* Mobile/tablet list view */}
@@ -84,7 +149,6 @@ export const MapWithList = (props: IProps) => {
background: 'white',
width: '100%',
overflow: 'scroll',
padding: 2,
}}
>
<Flex
@@ -99,26 +163,20 @@ export const MapWithList = (props: IProps) => {
<Button
data-cy="ShowMapButton"
icon="map"
sx={{ position: 'sticky' }}
sx={{ position: 'sticky', marginTop: 2 }}
onClick={() => setShowMobileList(false)}
small
>
Show map view
</Button>
</Flex>
<Heading
data-cy="welome-header"
variant="small"
sx={{ padding: 2, paddingTop: '50px' }}
>
Welcome to our world!{' '}
{pins && `${pins.length} members (and counting...)`}
</Heading>
<CardList
columnsCountBreakPoints={{ 300: 1, 600: 2 }}
dataCy="mobile"
list={pins}
filteredList={filteredPins}
<MapWithListHeader
pins={pins}
activePinFilters={activePinFilters}
availableFilters={availableFilters}
onFilterChange={onFilterChange}
filteredPins={filteredPins}
viewport="mobile"
/>
</Box>

View File

@@ -0,0 +1,62 @@
import { CardList, FilterList } from 'oa-components'
import { Flex, Heading } from 'theme-ui'
import type { IMapPin } from 'src/models'
interface IProps {
activePinFilters: string[]
availableFilters: any
filteredPins: IMapPin[] | null
onFilterChange: (label: string) => void
pins: IMapPin[]
viewport: 'desktop' | 'mobile'
}
export const MapWithListHeader = (props: IProps) => {
const {
activePinFilters,
availableFilters,
filteredPins,
onFilterChange,
pins,
viewport,
} = props
const isMobile = viewport === 'mobile'
return (
<>
<Flex
sx={{
flexDirection: 'column',
backgroundColor: 'background',
gap: 2,
paddingY: 2,
paddingTop: isMobile ? '50px' : 2,
}}
>
<Heading
data-cy="welome-header"
sx={{
paddingX: 4,
}}
variant={isMobile ? 'small' : 'heading'}
>
Welcome to our world!{' '}
{pins && `${pins.length} members (and counting...)`}
</Heading>
<FilterList
activeFilters={activePinFilters}
availableFilters={availableFilters}
onFilterChange={onFilterChange}
/>
</Flex>
<CardList
columnsCountBreakPoints={isMobile ? { 300: 1, 600: 2 } : undefined}
dataCy={viewport}
list={pins}
filteredList={filteredPins}
/>
</>
)
}

View File

@@ -153,7 +153,7 @@ const MapsPage = observer(() => {
return (
// the calculation for the height is kind of hacky for now, will set properly on final mockups
<Box id="mapPage" sx={{ height: 'calc(100vh - 150px)', width: '100%' }}>
<Box id="mapPage" sx={{ height: 'calc(100vh - 120px)', width: '100%' }}>
<NewMapBanner showNewMap={showNewMap} setShowNewMap={setShowNewMap} />
{!showNewMap && (
<>

View File

@@ -43,7 +43,7 @@ export const PatreonIntegration = ({ user }) => {
<Flex
sx={{
alignItems: ['flex-start', 'flex-start', 'flex-start'],
backgroundColor: 'offwhite',
backgroundColor: 'offWhite',
borderRadius: 3,
flexDirection: 'column',
justifyContent: 'space-between',