UNPKG

@craftercms/studio-ui

Version:

Services, components, models & utils to build CrafterCMS authoring extensions.

287 lines (285 loc) 9.56 kB
/* * Copyright (C) 2007-2022 Crafter Software Corporation. All Rights Reserved. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Lesser General Public License version 3 as published by * the Free Software Foundation. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. */ /* * Copyright (C) 2007-2022 Crafter Software Corporation. All Rights Reserved. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License version 3 as published by * the Free Software Foundation. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. */ import InputBase from '@mui/material/InputBase'; import React, { useEffect, useState } from 'react'; import { debounceTime, switchMap, tap } from 'rxjs/operators'; import { search } from '../../services/search'; import { makeStyles } from 'tss-react/mui'; import useAutocomplete from '@mui/material/useAutocomplete'; import { CircularProgress, IconButton, List, ListItem, ListItemIcon, ListItemText, Paper } from '@mui/material'; import LoadingState from '../LoadingState/LoadingState'; import EmptyState from '../EmptyState/EmptyState'; import Page from '../../icons/Page'; import CloseIcon from '@mui/icons-material/Close'; import { getPreviewURLFromPath } from '../../utils/path'; import { FormattedMessage } from 'react-intl'; import parse from 'autosuggest-highlight/parse'; import match from 'autosuggest-highlight/match'; import palette from '../../styles/palette'; import { useActiveSiteId } from '../../hooks/useActiveSiteId'; import { useContentTypeList } from '../../hooks/useContentTypeList'; import { useSubject } from '../../hooks/useSubject'; const useStyles = makeStyles()((theme) => ({ container: { width: '100%', position: 'relative' }, closeIcon: { padding: '3px' }, progress: { position: 'absolute', right: 0 }, inputRoot: { width: '100%', background: 'none' }, input: {}, paper: { width: 400, position: 'absolute', right: '-52px', top: '50px' }, listBox: { overflow: 'auto', maxHeight: 600, margin: 0, padding: 0, listStyle: 'none', '& li[data-focus="true"]': { backgroundColor: 'rgba(0, 0, 0, 0.04)' }, '& li:active': { backgroundColor: 'rgba(0, 0, 0, 0.04)', color: 'white' } }, listItemIcon: { minWidth: 'auto', paddingRight: '16px' }, highlighted: { display: 'inline-block', background: 'yellow', color: theme.palette.mode === 'dark' ? palette.gray.medium6 : theme.palette.text.secondary } })); export function PagesSearchAhead(props) { var _a; const { value, placeholder = '', disabled = false, onEnter, onFocus, onBlur, autoFocus = true } = props; const { classes, cx } = useStyles(); const onSearch$ = useSubject(); const site = useActiveSiteId(); const contentTypes = useContentTypeList((contentType) => contentType.id.startsWith('/page')); const [keyword, setKeyword] = useState(''); const [isFetching, setIsFetching] = useState(false); const [items, setItems] = useState(null); const [dirty, setDirty] = useState(false); const { getRootProps, getInputProps, getListboxProps, getOptionProps, groupedOptions, popupOpen } = useAutocomplete({ freeSolo: true, inputValue: keyword, disableCloseOnSelect: true, onInputChange: (e, value, reason) => { if (reason === 'reset') { const previewUrl = getPreviewURLFromPath(value); setKeyword(previewUrl); onEnter(previewUrl); setDirty(false); } else { setKeyword(value); if (value) { onSearch$.next(value); } else { setDirty(true); } } }, options: keyword && items ? items : [], filterOptions: (options, state) => options, getOptionLabel: (item) => { return typeof item === 'string' ? item : item.path; }, isOptionEqualToValue: (option, value) => option.path === value.path }); useEffect(() => { setKeyword(value); }, [value]); useEffect(() => { const subscription = onSearch$ .pipe( tap(() => { setIsFetching(true); setDirty(true); }), debounceTime(400), switchMap((keywords) => search(site, { keywords, filters: { 'content-type': contentTypes.map((contentType) => contentType.id) } }) ) ) .subscribe((response) => { setIsFetching(false); setItems(response.items); }); return () => subscription.unsubscribe(); }, [contentTypes, onSearch$, site]); const onClean = () => { setItems(null); setKeyword(value); setDirty(false); }; const inputProps = getInputProps(); return React.createElement( 'div', { className: classes.container }, React.createElement( 'div', Object.assign({}, getRootProps()), React.createElement(InputBase, { onKeyUp: (e) => { if (e.key === 'Enter') { if (keyword.startsWith('/')) { onEnter(keyword); } else if (groupedOptions.length > 0) { // TODO: // 1. Fix typing so cast is not required const previewUrl = getPreviewURLFromPath(groupedOptions[0].path); onEnter(previewUrl); setKeyword(previewUrl); setItems(null); setDirty(false); } } }, onFocus: (e) => { onFocus === null || onFocus === void 0 ? void 0 : onFocus(); inputProps.onFocus(e); e.target.select(); }, onBlur: (e) => { onBlur === null || onBlur === void 0 ? void 0 : onBlur(); inputProps.onFocus(e); onClean(); }, autoFocus: autoFocus, placeholder: placeholder, disabled: disabled, classes: { root: classes.inputRoot, input: cx(classes.input, (_a = props.classes) === null || _a === void 0 ? void 0 : _a.input) }, endAdornment: isFetching ? React.createElement(CircularProgress, { className: classes.progress, size: 15 }) : keyword && keyword !== value ? React.createElement( IconButton, { className: classes.closeIcon, onClick: onClean, size: 'large' }, React.createElement(CloseIcon, { fontSize: 'small' }) ) : null, inputProps: inputProps }) ), popupOpen && dirty && React.createElement( Paper, { className: classes.paper }, isFetching && React.createElement(LoadingState, null), !isFetching && groupedOptions.length > 0 && React.createElement( List, Object.assign({ dense: true, className: classes.listBox }, getListboxProps()), groupedOptions.map((option, index) => React.createElement( ListItem, Object.assign({ button: true, dense: true, component: 'li' }, getOptionProps({ option, index })), React.createElement(ListItemIcon, { className: classes.listItemIcon }, React.createElement(Page, null)), React.createElement(Option, { name: option.name, path: getPreviewURLFromPath(option.path), keyword: keyword, highlighted: classes.highlighted }) ) ) ), !isFetching && groupedOptions.length === 0 && React.createElement(EmptyState, { title: React.createElement(FormattedMessage, { id: 'searchAhead.noResults', defaultMessage: 'No Results.' }), styles: { image: { width: 100 } } }) ) ); } function Option(props) { const { name, path, keyword, highlighted } = props; const nameMatches = match(name, keyword); const pathMatches = match(path, keyword); const nameParts = parse(name, nameMatches); const pathParts = parse(path, pathMatches); return React.createElement(ListItemText, { primary: React.createElement( React.Fragment, null, nameParts.map((part, i) => part.highlight ? React.createElement('span', { key: i, className: highlighted }, ' ', part.text, ' ') : part.text ) ), secondary: React.createElement( React.Fragment, null, pathParts.map((part, i) => part.highlight ? React.createElement('span', { key: i, className: highlighted }, ' ', part.text, ' ') : part.text ) ) }); } export default PagesSearchAhead;