From bd289394abdbcc7b2c8f690b9f00d5b164608885 Mon Sep 17 00:00:00 2001 From: Theophile Sandoz Date: Wed, 1 Sep 2021 13:41:46 +0200 Subject: [PATCH 01/11] Implement the `` component --- .../Search/HighlightedText.stories.tsx | 19 ++++++++++ .../components/Search/HighlightedText.tsx | 36 +++++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 packages/ui/src/common/components/Search/HighlightedText.stories.tsx create mode 100644 packages/ui/src/common/components/Search/HighlightedText.tsx diff --git a/packages/ui/src/common/components/Search/HighlightedText.stories.tsx b/packages/ui/src/common/components/Search/HighlightedText.stories.tsx new file mode 100644 index 0000000000..5129dbceb7 --- /dev/null +++ b/packages/ui/src/common/components/Search/HighlightedText.stories.tsx @@ -0,0 +1,19 @@ +import { Meta, Story } from '@storybook/react' +import React from 'react' + +import { HighlightedText } from './HighlightedText' + +export default { + title: 'Common/Search/HighlightedText', + component: HighlightedText, +} as Meta + +const Template: Story<{ word: string; text: string }> = ({ word, text }) => ( + {text} +) + +export const Default = Template.bind({}) +Default.args = { + word: 'council', + text: '...the council has a fixed number of seats NUMBER_OF_COUNCIL_SEATS occupied by members,…', +} diff --git a/packages/ui/src/common/components/Search/HighlightedText.tsx b/packages/ui/src/common/components/Search/HighlightedText.tsx new file mode 100644 index 0000000000..64961c73ba --- /dev/null +++ b/packages/ui/src/common/components/Search/HighlightedText.tsx @@ -0,0 +1,36 @@ +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 + children: string +} +export const HighlightedText = memo(({ pattern, children }: HighlightedTextProps) => ( + <> + {[...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), + {node.slice(start, end)}, + node.slice(end), + ...nodes, + ] + }, + [children] + )} + +)) + +const HighlightedWord = styled.span` + background-color: ${Colors.Black[200]}; + color: ${Colors.Black[900]}; +` From 98a11083d4981eddd3b3aa7d5296b23fefef0ad0 Mon Sep 17 00:00:00 2001 From: Theophile Sandoz Date: Wed, 1 Sep 2021 13:51:21 +0200 Subject: [PATCH 02/11] Implement the search results breadcrumbs --- .../Search/SearchResultBreadcrumbs.tsx | 42 +++++++++++++ .../Sidebar/Breadcrumbs/BreadcrumbsItem.tsx | 2 +- .../useForumMultiQueryCategoryBreadCrumbs.ts | 35 +++++++++++ .../queries/__generated__/forum.generated.tsx | 61 +++++++++++++++++++ packages/ui/src/forum/queries/forum.graphql | 7 +++ 5 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 packages/ui/src/common/components/Search/SearchResultBreadcrumbs.tsx create mode 100644 packages/ui/src/forum/hooks/useForumMultiQueryCategoryBreadCrumbs.ts diff --git a/packages/ui/src/common/components/Search/SearchResultBreadcrumbs.tsx b/packages/ui/src/common/components/Search/SearchResultBreadcrumbs.tsx new file mode 100644 index 0000000000..90e4624210 --- /dev/null +++ b/packages/ui/src/common/components/Search/SearchResultBreadcrumbs.tsx @@ -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 ( + + Forum + + {breadcrumbs.map(({ id, title }) => ( + + {title} + + ))} + + ) +}) + +const ResultBreadcrumbsList = styled(BreadcrumbsListComponent)` + color: ${Colors.Black[500]}; + + ${BreadcrumbsItemLink} { + &, + &:visited { + color: ${Colors.Black[400]}; + font-family: ${Fonts.Grotesk}; + &:last-child { + color: ${Colors.Black[500]}; + } + } + } +` diff --git a/packages/ui/src/common/components/page/Sidebar/Breadcrumbs/BreadcrumbsItem.tsx b/packages/ui/src/common/components/page/Sidebar/Breadcrumbs/BreadcrumbsItem.tsx index 17e36b4fec..a6a673e88c 100644 --- a/packages/ui/src/common/components/page/Sidebar/Breadcrumbs/BreadcrumbsItem.tsx +++ b/packages/ui/src/common/components/page/Sidebar/Breadcrumbs/BreadcrumbsItem.tsx @@ -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]}; diff --git a/packages/ui/src/forum/hooks/useForumMultiQueryCategoryBreadCrumbs.ts b/packages/ui/src/forum/hooks/useForumMultiQueryCategoryBreadCrumbs.ts new file mode 100644 index 0000000000..9f47fa5eec --- /dev/null +++ b/packages/ui/src/forum/hooks/useForumMultiQueryCategoryBreadCrumbs.ts @@ -0,0 +1,35 @@ +import { useEffect, useMemo, useState } from 'react' + +import { useGetForumCategoryBreadcrumbLazyQuery } from '../queries' +import { asSubCategory, CategoryBreadcrumb } from '../types' + +// The goal of this hook is to utilize apollo cache to be faster than `useForumCategoryBreadcrumbs` +// when there are a lot of breadcrumbs to show, like on search results +export const useForumMultiQueryCategoryBreadCrumbs = (id: string) => { + const placeholder = useMemo(() => [{ id, title: `Category ${id}` }], [id]) + const [isLoading, setIsLoading] = useState(true) + const [breadcrumbs, setBreadcrumbs] = useState([]) + const [getCategory, { data }] = useGetForumCategoryBreadcrumbLazyQuery() + + useEffect(() => { + setBreadcrumbs([]) + getCategory({ variables: { where: { id } } }) + }, [id]) + + useEffect(() => { + const category = data?.forumCategoryByUniqueInput + if (!category) return + + if (category.parentId) { + getCategory({ variables: { where: { id: category.parentId } } }) + } else { + setIsLoading(false) + } + setBreadcrumbs([asSubCategory(category), ...breadcrumbs]) + }, [data]) + + return { + isLoading, + breadcrumbs: isLoading ? placeholder : breadcrumbs, + } +} diff --git a/packages/ui/src/forum/queries/__generated__/forum.generated.tsx b/packages/ui/src/forum/queries/__generated__/forum.generated.tsx index 7f464ebf96..c4f195c435 100644 --- a/packages/ui/src/forum/queries/__generated__/forum.generated.tsx +++ b/packages/ui/src/forum/queries/__generated__/forum.generated.tsx @@ -168,6 +168,17 @@ export type GetForumCategoryBreadcrumbsQuery = { forumCategoryByUniqueInput?: Types.Maybe<{ __typename: 'ForumCategory' } & ForumCategoryBreadcrumbsFieldsFragment> } +export type GetForumCategoryBreadcrumbQueryVariables = Types.Exact<{ + where: Types.ForumCategoryWhereUniqueInput +}> + +export type GetForumCategoryBreadcrumbQuery = { + __typename: 'Query' + forumCategoryByUniqueInput?: Types.Maybe< + { __typename: 'ForumCategory'; parentId?: Types.Maybe } & ForumSubCategoryFieldsFragment + > +} + export type GetForumThreadBreadcrumbsQueryVariables = Types.Exact<{ where: Types.ForumThreadWhereUniqueInput }> @@ -576,6 +587,56 @@ export type GetForumCategoryBreadcrumbsQueryResult = Apollo.QueryResult< GetForumCategoryBreadcrumbsQuery, GetForumCategoryBreadcrumbsQueryVariables > +export const GetForumCategoryBreadcrumbDocument = gql` + query GetForumCategoryBreadcrumb($where: ForumCategoryWhereUniqueInput!) { + forumCategoryByUniqueInput(where: $where) { + ...ForumSubCategoryFields + parentId + } + } + ${ForumSubCategoryFieldsFragmentDoc} +` + +/** + * __useGetForumCategoryBreadcrumbQuery__ + * + * To run a query within a React component, call `useGetForumCategoryBreadcrumbQuery` and pass it any options that fit your needs. + * When your component renders, `useGetForumCategoryBreadcrumbQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useGetForumCategoryBreadcrumbQuery({ + * variables: { + * where: // value for 'where' + * }, + * }); + */ +export function useGetForumCategoryBreadcrumbQuery( + baseOptions: Apollo.QueryHookOptions +) { + const options = { ...defaultOptions, ...baseOptions } + return Apollo.useQuery( + GetForumCategoryBreadcrumbDocument, + options + ) +} +export function useGetForumCategoryBreadcrumbLazyQuery( + baseOptions?: Apollo.LazyQueryHookOptions +) { + const options = { ...defaultOptions, ...baseOptions } + return Apollo.useLazyQuery( + GetForumCategoryBreadcrumbDocument, + options + ) +} +export type GetForumCategoryBreadcrumbQueryHookResult = ReturnType +export type GetForumCategoryBreadcrumbLazyQueryHookResult = ReturnType +export type GetForumCategoryBreadcrumbQueryResult = Apollo.QueryResult< + GetForumCategoryBreadcrumbQuery, + GetForumCategoryBreadcrumbQueryVariables +> export const GetForumThreadBreadcrumbsDocument = gql` query GetForumThreadBreadcrumbs($where: ForumThreadWhereUniqueInput!) { forumThreadByUniqueInput(where: $where) { diff --git a/packages/ui/src/forum/queries/forum.graphql b/packages/ui/src/forum/queries/forum.graphql index 39ebf54dcd..f2e6e0787e 100644 --- a/packages/ui/src/forum/queries/forum.graphql +++ b/packages/ui/src/forum/queries/forum.graphql @@ -156,6 +156,13 @@ query GetForumCategoryBreadcrumbs($where: ForumCategoryWhereUniqueInput!) { } } +query GetForumCategoryBreadcrumb($where: ForumCategoryWhereUniqueInput!) { + forumCategoryByUniqueInput(where: $where) { + ...ForumSubCategoryFields + parentId + } +} + query GetForumThreadBreadcrumbs($where: ForumThreadWhereUniqueInput!) { forumThreadByUniqueInput(where: $where) { ...ForumThreadBreadcrumbsFields From e774fa29b32fa70e4492007f7878cfc21004819d Mon Sep 17 00:00:00 2001 From: Theophile Sandoz Date: Wed, 1 Sep 2021 17:21:49 +0200 Subject: [PATCH 03/11] Search forum posts by text --- packages/ui/src/common/hooks/useSearch.ts | 59 +++++++++++++++++ .../queries/__generated__/forum.generated.tsx | 65 +++++++++++++++++++ packages/ui/src/forum/queries/forum.graphql | 12 ++++ 3 files changed, 136 insertions(+) create mode 100644 packages/ui/src/common/hooks/useSearch.ts diff --git a/packages/ui/src/common/hooks/useSearch.ts b/packages/ui/src/common/hooks/useSearch.ts new file mode 100644 index 0000000000..11922e7fdd --- /dev/null +++ b/packages/ui/src/common/hooks/useSearch.ts @@ -0,0 +1,59 @@ +import { useEffect, useMemo } from 'react' + +import { useSearchForumPostLazyQuery } from '@/forum/queries' + +import { useDebounce } from './useDebounce' + +export type SearchKind = 'FORUM' + +const MAX_RESULTS = 20 + +export const useSearch = (search: string, kind: SearchKind) => { + const [searchForum, postResult] = useSearchForumPostLazyQuery() + + const searchDebounced = useDebounce(search, 400) + + useEffect(() => { + if (searchDebounced.length > 2) + searchForum({ + variables: { + where: { + thread: { status_json: { isTypeOf_eq: 'ThreadStatusActive' } }, + text_contains: searchDebounced, + }, + limit: 500, + }, + }) + }, [searchDebounced, kind]) + + const [forum, isLoadingPosts] = useMemo(() => { + const posts = [...(postResult.data?.forumPosts ?? [])] + .sort(byBestMatch(searchDebounced, [({ thread }) => thread.title, ({ text }) => text])) + .slice(0, MAX_RESULTS) + return [posts, postResult.loading] + }, [postResult]) + + return { + forum, + isLoading: isLoadingPosts, + } +} + +const byBestMatch = >(search: string, fields: ((x: T) => string)[]) => { + const patterns = [RegExp(`\\b${search}\\b`, 'gi'), RegExp(search, 'gi')] + + return (a: T, b: T): number => { + for (const field of fields) { + const fieldA = field(a) + const fieldB = field(b) + if (fieldA === fieldB) continue + for (const pattern of patterns) { + const matchA = fieldA.match(pattern)?.length ?? 0 + const matchB = fieldB.match(pattern)?.length ?? 0 + + if (matchA !== matchB) return matchB - matchA + } + } + return 0 + } +} diff --git a/packages/ui/src/forum/queries/__generated__/forum.generated.tsx b/packages/ui/src/forum/queries/__generated__/forum.generated.tsx index c4f195c435..00f412c271 100644 --- a/packages/ui/src/forum/queries/__generated__/forum.generated.tsx +++ b/packages/ui/src/forum/queries/__generated__/forum.generated.tsx @@ -292,6 +292,23 @@ export type GetForumPostParentsQuery = { forumPostByUniqueInput?: Types.Maybe<{ __typename: 'ForumPost' } & ForumPostParentsFragment> } +export type SearchForumPostQueryVariables = Types.Exact<{ + where: Types.ForumPostWhereInput + orderBy?: Types.Maybe | Types.ForumPostOrderByInput> + offset?: Types.Maybe + limit?: Types.Maybe +}> + +export type SearchForumPostQuery = { + __typename: 'Query' + forumPosts: Array<{ + __typename: 'ForumPost' + id: string + text: string + thread: { __typename: 'ForumThread'; id: string; title: string; categoryId: string } + }> +} + export const ForumModeratorFieldsFragmentDoc = gql` fragment ForumModeratorFields on Worker { id @@ -1110,3 +1127,51 @@ export type GetForumPostParentsQueryResult = Apollo.QueryResult< GetForumPostParentsQuery, GetForumPostParentsQueryVariables > +export const SearchForumPostDocument = gql` + query SearchForumPost($where: ForumPostWhereInput!, $orderBy: [ForumPostOrderByInput!], $offset: Int, $limit: Int) { + forumPosts(where: $where, orderBy: $orderBy, offset: $offset, limit: $limit) { + id + text + thread { + id + title + categoryId + } + } + } +` + +/** + * __useSearchForumPostQuery__ + * + * To run a query within a React component, call `useSearchForumPostQuery` and pass it any options that fit your needs. + * When your component renders, `useSearchForumPostQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useSearchForumPostQuery({ + * variables: { + * where: // value for 'where' + * orderBy: // value for 'orderBy' + * offset: // value for 'offset' + * limit: // value for 'limit' + * }, + * }); + */ +export function useSearchForumPostQuery( + baseOptions: Apollo.QueryHookOptions +) { + const options = { ...defaultOptions, ...baseOptions } + return Apollo.useQuery(SearchForumPostDocument, options) +} +export function useSearchForumPostLazyQuery( + baseOptions?: Apollo.LazyQueryHookOptions +) { + const options = { ...defaultOptions, ...baseOptions } + return Apollo.useLazyQuery(SearchForumPostDocument, options) +} +export type SearchForumPostQueryHookResult = ReturnType +export type SearchForumPostLazyQueryHookResult = ReturnType +export type SearchForumPostQueryResult = Apollo.QueryResult diff --git a/packages/ui/src/forum/queries/forum.graphql b/packages/ui/src/forum/queries/forum.graphql index f2e6e0787e..a1ef9eb7cf 100644 --- a/packages/ui/src/forum/queries/forum.graphql +++ b/packages/ui/src/forum/queries/forum.graphql @@ -236,3 +236,15 @@ query GetForumPostParents($where: ForumPostWhereUniqueInput!) { ...ForumPostParents } } + +query SearchForumPost($where: ForumPostWhereInput!, $orderBy: [ForumPostOrderByInput!], $offset: Int, $limit: Int) { + forumPosts(where: $where, orderBy: $orderBy, offset: $offset, limit: $limit) { + id + text + thread { + id + title + categoryId + } + } +} From 10fdb9a80ad0e6050314cae9c32da56b6000df47 Mon Sep 17 00:00:00 2001 From: Theophile Sandoz Date: Wed, 1 Sep 2021 17:23:36 +0200 Subject: [PATCH 04/11] Make the highlight optional --- .../src/common/components/Search/HighlightedText.stories.tsx | 2 +- packages/ui/src/common/components/Search/HighlightedText.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/common/components/Search/HighlightedText.stories.tsx b/packages/ui/src/common/components/Search/HighlightedText.stories.tsx index 5129dbceb7..05b85dda2c 100644 --- a/packages/ui/src/common/components/Search/HighlightedText.stories.tsx +++ b/packages/ui/src/common/components/Search/HighlightedText.stories.tsx @@ -9,7 +9,7 @@ export default { } as Meta const Template: Story<{ word: string; text: string }> = ({ word, text }) => ( - {text} + {text} ) export const Default = Template.bind({}) diff --git a/packages/ui/src/common/components/Search/HighlightedText.tsx b/packages/ui/src/common/components/Search/HighlightedText.tsx index 64961c73ba..13bd54809f 100644 --- a/packages/ui/src/common/components/Search/HighlightedText.tsx +++ b/packages/ui/src/common/components/Search/HighlightedText.tsx @@ -7,12 +7,12 @@ import { isString } from '@/common/utils' type Node = ReactElement | string interface HighlightedTextProps { - pattern: RegExp + pattern: RegExp | null children: string } export const HighlightedText = memo(({ pattern, children }: HighlightedTextProps) => ( <> - {[...children.matchAll(pattern)].reduceRight( + {(pattern ? [...children.matchAll(pattern)] : []).reduceRight( ([node, ...nodes]: Node[], match, index): Node[] => { if (!isString(node)) return [node, ...nodes] From a0d2a8d121cde3f793266cb996e531f2f1914a93 Mon Sep 17 00:00:00 2001 From: Theophile Sandoz Date: Wed, 1 Sep 2021 17:25:10 +0200 Subject: [PATCH 05/11] Implement the `` component --- .../components/Search/SearchResultItem.tsx | 42 +++++++ .../Search/SearchResultsModal.stories.tsx | 40 +++++++ .../components/Search/SearchResultsModal.tsx | 106 ++++++++++++++++++ .../components/forms/InputComponent.tsx | 2 +- 4 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 packages/ui/src/common/components/Search/SearchResultItem.tsx create mode 100644 packages/ui/src/common/components/Search/SearchResultsModal.stories.tsx create mode 100644 packages/ui/src/common/components/Search/SearchResultsModal.tsx diff --git a/packages/ui/src/common/components/Search/SearchResultItem.tsx b/packages/ui/src/common/components/Search/SearchResultItem.tsx new file mode 100644 index 0000000000..812af6e9a5 --- /dev/null +++ b/packages/ui/src/common/components/Search/SearchResultItem.tsx @@ -0,0 +1,42 @@ +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) => ( + + {breadcrumbs} + +
+ {title} +
+

+ {children} +

+
+
+) + +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; + } +` diff --git a/packages/ui/src/common/components/Search/SearchResultsModal.stories.tsx b/packages/ui/src/common/components/Search/SearchResultsModal.stories.tsx new file mode 100644 index 0000000000..ad68eda858 --- /dev/null +++ b/packages/ui/src/common/components/Search/SearchResultsModal.stories.tsx @@ -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 = ({ search, hideModal, showModal }) => { + const modalData = { search } + return ( + + + + + + + + ) +} + +export const Default = Template.bind({}) +Default.args = { + search: 'dolor', +} diff --git a/packages/ui/src/common/components/Search/SearchResultsModal.tsx b/packages/ui/src/common/components/Search/SearchResultsModal.tsx new file mode 100644 index 0000000000..b585cb4a4e --- /dev/null +++ b/packages/ui/src/common/components/Search/SearchResultsModal.tsx @@ -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() + + const [search, setSearch] = useState(modalData.search) + const [activeTab, setActiveTab] = useState('FORUM') + const { forum, isLoading } = useSearch(search, activeTab) + const pattern = useMemo(() => (search ? RegExp(search, 'ig') : null), [search]) + + return ( + event.target === event.currentTarget && hideModal()}> + + + + + setSearch(event.target.value)} /> + + + + + + setActiveTab('FORUM'), count: 4 }]} + tabsSize="xs" + /> + + {isLoading ? ( + + ) : activeTab === 'FORUM' ? ( + forum.map(({ id, text, thread }, index) => ( + } + to={`${ForumRoutes.thread}/${thread.id}?post=${id}`} + title={thread.title} + > + {text} + + )) + ) : null} + + + + + ) +} + +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: , + 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; + } +` diff --git a/packages/ui/src/common/components/forms/InputComponent.tsx b/packages/ui/src/common/components/forms/InputComponent.tsx index 525eb902cc..0766796ab3 100644 --- a/packages/ui/src/common/components/forms/InputComponent.tsx +++ b/packages/ui/src/common/components/forms/InputComponent.tsx @@ -267,7 +267,7 @@ const InputLabel = styled(Label)` color: ${({ disabled }) => (disabled ? Colors.Black[500] : Colors.Black[900])}; ` -const InputIcon = styled.div` +export const InputIcon = styled.div` display: flex; position: absolute; width: 16px; From 01d186ffb0892c426a2ba640e472466f527bd98b Mon Sep 17 00:00:00 2001 From: Theophile Sandoz Date: Wed, 1 Sep 2021 19:02:26 +0200 Subject: [PATCH 06/11] Shorten the results text --- .../Search/HighlightedText.stories.tsx | 14 +++- .../components/Search/HighlightedText.tsx | 83 ++++++++++++++----- .../components/Search/SearchResultItem.tsx | 4 +- 3 files changed, 78 insertions(+), 23 deletions(-) diff --git a/packages/ui/src/common/components/Search/HighlightedText.stories.tsx b/packages/ui/src/common/components/Search/HighlightedText.stories.tsx index 05b85dda2c..dbf82fff09 100644 --- a/packages/ui/src/common/components/Search/HighlightedText.stories.tsx +++ b/packages/ui/src/common/components/Search/HighlightedText.stories.tsx @@ -8,12 +8,20 @@ export default { component: HighlightedText, } as Meta -const Template: Story<{ word: string; text: string }> = ({ word, text }) => ( - {text} +interface Props { + word: string + shorten: boolean + text: string +} +const Template: Story = ({ word, shorten, text }) => ( + + {text} + ) export const Default = Template.bind({}) Default.args = { word: 'council', - text: '...the council has a fixed number of seats NUMBER_OF_COUNCIL_SEATS occupied by members,…', + 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.', } diff --git a/packages/ui/src/common/components/Search/HighlightedText.tsx b/packages/ui/src/common/components/Search/HighlightedText.tsx index 13bd54809f..b7e56ed8af 100644 --- a/packages/ui/src/common/components/Search/HighlightedText.tsx +++ b/packages/ui/src/common/components/Search/HighlightedText.tsx @@ -8,29 +8,74 @@ type Node = ReactElement | string interface HighlightedTextProps { pattern: RegExp | null + shorten?: boolean children: string } -export const HighlightedText = memo(({ pattern, children }: HighlightedTextProps) => ( - <> - {(pattern ? [...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), - {node.slice(start, end)}, - node.slice(end), - ...nodes, - ] - }, - [children] - )} - -)) +export const HighlightedText = memo(({ pattern, shorten, children }: HighlightedTextProps) => { + const nodes = (pattern ? [...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), + {node.slice(start, end)}, + 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), + 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('.'), + longest.lastIndexOf(',') + ) + + return index > 0 ? longest.slice(index - limit + 1) : longest +} diff --git a/packages/ui/src/common/components/Search/SearchResultItem.tsx b/packages/ui/src/common/components/Search/SearchResultItem.tsx index 812af6e9a5..09554d8490 100644 --- a/packages/ui/src/common/components/Search/SearchResultItem.tsx +++ b/packages/ui/src/common/components/Search/SearchResultItem.tsx @@ -21,7 +21,9 @@ export const SearchResultItem = ({ pattern, breadcrumbs, to, title, children }: {title}

- {children} + + {children} +

From 57c915cd53ad3135c353ae9aae797e664d57d196 Mon Sep 17 00:00:00 2001 From: Theophile Sandoz Date: Wed, 1 Sep 2021 19:14:18 +0200 Subject: [PATCH 07/11] Add `` to the app --- packages/ui/src/app/GlobalModals.tsx | 4 ++++ packages/ui/src/forum/components/ForumPageHeader.tsx | 9 ++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/app/GlobalModals.tsx b/packages/ui/src/app/GlobalModals.tsx index 8088283c4c..0be3706214 100644 --- a/packages/ui/src/app/GlobalModals.tsx +++ b/packages/ui/src/app/GlobalModals.tsx @@ -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' @@ -39,6 +40,7 @@ export type ModalNames = | ModalName | ModalName | ModalName + | ModalName export const GlobalModals = () => { const { modal } = useModal() @@ -78,6 +80,8 @@ export const GlobalModals = () => { return case 'EditThreadTitleModal': return + case 'SearchResults': + return default: return null } diff --git a/packages/ui/src/forum/components/ForumPageHeader.tsx b/packages/ui/src/forum/components/ForumPageHeader.tsx index 46292ed7d0..c6508a4e8b 100644 --- a/packages/ui/src/forum/components/ForumPageHeader.tsx +++ b/packages/ui/src/forum/components/ForumPageHeader.tsx @@ -3,6 +3,7 @@ import React, { useState } from 'react' import { PageHeaderRow, PageHeaderWrapper } from '@/app/components/PageLayout' import { ButtonsGroup } from '@/common/components/buttons' import { SearchBox } from '@/common/components/forms/FilterBox/FilterSearchBox' +import { useModal } from '@/common/hooks/useModal' interface ForumPageHeaderProps { title: React.ReactNode @@ -12,12 +13,18 @@ interface ForumPageHeaderProps { export const ForumPageHeader = ({ title, children, buttons }: ForumPageHeaderProps) => { const [search, setSearch] = useState('') + const { showModal } = useModal() + return ( {title} - + showModal({ modal: 'SearchResults', data: { search } })} + /> {buttons} From a05904d95d12ee8daec866b86e3e7bdd2a62cacb Mon Sep 17 00:00:00 2001 From: Theophile Sandoz Date: Wed, 1 Sep 2021 19:25:48 +0200 Subject: [PATCH 08/11] Fix linting --- .../src/common/components/Search/HighlightedText.stories.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/common/components/Search/HighlightedText.stories.tsx b/packages/ui/src/common/components/Search/HighlightedText.stories.tsx index dbf82fff09..753ed29528 100644 --- a/packages/ui/src/common/components/Search/HighlightedText.stories.tsx +++ b/packages/ui/src/common/components/Search/HighlightedText.stories.tsx @@ -23,5 +23,6 @@ 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.', + 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.', } From 01429293a9564699f9ce0ff87b1319d2576c265e Mon Sep 17 00:00:00 2001 From: Theophile Sandoz Date: Thu, 2 Sep 2021 17:15:01 +0200 Subject: [PATCH 09/11] Clarify the `` no pattern case --- .../ui/src/common/components/Search/HighlightedText.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/common/components/Search/HighlightedText.tsx b/packages/ui/src/common/components/Search/HighlightedText.tsx index b7e56ed8af..27ba29d050 100644 --- a/packages/ui/src/common/components/Search/HighlightedText.tsx +++ b/packages/ui/src/common/components/Search/HighlightedText.tsx @@ -12,7 +12,11 @@ interface HighlightedTextProps { children: string } export const HighlightedText = memo(({ pattern, shorten, children }: HighlightedTextProps) => { - const nodes = (pattern ? [...children.matchAll(pattern)] : []).reduceRight( + if (!pattern) { + return <>{children} + } + + const nodes = [...children.matchAll(pattern)].reduceRight( ([node, ...nodes]: Node[], match, index): Node[] => { if (!isString(node)) return [node, ...nodes] From 211ddbf2e48fe770ca7897576b5aa2c4012d0346 Mon Sep 17 00:00:00 2001 From: Theophile Sandoz Date: Thu, 2 Sep 2021 18:24:32 +0200 Subject: [PATCH 10/11] Trim the shortened text extra spaces --- .../ui/src/common/components/Search/HighlightedText.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/common/components/Search/HighlightedText.tsx b/packages/ui/src/common/components/Search/HighlightedText.tsx index 27ba29d050..ba6ebe500d 100644 --- a/packages/ui/src/common/components/Search/HighlightedText.tsx +++ b/packages/ui/src/common/components/Search/HighlightedText.tsx @@ -63,7 +63,7 @@ const getStart = (text: string, limit = 30) => { const index = Math.min( limit, ...[ - longest.indexOf(' ', longest.indexOf(' ', longest.indexOf(' ') + 1) + 1), + longest.indexOf(' ', longest.indexOf(' ', longest.indexOf(' ') + 1) + 1) - 1, longest.indexOf('.'), longest.indexOf(','), ].filter((index) => index >= 0) @@ -77,8 +77,8 @@ const getEnd = (text: string, limit = 30) => { const index = Math.max( 0, longest.lastIndexOf(' ', longest.lastIndexOf(' ', longest.lastIndexOf(' ') - 1) - 1), - longest.lastIndexOf('.'), - longest.lastIndexOf(',') + longest.lastIndexOf('.') + 1, + longest.lastIndexOf(',') + 1 ) return index > 0 ? longest.slice(index - limit + 1) : longest From 8888bb0f53f7c276879e518e9490a5e88a983d9e Mon Sep 17 00:00:00 2001 From: Theophile Sandoz Date: Thu, 2 Sep 2021 18:26:17 +0200 Subject: [PATCH 11/11] Add a test for `` --- .../components/Search/HighlightedText.tsx | 4 +- .../components/HighlightedText.test.tsx | 42 +++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) create mode 100644 packages/ui/test/common/components/HighlightedText.test.tsx diff --git a/packages/ui/src/common/components/Search/HighlightedText.tsx b/packages/ui/src/common/components/Search/HighlightedText.tsx index ba6ebe500d..526c34d7e6 100644 --- a/packages/ui/src/common/components/Search/HighlightedText.tsx +++ b/packages/ui/src/common/components/Search/HighlightedText.tsx @@ -7,11 +7,11 @@ import { isString } from '@/common/utils' type Node = ReactElement | string interface HighlightedTextProps { - pattern: RegExp | null + pattern?: RegExp | null shorten?: boolean children: string } -export const HighlightedText = memo(({ pattern, shorten, children }: HighlightedTextProps) => { +export const HighlightedText = memo(({ pattern = null, shorten, children }: HighlightedTextProps) => { if (!pattern) { return <>{children} } diff --git a/packages/ui/test/common/components/HighlightedText.test.tsx b/packages/ui/test/common/components/HighlightedText.test.tsx new file mode 100644 index 0000000000..d02ec460bb --- /dev/null +++ b/packages/ui/test/common/components/HighlightedText.test.tsx @@ -0,0 +1,42 @@ +import { render } from '@testing-library/react' +import React from 'react' + +import { HighlightedText } from '@/common/components/Search/HighlightedText' + +const 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.' + +describe('UI: HighlightedText', () => { + it('No pattern', async () => { + const { container } = render({TEXT}) + + expect(container.innerHTML).toBe(TEXT) + }) + + it('Pattern', async () => { + const pattern = /council/gi + const { container } = render({TEXT}) + + const highlighted = Array.from(container.children) + + expect(container.textContent).toBe(TEXT) + expect(highlighted).toHaveLength(4) + highlighted.forEach((element) => { + expect(element.tagName).toBe('SPAN') + expect(element.innerHTML).toMatch(/^council$/i) + }) + }) + + it('Shorten', async () => { + const pattern = /council/gi + const { container } = render( + + {TEXT} + + ) + + expect(container.textContent).toBe( + 'The council has a fixed number of seats NUMBER_OF_COUNCIL_SEATS occupied by members, called councilors. ... The council body has ...' + ) + }) +})