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

🐶 Implement the forum search results popup #1309

Merged
merged 11 commits into from
Sep 2, 2021
4 changes: 4 additions & 0 deletions packages/ui/src/app/GlobalModals.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React from 'react'

import { MoveFundsModal, MoveFundsModalCall } from '@/accounts/modals/MoveFoundsModal'
import { TransferModal, TransferModalCall } from '@/accounts/modals/TransferModal'
import { SearchResultsModal, SearchResultsModalCall } from '@/common/components/Search/SearchResultsModal'
import { useModal } from '@/common/hooks/useModal'
import { ModalName } from '@/common/providers/modal/types'
import { CreateThreadModal, CreateThreadModalCall } from '@/forum/modals/CreateThreadModal'
Expand Down Expand Up @@ -39,6 +40,7 @@ export type ModalNames =
| ModalName<EditPostModalCall>
| ModalName<PostHistoryModalCall>
| ModalName<EditThreadTitleModalCall>
| ModalName<SearchResultsModalCall>

export const GlobalModals = () => {
const { modal } = useModal()
Expand Down Expand Up @@ -78,6 +80,8 @@ export const GlobalModals = () => {
return <PostHistoryModal />
case 'EditThreadTitleModal':
return <EditThreadTitleModal />
case 'SearchResults':
return <SearchResultsModal />
default:
return null
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { Meta, Story } from '@storybook/react'
import React from 'react'

import { HighlightedText } from './HighlightedText'

export default {
title: 'Common/Search/HighlightedText',
component: HighlightedText,
} as Meta

interface Props {
word: string
shorten: boolean
text: string
}
const Template: Story<Props> = ({ word, shorten, text }) => (
<HighlightedText pattern={word ? RegExp(word, 'ig') : null} shorten={shorten}>
{text}
</HighlightedText>
)

export const Default = Template.bind({})
Default.args = {
word: 'council',
shorten: true,
text:
'The council has a fixed number of seats NUMBER_OF_COUNCIL_SEATS occupied by members, called councilors. The seats are always occupied, allowing the platform to dispose of all proposals they may come in at any time. The council body has two high level states described as follows.',
}
85 changes: 85 additions & 0 deletions packages/ui/src/common/components/Search/HighlightedText.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import React, { memo, ReactElement } from 'react'
import styled from 'styled-components'

import { Colors } from '@/common/constants'
import { isString } from '@/common/utils'

type Node = ReactElement | string

interface HighlightedTextProps {
pattern?: RegExp | null
shorten?: boolean
children: string
}
export const HighlightedText = memo(({ pattern = null, shorten, children }: HighlightedTextProps) => {
if (!pattern) {
return <>{children}</>
}

const nodes = [...children.matchAll(pattern)].reduceRight(
([node, ...nodes]: Node[], match, index): Node[] => {
if (!isString(node)) return [node, ...nodes]

const start = match.index ?? 0
const end = start + match[0].length
return [
node.slice(0, start),
<HighlightedWord key={index}>{node.slice(start, end)}</HighlightedWord>,
node.slice(end),
...nodes,
]
},
[children]
)

if (shorten) {
return (
<>
{nodes.map((node, index, { length }) => {
if (!isString(node) || node.length < 50) {
return node
} else if (index === 0) {
return `... ${getEnd(node)}`
} else if (index === length - 1) {
return `${getStart(node)} ...`
} else {
return `${getStart(node)} ... ${getEnd(node)}`
}
})}
</>
)
} else {
return <>{nodes}</>
}
})

const HighlightedWord = styled.span`
background-color: ${Colors.Black[200]};
color: ${Colors.Black[900]};
`

const getStart = (text: string, limit = 30) => {
const longest = text.slice(0, limit)
const index = Math.min(
limit,
...[
longest.indexOf(' ', longest.indexOf(' ', longest.indexOf(' ') + 1) + 1) - 1,
longest.indexOf('.'),
longest.indexOf(','),
].filter((index) => index >= 0)
)

return index < limit ? longest.slice(0, 1 + index) : longest
}

const getEnd = (text: string, limit = 30) => {
const longest = text.slice(-limit)
const index = Math.max(
0,
longest.lastIndexOf(' ', longest.lastIndexOf(' ', longest.lastIndexOf(' ') - 1) - 1),
longest.lastIndexOf('.') + 1,
longest.lastIndexOf(',') + 1
)

return index > 0 ? longest.slice(index - limit + 1) : longest
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import React, { memo } from 'react'
import styled from 'styled-components'

import { BreadcrumbsItem, BreadcrumbsItemLink } from '@/common/components/page/Sidebar/Breadcrumbs/BreadcrumbsItem'
import { BreadcrumbsListComponent } from '@/common/components/page/Sidebar/Breadcrumbs/BreadcrumbsList'
import { Colors, Fonts } from '@/common/constants'
import { ForumRoutes } from '@/forum/constant'
import { useForumMultiQueryCategoryBreadCrumbs } from '@/forum/hooks/useForumMultiQueryCategoryBreadCrumbs'

interface ForumPostResultBreadcrumbsProps {
id: string
}
export const ForumPostResultBreadcrumbs = memo(({ id }: ForumPostResultBreadcrumbsProps) => {
const { breadcrumbs } = useForumMultiQueryCategoryBreadCrumbs(id)

return (
<ResultBreadcrumbsList>
<BreadcrumbsItemLink to="/forum">Forum</BreadcrumbsItemLink>

{breadcrumbs.map(({ id, title }) => (
<BreadcrumbsItem key={id} url={`${ForumRoutes.category}/${id}`} isLink>
{title}
</BreadcrumbsItem>
))}
</ResultBreadcrumbsList>
)
})

const ResultBreadcrumbsList = styled(BreadcrumbsListComponent)`
color: ${Colors.Black[500]};

${BreadcrumbsItemLink} {
&,
&:visited {
color: ${Colors.Black[400]};
font-family: ${Fonts.Grotesk};
&:last-child {
color: ${Colors.Black[500]};
}
}
}
`
44 changes: 44 additions & 0 deletions packages/ui/src/common/components/Search/SearchResultItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import React, { ReactNode } from 'react'
import styled from 'styled-components'

import { GhostRouterLink } from '@/common/components/RouterLink'
import { Colors } from '@/common/constants'

import { HighlightedText } from './HighlightedText'

interface SearchResultItemProp {
pattern: RegExp | null
breadcrumbs: ReactNode
to: string
title: string
children: string
}
export const SearchResultItem = ({ pattern, breadcrumbs, to, title, children }: SearchResultItemProp) => (
<ResultItemStyle>
{breadcrumbs}
<GhostRouterLink to={to}>
<h5>
<HighlightedText pattern={pattern}>{title}</HighlightedText>
</h5>
<p>
<HighlightedText pattern={pattern} shorten>
{children}
</HighlightedText>
</p>
</GhostRouterLink>
</ResultItemStyle>
)

const ResultItemStyle = styled.div`
border-bottom: solid 1px ${Colors.Black[200]};
color: ${Colors.Black[400]};
padding-bottom: 14px;

h5 {
font-size: 24px;
padding: 8px 0;
}
p {
font-size: 16px;
}
`
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { Meta, Story } from '@storybook/react'
import React from 'react'
import { MemoryRouter } from 'react-router-dom'

import { ModalContext } from '@/common/providers/modal/context'
import { MockApolloProvider } from '@/mocks/components/storybook/MockApolloProvider'

import { SearchResultsModal } from './SearchResultsModal'

export default {
title: 'Common/Search/SearchResultsModal',
component: SearchResultsModal,
argTypes: {
hideModal: { action: 'hideModal' },
showModal: { action: 'showModal' },
},
} as Meta

interface Props {
search: string
hideModal: () => void
showModal: () => void
}
const Template: Story<Props> = ({ search, hideModal, showModal }) => {
const modalData = { search }
return (
<MockApolloProvider members workers workingGroups forum>
<MemoryRouter>
<ModalContext.Provider value={{ modalData, modal: null, hideModal, showModal }}>
<SearchResultsModal />
</ModalContext.Provider>
</MemoryRouter>
</MockApolloProvider>
)
}

export const Default = Template.bind({})
Default.args = {
search: 'dolor',
}
106 changes: 106 additions & 0 deletions packages/ui/src/common/components/Search/SearchResultsModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import React, { useMemo, useState } from 'react'
import styled from 'styled-components'

import { Close, CloseButton } from '@/common/components/buttons'
import { Input, InputComponent, InputIcon, InputText } from '@/common/components/forms'
import { SearchIcon } from '@/common/components/icons'
import { Loading } from '@/common/components/Loading'
import { RowGapBlock } from '@/common/components/page/PageContent'
import { SearchResultItem } from '@/common/components/Search/SearchResultItem'
import { SidePane, SidePaneBody, SidePaneGlass } from '@/common/components/SidePane'
import { Tabs } from '@/common/components/Tabs'
import { Fonts } from '@/common/constants'
import { useModal } from '@/common/hooks/useModal'
import { SearchKind, useSearch } from '@/common/hooks/useSearch'
import { ModalWithDataCall } from '@/common/providers/modal/types'
import { ForumRoutes } from '@/forum/constant'

import { ForumPostResultBreadcrumbs } from './SearchResultBreadcrumbs'

export type SearchResultsModalCall = ModalWithDataCall<'SearchResults', { search: string }>

export const SearchResultsModal = () => {
const { hideModal, modalData } = useModal<SearchResultsModalCall>()

const [search, setSearch] = useState(modalData.search)
const [activeTab, setActiveTab] = useState<SearchKind>('FORUM')
const { forum, isLoading } = useSearch(search, activeTab)
const pattern = useMemo(() => (search ? RegExp(search, 'ig') : null), [search])

return (
<SidePaneGlass onClick={(event) => event.target === event.currentTarget && hideModal()}>
<SearchResultsSidePane>
<SearchResultsHeader>
<CloseButton onClick={hideModal} />
<SearchInput>
<InputText placeholder="Search" value={search} onChange={(event) => setSearch(event.target.value)} />
</SearchInput>
</SearchResultsHeader>

<SidePaneBody>
<RowGapBlock gap={24}>
<Tabs
tabs={[{ title: 'Forum', active: activeTab === 'FORUM', onClick: () => setActiveTab('FORUM'), count: 4 }]}
tabsSize="xs"
/>

{isLoading ? (
<Loading />
) : activeTab === 'FORUM' ? (
forum.map(({ id, text, thread }, index) => (
<SearchResultItem
key={index}
pattern={pattern}
breadcrumbs={<ForumPostResultBreadcrumbs id={thread.categoryId} />}
to={`${ForumRoutes.thread}/${thread.id}?post=${id}`}
title={thread.title}
>
{text}
</SearchResultItem>
))
) : null}
</RowGapBlock>
</SidePaneBody>
</SearchResultsSidePane>
</SidePaneGlass>
)
}

const SearchResultsSidePane = styled(SidePane)`
grid-template-rows: 88px 1fr;

${SidePaneBody} {
padding: 24px;
}
`

const SearchResultsHeader = styled.div`
position: relative;
${Close} {
position: absolute;
top: 18px;
right: 24px;
z-index: 1;
}
`

const SearchInput = styled(InputComponent).attrs({
icon: <SearchIcon />,
borderless: true,
inputSize: 'auto',
})`
align-items: stretch;
height: 100%;

${InputIcon} {
left: 40px;
}

${Input} {
font-family: ${Fonts.Grotesk};
font-size: 24px;
font-weight: 700;
line-height: 32px;
padding: 0 40px 1px 72px;
}
`
2 changes: 1 addition & 1 deletion packages/ui/src/common/components/forms/InputComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,7 @@ const InputLabel = styled(Label)<DisabledInputProps>`
color: ${({ disabled }) => (disabled ? Colors.Black[500] : Colors.Black[900])};
`

const InputIcon = styled.div<DisabledInputProps>`
export const InputIcon = styled.div<DisabledInputProps>`
display: flex;
position: absolute;
width: 16px;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export const BreadcrumbsItem = React.memo(({ url, children, isLink }: Breadcrumb
)
})

const BreadcrumbsItemLink = styled(Link)`
export const BreadcrumbsItemLink = styled(Link)`
&,
&:visited {
color: ${Colors.Black[500]};
Expand Down
Loading