UNPKG

@craftercms/studio-ui

Version:

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

331 lines (329 loc) 12.2 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 React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import List from '@mui/material/List'; import SearchBar from '../SearchBar/SearchBar'; import { DraggablePanelListItem } from '../DraggablePanelListItem/DraggablePanelListItem'; import { getHostToGuestBus } from '../../utils/subjects'; import { assetDragEnded, assetDragStarted, componentInstanceDragEnded, componentInstanceDragStarted, setPreviewEditMode } from '../../state/actions/preview'; import { search } from '../../services/search'; import { createLookupTable, nou } from '../../utils/object'; import { fetchContentInstance } from '../../services/content'; import { forkJoin, of } from 'rxjs'; import { map, switchMap, takeUntil } from 'rxjs/operators'; import { useDispatch } from 'react-redux'; import { useSelection } from '../../hooks/useSelection'; import { useActiveSiteId } from '../../hooks/useActiveSiteId'; import { useContentTypeList } from '../../hooks/useContentTypeList'; import { useMount } from '../../hooks/useMount'; import { useDebouncedInput } from '../../hooks/useDebouncedInput'; import { useSpreadState } from '../../hooks/useSpreadState'; import { useSubject } from '../../hooks/useSubject'; import Pagination from '../Pagination'; import { getFileNameFromPath } from '../../utils/path'; import { makeStyles } from 'tss-react/mui'; import { ApiResponseErrorState } from '../ApiResponseErrorState'; import { LoadingState } from '../LoadingState'; import { EmptyState } from '../EmptyState'; import { ErrorBoundary } from '../ErrorBoundary'; import FormHelperText from '@mui/material/FormHelperText'; import HourglassEmptyRounded from '@mui/icons-material/HourglassEmptyRounded'; import Alert from '@mui/material/Alert'; const translations = defineMessages({ previewSearchPanelTitle: { id: 'previewSearchPanel.title', defaultMessage: 'Search' }, previousPage: { id: 'pagination.previousPage', defaultMessage: 'Previous page' }, nextPage: { id: 'pagination.nextPage', defaultMessage: 'Next page' }, noResults: { id: 'previewSearchPanel.noResults', defaultMessage: 'No results found' } }); const useStyles = makeStyles()((theme) => ({ searchContainer: { padding: `${theme.spacing(1)} ${theme.spacing(1)} 0` } })); function SearchResults(props) { const { items, contentInstanceLookup, onDragStart, onDragEnd } = props; return React.createElement( List, null, items.map((item) => React.createElement(DraggablePanelListItem, { key: item.path, primaryText: item.name ?? getFileNameFromPath(item.path), avatarSrc: item.type === 'Image' ? item.path : null, avatarColorBase: contentInstanceLookup[item.path]?.craftercms.contentTypeId, onDragStart: () => onDragStart(item), onDragEnd: () => onDragEnd(item) }) ) ); } const initialSearchParameters = { keywords: '', offset: 0, limit: 10, orOperator: true }; const mimeTypes = ['image/png', 'image/jpeg', 'image/gif', 'video/mp4', 'image/svg+xml']; export function PreviewSearchPanel() { const { classes } = useStyles(); const { formatMessage } = useIntl(); const [keyword, setKeyword] = useState(''); const [error, setError] = useState(null); const site = useActiveSiteId(); const hostToGuest$ = getHostToGuestBus(); const [state, setState] = useSpreadState({ isFetching: null, contentInstanceLookup: null, limit: 10, items: null, count: null }); const dispatch = useDispatch(); const editMode = useSelection((state) => state.preview.editMode); const allowedTypesData = useSelection((state) => state.preview.guest?.allowedContentTypes); const contentTypesUpdated = useSelection((state) => state.preview.guest?.contentTypesUpdated); const awaitingGuestCheckIn = nou(allowedTypesData); const contentTypes = useContentTypeList( (contentType) => contentType.id !== '/component/level-descriptor' && contentType.type === 'component' ); const contentTypesLookup = useMemo( () => (contentTypes ? createLookupTable(contentTypes, 'id') : null), [contentTypes] ); const onSearchSubscription = useRef(); const unMount$ = useSubject(); const [pageNumber, setPageNumber] = useState(0); const onSearch = useCallback( (keywords = '', options) => { setState({ isFetching: true }); setError(null); const allowedTypes = Object.entries(allowedTypesData ?? {}).flatMap(([key, type]) => (type.shared ? [key] : [])); return search(site, { ...initialSearchParameters, keywords, ...options, filters: { ...(allowedTypes.length > 0 ? { 'content-type': allowedTypes } : {}), 'mime-type': mimeTypes } }) .pipe( takeUntil(unMount$), switchMap((result) => { const requests = []; result.items.forEach((item) => { if (item.type === 'Component') { requests.push(fetchContentInstance(site, item.path, contentTypesLookup)); } }); return requests.length ? forkJoin(requests).pipe(map((contentInstances) => ({ contentInstances, result }))) : of({ result, contentInstances: null }); }) ) .subscribe({ next: (response) => { setPageNumber(options ? options.offset / options.limit : 0); if (response.contentInstances) { setState({ isFetching: false, items: response.result.items, contentInstanceLookup: createLookupTable(response.contentInstances, 'craftercms.path'), count: response.result.total, limit: options?.limit ?? initialSearchParameters.limit }); } else { setState({ isFetching: false, items: response.result.items, count: response.result.total, limit: options?.limit ?? initialSearchParameters.limit }); } }, error: ({ response }) => { setState({ isFetching: false }); setError(response.response); } }); }, [setState, site, unMount$, contentTypesLookup, allowedTypesData] ); useMount(() => { return () => { unMount$.next(); unMount$.complete(); onSearchSubscription.current?.unsubscribe(); }; }); useEffect(() => { if (contentTypes && contentTypesLookup && !awaitingGuestCheckIn) { onSearchSubscription.current?.unsubscribe(); onSearchSubscription.current = onSearch(); return () => { onSearchSubscription.current?.unsubscribe(); }; } }, [contentTypes, contentTypesLookup, onSearch, awaitingGuestCheckIn]); const onSearch$ = useDebouncedInput((keyword) => { onSearchSubscription.current?.unsubscribe(); onSearchSubscription.current = onSearch(keyword); }, 400); function handleSearchKeyword(keyword) { setKeyword(keyword); onSearch$.next(keyword); } function onPageChanged(page) { onSearchSubscription.current?.unsubscribe(); onSearchSubscription.current = onSearch(keyword, { offset: page * state.limit, limit: state.limit }); } function onRowsPerPageChange(e) { onSearchSubscription.current?.unsubscribe(); onSearchSubscription.current = onSearch(keyword, { offset: 0, limit: Number(e.target.value) }); } const onDragStart = (item) => { if (!editMode) { dispatch(setPreviewEditMode({ editMode: true })); } if (item.type === 'Component') { const instance = state.contentInstanceLookup[item.path]; hostToGuest$.next( componentInstanceDragStarted({ instance, contentType: contentTypesLookup[instance.craftercms.contentTypeId] }) ); } else { hostToGuest$.next(assetDragStarted({ asset: item })); } }; const onDragEnd = (item) => { hostToGuest$.next({ type: item.type === 'Component' ? componentInstanceDragEnded.type : assetDragEnded.type }); }; return React.createElement( React.Fragment, null, React.createElement( 'div', { className: classes.searchContainer }, contentTypesUpdated && React.createElement( Alert, { severity: 'warning', variant: 'outlined', sx: { border: 0 } }, React.createElement(FormattedMessage, { defaultMessage: 'Content type definitions have changed. Please refresh the preview application.' }) ), React.createElement(SearchBar, { keyword: keyword, placeholder: formatMessage(translations.previewSearchPanelTitle), onChange: (keyword) => handleSearchKeyword(keyword), showDecoratorIcon: true, showActionButton: Boolean(keyword) }) ), state.items && !error && React.createElement(Pagination, { count: state.count, rowsPerPage: state.limit, page: pageNumber, onPageChange: (e, page) => onPageChanged(page), onRowsPerPageChange: onRowsPerPageChange }), awaitingGuestCheckIn ? React.createElement( Alert, { severity: 'info', variant: 'outlined', icon: React.createElement(HourglassEmptyRounded, null), sx: { border: 0 } }, React.createElement(FormattedMessage, { defaultMessage: 'Waiting for the preview application to load.' }) ) : React.createElement( ErrorBoundary, null, error ? React.createElement(ApiResponseErrorState, { error: error }) : state.isFetching ? React.createElement(LoadingState, null) : state.items && state.items.length ? React.createElement(SearchResults, { items: state.items, contentInstanceLookup: state.contentInstanceLookup ?? {}, onDragStart: onDragStart, onDragEnd: onDragEnd }) : state.items && state.items.length === 0 ? React.createElement(EmptyState, { title: formatMessage(translations.noResults) }) : React.createElement(React.Fragment, null) ), React.createElement( FormHelperText, { sx: { margin: '10px 16px', pt: 1, textAlign: 'center', borderTop: (theme) => `1px solid ${theme.palette.divider}` } }, React.createElement(FormattedMessage, { defaultMessage: 'Only shared instances and assets are shown here' }) ) ); } export default PreviewSearchPanel;