@craftercms/studio-ui
Version:
Services, components, models & utils to build CrafterCMS authoring extensions.
287 lines (285 loc) • 9.56 kB
JavaScript
/*
* 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;