@craftercms/studio-ui
Version:
Services, components, models & utils to build CrafterCMS authoring extensions.
331 lines (329 loc) • 12.2 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 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;