feat(frontend): add search in cheatsheet view

Signed-off-by: Philip Molares <philip.molares@udo.edu>
This commit is contained in:
Philip Molares 2023-06-24 15:14:49 +02:00
parent 1624ff9c3b
commit 81927b88f2
5 changed files with 149 additions and 9 deletions

View file

@ -645,6 +645,7 @@
}, },
"cheatsheet": { "cheatsheet": {
"button": "Open Cheatsheet", "button": "Open Cheatsheet",
"search": "Search for Cheatsheets",
"modal": { "modal": {
"popup": "Open in new tab", "popup": "Open in new tab",
"title": "Cheatsheet", "title": "Cheatsheet",

View file

@ -68,6 +68,12 @@ export const CategoryAccordion: React.FC<GroupAccordionProps> = ({ extensions, s
</Accordion.Item> </Accordion.Item>
)) ))
}, [groupEntries, onStateChange, selectedEntry]) }, [groupEntries, onStateChange, selectedEntry])
const defaultActiveKey = useMemo(() => {
return <Accordion defaultActiveKey={groupEntries[0][0]}>{elements}</Accordion> if (groupEntries.length === 0) {
return ''
}
return groupEntries[0][0]
}, [groupEntries])
return <Accordion defaultActiveKey={defaultActiveKey}>{elements}</Accordion>
} }

View file

@ -3,13 +3,14 @@
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { allAppExtensions } from '../../../../extensions/all-app-extensions'
import type { CheatsheetEntry, CheatsheetExtension } from '../../cheatsheet/cheatsheet-extension' import type { CheatsheetEntry, CheatsheetExtension } from '../../cheatsheet/cheatsheet-extension'
import { isCheatsheetGroup } from '../../cheatsheet/cheatsheet-extension' import { isCheatsheetGroup } from '../../cheatsheet/cheatsheet-extension'
import { CategoryAccordion } from './category-accordion' import { CategoryAccordion } from './category-accordion'
import { CheatsheetEntryPane } from './cheatsheet-entry-pane' import { CheatsheetEntryPane } from './cheatsheet-entry-pane'
import { CheatsheetSearch } from './cheatsheet-search'
import styles from './cheatsheet.module.scss'
import { TopicSelection } from './topic-selection' import { TopicSelection } from './topic-selection'
import React, { useCallback, useMemo, useState } from 'react' import React, { useCallback, useState } from 'react'
import { Col, ListGroup, Row } from 'react-bootstrap' import { Col, ListGroup, Row } from 'react-bootstrap'
import { Trans } from 'react-i18next' import { Trans } from 'react-i18next'
@ -17,6 +18,7 @@ import { Trans } from 'react-i18next'
* Renders the tab content for the cheatsheet. * Renders the tab content for the cheatsheet.
*/ */
export const CheatsheetContent: React.FC = () => { export const CheatsheetContent: React.FC = () => {
const [visibleExtensions, setVisibleExtensions] = useState<CheatsheetExtension[]>([])
const [selectedExtension, setSelectedExtension] = useState<CheatsheetExtension>() const [selectedExtension, setSelectedExtension] = useState<CheatsheetExtension>()
const [selectedEntry, setSelectedEntry] = useState<CheatsheetEntry>() const [selectedEntry, setSelectedEntry] = useState<CheatsheetEntry>()
@ -25,12 +27,15 @@ export const CheatsheetContent: React.FC = () => {
setSelectedEntry(isCheatsheetGroup(value) ? value.entries[0] : value) setSelectedEntry(isCheatsheetGroup(value) ? value.entries[0] : value)
}, []) }, [])
const extensions = useMemo(() => allAppExtensions.flatMap((extension) => extension.buildCheatsheetExtensions()), [])
return ( return (
<Row className={`mt-2`}> <Row className={`mt-2`}>
<Col xs={3}> <Col xs={3} className={styles.sidebar}>
<CategoryAccordion extensions={extensions} selectedEntry={selectedExtension} onStateChange={changeExtension} /> <CheatsheetSearch setVisibleExtensions={setVisibleExtensions} />
<CategoryAccordion
extensions={visibleExtensions}
selectedEntry={selectedExtension}
onStateChange={changeExtension}
/>
</Col> </Col>
<Col xs={9}> <Col xs={9}>
<ListGroup> <ListGroup>

View file

@ -0,0 +1,104 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { allAppExtensions } from '../../../../extensions/all-app-extensions'
import type { SearchIndexEntry } from '../../../../hooks/common/use-document-search'
import { useDocumentSearch } from '../../../../hooks/common/use-document-search'
import { useOnInputChange } from '../../../../hooks/common/use-on-input-change'
import { UiIcon } from '../../../common/icons/ui-icon'
import type { CheatsheetSingleEntry, CheatsheetExtension } from '../../cheatsheet/cheatsheet-extension'
import { hasCheatsheetTopics } from '../../cheatsheet/cheatsheet-extension'
import styles from './cheatsheet.module.scss'
import type { IndexOptionsForDocumentSearch } from 'flexsearch-ts'
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { FormControl, InputGroup } from 'react-bootstrap'
import { X } from 'react-bootstrap-icons'
import { useTranslation } from 'react-i18next'
interface CheatsheetSearchIndexEntry extends SearchIndexEntry {
title: string
description: string
example: string
}
const searchOptions: IndexOptionsForDocumentSearch<CheatsheetSearchIndexEntry> = {
document: {
id: 'id',
field: ['title', 'description', 'example']
}
}
export interface CheatsheetSearchProps {
setVisibleExtensions: React.Dispatch<React.SetStateAction<CheatsheetExtension[]>>
}
/**
* Renders a search field and handles the extension selection to display.
* An empty search input leads to all extensions being set.
*
* @param setVisibleExtensions This sets the extensions that are displayed in the cheatsheet view.
*/
export const CheatsheetSearch: React.FC<CheatsheetSearchProps> = ({ setVisibleExtensions }) => {
const { t } = useTranslation()
const [searchTerm, setSearchTerm] = useState('')
const allCheatsheetExtensions = useMemo(
() => allAppExtensions.flatMap((extension) => extension.buildCheatsheetExtensions()),
[]
)
const buildSearchIndexDocument = useCallback(
(entry: CheatsheetEntry, rootI18nKey: string | undefined = undefined): CheatsheetSearchIndexEntry => {
const rootI18nKeyWithDot = rootI18nKey ? `${rootI18nKey}.` : ''
return {
id: rootI18nKey ? rootI18nKey : entry.i18nKey,
title: t(`cheatsheet.${rootI18nKeyWithDot}${entry.i18nKey}.title`),
description: t(`cheatsheet.${rootI18nKeyWithDot}${entry.i18nKey}.description`),
example: t(`cheatsheet.${rootI18nKeyWithDot}${entry.i18nKey}.example`)
}
},
[t]
)
const cheatsheetSearchIndexEntries = useMemo(
() =>
allCheatsheetExtensions.flatMap((entry) => {
if (hasCheatsheetTopics(entry)) {
return entry.topics.map((innerEntry) => buildSearchIndexEntry(innerEntry, entry.i18nKey))
}
return buildSearchIndexDocument(entry)
}),
[buildSearchIndexDocument, allCheatsheetExtensions]
)
const searchResults = useDocumentSearch(cheatsheetSearchIndexEntries, searchOptions, searchTerm)
useEffect(() => {
if (searchTerm.trim() === '') {
setVisibleExtensions(allCheatsheetExtensions)
return
}
const mappedResults = searchResults.flatMap((result) => result.result)
const extensionResults = allCheatsheetExtensions.filter((extension) => {
return mappedResults.includes(extension.i18nKey)
})
setVisibleExtensions(extensionResults)
}, [allCheatsheetExtensions, searchResults, searchTerm, setVisibleExtensions])
const onChange = useOnInputChange((search) => {
setSearchTerm(search)
})
const clearSearch = useCallback(() => {
setSearchTerm('')
}, [setSearchTerm])
return (
<InputGroup className='mb-3'>
<FormControl
placeholder={t('cheatsheet.search') ?? undefined}
aria-label={t('cheatsheet.search') ?? undefined}
onChange={onChange}
value={searchTerm}
/>
<button className={styles.innerBtn} onClick={clearSearch}>
<UiIcon icon={X} />
</button>
</InputGroup>
)
}

View file

@ -1,5 +1,5 @@
/* /*!
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
@ -13,3 +13,27 @@
top: 1rem; top: 1rem;
bottom: 1rem; bottom: 1rem;
} }
.sidebar {
display: flex;
flex-direction: column;
gap: 1rem;
}
.innerBtn {
position: absolute;
font-weight: 550;
border: none;
background-color: transparent;
align-self: center;
left: calc(100% - 30px);
z-index: 99999;
border-radius: var(--bs-border-radius);
filter: var(--bs-btn-close-white-filter);
}
.innerBtn:hover {
color: var(--bs-btn-close-color);
text-decoration: none;
opacity: var(--bs-btn-close-hover-opacity);
}