Feature/rebrand (#3)
This commit is contained in:
committed by
GitHub
parent
213e51c52d
commit
8acda23acc
353
src/components/App/App.js
Normal file
353
src/components/App/App.js
Normal file
@ -0,0 +1,353 @@
|
||||
import styled from '@emotion/styled';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { hot } from 'react-hot-loader';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { translate } from 'react-polyglot';
|
||||
import { connect } from 'react-redux';
|
||||
import { Redirect, Route, Switch } from 'react-router-dom';
|
||||
import { ScrollSync } from 'react-scroll-sync';
|
||||
import TopBarProgress from 'react-topbar-progress-indicator';
|
||||
|
||||
import { loginUser, logoutUser } from '../../actions/auth';
|
||||
import { createNewEntry } from '../../actions/collections';
|
||||
import { openMediaLibrary } from '../../actions/mediaLibrary';
|
||||
import { currentBackend } from '../../backend';
|
||||
import { EDITORIAL_WORKFLOW, SIMPLE } from '../../constants/publishModes';
|
||||
import { history } from '../../routing/history';
|
||||
import { colors, Loader } from '../../ui';
|
||||
import Collection from '../Collection/Collection';
|
||||
import Editor from '../Editor/Editor';
|
||||
import MediaLibrary from '../MediaLibrary/MediaLibrary';
|
||||
import Page from '../page/Page';
|
||||
import Snackbars from '../snackbar/Snackbars';
|
||||
import { Alert } from '../UI/Alert';
|
||||
import { Confirm } from '../UI/Confirm';
|
||||
import Workflow from '../Workflow/Workflow';
|
||||
import Header from './Header';
|
||||
import NotFoundPage from './NotFoundPage';
|
||||
|
||||
TopBarProgress.config({
|
||||
barColors: {
|
||||
0: colors.active,
|
||||
'1.0': colors.active,
|
||||
},
|
||||
shadowBlur: 0,
|
||||
barThickness: 2,
|
||||
});
|
||||
|
||||
const AppRoot = styled.div`
|
||||
width: 100%;
|
||||
min-width: 1200px;
|
||||
height: 100vh;
|
||||
position: relative;
|
||||
overflow-y: auto;
|
||||
`;
|
||||
|
||||
const AppWrapper = styled.div`
|
||||
width: 100%;
|
||||
min-width: 1200px;
|
||||
min-height: 100vh;
|
||||
`;
|
||||
|
||||
const AppMainContainer = styled.div`
|
||||
min-width: 1200px;
|
||||
max-width: 1440px;
|
||||
margin: 0 auto;
|
||||
`;
|
||||
|
||||
const ErrorContainer = styled.div`
|
||||
margin: 20px;
|
||||
`;
|
||||
|
||||
const ErrorCodeBlock = styled.pre`
|
||||
margin-left: 20px;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
`;
|
||||
|
||||
function getDefaultPath(collections) {
|
||||
const first = collections
|
||||
.filter(
|
||||
collection =>
|
||||
collection.get('hide') !== true &&
|
||||
(!collection.has('files') || collection.get('files').size > 1),
|
||||
)
|
||||
.first();
|
||||
if (first) {
|
||||
return `/collections/${first.get('name')}`;
|
||||
} else {
|
||||
throw new Error('Could not find a non hidden collection');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns default collection name if only one collection
|
||||
*
|
||||
* @param {Collection} collection
|
||||
* @returns {string}
|
||||
*/
|
||||
function getDefaultCollectionPath(collection) {
|
||||
if (collection.has('files') && collection.get('files').size === 1) {
|
||||
return `/collections/${collection.get('name')}/entries/${collection
|
||||
.get('files')
|
||||
.first()
|
||||
.get('name')}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function RouteInCollectionDefault({ collections, render, ...props }) {
|
||||
const defaultPath = getDefaultPath(collections);
|
||||
return (
|
||||
<Route
|
||||
{...props}
|
||||
render={routeProps => {
|
||||
const collectionExists = collections.get(routeProps.match.params.name);
|
||||
if (!collectionExists) {
|
||||
return <Redirect to={defaultPath} />;
|
||||
}
|
||||
|
||||
const defaultCollectionPath = getDefaultCollectionPath(collectionExists);
|
||||
if (defaultCollectionPath !== null) {
|
||||
return <Redirect to={defaultCollectionPath} />;
|
||||
}
|
||||
|
||||
return render(routeProps);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function RouteInCollection({ collections, render, ...props }) {
|
||||
const defaultPath = getDefaultPath(collections);
|
||||
return (
|
||||
<Route
|
||||
{...props}
|
||||
render={routeProps => {
|
||||
const collectionExists = collections.get(routeProps.match.params.name);
|
||||
return collectionExists ? render(routeProps) : <Redirect to={defaultPath} />;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
class App extends React.Component {
|
||||
static propTypes = {
|
||||
auth: PropTypes.object.isRequired,
|
||||
config: PropTypes.object.isRequired,
|
||||
collections: ImmutablePropTypes.map.isRequired,
|
||||
loginUser: PropTypes.func.isRequired,
|
||||
logoutUser: PropTypes.func.isRequired,
|
||||
user: PropTypes.object,
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
publishMode: PropTypes.oneOf([SIMPLE, EDITORIAL_WORKFLOW]),
|
||||
siteId: PropTypes.string,
|
||||
useMediaLibrary: PropTypes.bool,
|
||||
openMediaLibrary: PropTypes.func.isRequired,
|
||||
showMediaButton: PropTypes.bool,
|
||||
scrollSyncEnabled: PropTypes.bool.isRequired,
|
||||
t: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
configError(config) {
|
||||
const t = this.props.t;
|
||||
return (
|
||||
<ErrorContainer>
|
||||
<h1>{t('app.app.errorHeader')}</h1>
|
||||
<div>
|
||||
<strong>{t('app.app.configErrors')}:</strong>
|
||||
<ErrorCodeBlock>{config.error}</ErrorCodeBlock>
|
||||
<span>{t('app.app.checkConfigYml')}</span>
|
||||
</div>
|
||||
</ErrorContainer>
|
||||
);
|
||||
}
|
||||
|
||||
handleLogin(credentials) {
|
||||
this.props.loginUser(credentials);
|
||||
}
|
||||
|
||||
authenticating() {
|
||||
const { auth, t } = this.props;
|
||||
const backend = currentBackend(this.props.config);
|
||||
|
||||
if (backend == null) {
|
||||
return (
|
||||
<div>
|
||||
<h1>{t('app.app.waitingBackend')}</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{React.createElement(backend.authComponent(), {
|
||||
onLogin: this.handleLogin.bind(this),
|
||||
error: auth.error,
|
||||
inProgress: auth.isFetching,
|
||||
siteId: this.props.config.backend.site_domain,
|
||||
base_url: this.props.config.backend.base_url,
|
||||
authEndpoint: this.props.config.backend.auth_endpoint,
|
||||
config: this.props.config,
|
||||
clearHash: () => history.replace('/'),
|
||||
t,
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
handleLinkClick(event, handler, ...args) {
|
||||
event.preventDefault();
|
||||
handler(...args);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
user,
|
||||
config,
|
||||
collections,
|
||||
logoutUser,
|
||||
isFetching,
|
||||
publishMode,
|
||||
useMediaLibrary,
|
||||
openMediaLibrary,
|
||||
t,
|
||||
showMediaButton,
|
||||
scrollSyncEnabled,
|
||||
} = this.props;
|
||||
|
||||
if (config === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (config.error) {
|
||||
return this.configError(config);
|
||||
}
|
||||
|
||||
if (config.isFetching) {
|
||||
return <Loader active>{t('app.app.loadingConfig')}</Loader>;
|
||||
}
|
||||
|
||||
if (user == null) {
|
||||
return this.authenticating(t);
|
||||
}
|
||||
|
||||
const defaultPath = getDefaultPath(collections);
|
||||
const hasWorkflow = publishMode === EDITORIAL_WORKFLOW;
|
||||
|
||||
return (
|
||||
<ScrollSync enabled={scrollSyncEnabled}>
|
||||
<AppRoot id="cms-root">
|
||||
<AppWrapper className="cms-wrapper">
|
||||
<Snackbars />
|
||||
<Header
|
||||
user={user}
|
||||
collections={collections}
|
||||
onCreateEntryClick={createNewEntry}
|
||||
onLogoutClick={logoutUser}
|
||||
openMediaLibrary={openMediaLibrary}
|
||||
hasWorkflow={hasWorkflow}
|
||||
displayUrl={config.display_url}
|
||||
isTestRepo={config.backend.name === 'test-repo'}
|
||||
showMediaButton={showMediaButton}
|
||||
/>
|
||||
<AppMainContainer>
|
||||
{isFetching && <TopBarProgress />}
|
||||
<Switch>
|
||||
<Redirect exact from="/" to={defaultPath} />
|
||||
<Redirect exact from="/search/" to={defaultPath} />
|
||||
<RouteInCollection
|
||||
exact
|
||||
collections={collections}
|
||||
path="/collections/:name/search/"
|
||||
render={({ match }) => <Redirect to={`/collections/${match.params.name}`} />}
|
||||
/>
|
||||
<Redirect
|
||||
// This happens on Identity + Invite Only + External Provider email not matching
|
||||
// the registered user
|
||||
from="/error=access_denied&error_description=Signups+not+allowed+for+this+instance"
|
||||
to={defaultPath}
|
||||
/>
|
||||
{hasWorkflow ? <Route path="/workflow" component={Workflow} /> : null}
|
||||
<RouteInCollectionDefault
|
||||
exact
|
||||
collections={collections}
|
||||
path="/collections/:name"
|
||||
render={props => <Collection {...props} />}
|
||||
/>
|
||||
<RouteInCollection
|
||||
path="/collections/:name/new"
|
||||
collections={collections}
|
||||
render={props => <Editor {...props} newRecord />}
|
||||
/>
|
||||
<RouteInCollection
|
||||
path="/collections/:name/entries/*"
|
||||
collections={collections}
|
||||
render={props => <Editor {...props} />}
|
||||
/>
|
||||
<RouteInCollection
|
||||
path="/collections/:name/search/:searchTerm"
|
||||
collections={collections}
|
||||
render={props => <Collection {...props} isSearchResults isSingleSearchResult />}
|
||||
/>
|
||||
<RouteInCollection
|
||||
collections={collections}
|
||||
path="/collections/:name/filter/:filterTerm*"
|
||||
render={props => <Collection {...props} />}
|
||||
/>
|
||||
<Route
|
||||
path="/search/:searchTerm"
|
||||
render={props => <Collection {...props} isSearchResults />}
|
||||
/>
|
||||
<RouteInCollection
|
||||
path="/edit/:name/:entryName"
|
||||
collections={collections}
|
||||
render={({ match }) => {
|
||||
const { name, entryName } = match.params;
|
||||
return <Redirect to={`/collections/${name}/entries/${entryName}`} />;
|
||||
}}
|
||||
/>
|
||||
<Route path="/page/:id" render={props => <Page {...props} />} />
|
||||
<Route component={NotFoundPage} />
|
||||
</Switch>
|
||||
{useMediaLibrary ? <MediaLibrary /> : null}
|
||||
<Alert />
|
||||
<Confirm />
|
||||
</AppMainContainer>
|
||||
</AppWrapper>
|
||||
</AppRoot>
|
||||
</ScrollSync>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state) {
|
||||
const { auth, config, collections, globalUI, mediaLibrary, scroll } = state;
|
||||
const user = auth.user;
|
||||
const isFetching = globalUI.isFetching;
|
||||
const publishMode = config.publish_mode;
|
||||
const useMediaLibrary = !mediaLibrary.get('externalLibrary');
|
||||
const showMediaButton = mediaLibrary.get('showMediaButton');
|
||||
const scrollSyncEnabled = scroll.isScrolling;
|
||||
return {
|
||||
auth,
|
||||
config,
|
||||
collections,
|
||||
user,
|
||||
isFetching,
|
||||
publishMode,
|
||||
showMediaButton,
|
||||
useMediaLibrary,
|
||||
scrollSyncEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
openMediaLibrary,
|
||||
loginUser,
|
||||
logoutUser,
|
||||
};
|
||||
|
||||
export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(translate()(App)));
|
236
src/components/App/Header.js
Normal file
236
src/components/App/Header.js
Normal file
@ -0,0 +1,236 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import styled from '@emotion/styled';
|
||||
import { css } from '@emotion/react';
|
||||
import { translate } from 'react-polyglot';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import {
|
||||
Icon,
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
StyledDropdownButton,
|
||||
colors,
|
||||
lengths,
|
||||
shadows,
|
||||
buttons,
|
||||
zIndex,
|
||||
} from '../../ui';
|
||||
import { SettingsDropdown } from '../UI';
|
||||
import { checkBackendStatus } from '../../actions/status';
|
||||
|
||||
const styles = {
|
||||
buttonActive: css`
|
||||
color: ${colors.active};
|
||||
`,
|
||||
};
|
||||
|
||||
function AppHeader(props) {
|
||||
return (
|
||||
<header
|
||||
css={css`
|
||||
${shadows.dropMain};
|
||||
position: sticky;
|
||||
width: 100%;
|
||||
top: 0;
|
||||
background-color: ${colors.foreground};
|
||||
z-index: ${zIndex.zIndex300};
|
||||
height: ${lengths.topBarHeight};
|
||||
`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const AppHeaderContent = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
min-width: 1200px;
|
||||
max-width: 1440px;
|
||||
padding: 0 12px;
|
||||
margin: 0 auto;
|
||||
`;
|
||||
|
||||
const AppHeaderButton = styled.button`
|
||||
${buttons.button};
|
||||
background: none;
|
||||
color: #7b8290;
|
||||
font-family: inherit;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
display: inline-flex;
|
||||
padding: 16px 20px;
|
||||
align-items: center;
|
||||
|
||||
${Icon} {
|
||||
margin-right: 4px;
|
||||
color: #b3b9c4;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:active,
|
||||
&:focus {
|
||||
${styles.buttonActive};
|
||||
|
||||
${Icon} {
|
||||
${styles.buttonActive};
|
||||
}
|
||||
}
|
||||
|
||||
${props => css`
|
||||
&.${props.activeClassName} {
|
||||
${styles.buttonActive};
|
||||
|
||||
${Icon} {
|
||||
${styles.buttonActive};
|
||||
}
|
||||
}
|
||||
`};
|
||||
`;
|
||||
|
||||
const AppHeaderNavLink = AppHeaderButton.withComponent(NavLink);
|
||||
|
||||
const AppHeaderActions = styled.div`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const AppHeaderQuickNewButton = styled(StyledDropdownButton)`
|
||||
${buttons.button};
|
||||
${buttons.medium};
|
||||
${buttons.gray};
|
||||
margin-right: 8px;
|
||||
|
||||
&:after {
|
||||
top: 11px;
|
||||
}
|
||||
`;
|
||||
|
||||
const AppHeaderNavList = styled.ul`
|
||||
display: flex;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
`;
|
||||
|
||||
class Header extends React.Component {
|
||||
static propTypes = {
|
||||
user: PropTypes.object.isRequired,
|
||||
collections: ImmutablePropTypes.map.isRequired,
|
||||
onCreateEntryClick: PropTypes.func.isRequired,
|
||||
onLogoutClick: PropTypes.func.isRequired,
|
||||
openMediaLibrary: PropTypes.func.isRequired,
|
||||
hasWorkflow: PropTypes.bool.isRequired,
|
||||
displayUrl: PropTypes.string,
|
||||
isTestRepo: PropTypes.bool,
|
||||
t: PropTypes.func.isRequired,
|
||||
checkBackendStatus: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
intervalId;
|
||||
|
||||
componentDidMount() {
|
||||
this.intervalId = setInterval(() => {
|
||||
this.props.checkBackendStatus();
|
||||
}, 5 * 60 * 1000);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
clearInterval(this.intervalId);
|
||||
}
|
||||
|
||||
handleCreatePostClick = collectionName => {
|
||||
const { onCreateEntryClick } = this.props;
|
||||
if (onCreateEntryClick) {
|
||||
onCreateEntryClick(collectionName);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
user,
|
||||
collections,
|
||||
onLogoutClick,
|
||||
openMediaLibrary,
|
||||
hasWorkflow,
|
||||
displayUrl,
|
||||
isTestRepo,
|
||||
t,
|
||||
showMediaButton,
|
||||
} = this.props;
|
||||
|
||||
const createableCollections = collections
|
||||
.filter(collection => collection.get('create'))
|
||||
.toList();
|
||||
|
||||
return (
|
||||
<AppHeader>
|
||||
<AppHeaderContent>
|
||||
<nav>
|
||||
<AppHeaderNavList>
|
||||
<li>
|
||||
<AppHeaderNavLink
|
||||
to="/"
|
||||
activeClassName="header-link-active"
|
||||
isActive={(match, location) => location.pathname.startsWith('/collections/')}
|
||||
>
|
||||
<Icon type="page" />
|
||||
{t('app.header.content')}
|
||||
</AppHeaderNavLink>
|
||||
</li>
|
||||
{hasWorkflow && (
|
||||
<li>
|
||||
<AppHeaderNavLink to="/workflow" activeClassName="header-link-active">
|
||||
<Icon type="workflow" />
|
||||
{t('app.header.workflow')}
|
||||
</AppHeaderNavLink>
|
||||
</li>
|
||||
)}
|
||||
{showMediaButton && (
|
||||
<li>
|
||||
<AppHeaderButton onClick={openMediaLibrary}>
|
||||
<Icon type="media-alt" />
|
||||
{t('app.header.media')}
|
||||
</AppHeaderButton>
|
||||
</li>
|
||||
)}
|
||||
</AppHeaderNavList>
|
||||
</nav>
|
||||
<AppHeaderActions>
|
||||
{createableCollections.size > 0 && (
|
||||
<Dropdown
|
||||
renderButton={() => (
|
||||
<AppHeaderQuickNewButton> {t('app.header.quickAdd')}</AppHeaderQuickNewButton>
|
||||
)}
|
||||
dropdownTopOverlap="30px"
|
||||
dropdownWidth="160px"
|
||||
dropdownPosition="left"
|
||||
>
|
||||
{createableCollections.map(collection => (
|
||||
<DropdownItem
|
||||
key={collection.get('name')}
|
||||
label={collection.get('label_singular') || collection.get('label')}
|
||||
onClick={() => this.handleCreatePostClick(collection.get('name'))}
|
||||
/>
|
||||
))}
|
||||
</Dropdown>
|
||||
)}
|
||||
<SettingsDropdown
|
||||
displayUrl={displayUrl}
|
||||
isTestRepo={isTestRepo}
|
||||
imageUrl={user?.avatar_url}
|
||||
onLogoutClick={onLogoutClick}
|
||||
/>
|
||||
</AppHeaderActions>
|
||||
</AppHeaderContent>
|
||||
</AppHeader>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
checkBackendStatus,
|
||||
};
|
||||
|
||||
export default connect(null, mapDispatchToProps)(translate()(Header));
|
24
src/components/App/NotFoundPage.js
Normal file
24
src/components/App/NotFoundPage.js
Normal file
@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import { translate } from 'react-polyglot';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { lengths } from '../../ui';
|
||||
|
||||
const NotFoundContainer = styled.div`
|
||||
margin: ${lengths.pageMargin};
|
||||
`;
|
||||
|
||||
function NotFoundPage({ t }) {
|
||||
return (
|
||||
<NotFoundContainer>
|
||||
<h2>{t('app.notFoundPage.header')}</h2>
|
||||
</NotFoundContainer>
|
||||
);
|
||||
}
|
||||
|
||||
NotFoundPage.propTypes = {
|
||||
t: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default translate()(NotFoundPage);
|
256
src/components/Collection/Collection.tsx
Normal file
256
src/components/Collection/Collection.tsx
Normal file
@ -0,0 +1,256 @@
|
||||
import styled from '@emotion/styled';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { changeViewStyle, filterByField, groupByField, sortByField } from '../../actions/entries';
|
||||
import { SortDirection } from '../../interface';
|
||||
import { getNewEntryUrl } from '../../lib/urlHelper';
|
||||
import {
|
||||
selectSortableFields,
|
||||
selectViewFilters,
|
||||
selectViewGroups,
|
||||
} from '../../reducers/collections';
|
||||
import {
|
||||
selectEntriesFilter,
|
||||
selectEntriesGroup,
|
||||
selectEntriesSort,
|
||||
selectViewStyle,
|
||||
} from '../../reducers/entries';
|
||||
import { components, lengths } from '../../ui';
|
||||
import CollectionControls from './CollectionControls';
|
||||
import CollectionTop from './CollectionTop';
|
||||
import EntriesCollection from './Entries/EntriesCollection';
|
||||
import EntriesSearch from './Entries/EntriesSearch';
|
||||
import Sidebar from './Sidebar';
|
||||
|
||||
import type { RouteComponentProps } from 'react-router-dom';
|
||||
import type {
|
||||
CmsSortableFieldsDefault,
|
||||
TranslatedProps,
|
||||
ViewFilter,
|
||||
ViewGroup,
|
||||
} from '../../interface';
|
||||
import type { Collection, State } from '../../types/redux';
|
||||
import type { StaticallyTypedRecord } from '../../types/immutable';
|
||||
|
||||
const CollectionContainer = styled.div`
|
||||
margin: ${lengths.pageMargin};
|
||||
`;
|
||||
|
||||
const CollectionMain = styled.main`
|
||||
padding-left: 280px;
|
||||
`;
|
||||
|
||||
const SearchResultContainer = styled.div`
|
||||
${components.cardTop};
|
||||
margin-bottom: 22px;
|
||||
`;
|
||||
|
||||
const SearchResultHeading = styled.h1`
|
||||
${components.cardTopHeading};
|
||||
`;
|
||||
|
||||
interface CollectionRouterParams {
|
||||
name: string;
|
||||
searchTerm?: string;
|
||||
filterTerm?: string;
|
||||
}
|
||||
|
||||
interface CollectionViewProps extends RouteComponentProps<CollectionRouterParams> {
|
||||
isSearchResults?: boolean;
|
||||
isSingleSearchResult?: boolean;
|
||||
}
|
||||
|
||||
const CollectionView = ({
|
||||
collection,
|
||||
collections,
|
||||
collectionName,
|
||||
isSearchEnabled,
|
||||
isSearchResults,
|
||||
isSingleSearchResult,
|
||||
searchTerm,
|
||||
sortableFields,
|
||||
onSortClick,
|
||||
sort,
|
||||
viewFilters,
|
||||
viewGroups,
|
||||
filterTerm,
|
||||
t,
|
||||
onFilterClick,
|
||||
onGroupClick,
|
||||
filter,
|
||||
group,
|
||||
onChangeViewStyle,
|
||||
viewStyle,
|
||||
}: ReturnType<typeof mergeProps>) => {
|
||||
const [readyToLoad, setReadyToLoad] = useState(false);
|
||||
|
||||
const newEntryUrl = useMemo(() => {
|
||||
let url = collection.get('create') ? getNewEntryUrl(collectionName) : '';
|
||||
if (url && filterTerm) {
|
||||
url = getNewEntryUrl(collectionName);
|
||||
if (filterTerm) {
|
||||
url = `${newEntryUrl}?path=${filterTerm}`;
|
||||
}
|
||||
}
|
||||
return url;
|
||||
}, [collection, collectionName, filterTerm]);
|
||||
|
||||
const searchResultKey = useMemo(
|
||||
() => `collection.collectionTop.searchResults${isSingleSearchResult ? 'InCollection' : ''}`,
|
||||
[isSingleSearchResult],
|
||||
);
|
||||
|
||||
const renderEntriesCollection = useCallback(() => {
|
||||
return (
|
||||
<EntriesCollection collection={collection} viewStyle={viewStyle} filterTerm={filterTerm} readyToLoad={readyToLoad} />
|
||||
);
|
||||
}, [collection, filterTerm, viewStyle, readyToLoad]);
|
||||
|
||||
const renderEntriesSearch = useCallback(() => {
|
||||
return (
|
||||
<EntriesSearch
|
||||
collections={isSingleSearchResult ? collections.filter(c => c === collection) : collections}
|
||||
searchTerm={searchTerm}
|
||||
/>
|
||||
);
|
||||
}, [searchTerm, collections, collection, isSingleSearchResult]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sort?.first()?.get('key')) {
|
||||
setReadyToLoad(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultSort = collection.getIn(['sortable_fields', 'default']) as
|
||||
| StaticallyTypedRecord<CmsSortableFieldsDefault>
|
||||
| undefined;
|
||||
|
||||
if (!defaultSort || !defaultSort.get('field')) {
|
||||
setReadyToLoad(true);
|
||||
return;
|
||||
}
|
||||
|
||||
let alive = true;
|
||||
const sortEntries = async () => {
|
||||
await onSortClick(
|
||||
defaultSort.get('field'),
|
||||
defaultSort.get('direction') ?? SortDirection.Ascending,
|
||||
);
|
||||
if (alive) {
|
||||
setReadyToLoad(true);
|
||||
}
|
||||
};
|
||||
|
||||
sortEntries();
|
||||
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [collection]);
|
||||
|
||||
return (
|
||||
<CollectionContainer>
|
||||
<Sidebar
|
||||
collections={collections}
|
||||
collection={(!isSearchResults || isSingleSearchResult) && collection}
|
||||
isSearchEnabled={isSearchEnabled}
|
||||
searchTerm={searchTerm}
|
||||
filterTerm={filterTerm}
|
||||
/>
|
||||
<CollectionMain>
|
||||
{isSearchResults ? (
|
||||
<SearchResultContainer>
|
||||
<SearchResultHeading>
|
||||
{t(searchResultKey, { searchTerm, collection: collection.get('label') })}
|
||||
</SearchResultHeading>
|
||||
</SearchResultContainer>
|
||||
) : (
|
||||
<>
|
||||
<CollectionTop collection={collection} newEntryUrl={newEntryUrl} />
|
||||
<CollectionControls
|
||||
viewStyle={viewStyle}
|
||||
onChangeViewStyle={onChangeViewStyle}
|
||||
sortableFields={sortableFields}
|
||||
onSortClick={onSortClick}
|
||||
sort={sort}
|
||||
viewFilters={viewFilters}
|
||||
viewGroups={viewGroups}
|
||||
t={t}
|
||||
onFilterClick={onFilterClick}
|
||||
onGroupClick={onGroupClick}
|
||||
filter={filter}
|
||||
group={group}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{isSearchResults ? renderEntriesSearch() : renderEntriesCollection()}
|
||||
</CollectionMain>
|
||||
</CollectionContainer>
|
||||
);
|
||||
};
|
||||
|
||||
function mapStateToProps(state: State, ownProps: TranslatedProps<CollectionViewProps>) {
|
||||
const { collections } = state;
|
||||
const isSearchEnabled = state.config && state.config.search != false;
|
||||
const { isSearchResults, match, t } = ownProps;
|
||||
const { name, searchTerm = '', filterTerm = '' } = match.params;
|
||||
const collection: Collection = name ? collections.get(name) : collections.first();
|
||||
const sort = selectEntriesSort(state.entries, collection.get('name'));
|
||||
const sortableFields = selectSortableFields(collection, t);
|
||||
const viewFilters = selectViewFilters(collection);
|
||||
const viewGroups = selectViewGroups(collection);
|
||||
const filter = selectEntriesFilter(state.entries, collection.get('name'));
|
||||
const group = selectEntriesGroup(state.entries, collection.get('name'));
|
||||
const viewStyle = selectViewStyle(state.entries);
|
||||
|
||||
return {
|
||||
collection,
|
||||
collections,
|
||||
collectionName: name,
|
||||
isSearchEnabled,
|
||||
isSearchResults,
|
||||
searchTerm,
|
||||
filterTerm,
|
||||
sort,
|
||||
sortableFields,
|
||||
viewFilters,
|
||||
viewGroups,
|
||||
filter,
|
||||
group,
|
||||
viewStyle,
|
||||
};
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
sortByField,
|
||||
filterByField,
|
||||
changeViewStyle,
|
||||
groupByField,
|
||||
};
|
||||
|
||||
function mergeProps(
|
||||
stateProps: ReturnType<typeof mapStateToProps>,
|
||||
dispatchProps: typeof mapDispatchToProps,
|
||||
ownProps: TranslatedProps<CollectionViewProps>,
|
||||
) {
|
||||
return {
|
||||
...stateProps,
|
||||
...ownProps,
|
||||
onSortClick: (key: string, direction: SortDirection) =>
|
||||
dispatchProps.sortByField(stateProps.collection, key, direction),
|
||||
onFilterClick: (filter: ViewFilter) =>
|
||||
dispatchProps.filterByField(stateProps.collection, filter),
|
||||
onGroupClick: (group: ViewGroup) => dispatchProps.groupByField(stateProps.collection, group),
|
||||
onChangeViewStyle: (viewStyle: string) => dispatchProps.changeViewStyle(viewStyle),
|
||||
};
|
||||
}
|
||||
|
||||
const ConnectedCollection = connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps,
|
||||
mergeProps,
|
||||
)(CollectionView);
|
||||
|
||||
export default translate()(ConnectedCollection);
|
58
src/components/Collection/CollectionControls.js
Normal file
58
src/components/Collection/CollectionControls.js
Normal file
@ -0,0 +1,58 @@
|
||||
import React from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { lengths } from '../../ui';
|
||||
import ViewStyleControl from './ViewStyleControl';
|
||||
import SortControl from './SortControl';
|
||||
import FilterControl from './FilterControl';
|
||||
import GroupControl from './GroupControl';
|
||||
|
||||
const CollectionControlsContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: row-reverse;
|
||||
margin-top: 22px;
|
||||
width: ${lengths.topCardWidth};
|
||||
max-width: 100%;
|
||||
|
||||
& > div {
|
||||
margin-left: 6px;
|
||||
}
|
||||
`;
|
||||
|
||||
function CollectionControls({
|
||||
viewStyle,
|
||||
onChangeViewStyle,
|
||||
sortableFields,
|
||||
onSortClick,
|
||||
sort,
|
||||
viewFilters,
|
||||
viewGroups,
|
||||
onFilterClick,
|
||||
onGroupClick,
|
||||
t,
|
||||
filter,
|
||||
group,
|
||||
}) {
|
||||
return (
|
||||
<CollectionControlsContainer>
|
||||
<ViewStyleControl viewStyle={viewStyle} onChangeViewStyle={onChangeViewStyle} />
|
||||
{viewGroups.length > 0 && (
|
||||
<GroupControl viewGroups={viewGroups} onGroupClick={onGroupClick} t={t} group={group} />
|
||||
)}
|
||||
{viewFilters.length > 0 && (
|
||||
<FilterControl
|
||||
viewFilters={viewFilters}
|
||||
onFilterClick={onFilterClick}
|
||||
t={t}
|
||||
filter={filter}
|
||||
/>
|
||||
)}
|
||||
{sortableFields.length > 0 && (
|
||||
<SortControl fields={sortableFields} sort={sort} onSortClick={onSortClick} />
|
||||
)}
|
||||
</CollectionControlsContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export default CollectionControls;
|
239
src/components/Collection/CollectionSearch.js
Normal file
239
src/components/Collection/CollectionSearch.js
Normal file
@ -0,0 +1,239 @@
|
||||
import React from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import { translate } from 'react-polyglot';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
|
||||
import { colorsRaw, colors, Icon, lengths, zIndex } from '../../ui';
|
||||
|
||||
const SearchContainer = styled.div`
|
||||
margin: 0 12px;
|
||||
position: relative;
|
||||
|
||||
${Icon} {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 6px;
|
||||
z-index: ${zIndex.zIndex2};
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const InputContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const SearchInput = styled.input`
|
||||
background-color: #eff0f4;
|
||||
border-radius: ${lengths.borderRadius};
|
||||
font-size: 14px;
|
||||
padding: 10px 6px 10px 32px;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
z-index: ${zIndex.zIndex1};
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
box-shadow: inset 0 0 0 2px ${colorsRaw.blue};
|
||||
}
|
||||
`;
|
||||
|
||||
const SuggestionsContainer = styled.div`
|
||||
position: relative;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const Suggestions = styled.ul`
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 10px 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
background-color: #fff;
|
||||
border-radius: ${lengths.borderRadius};
|
||||
border: 1px solid ${colors.textFieldBorder};
|
||||
z-index: ${zIndex.zIndex1};
|
||||
`;
|
||||
|
||||
const SuggestionHeader = styled.li`
|
||||
padding: 0 6px 6px 32px;
|
||||
font-size: 12px;
|
||||
color: ${colors.text};
|
||||
`;
|
||||
|
||||
const SuggestionItem = styled.li(
|
||||
({ isActive }) => `
|
||||
color: ${isActive ? colors.active : colorsRaw.grayDark};
|
||||
background-color: ${isActive ? colors.activeBackground : 'inherit'};
|
||||
padding: 6px 6px 6px 32px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
color: ${colors.active};
|
||||
background-color: ${colors.activeBackground};
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const SuggestionDivider = styled.div`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
class CollectionSearch extends React.Component {
|
||||
static propTypes = {
|
||||
collections: ImmutablePropTypes.map.isRequired,
|
||||
collection: ImmutablePropTypes.map,
|
||||
searchTerm: PropTypes.string.isRequired,
|
||||
onSubmit: PropTypes.func.isRequired,
|
||||
t: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
state = {
|
||||
query: this.props.searchTerm,
|
||||
suggestionsVisible: false,
|
||||
// default to the currently selected
|
||||
selectedCollectionIdx: this.getSelectedSelectionBasedOnProps(),
|
||||
};
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (prevProps.collection !== this.props.collection) {
|
||||
const selectedCollectionIdx = this.getSelectedSelectionBasedOnProps();
|
||||
this.setState({ selectedCollectionIdx });
|
||||
}
|
||||
}
|
||||
|
||||
getSelectedSelectionBasedOnProps() {
|
||||
const { collection, collections } = this.props;
|
||||
return collection ? collections.keySeq().indexOf(collection.get('name')) : -1;
|
||||
}
|
||||
|
||||
toggleSuggestions(visible) {
|
||||
this.setState({ suggestionsVisible: visible });
|
||||
}
|
||||
|
||||
selectNextSuggestion() {
|
||||
const { collections } = this.props;
|
||||
const { selectedCollectionIdx } = this.state;
|
||||
this.setState({
|
||||
selectedCollectionIdx: Math.min(selectedCollectionIdx + 1, collections.size - 1),
|
||||
});
|
||||
}
|
||||
|
||||
selectPreviousSuggestion() {
|
||||
const { selectedCollectionIdx } = this.state;
|
||||
this.setState({
|
||||
selectedCollectionIdx: Math.max(selectedCollectionIdx - 1, -1),
|
||||
});
|
||||
}
|
||||
|
||||
resetSelectedSuggestion() {
|
||||
this.setState({
|
||||
selectedCollectionIdx: -1,
|
||||
});
|
||||
}
|
||||
|
||||
submitSearch = () => {
|
||||
const { onSubmit, collections } = this.props;
|
||||
const { selectedCollectionIdx, query } = this.state;
|
||||
|
||||
this.toggleSuggestions(false);
|
||||
if (selectedCollectionIdx !== -1) {
|
||||
onSubmit(query, collections.toIndexedSeq().getIn([selectedCollectionIdx, 'name']));
|
||||
} else {
|
||||
onSubmit(query);
|
||||
}
|
||||
};
|
||||
|
||||
handleKeyDown = event => {
|
||||
const { suggestionsVisible } = this.state;
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
this.submitSearch();
|
||||
}
|
||||
|
||||
if (suggestionsVisible) {
|
||||
// allow closing of suggestions with escape key
|
||||
if (event.key === 'Escape') {
|
||||
this.toggleSuggestions(false);
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
this.selectNextSuggestion();
|
||||
event.preventDefault();
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
this.selectPreviousSuggestion();
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
handleQueryChange = query => {
|
||||
this.setState({ query });
|
||||
this.toggleSuggestions(query !== '');
|
||||
if (query === '') {
|
||||
this.resetSelectedSuggestion();
|
||||
}
|
||||
};
|
||||
|
||||
handleSuggestionClick = (event, idx) => {
|
||||
this.setState({ selectedCollectionIdx: idx }, this.submitSearch);
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
render() {
|
||||
const { collections, t } = this.props;
|
||||
const { suggestionsVisible, selectedCollectionIdx, query } = this.state;
|
||||
return (
|
||||
<SearchContainer
|
||||
onBlur={() => this.toggleSuggestions(false)}
|
||||
onFocus={() => this.toggleSuggestions(query !== '')}
|
||||
>
|
||||
<InputContainer>
|
||||
<Icon type="search" />
|
||||
<SearchInput
|
||||
onChange={e => this.handleQueryChange(e.target.value)}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
onClick={() => this.toggleSuggestions(true)}
|
||||
placeholder={t('collection.sidebar.searchAll')}
|
||||
value={query}
|
||||
/>
|
||||
</InputContainer>
|
||||
{suggestionsVisible && (
|
||||
<SuggestionsContainer>
|
||||
<Suggestions>
|
||||
<SuggestionHeader>{t('collection.sidebar.searchIn')}</SuggestionHeader>
|
||||
<SuggestionItem
|
||||
isActive={selectedCollectionIdx === -1}
|
||||
onClick={e => this.handleSuggestionClick(e, -1)}
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
>
|
||||
{t('collection.sidebar.allCollections')}
|
||||
</SuggestionItem>
|
||||
<SuggestionDivider />
|
||||
{collections.toIndexedSeq().map((collection, idx) => (
|
||||
<SuggestionItem
|
||||
key={idx}
|
||||
isActive={idx === selectedCollectionIdx}
|
||||
onClick={e => this.handleSuggestionClick(e, idx)}
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
>
|
||||
{collection.get('label')}
|
||||
</SuggestionItem>
|
||||
))}
|
||||
</Suggestions>
|
||||
</SuggestionsContainer>
|
||||
)}
|
||||
</SearchContainer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default translate()(CollectionSearch);
|
82
src/components/Collection/CollectionTop.js
Normal file
82
src/components/Collection/CollectionTop.js
Normal file
@ -0,0 +1,82 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import React from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import { translate } from 'react-polyglot';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { components, buttons, shadows } from '../../ui';
|
||||
|
||||
const CollectionTopContainer = styled.div`
|
||||
${components.cardTop};
|
||||
margin-bottom: 22px;
|
||||
`;
|
||||
|
||||
const CollectionTopRow = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
`;
|
||||
|
||||
const CollectionTopHeading = styled.h1`
|
||||
${components.cardTopHeading};
|
||||
`;
|
||||
|
||||
const CollectionTopNewButton = styled(Link)`
|
||||
${buttons.button};
|
||||
${shadows.dropDeep};
|
||||
${buttons.default};
|
||||
${buttons.gray};
|
||||
|
||||
padding: 0 30px;
|
||||
`;
|
||||
|
||||
const CollectionTopDescription = styled.p`
|
||||
${components.cardTopDescription};
|
||||
margin-bottom: 0;
|
||||
`;
|
||||
|
||||
function getCollectionProps(collection) {
|
||||
const collectionLabel = collection.get('label');
|
||||
const collectionLabelSingular = collection.get('label_singular');
|
||||
const collectionDescription = collection.get('description');
|
||||
|
||||
return {
|
||||
collectionLabel,
|
||||
collectionLabelSingular,
|
||||
collectionDescription,
|
||||
};
|
||||
}
|
||||
|
||||
function CollectionTop({ collection, newEntryUrl, t }) {
|
||||
const { collectionLabel, collectionLabelSingular, collectionDescription } = getCollectionProps(
|
||||
collection,
|
||||
t,
|
||||
);
|
||||
|
||||
return (
|
||||
<CollectionTopContainer>
|
||||
<CollectionTopRow>
|
||||
<CollectionTopHeading>{collectionLabel}</CollectionTopHeading>
|
||||
{newEntryUrl ? (
|
||||
<CollectionTopNewButton to={newEntryUrl}>
|
||||
{t('collection.collectionTop.newButton', {
|
||||
collectionLabel: collectionLabelSingular || collectionLabel,
|
||||
})}
|
||||
</CollectionTopNewButton>
|
||||
) : null}
|
||||
</CollectionTopRow>
|
||||
{collectionDescription ? (
|
||||
<CollectionTopDescription>{collectionDescription}</CollectionTopDescription>
|
||||
) : null}
|
||||
</CollectionTopContainer>
|
||||
);
|
||||
}
|
||||
|
||||
CollectionTop.propTypes = {
|
||||
collection: ImmutablePropTypes.map.isRequired,
|
||||
newEntryUrl: PropTypes.string,
|
||||
t: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default translate()(CollectionTop);
|
28
src/components/Collection/ControlButton.js
Normal file
28
src/components/Collection/ControlButton.js
Normal file
@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
import { css } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { buttons, StyledDropdownButton, colors } from '../../ui';
|
||||
|
||||
const Button = styled(StyledDropdownButton)`
|
||||
${buttons.button};
|
||||
${buttons.medium};
|
||||
${buttons.grayText};
|
||||
font-size: 14px;
|
||||
|
||||
&:after {
|
||||
top: 11px;
|
||||
}
|
||||
`;
|
||||
|
||||
export function ControlButton({ active, title }) {
|
||||
return (
|
||||
<Button
|
||||
css={css`
|
||||
color: ${active ? colors.active : undefined};
|
||||
`}
|
||||
>
|
||||
{title}
|
||||
</Button>
|
||||
);
|
||||
}
|
73
src/components/Collection/Entries/Entries.js
Normal file
73
src/components/Collection/Entries/Entries.js
Normal file
@ -0,0 +1,73 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { translate } from 'react-polyglot';
|
||||
|
||||
import { Loader, lengths } from '../../../ui';
|
||||
import EntryListing from './EntryListing';
|
||||
|
||||
const PaginationMessage = styled.div`
|
||||
width: ${lengths.topCardWidth};
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const NoEntriesMessage = styled(PaginationMessage)`
|
||||
margin-top: 16px;
|
||||
`;
|
||||
|
||||
function Entries({
|
||||
collections,
|
||||
entries,
|
||||
isFetching,
|
||||
viewStyle,
|
||||
cursor,
|
||||
handleCursorActions,
|
||||
t,
|
||||
page,
|
||||
}) {
|
||||
const loadingMessages = [
|
||||
t('collection.entries.loadingEntries'),
|
||||
t('collection.entries.cachingEntries'),
|
||||
t('collection.entries.longerLoading'),
|
||||
];
|
||||
|
||||
if (isFetching && page === undefined) {
|
||||
return <Loader active>{loadingMessages}</Loader>;
|
||||
}
|
||||
|
||||
const hasEntries = (entries && entries.size > 0) || cursor?.actions?.has('append_next');
|
||||
if (hasEntries) {
|
||||
return (
|
||||
<>
|
||||
<EntryListing
|
||||
collections={collections}
|
||||
entries={entries}
|
||||
viewStyle={viewStyle}
|
||||
cursor={cursor}
|
||||
handleCursorActions={handleCursorActions}
|
||||
page={page}
|
||||
/>
|
||||
{isFetching && page !== undefined && entries.size > 0 ? (
|
||||
<PaginationMessage>{t('collection.entries.loadingEntries')}</PaginationMessage>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <NoEntriesMessage>{t('collection.entries.noEntries')}</NoEntriesMessage>;
|
||||
}
|
||||
|
||||
Entries.propTypes = {
|
||||
collections: ImmutablePropTypes.iterable.isRequired,
|
||||
entries: ImmutablePropTypes.list,
|
||||
page: PropTypes.number,
|
||||
isFetching: PropTypes.bool,
|
||||
viewStyle: PropTypes.string,
|
||||
cursor: PropTypes.any.isRequired,
|
||||
handleCursorActions: PropTypes.func.isRequired,
|
||||
t: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default translate()(Entries);
|
166
src/components/Collection/Entries/EntriesCollection.js
Normal file
166
src/components/Collection/Entries/EntriesCollection.js
Normal file
@ -0,0 +1,166 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { connect } from 'react-redux';
|
||||
import styled from '@emotion/styled';
|
||||
import { translate } from 'react-polyglot';
|
||||
import { partial } from 'lodash';
|
||||
|
||||
import { colors } from '../../../ui';
|
||||
import { Cursor } from '../../../lib/util';
|
||||
import {
|
||||
loadEntries as actionLoadEntries,
|
||||
traverseCollectionCursor as actionTraverseCollectionCursor,
|
||||
} from '../../../actions/entries';
|
||||
import {
|
||||
selectEntries,
|
||||
selectEntriesLoaded,
|
||||
selectIsFetching,
|
||||
selectGroups,
|
||||
} from '../../../reducers/entries';
|
||||
import { selectCollectionEntriesCursor } from '../../../reducers/cursors';
|
||||
import Entries from './Entries';
|
||||
|
||||
const GroupHeading = styled.h2`
|
||||
font-size: 23px;
|
||||
font-weight: 600;
|
||||
color: ${colors.textLead};
|
||||
`;
|
||||
|
||||
const GroupContainer = styled.div``;
|
||||
|
||||
function getGroupEntries(entries, paths) {
|
||||
return entries.filter(entry => paths.has(entry.get('path')));
|
||||
}
|
||||
|
||||
function getGroupTitle(group, t) {
|
||||
const { label, value } = group;
|
||||
if (value === undefined) {
|
||||
return t('collection.groups.other');
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? label : t('collection.groups.negateLabel', { label });
|
||||
}
|
||||
return `${label} ${value}`.trim();
|
||||
}
|
||||
|
||||
function withGroups(groups, entries, EntriesToRender, t) {
|
||||
return groups.map(group => {
|
||||
const title = getGroupTitle(group, t);
|
||||
return (
|
||||
<GroupContainer key={group.id} id={group.id}>
|
||||
<GroupHeading>{title}</GroupHeading>
|
||||
<EntriesToRender entries={getGroupEntries(entries, group.paths)} />
|
||||
</GroupContainer>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
class EntriesCollection extends React.Component {
|
||||
static propTypes = {
|
||||
collection: ImmutablePropTypes.map.isRequired,
|
||||
page: PropTypes.number,
|
||||
entries: ImmutablePropTypes.list,
|
||||
groups: PropTypes.array,
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
viewStyle: PropTypes.string,
|
||||
cursor: PropTypes.object.isRequired,
|
||||
loadEntries: PropTypes.func.isRequired,
|
||||
traverseCollectionCursor: PropTypes.func.isRequired,
|
||||
entriesLoaded: PropTypes.bool,
|
||||
readyToLoad: PropTypes.bool,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
const { collection, entriesLoaded, loadEntries, readyToLoad } = this.props;
|
||||
if (collection && !entriesLoaded && readyToLoad) {
|
||||
loadEntries(collection);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const { collection, entriesLoaded, loadEntries, readyToLoad } = this.props;
|
||||
if (!entriesLoaded && readyToLoad && !prevProps.readyToLoad) {
|
||||
loadEntries(collection);
|
||||
}
|
||||
}
|
||||
|
||||
handleCursorActions = (cursor, action) => {
|
||||
const { collection, traverseCollectionCursor } = this.props;
|
||||
traverseCollectionCursor(collection, action);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { collection, entries, groups, isFetching, viewStyle, cursor, page, t } = this.props;
|
||||
|
||||
const EntriesToRender = ({ entries }) => {
|
||||
return (
|
||||
<Entries
|
||||
collections={collection}
|
||||
entries={entries}
|
||||
isFetching={isFetching}
|
||||
collectionName={collection.get('label')}
|
||||
viewStyle={viewStyle}
|
||||
cursor={cursor}
|
||||
handleCursorActions={partial(this.handleCursorActions, cursor)}
|
||||
page={page}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
if (groups && groups.length > 0) {
|
||||
return withGroups(groups, entries, EntriesToRender, t);
|
||||
}
|
||||
|
||||
return <EntriesToRender entries={entries} />;
|
||||
}
|
||||
}
|
||||
|
||||
export function filterNestedEntries(path, collectionFolder, entries) {
|
||||
const filtered = entries.filter(e => {
|
||||
const entryPath = e.get('path').slice(collectionFolder.length + 1);
|
||||
if (!entryPath.startsWith(path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// only show immediate children
|
||||
if (path) {
|
||||
// non root path
|
||||
const trimmed = entryPath.slice(path.length + 1);
|
||||
return trimmed.split('/').length === 2;
|
||||
} else {
|
||||
// root path
|
||||
return entryPath.split('/').length <= 2;
|
||||
}
|
||||
});
|
||||
return filtered;
|
||||
}
|
||||
|
||||
function mapStateToProps(state, ownProps) {
|
||||
const { collection, viewStyle, filterTerm } = ownProps;
|
||||
const page = state.entries.getIn(['pages', collection.get('name'), 'page']);
|
||||
|
||||
let entries = selectEntries(state.entries, collection);
|
||||
const groups = selectGroups(state.entries, collection);
|
||||
|
||||
if (collection.has('nested')) {
|
||||
const collectionFolder = collection.get('folder');
|
||||
entries = filterNestedEntries(filterTerm || '', collectionFolder, entries);
|
||||
}
|
||||
const entriesLoaded = selectEntriesLoaded(state.entries, collection.get('name'));
|
||||
const isFetching = selectIsFetching(state.entries, collection.get('name'));
|
||||
|
||||
const rawCursor = selectCollectionEntriesCursor(state.cursors, collection.get('name'));
|
||||
const cursor = Cursor.create(rawCursor).clearData();
|
||||
|
||||
return { collection, page, entries, groups, entriesLoaded, isFetching, viewStyle, cursor };
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
loadEntries: actionLoadEntries,
|
||||
traverseCollectionCursor: actionTraverseCollectionCursor,
|
||||
};
|
||||
|
||||
const ConnectedEntriesCollection = connect(mapStateToProps, mapDispatchToProps)(EntriesCollection);
|
||||
|
||||
export default translate()(ConnectedEntriesCollection);
|
91
src/components/Collection/Entries/EntriesSearch.js
Normal file
91
src/components/Collection/Entries/EntriesSearch.js
Normal file
@ -0,0 +1,91 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { connect } from 'react-redux';
|
||||
import { isEqual } from 'lodash';
|
||||
|
||||
import { Cursor } from '../../../lib/util';
|
||||
import { selectSearchedEntries } from '../../../reducers';
|
||||
import {
|
||||
searchEntries as actionSearchEntries,
|
||||
clearSearch as actionClearSearch,
|
||||
} from '../../../actions/search';
|
||||
import Entries from './Entries';
|
||||
|
||||
class EntriesSearch extends React.Component {
|
||||
static propTypes = {
|
||||
isFetching: PropTypes.bool,
|
||||
searchEntries: PropTypes.func.isRequired,
|
||||
clearSearch: PropTypes.func.isRequired,
|
||||
searchTerm: PropTypes.string.isRequired,
|
||||
collections: ImmutablePropTypes.seq,
|
||||
collectionNames: PropTypes.array,
|
||||
entries: ImmutablePropTypes.list,
|
||||
page: PropTypes.number,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
const { searchTerm, searchEntries, collectionNames } = this.props;
|
||||
searchEntries(searchTerm, collectionNames);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const { searchTerm, collectionNames } = this.props;
|
||||
|
||||
// check if the search parameters are the same
|
||||
if (prevProps.searchTerm === searchTerm && isEqual(prevProps.collectionNames, collectionNames))
|
||||
return;
|
||||
|
||||
const { searchEntries } = prevProps;
|
||||
searchEntries(searchTerm, collectionNames);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.props.clearSearch();
|
||||
}
|
||||
|
||||
getCursor = () => {
|
||||
const { page } = this.props;
|
||||
return Cursor.create({
|
||||
actions: isNaN(page) ? [] : ['append_next'],
|
||||
});
|
||||
};
|
||||
|
||||
handleCursorActions = action => {
|
||||
const { page, searchTerm, searchEntries, collectionNames } = this.props;
|
||||
if (action === 'append_next') {
|
||||
const nextPage = page + 1;
|
||||
searchEntries(searchTerm, collectionNames, nextPage);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { collections, entries, isFetching } = this.props;
|
||||
return (
|
||||
<Entries
|
||||
cursor={this.getCursor()}
|
||||
handleCursorActions={this.handleCursorActions}
|
||||
collections={collections}
|
||||
entries={entries}
|
||||
isFetching={isFetching}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state, ownProps) {
|
||||
const { searchTerm } = ownProps;
|
||||
const collections = ownProps.collections.toIndexedSeq();
|
||||
const collectionNames = ownProps.collections.keySeq().toArray();
|
||||
const isFetching = state.search.isFetching;
|
||||
const page = state.search.page;
|
||||
const entries = selectSearchedEntries(state, collectionNames);
|
||||
return { isFetching, page, collections, collectionNames, entries, searchTerm };
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
searchEntries: actionSearchEntries,
|
||||
clearSearch: actionClearSearch,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(EntriesSearch);
|
167
src/components/Collection/Entries/EntryCard.js
Normal file
167
src/components/Collection/Entries/EntryCard.js
Normal file
@ -0,0 +1,167 @@
|
||||
import React from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import { connect } from 'react-redux';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { colors, colorsRaw, components, lengths, zIndex } from '../../../ui';
|
||||
import { boundGetAsset } from '../../../actions/media';
|
||||
import { VIEW_STYLE_LIST, VIEW_STYLE_GRID } from '../../../constants/collectionViews';
|
||||
import { selectIsLoadingAsset } from '../../../reducers/medias';
|
||||
import { selectEntryCollectionTitle } from '../../../reducers/collections';
|
||||
|
||||
const ListCard = styled.li`
|
||||
${components.card};
|
||||
width: ${lengths.topCardWidth};
|
||||
margin-left: 12px;
|
||||
margin-bottom: 10px;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const ListCardLink = styled(Link)`
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
padding: 16px 22px;
|
||||
|
||||
&:hover {
|
||||
background-color: ${colors.foreground};
|
||||
}
|
||||
`;
|
||||
|
||||
const GridCard = styled.li`
|
||||
${components.card};
|
||||
flex: 0 0 335px;
|
||||
height: 240px;
|
||||
overflow: hidden;
|
||||
margin-left: 12px;
|
||||
margin-bottom: 16px;
|
||||
`;
|
||||
|
||||
const GridCardLink = styled(Link)`
|
||||
display: block;
|
||||
height: 100%;
|
||||
outline-offset: -2px;
|
||||
|
||||
&,
|
||||
&:hover {
|
||||
background-color: ${colors.foreground};
|
||||
color: ${colors.text};
|
||||
}
|
||||
`;
|
||||
|
||||
const CollectionLabel = styled.h2`
|
||||
font-size: 12px;
|
||||
color: ${colors.textLead};
|
||||
text-transform: uppercase;
|
||||
`;
|
||||
|
||||
const ListCardTitle = styled.h2`
|
||||
margin-bottom: 0;
|
||||
`;
|
||||
|
||||
const CardHeading = styled.h2`
|
||||
margin: 0 0 2px;
|
||||
`;
|
||||
|
||||
const CardBody = styled.div`
|
||||
padding: 16px 22px;
|
||||
height: 90px;
|
||||
position: relative;
|
||||
margin-bottom: ${props => props.hasImage && 0};
|
||||
|
||||
&:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
display: block;
|
||||
z-index: ${zIndex.zIndex1};
|
||||
bottom: 0;
|
||||
left: -20%;
|
||||
height: 140%;
|
||||
width: 140%;
|
||||
box-shadow: inset 0 -15px 24px ${colorsRaw.white};
|
||||
}
|
||||
`;
|
||||
|
||||
const CardImage = styled.div`
|
||||
background-image: url(${props => props.src});
|
||||
background-position: center center;
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
height: 150px;
|
||||
`;
|
||||
|
||||
function EntryCard({
|
||||
path,
|
||||
summary,
|
||||
image,
|
||||
imageField,
|
||||
collectionLabel,
|
||||
viewStyle = VIEW_STYLE_LIST,
|
||||
getAsset,
|
||||
}) {
|
||||
if (viewStyle === VIEW_STYLE_LIST) {
|
||||
return (
|
||||
<ListCard>
|
||||
<ListCardLink to={path}>
|
||||
{collectionLabel ? <CollectionLabel>{collectionLabel}</CollectionLabel> : null}
|
||||
<ListCardTitle>{summary}</ListCardTitle>
|
||||
</ListCardLink>
|
||||
</ListCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (viewStyle === VIEW_STYLE_GRID) {
|
||||
return (
|
||||
<GridCard>
|
||||
<GridCardLink to={path}>
|
||||
<CardBody hasImage={image}>
|
||||
{collectionLabel ? <CollectionLabel>{collectionLabel}</CollectionLabel> : null}
|
||||
<CardHeading>{summary}</CardHeading>
|
||||
</CardBody>
|
||||
{image ? <CardImage src={getAsset(image, imageField).toString()} /> : null}
|
||||
</GridCardLink>
|
||||
</GridCard>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state, ownProps) {
|
||||
const { entry, inferedFields, collection } = ownProps;
|
||||
const entryData = entry.get('data');
|
||||
const summary = selectEntryCollectionTitle(collection, entry);
|
||||
|
||||
let image = entryData.get(inferedFields.imageField);
|
||||
if (image) {
|
||||
image = encodeURI(image);
|
||||
}
|
||||
|
||||
const isLoadingAsset = selectIsLoadingAsset(state.medias);
|
||||
|
||||
return {
|
||||
summary,
|
||||
path: `/collections/${collection.get('name')}/entries/${entry.get('slug')}`,
|
||||
image,
|
||||
imageFolder: collection
|
||||
.get('fields')
|
||||
?.find(f => f.get('name') === inferedFields.imageField && f.get('widget') === 'image'),
|
||||
isLoadingAsset,
|
||||
};
|
||||
}
|
||||
|
||||
function mapDispatchToProps(dispatch) {
|
||||
return {
|
||||
boundGetAsset: (collection, entry) => boundGetAsset(dispatch, collection, entry),
|
||||
};
|
||||
}
|
||||
|
||||
function mergeProps(stateProps, dispatchProps, ownProps) {
|
||||
return {
|
||||
...stateProps,
|
||||
...dispatchProps,
|
||||
...ownProps,
|
||||
getAsset: dispatchProps.boundGetAsset(ownProps.collection, ownProps.entry),
|
||||
};
|
||||
}
|
||||
|
||||
const ConnectedEntryCard = connect(mapStateToProps, mapDispatchToProps, mergeProps)(EntryCard);
|
||||
|
||||
export default ConnectedEntryCard;
|
87
src/components/Collection/Entries/EntryListing.js
Normal file
87
src/components/Collection/Entries/EntryListing.js
Normal file
@ -0,0 +1,87 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import styled from '@emotion/styled';
|
||||
import { Waypoint } from 'react-waypoint';
|
||||
import { Map } from 'immutable';
|
||||
|
||||
import { selectFields, selectInferedField } from '../../../reducers/collections';
|
||||
import EntryCard from './EntryCard';
|
||||
|
||||
const CardsGrid = styled.ul`
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
list-style-type: none;
|
||||
margin-left: -12px;
|
||||
margin-top: 16px;
|
||||
margin-bottom: 16px;
|
||||
padding-left: 0;
|
||||
`;
|
||||
|
||||
export default class EntryListing extends React.Component {
|
||||
static propTypes = {
|
||||
collections: ImmutablePropTypes.iterable.isRequired,
|
||||
entries: ImmutablePropTypes.list,
|
||||
viewStyle: PropTypes.string,
|
||||
cursor: PropTypes.any.isRequired,
|
||||
handleCursorActions: PropTypes.func.isRequired,
|
||||
page: PropTypes.number,
|
||||
};
|
||||
|
||||
hasMore = () => {
|
||||
const hasMore = this.props.cursor?.actions?.has('append_next');
|
||||
return hasMore;
|
||||
};
|
||||
|
||||
handleLoadMore = () => {
|
||||
if (this.hasMore()) {
|
||||
this.props.handleCursorActions('append_next');
|
||||
}
|
||||
};
|
||||
|
||||
inferFields = collection => {
|
||||
const titleField = selectInferedField(collection, 'title');
|
||||
const descriptionField = selectInferedField(collection, 'description');
|
||||
const imageField = selectInferedField(collection, 'image');
|
||||
const fields = selectFields(collection);
|
||||
const inferedFields = [titleField, descriptionField, imageField];
|
||||
const remainingFields =
|
||||
fields && fields.filter(f => inferedFields.indexOf(f.get('name')) === -1);
|
||||
return { titleField, descriptionField, imageField, remainingFields };
|
||||
};
|
||||
|
||||
renderCardsForSingleCollection = () => {
|
||||
const { collections, entries, viewStyle } = this.props;
|
||||
const inferedFields = this.inferFields(collections);
|
||||
const entryCardProps = { collection: collections, inferedFields, viewStyle };
|
||||
return entries.map((entry, idx) => <EntryCard {...entryCardProps} entry={entry} key={idx} />);
|
||||
};
|
||||
|
||||
renderCardsForMultipleCollections = () => {
|
||||
const { collections, entries } = this.props;
|
||||
const isSingleCollectionInList = collections.size === 1;
|
||||
return entries.map((entry, idx) => {
|
||||
const collectionName = entry.get('collection');
|
||||
const collection = collections.find(coll => coll.get('name') === collectionName);
|
||||
const collectionLabel = !isSingleCollectionInList && collection.get('label');
|
||||
const inferedFields = this.inferFields(collection);
|
||||
const entryCardProps = { collection, entry, inferedFields, collectionLabel };
|
||||
return <EntryCard {...entryCardProps} key={idx} />;
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const { collections, page } = this.props;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<CardsGrid>
|
||||
{Map.isMap(collections)
|
||||
? this.renderCardsForSingleCollection()
|
||||
: this.renderCardsForMultipleCollections()}
|
||||
{this.hasMore() && <Waypoint key={page} onEnter={this.handleLoadMore} />}
|
||||
</CardsGrid>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
39
src/components/Collection/FilterControl.js
Normal file
39
src/components/Collection/FilterControl.js
Normal file
@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
|
||||
import { Dropdown, DropdownCheckedItem } from '../../ui';
|
||||
import { ControlButton } from './ControlButton';
|
||||
|
||||
function FilterControl({ viewFilters, t, onFilterClick, filter }) {
|
||||
const hasActiveFilter = filter
|
||||
?.valueSeq()
|
||||
.toJS()
|
||||
.some(f => f.active === true);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
renderButton={() => {
|
||||
return (
|
||||
<ControlButton active={hasActiveFilter} title={t('collection.collectionTop.filterBy')} />
|
||||
);
|
||||
}}
|
||||
closeOnSelection={false}
|
||||
dropdownTopOverlap="30px"
|
||||
dropdownPosition="left"
|
||||
>
|
||||
{viewFilters.map(viewFilter => {
|
||||
return (
|
||||
<DropdownCheckedItem
|
||||
key={viewFilter.id}
|
||||
label={viewFilter.label}
|
||||
id={viewFilter.id}
|
||||
checked={filter.getIn([viewFilter.id, 'active'], false)}
|
||||
onClick={() => onFilterClick(viewFilter)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
export default translate()(FilterControl);
|
39
src/components/Collection/GroupControl.js
Normal file
39
src/components/Collection/GroupControl.js
Normal file
@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
|
||||
import { Dropdown, DropdownItem } from '../../ui';
|
||||
import { ControlButton } from './ControlButton';
|
||||
|
||||
function GroupControl({ viewGroups, t, onGroupClick, group }) {
|
||||
const hasActiveGroup = group
|
||||
?.valueSeq()
|
||||
.toJS()
|
||||
.some(f => f.active === true);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
renderButton={() => {
|
||||
return (
|
||||
<ControlButton active={hasActiveGroup} title={t('collection.collectionTop.groupBy')} />
|
||||
);
|
||||
}}
|
||||
closeOnSelection={false}
|
||||
dropdownTopOverlap="30px"
|
||||
dropdownWidth="160px"
|
||||
dropdownPosition="left"
|
||||
>
|
||||
{viewGroups.map(viewGroup => {
|
||||
return (
|
||||
<DropdownItem
|
||||
key={viewGroup.id}
|
||||
label={viewGroup.label}
|
||||
onClick={() => onGroupClick(viewGroup)}
|
||||
isActive={group.getIn([viewGroup.id, 'active'], false)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
export default translate()(GroupControl);
|
309
src/components/Collection/NestedCollection.js
Normal file
309
src/components/Collection/NestedCollection.js
Normal file
@ -0,0 +1,309 @@
|
||||
import React from 'react';
|
||||
import { List } from 'immutable';
|
||||
import { css } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { connect } from 'react-redux';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { dirname, sep } from 'path';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { sortBy } from 'lodash';
|
||||
|
||||
import { Icon, colors, components } from '../../ui';
|
||||
import { stringTemplate } from '../../lib/widgets';
|
||||
import { selectEntries } from '../../reducers/entries';
|
||||
import { selectEntryCollectionTitle } from '../../reducers/collections';
|
||||
|
||||
const { addFileTemplateFields } = stringTemplate;
|
||||
|
||||
const NodeTitleContainer = styled.div`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const NodeTitle = styled.div`
|
||||
margin-right: 4px;
|
||||
`;
|
||||
|
||||
const Caret = styled.div`
|
||||
position: relative;
|
||||
top: 2px;
|
||||
`;
|
||||
|
||||
const CaretDown = styled(Caret)`
|
||||
${components.caretDown};
|
||||
color: currentColor;
|
||||
`;
|
||||
|
||||
const CaretRight = styled(Caret)`
|
||||
${components.caretRight};
|
||||
color: currentColor;
|
||||
left: 2px;
|
||||
`;
|
||||
|
||||
const TreeNavLink = styled(NavLink)`
|
||||
display: flex;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
padding-left: ${props => props.depth * 20 + 12}px;
|
||||
border-left: 2px solid #fff;
|
||||
|
||||
${Icon} {
|
||||
margin-right: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
${props => css`
|
||||
&:hover,
|
||||
&:active,
|
||||
&.${props.activeClassName} {
|
||||
color: ${colors.active};
|
||||
background-color: ${colors.activeBackground};
|
||||
border-left-color: #4863c6;
|
||||
}
|
||||
`};
|
||||
`;
|
||||
|
||||
function getNodeTitle(node) {
|
||||
const title = node.isRoot
|
||||
? node.title
|
||||
: node.children.find(c => !c.isDir && c.title)?.title || node.title;
|
||||
return title;
|
||||
}
|
||||
|
||||
function TreeNode(props) {
|
||||
const { collection, treeData, depth = 0, onToggle } = props;
|
||||
const collectionName = collection.get('name');
|
||||
|
||||
const sortedData = sortBy(treeData, getNodeTitle);
|
||||
return sortedData.map(node => {
|
||||
const leaf = node.children.length <= 1 && !node.children[0]?.isDir && depth > 0;
|
||||
if (leaf) {
|
||||
return null;
|
||||
}
|
||||
let to = `/collections/${collectionName}`;
|
||||
if (depth > 0) {
|
||||
to = `${to}/filter${node.path}`;
|
||||
}
|
||||
const title = getNodeTitle(node);
|
||||
|
||||
const hasChildren = depth === 0 || node.children.some(c => c.children.some(c => c.isDir));
|
||||
|
||||
return (
|
||||
<React.Fragment key={node.path}>
|
||||
<TreeNavLink
|
||||
exact
|
||||
to={to}
|
||||
activeClassName="sidebar-active"
|
||||
onClick={() => onToggle({ node, expanded: !node.expanded })}
|
||||
depth={depth}
|
||||
data-testid={node.path}
|
||||
>
|
||||
<Icon type="write" />
|
||||
<NodeTitleContainer>
|
||||
<NodeTitle>{title}</NodeTitle>
|
||||
{hasChildren && (node.expanded ? <CaretDown /> : <CaretRight />)}
|
||||
</NodeTitleContainer>
|
||||
</TreeNavLink>
|
||||
{node.expanded && (
|
||||
<TreeNode
|
||||
collection={collection}
|
||||
depth={depth + 1}
|
||||
treeData={node.children}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
TreeNode.propTypes = {
|
||||
collection: ImmutablePropTypes.map.isRequired,
|
||||
depth: PropTypes.number,
|
||||
treeData: PropTypes.array.isRequired,
|
||||
onToggle: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export function walk(treeData, callback) {
|
||||
function traverse(children) {
|
||||
for (const child of children) {
|
||||
callback(child);
|
||||
traverse(child.children);
|
||||
}
|
||||
}
|
||||
|
||||
return traverse(treeData);
|
||||
}
|
||||
|
||||
export function getTreeData(collection, entries) {
|
||||
const collectionFolder = collection.get('folder');
|
||||
const rootFolder = '/';
|
||||
const entriesObj = entries
|
||||
.toJS()
|
||||
.map(e => ({ ...e, path: e.path.slice(collectionFolder.length) }));
|
||||
|
||||
const dirs = entriesObj.reduce((acc, entry) => {
|
||||
let dir = dirname(entry.path);
|
||||
while (!acc[dir] && dir && dir !== rootFolder) {
|
||||
const parts = dir.split(sep);
|
||||
acc[dir] = parts.pop();
|
||||
dir = parts.length && parts.join(sep);
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
if (collection.getIn(['nested', 'summary'])) {
|
||||
collection = collection.set('summary', collection.getIn(['nested', 'summary']));
|
||||
} else {
|
||||
collection = collection.delete('summary');
|
||||
}
|
||||
|
||||
const flatData = [
|
||||
{
|
||||
title: collection.get('label'),
|
||||
path: rootFolder,
|
||||
isDir: true,
|
||||
isRoot: true,
|
||||
},
|
||||
...Object.entries(dirs).map(([key, value]) => ({
|
||||
title: value,
|
||||
path: key,
|
||||
isDir: true,
|
||||
isRoot: false,
|
||||
})),
|
||||
...entriesObj.map((e, index) => {
|
||||
let entryMap = entries.get(index);
|
||||
entryMap = entryMap.set(
|
||||
'data',
|
||||
addFileTemplateFields(entryMap.get('path'), entryMap.get('data')),
|
||||
);
|
||||
const title = selectEntryCollectionTitle(collection, entryMap);
|
||||
return {
|
||||
...e,
|
||||
title,
|
||||
isDir: false,
|
||||
isRoot: false,
|
||||
};
|
||||
}),
|
||||
];
|
||||
|
||||
const parentsToChildren = flatData.reduce((acc, node) => {
|
||||
const parent = node.path === rootFolder ? '' : dirname(node.path);
|
||||
if (acc[parent]) {
|
||||
acc[parent].push(node);
|
||||
} else {
|
||||
acc[parent] = [node];
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
function reducer(acc, value) {
|
||||
const node = value;
|
||||
let children = [];
|
||||
if (parentsToChildren[node.path]) {
|
||||
children = parentsToChildren[node.path].reduce(reducer, []);
|
||||
}
|
||||
|
||||
acc.push({ ...node, children });
|
||||
return acc;
|
||||
}
|
||||
|
||||
const treeData = parentsToChildren[''].reduce(reducer, []);
|
||||
|
||||
return treeData;
|
||||
}
|
||||
|
||||
export function updateNode(treeData, node, callback) {
|
||||
let stop = false;
|
||||
|
||||
function updater(nodes) {
|
||||
if (stop) {
|
||||
return nodes;
|
||||
}
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
if (nodes[i].path === node.path) {
|
||||
nodes[i] = callback(node);
|
||||
stop = true;
|
||||
return nodes;
|
||||
}
|
||||
}
|
||||
nodes.forEach(node => updater(node.children));
|
||||
return nodes;
|
||||
}
|
||||
|
||||
return updater([...treeData]);
|
||||
}
|
||||
|
||||
export class NestedCollection extends React.Component {
|
||||
static propTypes = {
|
||||
collection: ImmutablePropTypes.map.isRequired,
|
||||
entries: ImmutablePropTypes.list.isRequired,
|
||||
filterTerm: PropTypes.string,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
treeData: getTreeData(this.props.collection, this.props.entries),
|
||||
selected: null,
|
||||
useFilter: true,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const { collection, entries, filterTerm } = this.props;
|
||||
if (
|
||||
collection !== prevProps.collection ||
|
||||
entries !== prevProps.entries ||
|
||||
filterTerm !== prevProps.filterTerm
|
||||
) {
|
||||
const expanded = {};
|
||||
walk(this.state.treeData, node => {
|
||||
if (node.expanded) {
|
||||
expanded[node.path] = true;
|
||||
}
|
||||
});
|
||||
const treeData = getTreeData(collection, entries);
|
||||
|
||||
const path = `/${filterTerm}`;
|
||||
walk(treeData, node => {
|
||||
if (expanded[node.path] || (this.state.useFilter && path.startsWith(node.path))) {
|
||||
node.expanded = true;
|
||||
}
|
||||
});
|
||||
this.setState({ treeData });
|
||||
}
|
||||
}
|
||||
|
||||
onToggle = ({ node, expanded }) => {
|
||||
if (!this.state.selected || this.state.selected.path === node.path || expanded) {
|
||||
const treeData = updateNode(this.state.treeData, node, node => ({
|
||||
...node,
|
||||
expanded,
|
||||
}));
|
||||
this.setState({ treeData, selected: node, useFilter: false });
|
||||
} else {
|
||||
// don't collapse non selected nodes when clicked
|
||||
this.setState({ selected: node, useFilter: false });
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { treeData } = this.state;
|
||||
const { collection } = this.props;
|
||||
|
||||
return <TreeNode collection={collection} treeData={treeData} onToggle={this.onToggle} />;
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state, ownProps) {
|
||||
const { collection } = ownProps;
|
||||
const entries = selectEntries(state.entries, collection) || List();
|
||||
return { entries };
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, null)(NestedCollection);
|
237
src/components/Collection/Sidebar.js
Normal file
237
src/components/Collection/Sidebar.js
Normal file
@ -0,0 +1,237 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import styled from '@emotion/styled';
|
||||
import { css } from '@emotion/react';
|
||||
import { translate } from 'react-polyglot';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
import { Icon, components, colors } from '../../ui';
|
||||
import { searchCollections } from '../../actions/collections';
|
||||
import CollectionSearch from './CollectionSearch';
|
||||
import NestedCollection from './NestedCollection';
|
||||
import { getAdditionalLinks, getIcon } from '../../lib/registry';
|
||||
|
||||
const styles = {
|
||||
sidebarNavLinkActive: css`
|
||||
color: ${colors.active};
|
||||
background-color: ${colors.activeBackground};
|
||||
border-left-color: #4863c6;
|
||||
`,
|
||||
};
|
||||
|
||||
const SidebarContainer = styled.aside`
|
||||
${components.card};
|
||||
width: 250px;
|
||||
padding: 8px 0 12px;
|
||||
position: fixed;
|
||||
max-height: calc(100vh - 112px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
const SidebarHeading = styled.h2`
|
||||
font-size: 23px;
|
||||
font-weight: 600;
|
||||
padding: 0;
|
||||
margin: 18px 12px 12px;
|
||||
color: ${colors.textLead};
|
||||
`;
|
||||
|
||||
const SidebarNavList = styled.ul`
|
||||
margin: 16px 0 0;
|
||||
padding-left: 0;
|
||||
list-style: none;
|
||||
overflow: auto;
|
||||
`;
|
||||
|
||||
const SidebarNavLink = styled(NavLink)`
|
||||
display: flex;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border-left: 2px solid #fff;
|
||||
z-index: -1;
|
||||
|
||||
${Icon} {
|
||||
margin-right: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
${props => css`
|
||||
&:hover,
|
||||
&:active,
|
||||
&.${props.activeClassName} {
|
||||
${styles.sidebarNavLinkActive};
|
||||
}
|
||||
`};
|
||||
`;
|
||||
|
||||
const AdditionalLink = styled.a`
|
||||
display: flex;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border-left: 2px solid #fff;
|
||||
z-index: -1;
|
||||
|
||||
${Icon} {
|
||||
margin-right: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:active {
|
||||
${styles.sidebarNavLinkActive};
|
||||
}
|
||||
`;
|
||||
|
||||
const IconWrapper = styled.div`
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 8px;
|
||||
`;
|
||||
|
||||
class Sidebar extends React.Component {
|
||||
static propTypes = {
|
||||
collections: ImmutablePropTypes.map.isRequired,
|
||||
collection: ImmutablePropTypes.map,
|
||||
isSearchEnabled: PropTypes.bool,
|
||||
searchTerm: PropTypes.string,
|
||||
filterTerm: PropTypes.string,
|
||||
t: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
renderLink = (collection, filterTerm) => {
|
||||
const collectionName = collection.get('name');
|
||||
const iconName = collection.get('icon');
|
||||
let icon = <Icon type="write" />;
|
||||
if (iconName) {
|
||||
const storedIcon = getIcon(iconName);
|
||||
if (storedIcon) {
|
||||
icon = storedIcon;
|
||||
}
|
||||
}
|
||||
if (collection.has('nested')) {
|
||||
return (
|
||||
<li key={collectionName}>
|
||||
<NestedCollection
|
||||
collection={collection}
|
||||
filterTerm={filterTerm}
|
||||
data-testid={collectionName}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<li key={collectionName}>
|
||||
<SidebarNavLink
|
||||
to={`/collections/${collectionName}`}
|
||||
activeClassName="sidebar-active"
|
||||
data-testid={collectionName}
|
||||
>
|
||||
{icon}
|
||||
{collection.get('label')}
|
||||
</SidebarNavLink>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
renderAdditionalLink = ({ id, title, data, iconName }) => {
|
||||
let icon = <Icon type="write" />;
|
||||
if (iconName) {
|
||||
const storedIcon = getIcon(iconName);
|
||||
if (storedIcon) {
|
||||
icon = storedIcon;
|
||||
}
|
||||
}
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<IconWrapper>{icon}</IconWrapper>
|
||||
{title}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<li key={title}>
|
||||
{typeof data === 'string' ? (
|
||||
<AdditionalLink href={data} target="_blank" rel="noopener">
|
||||
{content}
|
||||
</AdditionalLink>
|
||||
) : (
|
||||
<SidebarNavLink to={`/page/${id}`} activeClassName="sidebar-active">
|
||||
{content}
|
||||
</SidebarNavLink>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
renderLink = (collection, filterTerm) => {
|
||||
const collectionName = collection.get('name');
|
||||
const iconName = collection.get('icon');
|
||||
let icon = <Icon type="write" />;
|
||||
if (iconName) {
|
||||
const storedIcon = getIcon(iconName);
|
||||
if (storedIcon) {
|
||||
icon = storedIcon;
|
||||
}
|
||||
}
|
||||
if (collection.has('nested')) {
|
||||
return (
|
||||
<li key={collectionName}>
|
||||
<NestedCollection
|
||||
collection={collection}
|
||||
filterTerm={filterTerm}
|
||||
data-testid={collectionName}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<li key={collectionName}>
|
||||
<SidebarNavLink
|
||||
to={`/collections/${collectionName}`}
|
||||
activeClassName="sidebar-active"
|
||||
data-testid={collectionName}
|
||||
>
|
||||
<IconWrapper>{icon}</IconWrapper>
|
||||
{collection.get('label')}
|
||||
</SidebarNavLink>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { collections, collection, isSearchEnabled, searchTerm, t, filterTerm } = this.props;
|
||||
const additionalLinks = getAdditionalLinks();
|
||||
return (
|
||||
<SidebarContainer>
|
||||
<SidebarHeading>{t('collection.sidebar.collections')}</SidebarHeading>
|
||||
{isSearchEnabled && (
|
||||
<CollectionSearch
|
||||
searchTerm={searchTerm}
|
||||
collections={collections}
|
||||
collection={collection}
|
||||
onSubmit={(query, collection) => searchCollections(query, collection)}
|
||||
/>
|
||||
)}
|
||||
<SidebarNavList>
|
||||
{collections
|
||||
.toList()
|
||||
.filter(collection => collection.get('hide') !== true)
|
||||
.map(collection => this.renderLink(collection, filterTerm))}
|
||||
{Object.values(additionalLinks).map(this.renderAdditionalLink)}
|
||||
</SidebarNavList>
|
||||
</SidebarContainer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default translate()(Sidebar);
|
68
src/components/Collection/SortControl.js
Normal file
68
src/components/Collection/SortControl.js
Normal file
@ -0,0 +1,68 @@
|
||||
import React from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
|
||||
import { SortDirection } from '../../interface';
|
||||
import { Dropdown, DropdownItem } from '../../ui';
|
||||
import { ControlButton } from './ControlButton';
|
||||
|
||||
function nextSortDirection(direction) {
|
||||
switch (direction) {
|
||||
case SortDirection.Ascending:
|
||||
return SortDirection.Descending;
|
||||
case SortDirection.Descending:
|
||||
return SortDirection.None;
|
||||
default:
|
||||
return SortDirection.Ascending;
|
||||
}
|
||||
}
|
||||
|
||||
function sortIconProps(sortDir) {
|
||||
return {
|
||||
icon: 'chevron',
|
||||
iconDirection: sortIconDirections[sortDir],
|
||||
iconSmall: true,
|
||||
};
|
||||
}
|
||||
|
||||
const sortIconDirections = {
|
||||
[SortDirection.Ascending]: 'up',
|
||||
[SortDirection.Descending]: 'down',
|
||||
};
|
||||
|
||||
function SortControl({ t, fields, onSortClick, sort }) {
|
||||
const hasActiveSort = sort
|
||||
?.valueSeq()
|
||||
.toJS()
|
||||
.some(s => s.direction !== SortDirection.None);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
renderButton={() => {
|
||||
return (
|
||||
<ControlButton active={hasActiveSort} title={t('collection.collectionTop.sortBy')} />
|
||||
);
|
||||
}}
|
||||
closeOnSelection={false}
|
||||
dropdownTopOverlap="30px"
|
||||
dropdownWidth="160px"
|
||||
dropdownPosition="left"
|
||||
>
|
||||
{fields.map(field => {
|
||||
const sortDir = sort?.getIn([field.key, 'direction']);
|
||||
const isActive = sortDir && sortDir !== SortDirection.None;
|
||||
const nextSortDir = nextSortDirection(sortDir);
|
||||
return (
|
||||
<DropdownItem
|
||||
key={field.key}
|
||||
label={field.label}
|
||||
onClick={() => onSortClick(field.key, nextSortDir)}
|
||||
isActive={isActive}
|
||||
{...(isActive && sortIconProps(sortDir))}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
export default translate()(SortControl);
|
50
src/components/Collection/ViewStyleControl.js
Normal file
50
src/components/Collection/ViewStyleControl.js
Normal file
@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { Icon, buttons, colors } from '../../ui';
|
||||
import { VIEW_STYLE_LIST, VIEW_STYLE_GRID } from '../../constants/collectionViews';
|
||||
|
||||
const ViewControlsSection = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
max-width: 500px;
|
||||
`;
|
||||
|
||||
const ViewControlsButton = styled.button`
|
||||
${buttons.button};
|
||||
color: ${props => (props.isActive ? colors.active : '#b3b9c4')};
|
||||
background-color: transparent;
|
||||
display: block;
|
||||
padding: 0;
|
||||
margin: 0 4px;
|
||||
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
${Icon} {
|
||||
display: block;
|
||||
}
|
||||
`;
|
||||
|
||||
function ViewStyleControl({ viewStyle, onChangeViewStyle }) {
|
||||
return (
|
||||
<ViewControlsSection>
|
||||
<ViewControlsButton
|
||||
isActive={viewStyle === VIEW_STYLE_LIST}
|
||||
onClick={() => onChangeViewStyle(VIEW_STYLE_LIST)}
|
||||
>
|
||||
<Icon type="list" />
|
||||
</ViewControlsButton>
|
||||
<ViewControlsButton
|
||||
isActive={viewStyle === VIEW_STYLE_GRID}
|
||||
onClick={() => onChangeViewStyle(VIEW_STYLE_GRID)}
|
||||
>
|
||||
<Icon type="grid" />
|
||||
</ViewControlsButton>
|
||||
</ViewControlsSection>
|
||||
);
|
||||
}
|
||||
|
||||
export default ViewStyleControl;
|
570
src/components/Editor/Editor.js
Normal file
570
src/components/Editor/Editor.js
Normal file
@ -0,0 +1,570 @@
|
||||
import { debounce } from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { translate } from 'react-polyglot';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { logoutUser } from '../../actions/auth';
|
||||
import { loadDeployPreview } from '../../actions/deploys';
|
||||
import {
|
||||
deleteUnpublishedEntry,
|
||||
publishUnpublishedEntry,
|
||||
unpublishPublishedEntry,
|
||||
updateUnpublishedEntryStatus,
|
||||
} from '../../actions/editorialWorkflow';
|
||||
import {
|
||||
changeDraftField,
|
||||
changeDraftFieldValidation,
|
||||
createDraftDuplicateFromEntry,
|
||||
createEmptyDraft,
|
||||
deleteEntry,
|
||||
deleteLocalBackup,
|
||||
discardDraft,
|
||||
loadEntries,
|
||||
loadEntry,
|
||||
loadLocalBackup,
|
||||
persistEntry,
|
||||
persistLocalBackup,
|
||||
retrieveLocalBackup,
|
||||
} from '../../actions/entries';
|
||||
import { loadScroll, toggleScroll } from '../../actions/scroll';
|
||||
import { EDITORIAL_WORKFLOW, status } from '../../constants/publishModes';
|
||||
import { selectDeployPreview, selectEntry, selectUnpublishedEntry } from '../../reducers';
|
||||
import { selectFields } from '../../reducers/collections';
|
||||
import { history, navigateToCollection, navigateToNewEntry } from '../../routing/history';
|
||||
import { Loader } from '../../ui';
|
||||
import alert from '../UI/Alert';
|
||||
import confirm from '../UI/Confirm';
|
||||
import EditorInterface from './EditorInterface';
|
||||
import withWorkflow from './withWorkflow';
|
||||
|
||||
export class Editor extends React.Component {
|
||||
static propTypes = {
|
||||
changeDraftField: PropTypes.func.isRequired,
|
||||
changeDraftFieldValidation: PropTypes.func.isRequired,
|
||||
collection: ImmutablePropTypes.map.isRequired,
|
||||
createDraftDuplicateFromEntry: PropTypes.func.isRequired,
|
||||
createEmptyDraft: PropTypes.func.isRequired,
|
||||
discardDraft: PropTypes.func.isRequired,
|
||||
entry: ImmutablePropTypes.map,
|
||||
entryDraft: ImmutablePropTypes.map.isRequired,
|
||||
loadEntry: PropTypes.func.isRequired,
|
||||
persistEntry: PropTypes.func.isRequired,
|
||||
deleteEntry: PropTypes.func.isRequired,
|
||||
showDelete: PropTypes.bool.isRequired,
|
||||
fields: ImmutablePropTypes.list.isRequired,
|
||||
slug: PropTypes.string,
|
||||
newEntry: PropTypes.bool.isRequired,
|
||||
displayUrl: PropTypes.string,
|
||||
hasWorkflow: PropTypes.bool,
|
||||
useOpenAuthoring: PropTypes.bool,
|
||||
unpublishedEntry: PropTypes.bool,
|
||||
isModification: PropTypes.bool,
|
||||
collectionEntriesLoaded: PropTypes.bool,
|
||||
updateUnpublishedEntryStatus: PropTypes.func.isRequired,
|
||||
publishUnpublishedEntry: PropTypes.func.isRequired,
|
||||
deleteUnpublishedEntry: PropTypes.func.isRequired,
|
||||
logoutUser: PropTypes.func.isRequired,
|
||||
loadEntries: PropTypes.func.isRequired,
|
||||
deployPreview: PropTypes.object,
|
||||
loadDeployPreview: PropTypes.func.isRequired,
|
||||
currentStatus: PropTypes.string,
|
||||
user: PropTypes.object,
|
||||
location: PropTypes.shape({
|
||||
pathname: PropTypes.string,
|
||||
search: PropTypes.string,
|
||||
}),
|
||||
hasChanged: PropTypes.bool,
|
||||
t: PropTypes.func.isRequired,
|
||||
retrieveLocalBackup: PropTypes.func.isRequired,
|
||||
localBackup: ImmutablePropTypes.map,
|
||||
loadLocalBackup: PropTypes.func,
|
||||
persistLocalBackup: PropTypes.func.isRequired,
|
||||
deleteLocalBackup: PropTypes.func,
|
||||
toggleScroll: PropTypes.func.isRequired,
|
||||
scrollSyncEnabled: PropTypes.bool.isRequired,
|
||||
loadScroll: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
const {
|
||||
newEntry,
|
||||
collection,
|
||||
slug,
|
||||
loadEntry,
|
||||
createEmptyDraft,
|
||||
loadEntries,
|
||||
retrieveLocalBackup,
|
||||
collectionEntriesLoaded,
|
||||
t,
|
||||
} = this.props;
|
||||
|
||||
retrieveLocalBackup(collection, slug);
|
||||
|
||||
if (newEntry) {
|
||||
createEmptyDraft(collection, this.props.location.search);
|
||||
} else {
|
||||
loadEntry(collection, slug);
|
||||
}
|
||||
|
||||
const leaveMessage = t('editor.editor.onLeavePage');
|
||||
|
||||
this.exitBlocker = event => {
|
||||
if (this.props.entryDraft.get('hasChanged')) {
|
||||
// This message is ignored in most browsers, but its presence
|
||||
// triggers the confirmation dialog
|
||||
event.returnValue = leaveMessage;
|
||||
return leaveMessage;
|
||||
}
|
||||
};
|
||||
window.addEventListener('beforeunload', this.exitBlocker);
|
||||
|
||||
const navigationBlocker = (location, action) => {
|
||||
/**
|
||||
* New entry being saved and redirected to it's new slug based url.
|
||||
*/
|
||||
const isPersisting = this.props.entryDraft.getIn(['entry', 'isPersisting']);
|
||||
const newRecord = this.props.entryDraft.getIn(['entry', 'newRecord']);
|
||||
const newEntryPath = `/collections/${collection.get('name')}/new`;
|
||||
if (
|
||||
isPersisting &&
|
||||
newRecord &&
|
||||
this.props.location.pathname === newEntryPath &&
|
||||
action === 'PUSH'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.props.hasChanged) {
|
||||
return leaveMessage;
|
||||
}
|
||||
};
|
||||
|
||||
const unblock = history.block(navigationBlocker);
|
||||
|
||||
/**
|
||||
* This will run as soon as the location actually changes, unless creating
|
||||
* a new post. The confirmation above will run first.
|
||||
*/
|
||||
this.unlisten = history.listen((location, action) => {
|
||||
const newEntryPath = `/collections/${collection.get('name')}/new`;
|
||||
const entriesPath = `/collections/${collection.get('name')}/entries/`;
|
||||
const { pathname } = location;
|
||||
if (
|
||||
pathname.startsWith(newEntryPath) ||
|
||||
(pathname.startsWith(entriesPath) && action === 'PUSH')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.deleteBackup();
|
||||
|
||||
unblock();
|
||||
this.unlisten();
|
||||
});
|
||||
|
||||
if (!collectionEntriesLoaded) {
|
||||
loadEntries(collection);
|
||||
}
|
||||
}
|
||||
|
||||
async checkLocalBackup(prevProps) {
|
||||
const { t, hasChanged, localBackup, loadLocalBackup, entryDraft, collection } = this.props;
|
||||
|
||||
if (!prevProps.localBackup && localBackup) {
|
||||
const confirmLoadBackup = await confirm({
|
||||
title: 'editor.editor.confirmLoadBackupTitle',
|
||||
body: 'editor.editor.confirmLoadBackupBody',
|
||||
});
|
||||
if (confirmLoadBackup) {
|
||||
loadLocalBackup();
|
||||
} else {
|
||||
this.deleteBackup();
|
||||
}
|
||||
}
|
||||
|
||||
if (hasChanged) {
|
||||
this.createBackup(entryDraft.get('entry'), collection);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
this.checkLocalBackup(prevProps);
|
||||
|
||||
if (prevProps.entry === this.props.entry) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { newEntry, collection } = this.props;
|
||||
|
||||
if (newEntry) {
|
||||
prevProps.createEmptyDraft(collection, this.props.location.search);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.createBackup.flush();
|
||||
this.props.discardDraft();
|
||||
window.removeEventListener('beforeunload', this.exitBlocker);
|
||||
}
|
||||
|
||||
createBackup = debounce(function (entry, collection) {
|
||||
this.props.persistLocalBackup(entry, collection);
|
||||
}, 2000);
|
||||
|
||||
handleChangeDraftField = (field, value, metadata, i18n) => {
|
||||
const entries = [this.props.unPublishedEntry, this.props.publishedEntry].filter(Boolean);
|
||||
this.props.changeDraftField({ field, value, metadata, entries, i18n });
|
||||
};
|
||||
|
||||
handleChangeStatus = newStatusName => {
|
||||
const { entryDraft, updateUnpublishedEntryStatus, collection, slug, currentStatus } =
|
||||
this.props;
|
||||
if (entryDraft.get('hasChanged')) {
|
||||
alert({
|
||||
title: 'editor.editor.onUpdatingWithUnsavedChangesTitle',
|
||||
body: 'editor.editor.onUpdatingWithUnsavedChangesBody',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const newStatus = status.get(newStatusName);
|
||||
updateUnpublishedEntryStatus(collection.get('name'), slug, currentStatus, newStatus);
|
||||
};
|
||||
|
||||
deleteBackup() {
|
||||
const { deleteLocalBackup, collection, slug, newEntry } = this.props;
|
||||
this.createBackup.cancel();
|
||||
deleteLocalBackup(collection, !newEntry && slug);
|
||||
}
|
||||
|
||||
handlePersistEntry = async (opts = {}) => {
|
||||
const { createNew = false, duplicate = false } = opts;
|
||||
const {
|
||||
persistEntry,
|
||||
collection,
|
||||
currentStatus,
|
||||
hasWorkflow,
|
||||
loadEntry,
|
||||
slug,
|
||||
createDraftDuplicateFromEntry,
|
||||
entryDraft,
|
||||
} = this.props;
|
||||
|
||||
await persistEntry(collection);
|
||||
|
||||
this.deleteBackup();
|
||||
|
||||
if (createNew) {
|
||||
navigateToNewEntry(collection.get('name'));
|
||||
duplicate && createDraftDuplicateFromEntry(entryDraft.get('entry'));
|
||||
} else if (slug && hasWorkflow && !currentStatus) {
|
||||
loadEntry(collection, slug);
|
||||
}
|
||||
};
|
||||
|
||||
handlePublishEntry = async (opts = {}) => {
|
||||
const { createNew = false, duplicate = false } = opts;
|
||||
const {
|
||||
publishUnpublishedEntry,
|
||||
createDraftDuplicateFromEntry,
|
||||
entryDraft,
|
||||
collection,
|
||||
slug,
|
||||
currentStatus,
|
||||
} = this.props;
|
||||
if (currentStatus !== status.last()) {
|
||||
alert({
|
||||
title: 'editor.editor.onPublishingNotReadyTitle',
|
||||
body: 'editor.editor.onPublishingNotReadyBody',
|
||||
});
|
||||
return;
|
||||
} else if (entryDraft.get('hasChanged')) {
|
||||
alert({
|
||||
title: 'editor.editor.onPublishingWithUnsavedChangesTitle',
|
||||
body: 'editor.editor.onPublishingWithUnsavedChangesBody',
|
||||
});
|
||||
return;
|
||||
} else if (
|
||||
!(await confirm({
|
||||
title: 'editor.editor.onPublishingTitle',
|
||||
body: 'editor.editor.onPublishingBody',
|
||||
}))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await publishUnpublishedEntry(collection.get('name'), slug);
|
||||
|
||||
this.deleteBackup();
|
||||
|
||||
if (createNew) {
|
||||
navigateToNewEntry(collection.get('name'));
|
||||
}
|
||||
|
||||
duplicate && createDraftDuplicateFromEntry(entryDraft.get('entry'));
|
||||
};
|
||||
|
||||
handleUnpublishEntry = async () => {
|
||||
const { unpublishPublishedEntry, collection, slug } = this.props;
|
||||
if (
|
||||
!(await confirm({
|
||||
title: 'editor.editor.onUnpublishingTitle',
|
||||
body: 'editor.editor.onUnpublishingBody',
|
||||
color: 'error',
|
||||
}))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await unpublishPublishedEntry(collection, slug);
|
||||
|
||||
return navigateToCollection(collection.get('name'));
|
||||
};
|
||||
|
||||
handleDuplicateEntry = () => {
|
||||
const { createDraftDuplicateFromEntry, collection, entryDraft } = this.props;
|
||||
|
||||
navigateToNewEntry(collection.get('name'));
|
||||
createDraftDuplicateFromEntry(entryDraft.get('entry'));
|
||||
};
|
||||
|
||||
handleDeleteEntry = async () => {
|
||||
const { entryDraft, newEntry, collection, deleteEntry, slug } = this.props;
|
||||
if (entryDraft.get('hasChanged')) {
|
||||
if (
|
||||
!(await confirm({
|
||||
title: 'editor.editor.onDeleteWithUnsavedChangesTitle',
|
||||
body: 'editor.editor.onDeleteWithUnsavedChangesBody',
|
||||
color: 'error',
|
||||
}))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
} else if (
|
||||
!(await confirm({
|
||||
title: 'editor.editor.onDeletePublishedEntryTitle',
|
||||
body: 'editor.editor.onDeletePublishedEntryBody',
|
||||
color: 'error',
|
||||
}))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (newEntry) {
|
||||
return navigateToCollection(collection.get('name'));
|
||||
}
|
||||
|
||||
setTimeout(async () => {
|
||||
await deleteEntry(collection, slug);
|
||||
this.deleteBackup();
|
||||
return navigateToCollection(collection.get('name'));
|
||||
}, 0);
|
||||
};
|
||||
|
||||
handleDeleteUnpublishedChanges = async () => {
|
||||
const { entryDraft, collection, slug, deleteUnpublishedEntry, loadEntry, isModification } =
|
||||
this.props;
|
||||
if (
|
||||
entryDraft.get('hasChanged') &&
|
||||
!(await confirm({
|
||||
title: 'editor.editor.onDeleteUnpublishedChangesWithUnsavedChangesTitle',
|
||||
body: 'editor.editor.onDeleteUnpublishedChangesWithUnsavedChangesBody',
|
||||
color: 'error',
|
||||
}))
|
||||
) {
|
||||
return;
|
||||
} else if (
|
||||
!(await confirm({
|
||||
title: 'editor.editor.onDeleteUnpublishedChangesTitle',
|
||||
body: 'editor.editor.onDeleteUnpublishedChangesBody',
|
||||
color: 'error',
|
||||
}))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await deleteUnpublishedEntry(collection.get('name'), slug);
|
||||
|
||||
this.deleteBackup();
|
||||
|
||||
if (isModification) {
|
||||
loadEntry(collection, slug);
|
||||
} else {
|
||||
navigateToCollection(collection.get('name'));
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
entry,
|
||||
entryDraft,
|
||||
fields,
|
||||
collection,
|
||||
changeDraftFieldValidation,
|
||||
user,
|
||||
hasChanged,
|
||||
displayUrl,
|
||||
hasWorkflow,
|
||||
useOpenAuthoring,
|
||||
unpublishedEntry,
|
||||
newEntry,
|
||||
isModification,
|
||||
currentStatus,
|
||||
logoutUser,
|
||||
deployPreview,
|
||||
loadDeployPreview,
|
||||
draftKey,
|
||||
slug,
|
||||
t,
|
||||
editorBackLink,
|
||||
toggleScroll,
|
||||
scrollSyncEnabled,
|
||||
loadScroll,
|
||||
} = this.props;
|
||||
|
||||
const isPublished = !newEntry && !unpublishedEntry;
|
||||
|
||||
if (entry && entry.get('error')) {
|
||||
return (
|
||||
<div>
|
||||
<h3>{entry.get('error')}</h3>
|
||||
</div>
|
||||
);
|
||||
} else if (
|
||||
entryDraft == null ||
|
||||
entryDraft.get('entry') === undefined ||
|
||||
(entry && entry.get('isFetching'))
|
||||
) {
|
||||
return <Loader active>{t('editor.editor.loadingEntry')}</Loader>;
|
||||
}
|
||||
|
||||
return (
|
||||
<EditorInterface
|
||||
draftKey={draftKey}
|
||||
entry={entryDraft.get('entry')}
|
||||
collection={collection}
|
||||
fields={fields}
|
||||
fieldsMetaData={entryDraft.get('fieldsMetaData')}
|
||||
fieldsErrors={entryDraft.get('fieldsErrors')}
|
||||
onChange={this.handleChangeDraftField}
|
||||
onValidate={changeDraftFieldValidation}
|
||||
onPersist={this.handlePersistEntry}
|
||||
onDelete={this.handleDeleteEntry}
|
||||
onDeleteUnpublishedChanges={this.handleDeleteUnpublishedChanges}
|
||||
onChangeStatus={this.handleChangeStatus}
|
||||
onPublish={this.handlePublishEntry}
|
||||
unPublish={this.handleUnpublishEntry}
|
||||
onDuplicate={this.handleDuplicateEntry}
|
||||
showDelete={this.props.showDelete}
|
||||
user={user}
|
||||
hasChanged={hasChanged}
|
||||
displayUrl={displayUrl}
|
||||
hasWorkflow={hasWorkflow}
|
||||
useOpenAuthoring={useOpenAuthoring}
|
||||
hasUnpublishedChanges={unpublishedEntry}
|
||||
isNewEntry={newEntry}
|
||||
isModification={isModification}
|
||||
currentStatus={currentStatus}
|
||||
onLogoutClick={logoutUser}
|
||||
deployPreview={deployPreview}
|
||||
loadDeployPreview={opts => loadDeployPreview(collection, slug, entry, isPublished, opts)}
|
||||
editorBackLink={editorBackLink}
|
||||
toggleScroll={toggleScroll}
|
||||
scrollSyncEnabled={scrollSyncEnabled}
|
||||
loadScroll={loadScroll}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state, ownProps) {
|
||||
const { collections, entryDraft, auth, config, entries, globalUI, scroll } = state;
|
||||
const slug = ownProps.match.params[0];
|
||||
const collection = collections.get(ownProps.match.params.name);
|
||||
const collectionName = collection.get('name');
|
||||
const newEntry = ownProps.newRecord === true;
|
||||
const fields = selectFields(collection, slug);
|
||||
const entry = newEntry ? null : selectEntry(state, collectionName, slug);
|
||||
const user = auth.user;
|
||||
const hasChanged = entryDraft.get('hasChanged');
|
||||
const displayUrl = config.display_url;
|
||||
const hasWorkflow = config.publish_mode === EDITORIAL_WORKFLOW;
|
||||
const useOpenAuthoring = globalUI.useOpenAuthoring;
|
||||
const isModification = entryDraft.getIn(['entry', 'isModification']);
|
||||
const collectionEntriesLoaded = !!entries.getIn(['pages', collectionName]);
|
||||
const unPublishedEntry = selectUnpublishedEntry(state, collectionName, slug);
|
||||
const publishedEntry = selectEntry(state, collectionName, slug);
|
||||
const currentStatus = unPublishedEntry && unPublishedEntry.get('status');
|
||||
const deployPreview = selectDeployPreview(state, collectionName, slug);
|
||||
const localBackup = entryDraft.get('localBackup');
|
||||
const draftKey = entryDraft.get('key');
|
||||
let editorBackLink = `/collections/${collectionName}`;
|
||||
if (new URLSearchParams(ownProps.location.search).get('ref') === 'workflow') {
|
||||
editorBackLink = `/workflow`;
|
||||
}
|
||||
|
||||
if (collection.has('files') && collection.get('files').size === 1) {
|
||||
editorBackLink = '/';
|
||||
}
|
||||
|
||||
if (collection.has('nested') && slug) {
|
||||
const pathParts = slug.split('/');
|
||||
if (pathParts.length > 2) {
|
||||
editorBackLink = `${editorBackLink}/filter/${pathParts.slice(0, -2).join('/')}`;
|
||||
}
|
||||
}
|
||||
|
||||
const scrollSyncEnabled = scroll.isScrolling;
|
||||
|
||||
return {
|
||||
collection,
|
||||
collections,
|
||||
newEntry,
|
||||
entryDraft,
|
||||
fields,
|
||||
slug,
|
||||
entry,
|
||||
user,
|
||||
hasChanged,
|
||||
displayUrl,
|
||||
hasWorkflow,
|
||||
useOpenAuthoring,
|
||||
isModification,
|
||||
collectionEntriesLoaded,
|
||||
currentStatus,
|
||||
deployPreview,
|
||||
localBackup,
|
||||
draftKey,
|
||||
publishedEntry,
|
||||
unPublishedEntry,
|
||||
editorBackLink,
|
||||
scrollSyncEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
changeDraftField,
|
||||
changeDraftFieldValidation,
|
||||
loadEntry,
|
||||
loadEntries,
|
||||
loadDeployPreview,
|
||||
loadLocalBackup,
|
||||
retrieveLocalBackup,
|
||||
persistLocalBackup,
|
||||
deleteLocalBackup,
|
||||
createDraftDuplicateFromEntry,
|
||||
createEmptyDraft,
|
||||
discardDraft,
|
||||
persistEntry,
|
||||
deleteEntry,
|
||||
updateUnpublishedEntryStatus,
|
||||
publishUnpublishedEntry,
|
||||
unpublishPublishedEntry,
|
||||
deleteUnpublishedEntry,
|
||||
logoutUser,
|
||||
toggleScroll,
|
||||
loadScroll,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(withWorkflow(translate()(Editor)));
|
431
src/components/Editor/EditorControlPane/EditorControl.js
Normal file
431
src/components/Editor/EditorControlPane/EditorControl.js
Normal file
@ -0,0 +1,431 @@
|
||||
import React from 'react';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { translate } from 'react-polyglot';
|
||||
import { ClassNames, Global, css as coreCss } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { partial, uniqueId } from 'lodash';
|
||||
import { connect } from 'react-redux';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import gfm from 'remark-gfm';
|
||||
|
||||
import {
|
||||
FieldLabel,
|
||||
colors,
|
||||
transitions,
|
||||
lengths,
|
||||
borders,
|
||||
} from '../../../ui';
|
||||
import { resolveWidget, getEditorComponents } from '../../../lib/registry';
|
||||
import { clearFieldErrors, tryLoadEntry, validateMetaField } from '../../../actions/entries';
|
||||
import { addAsset, boundGetAsset } from '../../../actions/media';
|
||||
import { selectIsLoadingAsset } from '../../../reducers/medias';
|
||||
import { query, clearSearch } from '../../../actions/search';
|
||||
import {
|
||||
openMediaLibrary,
|
||||
removeInsertedMedia,
|
||||
clearMediaControl,
|
||||
removeMediaControl,
|
||||
persistMedia,
|
||||
} from '../../../actions/mediaLibrary';
|
||||
import Widget from './Widget';
|
||||
|
||||
/**
|
||||
* This is a necessary bridge as we are still passing classnames to widgets
|
||||
* for styling. Once that changes we can stop storing raw style strings like
|
||||
* this.
|
||||
*/
|
||||
const styleStrings = {
|
||||
widget: `
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: ${lengths.inputPadding};
|
||||
margin: 0;
|
||||
border: ${borders.textField};
|
||||
border-radius: ${lengths.borderRadius};
|
||||
border-top-left-radius: 0;
|
||||
outline: 0;
|
||||
box-shadow: none;
|
||||
background-color: ${colors.inputBackground};
|
||||
color: #444a57;
|
||||
transition: border-color ${transitions.main};
|
||||
position: relative;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
|
||||
select& {
|
||||
text-indent: 14px;
|
||||
height: 58px;
|
||||
}
|
||||
`,
|
||||
widgetActive: `
|
||||
border-color: ${colors.active};
|
||||
`,
|
||||
widgetError: `
|
||||
border-color: ${colors.errorText};
|
||||
`,
|
||||
disabled: `
|
||||
pointer-events: none;
|
||||
opacity: 0.5;
|
||||
background: #ccc;
|
||||
`,
|
||||
hidden: `
|
||||
visibility: hidden;
|
||||
`,
|
||||
};
|
||||
|
||||
const ControlContainer = styled.div`
|
||||
margin-top: 16px;
|
||||
`;
|
||||
|
||||
const ControlErrorsList = styled.ul`
|
||||
list-style-type: none;
|
||||
font-size: 12px;
|
||||
color: ${colors.errorText};
|
||||
margin-bottom: 5px;
|
||||
text-align: right;
|
||||
text-transform: uppercase;
|
||||
position: relative;
|
||||
font-weight: 600;
|
||||
top: 20px;
|
||||
`;
|
||||
|
||||
export const ControlHint = styled.p`
|
||||
margin-bottom: 0;
|
||||
padding: 3px 0;
|
||||
font-size: 12px;
|
||||
color: ${props =>
|
||||
props.error ? colors.errorText : props.active ? colors.active : colors.controlLabel};
|
||||
transition: color ${transitions.main};
|
||||
`;
|
||||
|
||||
function LabelComponent({ field, isActive, hasErrors, uniqueFieldId, isFieldOptional, t }) {
|
||||
const label = `${field.get('label', field.get('name'))}`;
|
||||
const labelComponent = (
|
||||
<FieldLabel isActive={isActive} hasErrors={hasErrors} htmlFor={uniqueFieldId}>
|
||||
{label} {`${isFieldOptional ? ` (${t('editor.editorControl.field.optional')})` : ''}`}
|
||||
</FieldLabel>
|
||||
);
|
||||
|
||||
return labelComponent;
|
||||
}
|
||||
|
||||
class EditorControl extends React.Component {
|
||||
static propTypes = {
|
||||
value: PropTypes.oneOfType([
|
||||
PropTypes.node,
|
||||
PropTypes.object,
|
||||
PropTypes.string,
|
||||
PropTypes.bool,
|
||||
]),
|
||||
field: ImmutablePropTypes.map.isRequired,
|
||||
fieldsMetaData: ImmutablePropTypes.map,
|
||||
fieldsErrors: ImmutablePropTypes.map,
|
||||
mediaPaths: ImmutablePropTypes.map.isRequired,
|
||||
boundGetAsset: PropTypes.func.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
openMediaLibrary: PropTypes.func.isRequired,
|
||||
addAsset: PropTypes.func.isRequired,
|
||||
removeInsertedMedia: PropTypes.func.isRequired,
|
||||
persistMedia: PropTypes.func.isRequired,
|
||||
onValidate: PropTypes.func,
|
||||
processControlRef: PropTypes.func,
|
||||
controlRef: PropTypes.func,
|
||||
query: PropTypes.func.isRequired,
|
||||
queryHits: PropTypes.object,
|
||||
isFetching: PropTypes.bool,
|
||||
clearSearch: PropTypes.func.isRequired,
|
||||
clearFieldErrors: PropTypes.func.isRequired,
|
||||
loadEntry: PropTypes.func.isRequired,
|
||||
t: PropTypes.func.isRequired,
|
||||
isEditorComponent: PropTypes.bool,
|
||||
isNewEditorComponent: PropTypes.bool,
|
||||
parentIds: PropTypes.arrayOf(PropTypes.string),
|
||||
entry: ImmutablePropTypes.map.isRequired,
|
||||
collection: ImmutablePropTypes.map.isRequired,
|
||||
isDisabled: PropTypes.bool,
|
||||
isHidden: PropTypes.bool,
|
||||
isFieldDuplicate: PropTypes.func,
|
||||
isFieldHidden: PropTypes.func,
|
||||
locale: PropTypes.string,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
parentIds: [],
|
||||
};
|
||||
|
||||
state = {
|
||||
activeLabel: false,
|
||||
};
|
||||
|
||||
uniqueFieldId = uniqueId(`${this.props.field.get('name')}-field-`);
|
||||
|
||||
isAncestorOfFieldError = () => {
|
||||
const { fieldsErrors } = this.props;
|
||||
|
||||
if (fieldsErrors && fieldsErrors.size > 0) {
|
||||
return Object.values(fieldsErrors.toJS()).some(arr =>
|
||||
arr.some(err => err.parentIds && err.parentIds.includes(this.uniqueFieldId)),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
value,
|
||||
entry,
|
||||
collection,
|
||||
config,
|
||||
field,
|
||||
fieldsMetaData,
|
||||
fieldsErrors,
|
||||
mediaPaths,
|
||||
boundGetAsset,
|
||||
onChange,
|
||||
openMediaLibrary,
|
||||
clearMediaControl,
|
||||
removeMediaControl,
|
||||
addAsset,
|
||||
removeInsertedMedia,
|
||||
persistMedia,
|
||||
onValidate,
|
||||
processControlRef,
|
||||
controlRef,
|
||||
query,
|
||||
queryHits,
|
||||
isFetching,
|
||||
clearSearch,
|
||||
clearFieldErrors,
|
||||
loadEntry,
|
||||
className,
|
||||
isSelected,
|
||||
isEditorComponent,
|
||||
isNewEditorComponent,
|
||||
parentIds,
|
||||
t,
|
||||
validateMetaField,
|
||||
isDisabled,
|
||||
isHidden,
|
||||
isFieldDuplicate,
|
||||
isFieldHidden,
|
||||
locale,
|
||||
} = this.props;
|
||||
|
||||
const widgetName = field.get('widget');
|
||||
const widget = resolveWidget(widgetName);
|
||||
const fieldName = field.get('name');
|
||||
const fieldHint = field.get('hint');
|
||||
const isFieldOptional = field.get('required') === false;
|
||||
const onValidateObject = onValidate;
|
||||
const metadata = fieldsMetaData && fieldsMetaData.get(fieldName);
|
||||
const errors = fieldsErrors && fieldsErrors.get(this.uniqueFieldId);
|
||||
const childErrors = this.isAncestorOfFieldError();
|
||||
const hasErrors = !!errors || childErrors;
|
||||
|
||||
return (
|
||||
<ClassNames>
|
||||
{({ css, cx }) => (
|
||||
<ControlContainer
|
||||
className={className}
|
||||
css={css`
|
||||
${isHidden && styleStrings.hidden};
|
||||
`}
|
||||
>
|
||||
{widget.globalStyles && <Global styles={coreCss`${widget.globalStyles}`} />}
|
||||
{errors && (
|
||||
<ControlErrorsList>
|
||||
{errors.map(
|
||||
error =>
|
||||
error.message &&
|
||||
typeof error.message === 'string' && (
|
||||
<li key={error.message.trim().replace(/[^a-z0-9]+/gi, '-')}>
|
||||
{error.message}
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
</ControlErrorsList>
|
||||
)}
|
||||
<LabelComponent
|
||||
field={field}
|
||||
isActive={isSelected || this.state.styleActive}
|
||||
hasErrors={hasErrors}
|
||||
uniqueFieldId={this.uniqueFieldId}
|
||||
isFieldOptional={isFieldOptional}
|
||||
t={t}
|
||||
/>
|
||||
<Widget
|
||||
classNameWrapper={cx(
|
||||
css`
|
||||
${styleStrings.widget};
|
||||
`,
|
||||
{
|
||||
[css`
|
||||
${styleStrings.widgetActive};
|
||||
`]: isSelected || this.state.styleActive,
|
||||
},
|
||||
{
|
||||
[css`
|
||||
${styleStrings.widgetError};
|
||||
`]: hasErrors,
|
||||
},
|
||||
{
|
||||
[css`
|
||||
${styleStrings.disabled}
|
||||
`]: isDisabled,
|
||||
},
|
||||
)}
|
||||
classNameWidget={css`
|
||||
${styleStrings.widget};
|
||||
`}
|
||||
classNameWidgetActive={css`
|
||||
${styleStrings.widgetActive};
|
||||
`}
|
||||
classNameLabel={css`
|
||||
${styleStrings.label};
|
||||
`}
|
||||
classNameLabelActive={css`
|
||||
${styleStrings.labelActive};
|
||||
`}
|
||||
controlComponent={widget.control}
|
||||
validator={widget.validator}
|
||||
entry={entry}
|
||||
collection={collection}
|
||||
config={config}
|
||||
field={field}
|
||||
uniqueFieldId={this.uniqueFieldId}
|
||||
value={value}
|
||||
mediaPaths={mediaPaths}
|
||||
metadata={metadata}
|
||||
onChange={(newValue, newMetadata) => onChange(field, newValue, newMetadata)}
|
||||
onValidate={onValidate && partial(onValidate, this.uniqueFieldId)}
|
||||
onOpenMediaLibrary={openMediaLibrary}
|
||||
onClearMediaControl={clearMediaControl}
|
||||
onRemoveMediaControl={removeMediaControl}
|
||||
onRemoveInsertedMedia={removeInsertedMedia}
|
||||
onPersistMedia={persistMedia}
|
||||
onAddAsset={addAsset}
|
||||
getAsset={boundGetAsset}
|
||||
hasActiveStyle={isSelected || this.state.styleActive}
|
||||
setActiveStyle={() => this.setState({ styleActive: true })}
|
||||
setInactiveStyle={() => this.setState({ styleActive: false })}
|
||||
resolveWidget={resolveWidget}
|
||||
widget={widget}
|
||||
getEditorComponents={getEditorComponents}
|
||||
ref={processControlRef && partial(processControlRef, field)}
|
||||
controlRef={controlRef}
|
||||
editorControl={ConnectedEditorControl}
|
||||
query={query}
|
||||
loadEntry={loadEntry}
|
||||
queryHits={queryHits[this.uniqueFieldId] || []}
|
||||
clearSearch={clearSearch}
|
||||
clearFieldErrors={clearFieldErrors}
|
||||
isFetching={isFetching}
|
||||
fieldsErrors={fieldsErrors}
|
||||
onValidateObject={onValidateObject}
|
||||
isEditorComponent={isEditorComponent}
|
||||
isNewEditorComponent={isNewEditorComponent}
|
||||
parentIds={parentIds}
|
||||
t={t}
|
||||
validateMetaField={validateMetaField}
|
||||
isDisabled={isDisabled}
|
||||
isFieldDuplicate={isFieldDuplicate}
|
||||
isFieldHidden={isFieldHidden}
|
||||
locale={locale}
|
||||
/>
|
||||
{fieldHint && (
|
||||
<ControlHint active={isSelected || this.state.styleActive} error={hasErrors}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[gfm]}
|
||||
allowedElements={['a', 'strong', 'em', 'del']}
|
||||
unwrapDisallowed={true}
|
||||
components={{
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
a: ({ node, ...props }) => (
|
||||
<a
|
||||
{...props}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: 'inherit' }}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{fieldHint}
|
||||
</ReactMarkdown>
|
||||
</ControlHint>
|
||||
)}
|
||||
</ControlContainer>
|
||||
)}
|
||||
</ClassNames>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state) {
|
||||
const { collections, entryDraft } = state;
|
||||
const entry = entryDraft.get('entry');
|
||||
const collection = collections.get(entryDraft.getIn(['entry', 'collection']));
|
||||
const isLoadingAsset = selectIsLoadingAsset(state.medias);
|
||||
|
||||
async function loadEntry(collectionName, slug) {
|
||||
const targetCollection = collections.get(collectionName);
|
||||
if (targetCollection) {
|
||||
const loadedEntry = await tryLoadEntry(state, targetCollection, slug);
|
||||
return loadedEntry;
|
||||
} else {
|
||||
throw new Error(`Can't find collection '${collectionName}'`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
mediaPaths: state.mediaLibrary.get('controlMedia'),
|
||||
isFetching: state.search.isFetching,
|
||||
queryHits: state.search.queryHits,
|
||||
config: state.config,
|
||||
entry,
|
||||
collection,
|
||||
isLoadingAsset,
|
||||
loadEntry,
|
||||
validateMetaField: (field, value, t) => validateMetaField(state, collection, field, value, t),
|
||||
};
|
||||
}
|
||||
|
||||
function mapDispatchToProps(dispatch) {
|
||||
const creators = bindActionCreators(
|
||||
{
|
||||
openMediaLibrary,
|
||||
clearMediaControl,
|
||||
removeMediaControl,
|
||||
removeInsertedMedia,
|
||||
persistMedia,
|
||||
addAsset,
|
||||
query,
|
||||
clearSearch,
|
||||
clearFieldErrors,
|
||||
},
|
||||
dispatch,
|
||||
);
|
||||
return {
|
||||
...creators,
|
||||
boundGetAsset: (collection, entry) => boundGetAsset(dispatch, collection, entry),
|
||||
};
|
||||
}
|
||||
|
||||
function mergeProps(stateProps, dispatchProps, ownProps) {
|
||||
return {
|
||||
...stateProps,
|
||||
...dispatchProps,
|
||||
...ownProps,
|
||||
boundGetAsset: dispatchProps.boundGetAsset(stateProps.collection, stateProps.entry),
|
||||
};
|
||||
}
|
||||
|
||||
const ConnectedEditorControl = connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps,
|
||||
mergeProps,
|
||||
)(translate()(EditorControl));
|
||||
|
||||
export default ConnectedEditorControl;
|
251
src/components/Editor/EditorControlPane/EditorControlPane.js
Normal file
251
src/components/Editor/EditorControlPane/EditorControlPane.js
Normal file
@ -0,0 +1,251 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { css } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { buttons, colors, Dropdown, DropdownItem, StyledDropdownButton, text } from '../../../ui';
|
||||
import EditorControl from './EditorControl';
|
||||
import {
|
||||
getI18nInfo,
|
||||
getLocaleDataPath,
|
||||
hasI18n,
|
||||
isFieldDuplicate,
|
||||
isFieldHidden,
|
||||
isFieldTranslatable,
|
||||
} from '../../../lib/i18n';
|
||||
|
||||
const ControlPaneContainer = styled.div`
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding-bottom: 16px;
|
||||
font-size: 16px;
|
||||
`;
|
||||
|
||||
const LocaleButton = styled(StyledDropdownButton)`
|
||||
${buttons.button};
|
||||
${buttons.medium};
|
||||
color: ${colors.controlLabel};
|
||||
background: ${colors.textFieldBorder};
|
||||
height: 100%;
|
||||
|
||||
&:after {
|
||||
top: 11px;
|
||||
}
|
||||
`;
|
||||
|
||||
const LocaleButtonWrapper = styled.div`
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
const LocaleRowWrapper = styled.div`
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
const StyledDropdown = styled(Dropdown)`
|
||||
width: max-content;
|
||||
margin-top: 20px;
|
||||
margin-bottom: 20px;
|
||||
margin-right: 20px;
|
||||
`;
|
||||
|
||||
function LocaleDropdown({ locales, dropdownText, onLocaleChange }) {
|
||||
return (
|
||||
<StyledDropdown
|
||||
renderButton={() => {
|
||||
return (
|
||||
<LocaleButtonWrapper>
|
||||
<LocaleButton>{dropdownText}</LocaleButton>
|
||||
</LocaleButtonWrapper>
|
||||
);
|
||||
}}
|
||||
>
|
||||
{locales.map(l => (
|
||||
<DropdownItem
|
||||
css={css`
|
||||
${text.fieldLabel}
|
||||
`}
|
||||
key={l}
|
||||
label={l}
|
||||
onClick={() => onLocaleChange(l)}
|
||||
/>
|
||||
))}
|
||||
</StyledDropdown>
|
||||
);
|
||||
}
|
||||
|
||||
function getFieldValue({ field, entry, isTranslatable, locale }) {
|
||||
if (field.get('meta')) {
|
||||
return entry.getIn(['meta', field.get('name')]);
|
||||
}
|
||||
|
||||
if (isTranslatable) {
|
||||
const dataPath = getLocaleDataPath(locale);
|
||||
return entry.getIn([...dataPath, field.get('name')]);
|
||||
}
|
||||
|
||||
return entry.getIn(['data', field.get('name')]);
|
||||
}
|
||||
|
||||
export default class ControlPane extends React.Component {
|
||||
state = {
|
||||
selectedLocale: this.props.locale,
|
||||
};
|
||||
|
||||
componentValidate = {};
|
||||
|
||||
controlRef(field, wrappedControl) {
|
||||
if (!wrappedControl) return;
|
||||
const name = field.get('name');
|
||||
|
||||
this.componentValidate[name] =
|
||||
wrappedControl.innerWrappedControl?.validate || wrappedControl.validate;
|
||||
}
|
||||
|
||||
handleLocaleChange = val => {
|
||||
this.setState({ selectedLocale: val });
|
||||
this.props.onLocaleChange(val);
|
||||
};
|
||||
|
||||
copyFromOtherLocale =
|
||||
({ targetLocale, t }) =>
|
||||
async sourceLocale => {
|
||||
if (
|
||||
!(await confirm({
|
||||
title: 'editor.editorControlPane.i18n.copyFromLocaleConfirmTitle',
|
||||
body: {
|
||||
key: 'editor.editorControlPane.i18n.copyFromLocaleConfirmBody',
|
||||
options: { locale: sourceLocale.toUpperCase() },
|
||||
},
|
||||
}))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const { entry, collection } = this.props;
|
||||
const { locales, defaultLocale } = getI18nInfo(collection);
|
||||
|
||||
const locale = this.state.selectedLocale;
|
||||
const i18n = locales && {
|
||||
currentLocale: locale,
|
||||
locales,
|
||||
defaultLocale,
|
||||
};
|
||||
|
||||
this.props.fields.forEach(field => {
|
||||
if (isFieldTranslatable(field, targetLocale, sourceLocale)) {
|
||||
const copyValue = getFieldValue({
|
||||
field,
|
||||
entry,
|
||||
locale: sourceLocale,
|
||||
isTranslatable: sourceLocale !== defaultLocale,
|
||||
});
|
||||
this.props.onChange(field, copyValue, undefined, i18n);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
validate = async () => {
|
||||
this.props.fields.forEach(field => {
|
||||
if (field.get('widget') === 'hidden') return;
|
||||
this.componentValidate[field.get('name')]();
|
||||
});
|
||||
};
|
||||
|
||||
switchToDefaultLocale = () => {
|
||||
if (hasI18n(this.props.collection)) {
|
||||
const { defaultLocale } = getI18nInfo(this.props.collection);
|
||||
return new Promise(resolve => this.setState({ selectedLocale: defaultLocale }, resolve));
|
||||
} else {
|
||||
return Promise.resolve();
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { collection, entry, fields, fieldsMetaData, fieldsErrors, onChange, onValidate, t } =
|
||||
this.props;
|
||||
|
||||
if (!collection || !fields) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (entry.size === 0 || entry.get('partial') === true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { locales, defaultLocale } = getI18nInfo(collection);
|
||||
const locale = this.state.selectedLocale;
|
||||
const i18n = locales && {
|
||||
currentLocale: locale,
|
||||
locales,
|
||||
defaultLocale,
|
||||
};
|
||||
|
||||
return (
|
||||
<ControlPaneContainer>
|
||||
{locales && (
|
||||
<LocaleRowWrapper>
|
||||
<LocaleDropdown
|
||||
locales={locales}
|
||||
dropdownText={t('editor.editorControlPane.i18n.writingInLocale', {
|
||||
locale: locale.toUpperCase(),
|
||||
})}
|
||||
onLocaleChange={this.handleLocaleChange}
|
||||
/>
|
||||
<LocaleDropdown
|
||||
locales={locales.filter(l => l !== locale)}
|
||||
dropdownText={t('editor.editorControlPane.i18n.copyFromLocale')}
|
||||
onLocaleChange={this.copyFromOtherLocale({ targetLocale: locale, t })}
|
||||
/>
|
||||
</LocaleRowWrapper>
|
||||
)}
|
||||
{fields
|
||||
.filter(f => f.get('widget') !== 'hidden')
|
||||
.map((field, i) => {
|
||||
const isTranslatable = isFieldTranslatable(field, locale, defaultLocale);
|
||||
const isDuplicate = isFieldDuplicate(field, locale, defaultLocale);
|
||||
const isHidden = isFieldHidden(field, locale, defaultLocale);
|
||||
const key = i18n ? `${locale}_${i}` : i;
|
||||
|
||||
return (
|
||||
<EditorControl
|
||||
key={key}
|
||||
field={field}
|
||||
value={getFieldValue({
|
||||
field,
|
||||
entry,
|
||||
locale,
|
||||
isTranslatable,
|
||||
})}
|
||||
fieldsMetaData={fieldsMetaData}
|
||||
fieldsErrors={fieldsErrors}
|
||||
onChange={(field, newValue, newMetadata) => {
|
||||
onChange(field, newValue, newMetadata, i18n);
|
||||
}}
|
||||
onValidate={onValidate}
|
||||
processControlRef={this.controlRef.bind(this)}
|
||||
controlRef={this.controlRef}
|
||||
entry={entry}
|
||||
collection={collection}
|
||||
isDisabled={isDuplicate}
|
||||
isHidden={isHidden}
|
||||
isFieldDuplicate={field => isFieldDuplicate(field, locale, defaultLocale)}
|
||||
isFieldHidden={field => isFieldHidden(field, locale, defaultLocale)}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ControlPaneContainer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ControlPane.propTypes = {
|
||||
collection: ImmutablePropTypes.map.isRequired,
|
||||
entry: ImmutablePropTypes.map.isRequired,
|
||||
fields: ImmutablePropTypes.list.isRequired,
|
||||
fieldsMetaData: ImmutablePropTypes.map.isRequired,
|
||||
fieldsErrors: ImmutablePropTypes.map.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onValidate: PropTypes.func.isRequired,
|
||||
locale: PropTypes.string,
|
||||
};
|
339
src/components/Editor/EditorControlPane/Widget.js
Normal file
339
src/components/Editor/EditorControlPane/Widget.js
Normal file
@ -0,0 +1,339 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { Map, List } from 'immutable';
|
||||
|
||||
import { getRemarkPlugins } from '../../../lib/registry';
|
||||
import ValidationErrorTypes from '../../../constants/validationErrorTypes';
|
||||
|
||||
function isEmpty(value) {
|
||||
return (
|
||||
value === null ||
|
||||
value === undefined ||
|
||||
(Object.prototype.hasOwnProperty.call(value, 'length') && value.length === 0) ||
|
||||
(value.constructor === Object && Object.keys(value).length === 0) ||
|
||||
(List.isList(value) && value.size === 0)
|
||||
);
|
||||
}
|
||||
|
||||
export default class Widget extends Component {
|
||||
static propTypes = {
|
||||
controlComponent: PropTypes.func.isRequired,
|
||||
validator: PropTypes.func,
|
||||
field: ImmutablePropTypes.map.isRequired,
|
||||
hasActiveStyle: PropTypes.bool,
|
||||
setActiveStyle: PropTypes.func.isRequired,
|
||||
setInactiveStyle: PropTypes.func.isRequired,
|
||||
classNameWrapper: PropTypes.string.isRequired,
|
||||
classNameWidget: PropTypes.string.isRequired,
|
||||
classNameWidgetActive: PropTypes.string.isRequired,
|
||||
classNameLabel: PropTypes.string.isRequired,
|
||||
classNameLabelActive: PropTypes.string.isRequired,
|
||||
value: PropTypes.oneOfType([
|
||||
PropTypes.node,
|
||||
PropTypes.object,
|
||||
PropTypes.string,
|
||||
PropTypes.bool,
|
||||
]),
|
||||
mediaPaths: ImmutablePropTypes.map.isRequired,
|
||||
metadata: ImmutablePropTypes.map,
|
||||
fieldsErrors: ImmutablePropTypes.map,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onValidate: PropTypes.func,
|
||||
onOpenMediaLibrary: PropTypes.func.isRequired,
|
||||
onClearMediaControl: PropTypes.func.isRequired,
|
||||
onRemoveMediaControl: PropTypes.func.isRequired,
|
||||
onPersistMedia: PropTypes.func.isRequired,
|
||||
onAddAsset: PropTypes.func.isRequired,
|
||||
onRemoveInsertedMedia: PropTypes.func.isRequired,
|
||||
getAsset: PropTypes.func.isRequired,
|
||||
resolveWidget: PropTypes.func.isRequired,
|
||||
widget: PropTypes.object.isRequired,
|
||||
getEditorComponents: PropTypes.func.isRequired,
|
||||
isFetching: PropTypes.bool,
|
||||
controlRef: PropTypes.func,
|
||||
query: PropTypes.func.isRequired,
|
||||
clearSearch: PropTypes.func.isRequired,
|
||||
clearFieldErrors: PropTypes.func.isRequired,
|
||||
queryHits: PropTypes.oneOfType([PropTypes.array, PropTypes.object]),
|
||||
editorControl: PropTypes.elementType.isRequired,
|
||||
uniqueFieldId: PropTypes.string.isRequired,
|
||||
loadEntry: PropTypes.func.isRequired,
|
||||
t: PropTypes.func.isRequired,
|
||||
onValidateObject: PropTypes.func,
|
||||
isEditorComponent: PropTypes.bool,
|
||||
isNewEditorComponent: PropTypes.bool,
|
||||
entry: ImmutablePropTypes.map.isRequired,
|
||||
isDisabled: PropTypes.bool,
|
||||
isFieldDuplicate: PropTypes.func,
|
||||
isFieldHidden: PropTypes.func,
|
||||
locale: PropTypes.string,
|
||||
};
|
||||
|
||||
shouldComponentUpdate(nextProps) {
|
||||
/**
|
||||
* Allow widgets to provide their own `shouldComponentUpdate` method.
|
||||
*/
|
||||
if (this.wrappedControlShouldComponentUpdate) {
|
||||
return this.wrappedControlShouldComponentUpdate(nextProps);
|
||||
}
|
||||
return (
|
||||
this.props.value !== nextProps.value ||
|
||||
this.props.classNameWrapper !== nextProps.classNameWrapper ||
|
||||
this.props.hasActiveStyle !== nextProps.hasActiveStyle
|
||||
);
|
||||
}
|
||||
|
||||
processInnerControlRef = ref => {
|
||||
if (!ref) return;
|
||||
|
||||
/**
|
||||
* If the widget is a container that receives state updates from the store,
|
||||
* we'll need to get the ref of the actual control via the `react-redux`
|
||||
* `getWrappedInstance` method. Note that connected widgets must pass
|
||||
* `withRef: true` to `connect` in the options object.
|
||||
*/
|
||||
this.innerWrappedControl = ref.getWrappedInstance ? ref.getWrappedInstance() : ref;
|
||||
|
||||
/**
|
||||
* Get the `shouldComponentUpdate` method from the wrapped control, and
|
||||
* provide the control instance is the `this` binding.
|
||||
*/
|
||||
const { shouldComponentUpdate: scu } = this.innerWrappedControl;
|
||||
this.wrappedControlShouldComponentUpdate = scu && scu.bind(this.innerWrappedControl);
|
||||
};
|
||||
|
||||
getValidateValue = () => {
|
||||
let value = this.innerWrappedControl?.getValidateValue?.() || this.props.value;
|
||||
// Convert list input widget value to string for validation test
|
||||
List.isList(value) && (value = value.join(','));
|
||||
return value;
|
||||
};
|
||||
|
||||
validate = (skipWrapped = false) => {
|
||||
const value = this.getValidateValue();
|
||||
const field = this.props.field;
|
||||
const errors = [];
|
||||
const validations = [this.validatePresence, this.validatePattern];
|
||||
if (field.get('meta')) {
|
||||
validations.push(this.props.validateMetaField);
|
||||
}
|
||||
validations.forEach(func => {
|
||||
const response = func(field, value, this.props.t);
|
||||
if (response.error) errors.push(response.error);
|
||||
});
|
||||
if (skipWrapped) {
|
||||
if (skipWrapped.error) errors.push(skipWrapped.error);
|
||||
} else {
|
||||
const wrappedError = this.validateWrappedControl(field);
|
||||
if (wrappedError.error) errors.push(wrappedError.error);
|
||||
}
|
||||
|
||||
this.props.onValidate(errors);
|
||||
};
|
||||
|
||||
validatePresence = (field, value) => {
|
||||
const { t, parentIds } = this.props;
|
||||
const isRequired = field.get('required', true);
|
||||
if (isRequired && isEmpty(value)) {
|
||||
const error = {
|
||||
type: ValidationErrorTypes.PRESENCE,
|
||||
parentIds,
|
||||
message: t('editor.editorControlPane.widget.required', {
|
||||
fieldLabel: field.get('label', field.get('name')),
|
||||
}),
|
||||
};
|
||||
|
||||
return { error };
|
||||
}
|
||||
return { error: false };
|
||||
};
|
||||
|
||||
validatePattern = (field, value) => {
|
||||
const { t, parentIds } = this.props;
|
||||
const pattern = field.get('pattern', false);
|
||||
|
||||
if (isEmpty(value)) {
|
||||
return { error: false };
|
||||
}
|
||||
|
||||
if (pattern && !RegExp(pattern.first()).test(value)) {
|
||||
const error = {
|
||||
type: ValidationErrorTypes.PATTERN,
|
||||
parentIds,
|
||||
message: t('editor.editorControlPane.widget.regexPattern', {
|
||||
fieldLabel: field.get('label', field.get('name')),
|
||||
pattern: pattern.last(),
|
||||
}),
|
||||
};
|
||||
|
||||
return { error };
|
||||
}
|
||||
|
||||
return { error: false };
|
||||
};
|
||||
|
||||
validateWrappedControl = field => {
|
||||
const { t, parentIds, validator, value } = this.props;
|
||||
const response = validator?.({ value, field, t });
|
||||
if (response !== undefined) {
|
||||
if (typeof response === 'boolean') {
|
||||
return { error: !response };
|
||||
} else if (Object.prototype.hasOwnProperty.call(response, 'error')) {
|
||||
return response;
|
||||
} else if (response instanceof Promise) {
|
||||
response.then(
|
||||
() => {
|
||||
this.validate({ error: false });
|
||||
},
|
||||
err => {
|
||||
const error = {
|
||||
type: ValidationErrorTypes.CUSTOM,
|
||||
message: `${field.get('label', field.get('name'))} - ${err}.`,
|
||||
};
|
||||
|
||||
this.validate({ error });
|
||||
},
|
||||
);
|
||||
|
||||
const error = {
|
||||
type: ValidationErrorTypes.CUSTOM,
|
||||
parentIds,
|
||||
message: t('editor.editorControlPane.widget.processing', {
|
||||
fieldLabel: field.get('label', field.get('name')),
|
||||
}),
|
||||
};
|
||||
|
||||
return { error };
|
||||
}
|
||||
}
|
||||
return { error: false };
|
||||
};
|
||||
|
||||
/**
|
||||
* In case the `onChangeObject` function is frozen by a child widget implementation,
|
||||
* e.g. when debounced, always get the latest object value instead of using
|
||||
* `this.props.value` directly.
|
||||
*/
|
||||
getObjectValue = () => this.props.value || Map();
|
||||
|
||||
/**
|
||||
* Change handler for fields that are nested within another field.
|
||||
*/
|
||||
onChangeObject = (field, newValue, newMetadata) => {
|
||||
const newObjectValue = this.getObjectValue().set(field.get('name'), newValue);
|
||||
return this.props.onChange(
|
||||
newObjectValue,
|
||||
newMetadata && { [this.props.field.get('name')]: newMetadata },
|
||||
);
|
||||
};
|
||||
|
||||
setInactiveStyle = () => {
|
||||
this.props.setInactiveStyle();
|
||||
if (this.props.field.has('pattern') && !isEmpty(this.getValidateValue())) {
|
||||
this.validate();
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
controlComponent,
|
||||
entry,
|
||||
collection,
|
||||
config,
|
||||
field,
|
||||
value,
|
||||
mediaPaths,
|
||||
metadata,
|
||||
onChange,
|
||||
onValidateObject,
|
||||
onOpenMediaLibrary,
|
||||
onRemoveMediaControl,
|
||||
onPersistMedia,
|
||||
onClearMediaControl,
|
||||
onAddAsset,
|
||||
onRemoveInsertedMedia,
|
||||
getAsset,
|
||||
classNameWrapper,
|
||||
classNameWidget,
|
||||
classNameWidgetActive,
|
||||
classNameLabel,
|
||||
classNameLabelActive,
|
||||
setActiveStyle,
|
||||
hasActiveStyle,
|
||||
editorControl,
|
||||
uniqueFieldId,
|
||||
resolveWidget,
|
||||
widget,
|
||||
getEditorComponents,
|
||||
query,
|
||||
queryHits,
|
||||
clearSearch,
|
||||
clearFieldErrors,
|
||||
isFetching,
|
||||
loadEntry,
|
||||
fieldsErrors,
|
||||
controlRef,
|
||||
isEditorComponent,
|
||||
isNewEditorComponent,
|
||||
parentIds,
|
||||
t,
|
||||
isDisabled,
|
||||
isFieldDuplicate,
|
||||
isFieldHidden,
|
||||
locale,
|
||||
} = this.props;
|
||||
|
||||
return React.createElement(controlComponent, {
|
||||
entry,
|
||||
collection,
|
||||
config,
|
||||
field,
|
||||
value,
|
||||
mediaPaths,
|
||||
metadata,
|
||||
onChange,
|
||||
onChangeObject: this.onChangeObject,
|
||||
onValidateObject,
|
||||
onOpenMediaLibrary,
|
||||
onClearMediaControl,
|
||||
onRemoveMediaControl,
|
||||
onPersistMedia,
|
||||
onAddAsset,
|
||||
onRemoveInsertedMedia,
|
||||
getAsset,
|
||||
forID: uniqueFieldId,
|
||||
ref: this.processInnerControlRef,
|
||||
validate: this.validate,
|
||||
classNameWrapper,
|
||||
classNameWidget,
|
||||
classNameWidgetActive,
|
||||
classNameLabel,
|
||||
classNameLabelActive,
|
||||
setActiveStyle,
|
||||
setInactiveStyle: () => this.setInactiveStyle(),
|
||||
hasActiveStyle,
|
||||
editorControl,
|
||||
resolveWidget,
|
||||
widget,
|
||||
getEditorComponents,
|
||||
getRemarkPlugins,
|
||||
query,
|
||||
queryHits,
|
||||
clearSearch,
|
||||
clearFieldErrors,
|
||||
isFetching,
|
||||
loadEntry,
|
||||
isEditorComponent,
|
||||
isNewEditorComponent,
|
||||
fieldsErrors,
|
||||
controlRef,
|
||||
parentIds,
|
||||
t,
|
||||
isDisabled,
|
||||
isFieldDuplicate,
|
||||
isFieldHidden,
|
||||
locale,
|
||||
});
|
||||
}
|
||||
}
|
416
src/components/Editor/EditorInterface.js
Normal file
416
src/components/Editor/EditorInterface.js
Normal file
@ -0,0 +1,416 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { css, Global } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { ScrollSync, ScrollSyncPane } from 'react-scroll-sync';
|
||||
|
||||
import {
|
||||
colors,
|
||||
colorsRaw,
|
||||
components,
|
||||
transitions,
|
||||
IconButton,
|
||||
zIndex,
|
||||
} from '../../ui';
|
||||
import EditorControlPane from './EditorControlPane/EditorControlPane';
|
||||
import EditorPreviewPane from './EditorPreviewPane/EditorPreviewPane';
|
||||
import EditorToolbar from './EditorToolbar';
|
||||
import { hasI18n, getI18nInfo, getPreviewEntry } from '../../lib/i18n';
|
||||
import { FILES } from '../../constants/collectionTypes';
|
||||
import { getFileFromSlug } from '../../reducers/collections';
|
||||
|
||||
const PREVIEW_VISIBLE = 'cms.preview-visible';
|
||||
const I18N_VISIBLE = 'cms.i18n-visible';
|
||||
|
||||
const styles = {
|
||||
splitPane: css`
|
||||
${components.card};
|
||||
border-radius: 0;
|
||||
height: 100%;
|
||||
`,
|
||||
pane: css`
|
||||
height: 100%;
|
||||
`,
|
||||
};
|
||||
|
||||
const EditorToggle = styled(IconButton)`
|
||||
margin-bottom: 12px;
|
||||
`;
|
||||
|
||||
function ReactSplitPaneGlobalStyles() {
|
||||
return (
|
||||
<Global
|
||||
styles={css`
|
||||
.Resizer.vertical {
|
||||
width: 21px;
|
||||
cursor: col-resize;
|
||||
position: relative;
|
||||
transition: background-color ${transitions.main};
|
||||
|
||||
&:before {
|
||||
content: '';
|
||||
width: 2px;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
left: 10px;
|
||||
background-color: ${colors.textFieldBorder};
|
||||
display: block;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:active {
|
||||
background-color: ${colorsRaw.GrayLight};
|
||||
}
|
||||
}
|
||||
`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledSplitPane = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: min(864px, 50%) auto;;
|
||||
height: 100%;
|
||||
|
||||
> div:nth-child(2)::before {
|
||||
content: '';
|
||||
width: 2px;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
background-color: rgb(223, 223, 227);
|
||||
display: block;
|
||||
}
|
||||
`;
|
||||
|
||||
const NoPreviewContainer = styled.div`
|
||||
${styles.splitPane};
|
||||
`;
|
||||
|
||||
const EditorContainer = styled.div`
|
||||
width: 100%;
|
||||
min-width: 1200px;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
overflow: hidden;
|
||||
padding-top: 66px;
|
||||
`;
|
||||
|
||||
const Editor = styled.div`
|
||||
height: 100%;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
background-color: ${colorsRaw.white};
|
||||
`;
|
||||
|
||||
const PreviewPaneContainer = styled.div`
|
||||
height: 100%;
|
||||
pointer-events: ${props => (props.blockEntry ? 'none' : 'auto')};
|
||||
overflow-y: ${props => (props.overFlow ? 'auto' : 'hidden')};
|
||||
`;
|
||||
|
||||
const ControlPaneContainer = styled(PreviewPaneContainer)`
|
||||
padding: 0 16px;
|
||||
position: relative;
|
||||
overflow-x: hidden;
|
||||
`;
|
||||
|
||||
const ViewControls = styled.div`
|
||||
position: fixed;
|
||||
bottom: 3px;
|
||||
right: 12px;
|
||||
z-index: ${zIndex.zIndex299};
|
||||
`;
|
||||
|
||||
function EditorContent({
|
||||
i18nVisible,
|
||||
previewVisible,
|
||||
editor,
|
||||
editorWithEditor,
|
||||
editorWithPreview,
|
||||
}) {
|
||||
if (i18nVisible) {
|
||||
return editorWithEditor;
|
||||
} else if (previewVisible) {
|
||||
return editorWithPreview;
|
||||
} else {
|
||||
return <NoPreviewContainer>{editor}</NoPreviewContainer>;
|
||||
}
|
||||
}
|
||||
|
||||
function isPreviewEnabled(collection, entry) {
|
||||
if (collection.get('type') === FILES) {
|
||||
const file = getFileFromSlug(collection, entry.get('slug'));
|
||||
const previewEnabled = file?.getIn(['editor', 'preview']);
|
||||
if (previewEnabled != null) return previewEnabled;
|
||||
}
|
||||
return collection.getIn(['editor', 'preview'], true);
|
||||
}
|
||||
|
||||
class EditorInterface extends Component {
|
||||
state = {
|
||||
previewVisible: localStorage.getItem(PREVIEW_VISIBLE) !== 'false',
|
||||
i18nVisible: localStorage.getItem(I18N_VISIBLE) !== 'false',
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.props.loadScroll();
|
||||
}
|
||||
|
||||
handleOnPersist = async (opts = {}) => {
|
||||
const { createNew = false, duplicate = false } = opts;
|
||||
await this.controlPaneRef.switchToDefaultLocale();
|
||||
this.controlPaneRef.validate();
|
||||
this.props.onPersist({ createNew, duplicate });
|
||||
};
|
||||
|
||||
handleOnPublish = async (opts = {}) => {
|
||||
const { createNew = false, duplicate = false } = opts;
|
||||
await this.controlPaneRef.switchToDefaultLocale();
|
||||
this.controlPaneRef.validate();
|
||||
this.props.onPublish({ createNew, duplicate });
|
||||
};
|
||||
|
||||
handleTogglePreview = () => {
|
||||
const newPreviewVisible = !this.state.previewVisible;
|
||||
this.setState({ previewVisible: newPreviewVisible });
|
||||
localStorage.setItem(PREVIEW_VISIBLE, newPreviewVisible);
|
||||
};
|
||||
|
||||
handleToggleScrollSync = () => {
|
||||
const { toggleScroll } = this.props;
|
||||
toggleScroll();
|
||||
};
|
||||
|
||||
handleToggleI18n = () => {
|
||||
const newI18nVisible = !this.state.i18nVisible;
|
||||
this.setState({ i18nVisible: newI18nVisible });
|
||||
localStorage.setItem(I18N_VISIBLE, newI18nVisible);
|
||||
};
|
||||
|
||||
handleLeftPanelLocaleChange = locale => {
|
||||
this.setState({ leftPanelLocale: locale });
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
collection,
|
||||
entry,
|
||||
fields,
|
||||
fieldsMetaData,
|
||||
fieldsErrors,
|
||||
onChange,
|
||||
showDelete,
|
||||
onDelete,
|
||||
onDeleteUnpublishedChanges,
|
||||
onChangeStatus,
|
||||
onPublish,
|
||||
unPublish,
|
||||
onDuplicate,
|
||||
onValidate,
|
||||
user,
|
||||
hasChanged,
|
||||
displayUrl,
|
||||
hasWorkflow,
|
||||
useOpenAuthoring,
|
||||
hasUnpublishedChanges,
|
||||
isNewEntry,
|
||||
isModification,
|
||||
currentStatus,
|
||||
onLogoutClick,
|
||||
loadDeployPreview,
|
||||
deployPreview,
|
||||
draftKey,
|
||||
editorBackLink,
|
||||
scrollSyncEnabled,
|
||||
t,
|
||||
} = this.props;
|
||||
|
||||
const previewEnabled = isPreviewEnabled(collection, entry);
|
||||
|
||||
const collectionI18nEnabled = hasI18n(collection);
|
||||
const { locales, defaultLocale } = getI18nInfo(this.props.collection);
|
||||
const editorProps = {
|
||||
collection,
|
||||
entry,
|
||||
fields,
|
||||
fieldsMetaData,
|
||||
fieldsErrors,
|
||||
onChange,
|
||||
onValidate,
|
||||
};
|
||||
|
||||
const leftPanelLocale = this.state.leftPanelLocale || locales?.[0];
|
||||
const editor = (
|
||||
<ControlPaneContainer id="control-pane" overFlow>
|
||||
<EditorControlPane
|
||||
{...editorProps}
|
||||
ref={c => (this.controlPaneRef = c)}
|
||||
locale={leftPanelLocale}
|
||||
t={t}
|
||||
onLocaleChange={this.handleLeftPanelLocaleChange}
|
||||
/>
|
||||
</ControlPaneContainer>
|
||||
);
|
||||
|
||||
const editor2 = (
|
||||
<ControlPaneContainer overFlow={!this.props.scrollSyncEnabled}>
|
||||
<EditorControlPane {...editorProps} locale={locales?.[1]} t={t} />
|
||||
</ControlPaneContainer>
|
||||
);
|
||||
|
||||
const previewEntry = collectionI18nEnabled
|
||||
? getPreviewEntry(entry, leftPanelLocale, defaultLocale)
|
||||
: entry;
|
||||
|
||||
const editorWithPreview = (
|
||||
<>
|
||||
<ReactSplitPaneGlobalStyles />
|
||||
<StyledSplitPane>
|
||||
<ScrollSyncPane>{editor}</ScrollSyncPane>
|
||||
<PreviewPaneContainer>
|
||||
<EditorPreviewPane
|
||||
collection={collection}
|
||||
entry={previewEntry}
|
||||
fields={fields}
|
||||
fieldsMetaData={fieldsMetaData}
|
||||
locale={leftPanelLocale}
|
||||
/>
|
||||
</PreviewPaneContainer>
|
||||
</StyledSplitPane>
|
||||
</>
|
||||
);
|
||||
|
||||
const editorWithEditor = (
|
||||
<ScrollSync enabled={this.props.scrollSyncEnabled}>
|
||||
<div>
|
||||
<StyledSplitPane>
|
||||
<ScrollSyncPane>{editor}</ScrollSyncPane>
|
||||
<ScrollSyncPane>{editor2}</ScrollSyncPane>
|
||||
</StyledSplitPane>
|
||||
</div>
|
||||
</ScrollSync>
|
||||
);
|
||||
|
||||
const i18nVisible = collectionI18nEnabled && this.state.i18nVisible;
|
||||
const previewVisible = previewEnabled && this.state.previewVisible;
|
||||
const scrollSyncVisible = i18nVisible || previewVisible;
|
||||
|
||||
return (
|
||||
<EditorContainer>
|
||||
<EditorToolbar
|
||||
isPersisting={entry.get('isPersisting')}
|
||||
isPublishing={entry.get('isPublishing')}
|
||||
isUpdatingStatus={entry.get('isUpdatingStatus')}
|
||||
isDeleting={entry.get('isDeleting')}
|
||||
onPersist={this.handleOnPersist}
|
||||
onPersistAndNew={() => this.handleOnPersist({ createNew: true })}
|
||||
onPersistAndDuplicate={() => this.handleOnPersist({ createNew: true, duplicate: true })}
|
||||
onDelete={onDelete}
|
||||
onDeleteUnpublishedChanges={onDeleteUnpublishedChanges}
|
||||
onChangeStatus={onChangeStatus}
|
||||
showDelete={showDelete}
|
||||
onPublish={onPublish}
|
||||
unPublish={unPublish}
|
||||
onDuplicate={onDuplicate}
|
||||
onPublishAndNew={() => this.handleOnPublish({ createNew: true })}
|
||||
onPublishAndDuplicate={() => this.handleOnPublish({ createNew: true, duplicate: true })}
|
||||
user={user}
|
||||
hasChanged={hasChanged}
|
||||
displayUrl={displayUrl}
|
||||
collection={collection}
|
||||
hasWorkflow={hasWorkflow}
|
||||
useOpenAuthoring={useOpenAuthoring}
|
||||
hasUnpublishedChanges={hasUnpublishedChanges}
|
||||
isNewEntry={isNewEntry}
|
||||
isModification={isModification}
|
||||
currentStatus={currentStatus}
|
||||
onLogoutClick={onLogoutClick}
|
||||
loadDeployPreview={loadDeployPreview}
|
||||
deployPreview={deployPreview}
|
||||
editorBackLink={editorBackLink}
|
||||
/>
|
||||
<Editor key={draftKey}>
|
||||
<ViewControls>
|
||||
{collectionI18nEnabled && (
|
||||
<EditorToggle
|
||||
isActive={i18nVisible}
|
||||
onClick={this.handleToggleI18n}
|
||||
size="large"
|
||||
type="page"
|
||||
title={t('editor.editorInterface.toggleI18n')}
|
||||
marginTop="70px"
|
||||
/>
|
||||
)}
|
||||
{previewEnabled && (
|
||||
<EditorToggle
|
||||
isActive={previewVisible}
|
||||
onClick={this.handleTogglePreview}
|
||||
size="large"
|
||||
type="eye"
|
||||
title={t('editor.editorInterface.togglePreview')}
|
||||
/>
|
||||
)}
|
||||
{scrollSyncVisible && (
|
||||
<EditorToggle
|
||||
isActive={scrollSyncEnabled}
|
||||
onClick={this.handleToggleScrollSync}
|
||||
size="large"
|
||||
type="scroll"
|
||||
title={t('editor.editorInterface.toggleScrollSync')}
|
||||
/>
|
||||
)}
|
||||
</ViewControls>
|
||||
<EditorContent
|
||||
i18nVisible={i18nVisible}
|
||||
previewVisible={previewVisible}
|
||||
editor={editor}
|
||||
editorWithEditor={editorWithEditor}
|
||||
editorWithPreview={editorWithPreview}
|
||||
/>
|
||||
</Editor>
|
||||
</EditorContainer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
EditorInterface.propTypes = {
|
||||
collection: ImmutablePropTypes.map.isRequired,
|
||||
entry: ImmutablePropTypes.map.isRequired,
|
||||
fields: ImmutablePropTypes.list.isRequired,
|
||||
fieldsMetaData: ImmutablePropTypes.map.isRequired,
|
||||
fieldsErrors: ImmutablePropTypes.map.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onValidate: PropTypes.func.isRequired,
|
||||
onPersist: PropTypes.func.isRequired,
|
||||
showDelete: PropTypes.bool.isRequired,
|
||||
onDelete: PropTypes.func.isRequired,
|
||||
onDeleteUnpublishedChanges: PropTypes.func.isRequired,
|
||||
onPublish: PropTypes.func.isRequired,
|
||||
unPublish: PropTypes.func.isRequired,
|
||||
onDuplicate: PropTypes.func.isRequired,
|
||||
onChangeStatus: PropTypes.func.isRequired,
|
||||
user: PropTypes.object,
|
||||
hasChanged: PropTypes.bool,
|
||||
displayUrl: PropTypes.string,
|
||||
hasWorkflow: PropTypes.bool,
|
||||
useOpenAuthoring: PropTypes.bool,
|
||||
hasUnpublishedChanges: PropTypes.bool,
|
||||
isNewEntry: PropTypes.bool,
|
||||
isModification: PropTypes.bool,
|
||||
currentStatus: PropTypes.string,
|
||||
onLogoutClick: PropTypes.func.isRequired,
|
||||
deployPreview: PropTypes.object,
|
||||
loadDeployPreview: PropTypes.func.isRequired,
|
||||
draftKey: PropTypes.string.isRequired,
|
||||
toggleScroll: PropTypes.func.isRequired,
|
||||
scrollSyncEnabled: PropTypes.bool.isRequired,
|
||||
loadScroll: PropTypes.func.isRequired,
|
||||
t: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default EditorInterface;
|
44
src/components/Editor/EditorPreviewPane/EditorPreview.js
Normal file
44
src/components/Editor/EditorPreviewPane/EditorPreview.js
Normal file
@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
function isVisible(field) {
|
||||
return field.get('widget') !== 'hidden';
|
||||
}
|
||||
|
||||
const PreviewContainer = styled.div`
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
font-family: Roboto, 'Helvetica Neue', HelveticaNeue, Helvetica, Arial, sans-serif;
|
||||
`;
|
||||
|
||||
/**
|
||||
* Use a stateful component so that child components can effectively utilize
|
||||
* `shouldComponentUpdate`.
|
||||
*/
|
||||
export default class Preview extends React.Component {
|
||||
render() {
|
||||
const { collection, fields, widgetFor } = this.props;
|
||||
if (!collection || !fields) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<PreviewContainer>
|
||||
{fields.filter(isVisible).map(field => (
|
||||
<div key={field.get('name')}>{widgetFor(field.get('name'))}</div>
|
||||
))}
|
||||
</PreviewContainer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Preview.propTypes = {
|
||||
collection: ImmutablePropTypes.map.isRequired,
|
||||
entry: ImmutablePropTypes.map.isRequired,
|
||||
fields: ImmutablePropTypes.list.isRequired,
|
||||
getAsset: PropTypes.func.isRequired,
|
||||
widgetFor: PropTypes.func.isRequired,
|
||||
};
|
@ -0,0 +1,52 @@
|
||||
/* eslint-disable @typescript-eslint/consistent-type-imports */
|
||||
/* eslint-disable func-style */
|
||||
import styled from '@emotion/styled';
|
||||
import { CmsWidgetPreviewProps } from '../../../interface';
|
||||
import React, { ComponentType, ReactNode, useMemo } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { ScrollSyncPane } from 'react-scroll-sync';
|
||||
|
||||
interface PreviewContentProps {
|
||||
previewComponent?:
|
||||
| React.ReactElement<unknown, string | React.JSXElementConstructor<any>>
|
||||
| ComponentType<CmsWidgetPreviewProps>;
|
||||
previewProps: CmsWidgetPreviewProps;
|
||||
}
|
||||
|
||||
const StyledPreviewContent = styled.div`
|
||||
width: calc(100% - min(864px, 50%));
|
||||
top: 66px;
|
||||
right: 0;
|
||||
position: absolute;
|
||||
height: calc(100% - 66px);
|
||||
overflow-y: auto;
|
||||
`;
|
||||
|
||||
const PreviewContent = ({ previewComponent, previewProps }: PreviewContentProps) => {
|
||||
const element = useMemo(() => document.getElementById('cms-root'), []);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!element) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let children: ReactNode;
|
||||
if (!previewComponent) {
|
||||
children = null;
|
||||
} else if (React.isValidElement(previewComponent)) {
|
||||
children = React.cloneElement(previewComponent, previewProps);
|
||||
} else {
|
||||
children = React.createElement(previewComponent, previewProps);
|
||||
}
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<ScrollSyncPane>
|
||||
<StyledPreviewContent className="preview-content">{children}</StyledPreviewContent>
|
||||
</ScrollSyncPane>,
|
||||
element,
|
||||
'preview-content'
|
||||
);
|
||||
}, [previewComponent, previewProps, element]);
|
||||
};
|
||||
|
||||
export default PreviewContent;
|
272
src/components/Editor/EditorPreviewPane/EditorPreviewPane.js
Normal file
272
src/components/Editor/EditorPreviewPane/EditorPreviewPane.js
Normal file
@ -0,0 +1,272 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import { List, Map } from 'immutable';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { lengths } from '../../../ui';
|
||||
import {
|
||||
resolveWidget,
|
||||
getPreviewTemplate,
|
||||
getPreviewStyles,
|
||||
getRemarkPlugins,
|
||||
} from '../../../lib/registry';
|
||||
import { ErrorBoundary } from '../../UI';
|
||||
import { selectTemplateName, selectInferedField, selectField } from '../../../reducers/collections';
|
||||
import { boundGetAsset } from '../../../actions/media';
|
||||
import { selectIsLoadingAsset } from '../../../reducers/medias';
|
||||
import { INFERABLE_FIELDS } from '../../../constants/fieldInference';
|
||||
import EditorPreviewContent from './EditorPreviewContent';
|
||||
import PreviewHOC from './PreviewHOC';
|
||||
import EditorPreview from './EditorPreview';
|
||||
|
||||
const PreviewPaneFrame = styled.div`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
background: #fff;
|
||||
border-radius: ${lengths.borderRadius};
|
||||
overflow: auto;
|
||||
`;
|
||||
|
||||
export class PreviewPane extends React.Component {
|
||||
getWidget = (field, value, metadata, props, idx = null) => {
|
||||
const { getAsset, entry } = props;
|
||||
const widget = resolveWidget(field.get('widget'));
|
||||
const key = idx ? field.get('name') + '_' + idx : field.get('name');
|
||||
const valueIsInMap = value && !widget.allowMapValue && Map.isMap(value);
|
||||
|
||||
/**
|
||||
* Use an HOC to provide conditional updates for all previews.
|
||||
*/
|
||||
return !widget.preview ? null : (
|
||||
<PreviewHOC
|
||||
previewComponent={widget.preview}
|
||||
key={key}
|
||||
field={field}
|
||||
getAsset={getAsset}
|
||||
value={valueIsInMap ? value.get(field.get('name')) : value}
|
||||
entry={entry}
|
||||
fieldsMetaData={metadata}
|
||||
resolveWidget={resolveWidget}
|
||||
getRemarkPlugins={getRemarkPlugins}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
inferedFields = {};
|
||||
|
||||
inferFields() {
|
||||
const titleField = selectInferedField(this.props.collection, 'title');
|
||||
const shortTitleField = selectInferedField(this.props.collection, 'shortTitle');
|
||||
const authorField = selectInferedField(this.props.collection, 'author');
|
||||
|
||||
this.inferedFields = {};
|
||||
if (titleField) this.inferedFields[titleField] = INFERABLE_FIELDS.title;
|
||||
if (shortTitleField) this.inferedFields[shortTitleField] = INFERABLE_FIELDS.shortTitle;
|
||||
if (authorField) this.inferedFields[authorField] = INFERABLE_FIELDS.author;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the widget component for a named field, and makes recursive calls
|
||||
* to retrieve components for nested and deeply nested fields, which occur in
|
||||
* object and list type fields. Used internally to retrieve widgets, and also
|
||||
* exposed for use in custom preview templates.
|
||||
*/
|
||||
widgetFor = (
|
||||
name,
|
||||
fields = this.props.fields,
|
||||
values = this.props.entry.get('data'),
|
||||
fieldsMetaData = this.props.fieldsMetaData,
|
||||
) => {
|
||||
// We retrieve the field by name so that this function can also be used in
|
||||
// custom preview templates, where the field object can't be passed in.
|
||||
let field = fields && fields.find(f => f.get('name') === name);
|
||||
let value = Map.isMap(values) && values.get(field.get('name'));
|
||||
if (field.get('meta')) {
|
||||
value = this.props.entry.getIn(['meta', field.get('name')]);
|
||||
}
|
||||
const nestedFields = field.get('fields');
|
||||
const singleField = field.get('field');
|
||||
const metadata = fieldsMetaData && fieldsMetaData.get(field.get('name'), Map());
|
||||
|
||||
if (nestedFields) {
|
||||
field = field.set('fields', this.getNestedWidgets(nestedFields, value, metadata));
|
||||
}
|
||||
|
||||
if (singleField) {
|
||||
field = field.set('field', this.getSingleNested(singleField, value, metadata));
|
||||
}
|
||||
|
||||
const labelledWidgets = ['string', 'text', 'number'];
|
||||
const inferedField = Object.entries(this.inferedFields)
|
||||
.filter(([key]) => {
|
||||
const fieldToMatch = selectField(this.props.collection, key);
|
||||
return fieldToMatch === field;
|
||||
})
|
||||
.map(([, value]) => value)[0];
|
||||
|
||||
if (inferedField) {
|
||||
value = inferedField.defaultPreview(value);
|
||||
} else if (
|
||||
value &&
|
||||
labelledWidgets.indexOf(field.get('widget')) !== -1 &&
|
||||
value.toString().length < 50
|
||||
) {
|
||||
value = (
|
||||
<div>
|
||||
<strong>{field.get('label', field.get('name'))}:</strong> {value}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return value ? this.getWidget(field, value, metadata, this.props) : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves widgets for nested fields (children of object/list fields)
|
||||
*/
|
||||
getNestedWidgets = (fields, values, fieldsMetaData) => {
|
||||
// Fields nested within a list field will be paired with a List of value Maps.
|
||||
if (List.isList(values)) {
|
||||
return values.map(value => this.widgetsForNestedFields(fields, value, fieldsMetaData));
|
||||
}
|
||||
// Fields nested within an object field will be paired with a single Map of values.
|
||||
return this.widgetsForNestedFields(fields, values, fieldsMetaData);
|
||||
};
|
||||
|
||||
getSingleNested = (field, values, fieldsMetaData) => {
|
||||
if (List.isList(values)) {
|
||||
return values.map((value, idx) =>
|
||||
this.getWidget(field, value, fieldsMetaData.get(field.get('name')), this.props, idx),
|
||||
);
|
||||
}
|
||||
return this.getWidget(field, values, fieldsMetaData.get(field.get('name')), this.props);
|
||||
};
|
||||
|
||||
/**
|
||||
* Use widgetFor as a mapping function for recursive widget retrieval
|
||||
*/
|
||||
widgetsForNestedFields = (fields, values, fieldsMetaData) => {
|
||||
return fields.map(field => this.widgetFor(field.get('name'), fields, values, fieldsMetaData));
|
||||
};
|
||||
|
||||
/**
|
||||
* This function exists entirely to expose nested widgets for object and list
|
||||
* fields to custom preview templates.
|
||||
*
|
||||
* TODO: see if widgetFor can now provide this functionality for preview templates
|
||||
*/
|
||||
widgetsFor = name => {
|
||||
const { fields, entry, fieldsMetaData } = this.props;
|
||||
const field = fields.find(f => f.get('name') === name);
|
||||
const nestedFields = field && field.get('fields');
|
||||
const value = entry.getIn(['data', field.get('name')]);
|
||||
const metadata = fieldsMetaData.get(field.get('name'), Map());
|
||||
|
||||
if (List.isList(value)) {
|
||||
return value.map(val => {
|
||||
const widgets =
|
||||
nestedFields &&
|
||||
Map(
|
||||
nestedFields.map((f, i) => [
|
||||
f.get('name'),
|
||||
<div key={i}>{this.getWidget(f, val, metadata.get(f.get('name')), this.props)}</div>,
|
||||
]),
|
||||
);
|
||||
return Map({ data: val, widgets });
|
||||
});
|
||||
}
|
||||
|
||||
return Map({
|
||||
data: value,
|
||||
widgets:
|
||||
nestedFields &&
|
||||
Map(
|
||||
nestedFields.map(f => [
|
||||
f.get('name'),
|
||||
this.getWidget(f, value, metadata.get(f.get('name')), this.props),
|
||||
]),
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const { entry, collection, config } = this.props;
|
||||
|
||||
if (!entry || !entry.get('data')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const previewComponent =
|
||||
getPreviewTemplate(selectTemplateName(collection, entry.get('slug'))) || EditorPreview;
|
||||
|
||||
this.inferFields();
|
||||
|
||||
const previewProps = {
|
||||
...this.props,
|
||||
widgetFor: this.widgetFor,
|
||||
widgetsFor: this.widgetsFor,
|
||||
};
|
||||
|
||||
const styleEls = getPreviewStyles().map((style, i) => {
|
||||
if (style.raw) {
|
||||
return <style key={i}>{style.value}</style>;
|
||||
}
|
||||
return <link key={i} href={style.value} type="text/css" rel="stylesheet" />;
|
||||
});
|
||||
|
||||
if (!collection) {
|
||||
<PreviewPaneFrame id="preview-pane" head={styleEls} />;
|
||||
}
|
||||
|
||||
const initialContent = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><base target="_blank"/></head>
|
||||
<body><div></div></body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
return (
|
||||
<ErrorBoundary config={config}>
|
||||
<PreviewPaneFrame id="preview-pane" head={styleEls} initialContent={initialContent}>
|
||||
<EditorPreviewContent
|
||||
{...{ previewComponent, previewProps: { ...previewProps, document, window } }}
|
||||
/>
|
||||
</PreviewPaneFrame>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
PreviewPane.propTypes = {
|
||||
collection: ImmutablePropTypes.map.isRequired,
|
||||
fields: ImmutablePropTypes.list.isRequired,
|
||||
entry: ImmutablePropTypes.map.isRequired,
|
||||
fieldsMetaData: ImmutablePropTypes.map.isRequired,
|
||||
getAsset: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
function mapStateToProps(state) {
|
||||
const isLoadingAsset = selectIsLoadingAsset(state.medias);
|
||||
return { isLoadingAsset, config: state.config };
|
||||
}
|
||||
|
||||
function mapDispatchToProps(dispatch) {
|
||||
return {
|
||||
boundGetAsset: (collection, entry) => boundGetAsset(dispatch, collection, entry),
|
||||
};
|
||||
}
|
||||
|
||||
function mergeProps(stateProps, dispatchProps, ownProps) {
|
||||
return {
|
||||
...stateProps,
|
||||
...dispatchProps,
|
||||
...ownProps,
|
||||
getAsset: dispatchProps.boundGetAsset(ownProps.collection, ownProps.entry),
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps, mergeProps)(PreviewPane);
|
33
src/components/Editor/EditorPreviewPane/PreviewHOC.js
Normal file
33
src/components/Editor/EditorPreviewPane/PreviewHOC.js
Normal file
@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
|
||||
class PreviewHOC extends React.Component {
|
||||
/**
|
||||
* Only re-render on value change, but always re-render objects and lists.
|
||||
* Their child widgets will each also be wrapped with this component, and
|
||||
* will only be updated on value change.
|
||||
*/
|
||||
shouldComponentUpdate(nextProps) {
|
||||
const isWidgetContainer = ['object', 'list'].includes(nextProps.field.get('widget'));
|
||||
return (
|
||||
isWidgetContainer ||
|
||||
this.props.value !== nextProps.value ||
|
||||
this.props.fieldsMetaData !== nextProps.fieldsMetaData ||
|
||||
this.props.getAsset !== nextProps.getAsset
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { previewComponent, ...props } = this.props;
|
||||
return React.createElement(previewComponent, props);
|
||||
}
|
||||
}
|
||||
|
||||
PreviewHOC.propTypes = {
|
||||
previewComponent: PropTypes.func.isRequired,
|
||||
field: ImmutablePropTypes.map.isRequired,
|
||||
value: PropTypes.oneOfType([PropTypes.node, PropTypes.object, PropTypes.string, PropTypes.bool]),
|
||||
};
|
||||
|
||||
export default PreviewHOC;
|
687
src/components/Editor/EditorToolbar.js
Normal file
687
src/components/Editor/EditorToolbar.js
Normal file
@ -0,0 +1,687 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { css } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { translate } from 'react-polyglot';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import {
|
||||
Icon,
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
StyledDropdownButton,
|
||||
colorsRaw,
|
||||
colors,
|
||||
components,
|
||||
buttons,
|
||||
zIndex,
|
||||
} from '../../ui';
|
||||
import { status } from '../../constants/publishModes';
|
||||
import { SettingsDropdown } from '../UI';
|
||||
|
||||
const styles = {
|
||||
noOverflow: css`
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
`,
|
||||
buttonMargin: css`
|
||||
margin: 0 10px;
|
||||
`,
|
||||
toolbarSection: css`
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 0 solid ${colors.textFieldBorder};
|
||||
`,
|
||||
publishedButton: css`
|
||||
background-color: ${colorsRaw.tealLight};
|
||||
color: ${colorsRaw.teal};
|
||||
`,
|
||||
};
|
||||
|
||||
const TooltipText = styled.div`
|
||||
visibility: hidden;
|
||||
width: 321px;
|
||||
background-color: #555;
|
||||
color: #fff;
|
||||
text-align: unset;
|
||||
border-radius: 6px;
|
||||
padding: 5px;
|
||||
|
||||
/* Position the tooltip text */
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: 145%;
|
||||
left: 50%;
|
||||
margin-left: -320px;
|
||||
|
||||
/* Fade in tooltip */
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
`;
|
||||
|
||||
const Tooltip = styled.div`
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
&:hover + ${TooltipText} {
|
||||
visibility: visible;
|
||||
opacity: 0.9;
|
||||
}
|
||||
`;
|
||||
|
||||
const TooltipContainer = styled.div`
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const DropdownButton = styled(StyledDropdownButton)`
|
||||
${styles.noOverflow}
|
||||
@media (max-width: 1200px) {
|
||||
padding-left: 10px;
|
||||
}
|
||||
`;
|
||||
|
||||
const ToolbarContainer = styled.div`
|
||||
box-shadow: 0 2px 6px 0 rgba(68, 74, 87, 0.05), 0 1px 3px 0 rgba(68, 74, 87, 0.1),
|
||||
0 2px 54px rgba(0, 0, 0, 0.1);
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
min-width: 1200px;
|
||||
z-index: ${zIndex.zIndex300};
|
||||
background-color: #fff;
|
||||
height: 66px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
`;
|
||||
|
||||
const ToolbarSectionMain = styled.div`
|
||||
${styles.toolbarSection};
|
||||
flex: 10;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0 10px;
|
||||
`;
|
||||
|
||||
const ToolbarSubSectionFirst = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const ToolbarSubSectionLast = styled(ToolbarSubSectionFirst)`
|
||||
justify-content: flex-end;
|
||||
`;
|
||||
|
||||
const ToolbarSectionBackLink = styled(Link)`
|
||||
${styles.toolbarSection};
|
||||
border-right-width: 1px;
|
||||
font-weight: normal;
|
||||
padding: 0 20px;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: #f1f2f4;
|
||||
}
|
||||
`;
|
||||
|
||||
const ToolbarSectionMeta = styled.div`
|
||||
${styles.toolbarSection};
|
||||
border-left-width: 1px;
|
||||
padding: 0 7px;
|
||||
`;
|
||||
|
||||
const ToolbarDropdown = styled(Dropdown)`
|
||||
${styles.buttonMargin};
|
||||
|
||||
${Icon} {
|
||||
color: ${colorsRaw.teal};
|
||||
}
|
||||
`;
|
||||
|
||||
const BackArrow = styled.div`
|
||||
color: ${colors.textLead};
|
||||
font-size: 21px;
|
||||
font-weight: 600;
|
||||
margin-right: 16px;
|
||||
`;
|
||||
|
||||
const BackCollection = styled.div`
|
||||
color: ${colors.textLead};
|
||||
font-size: 14px;
|
||||
`;
|
||||
|
||||
const BackStatus = styled.div`
|
||||
margin-top: 6px;
|
||||
`;
|
||||
|
||||
const BackStatusUnchanged = styled(BackStatus)`
|
||||
${components.textBadgeSuccess};
|
||||
`;
|
||||
|
||||
const BackStatusChanged = styled(BackStatus)`
|
||||
${components.textBadgeDanger};
|
||||
`;
|
||||
|
||||
const ToolbarButton = styled.button`
|
||||
${buttons.button};
|
||||
${buttons.default};
|
||||
${styles.buttonMargin};
|
||||
${styles.noOverflow};
|
||||
display: block;
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
padding: 0 10px;
|
||||
}
|
||||
`;
|
||||
|
||||
const DeleteButton = styled(ToolbarButton)`
|
||||
${buttons.lightRed};
|
||||
`;
|
||||
|
||||
const SaveButton = styled(ToolbarButton)`
|
||||
${buttons.lightBlue};
|
||||
&[disabled] {
|
||||
${buttons.disabled};
|
||||
}
|
||||
`;
|
||||
|
||||
const PublishedToolbarButton = styled(DropdownButton)`
|
||||
${styles.publishedButton}
|
||||
`;
|
||||
|
||||
const PublishedButton = styled(ToolbarButton)`
|
||||
${styles.publishedButton}
|
||||
`;
|
||||
|
||||
const PublishButton = styled(DropdownButton)`
|
||||
background-color: ${colorsRaw.teal};
|
||||
`;
|
||||
|
||||
const StatusButton = styled(DropdownButton)`
|
||||
background-color: ${colorsRaw.tealLight};
|
||||
color: ${colorsRaw.teal};
|
||||
`;
|
||||
|
||||
const PreviewButtonContainer = styled.div`
|
||||
margin-right: 12px;
|
||||
color: ${colorsRaw.blue};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
a,
|
||||
${Icon} {
|
||||
color: ${colorsRaw.blue};
|
||||
}
|
||||
|
||||
${Icon} {
|
||||
position: relative;
|
||||
top: 1px;
|
||||
}
|
||||
`;
|
||||
|
||||
const RefreshPreviewButton = styled.button`
|
||||
background: none;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
color: ${colorsRaw.blue};
|
||||
|
||||
span {
|
||||
margin-right: 6px;
|
||||
}
|
||||
`;
|
||||
|
||||
const PreviewLink = RefreshPreviewButton.withComponent('a');
|
||||
|
||||
const StatusDropdownItem = styled(DropdownItem)`
|
||||
${Icon} {
|
||||
color: ${colors.infoText};
|
||||
}
|
||||
`;
|
||||
|
||||
export class EditorToolbar extends React.Component {
|
||||
static propTypes = {
|
||||
isPersisting: PropTypes.bool,
|
||||
isPublishing: PropTypes.bool,
|
||||
isUpdatingStatus: PropTypes.bool,
|
||||
isDeleting: PropTypes.bool,
|
||||
onPersist: PropTypes.func.isRequired,
|
||||
onPersistAndNew: PropTypes.func.isRequired,
|
||||
onPersistAndDuplicate: PropTypes.func.isRequired,
|
||||
showDelete: PropTypes.bool.isRequired,
|
||||
onDelete: PropTypes.func.isRequired,
|
||||
onDeleteUnpublishedChanges: PropTypes.func.isRequired,
|
||||
onChangeStatus: PropTypes.func.isRequired,
|
||||
onPublish: PropTypes.func.isRequired,
|
||||
unPublish: PropTypes.func.isRequired,
|
||||
onDuplicate: PropTypes.func.isRequired,
|
||||
onPublishAndNew: PropTypes.func.isRequired,
|
||||
onPublishAndDuplicate: PropTypes.func.isRequired,
|
||||
user: PropTypes.object,
|
||||
hasChanged: PropTypes.bool,
|
||||
displayUrl: PropTypes.string,
|
||||
collection: ImmutablePropTypes.map.isRequired,
|
||||
hasWorkflow: PropTypes.bool,
|
||||
useOpenAuthoring: PropTypes.bool,
|
||||
hasUnpublishedChanges: PropTypes.bool,
|
||||
isNewEntry: PropTypes.bool,
|
||||
isModification: PropTypes.bool,
|
||||
currentStatus: PropTypes.string,
|
||||
onLogoutClick: PropTypes.func.isRequired,
|
||||
deployPreview: PropTypes.object,
|
||||
loadDeployPreview: PropTypes.func.isRequired,
|
||||
t: PropTypes.func.isRequired,
|
||||
editorBackLink: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
const { isNewEntry, loadDeployPreview } = this.props;
|
||||
if (!isNewEntry) {
|
||||
loadDeployPreview({ maxAttempts: 3 });
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const { isNewEntry, isPersisting, loadDeployPreview } = this.props;
|
||||
if (!isNewEntry && prevProps.isPersisting && !isPersisting) {
|
||||
loadDeployPreview({ maxAttempts: 3 });
|
||||
}
|
||||
}
|
||||
|
||||
renderSimpleControls = () => {
|
||||
const { collection, hasChanged, isNewEntry, showDelete, onDelete, t } = this.props;
|
||||
const canCreate = collection.get('create');
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isNewEntry && !hasChanged
|
||||
? this.renderExistingEntrySimplePublishControls({ canCreate })
|
||||
: this.renderNewEntrySimplePublishControls({ canCreate })}
|
||||
<div>
|
||||
{showDelete ? (
|
||||
<DeleteButton key="delete-button" onClick={onDelete}>
|
||||
{t('editor.editorToolbar.deleteEntry')}
|
||||
</DeleteButton>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
renderDeployPreviewControls = label => {
|
||||
const { deployPreview = {}, loadDeployPreview, t } = this.props;
|
||||
const { url, status, isFetching } = deployPreview;
|
||||
|
||||
if (!status) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deployPreviewReady = status === 'SUCCESS' && !isFetching;
|
||||
return (
|
||||
<PreviewButtonContainer>
|
||||
{deployPreviewReady ? (
|
||||
<PreviewLink
|
||||
key="preview-ready-button"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
href={url}
|
||||
>
|
||||
<span>{label}</span>
|
||||
<Icon type="new-tab" size="xsmall" />
|
||||
</PreviewLink>
|
||||
) : (
|
||||
<RefreshPreviewButton key="preview-pending-button" onClick={loadDeployPreview}>
|
||||
<span>{t('editor.editorToolbar.deployPreviewPendingButtonLabel')}</span>
|
||||
<Icon type="refresh" size="xsmall" />
|
||||
</RefreshPreviewButton>
|
||||
)}
|
||||
</PreviewButtonContainer>
|
||||
);
|
||||
};
|
||||
|
||||
renderStatusInfoTooltip = () => {
|
||||
const { t, currentStatus } = this.props;
|
||||
|
||||
const statusToLocaleKey = {
|
||||
[status.get('DRAFT')]: 'statusInfoTooltipDraft',
|
||||
[status.get('PENDING_REVIEW')]: 'statusInfoTooltipInReview',
|
||||
};
|
||||
|
||||
const statusKey = Object.keys(statusToLocaleKey).find(key => key === currentStatus);
|
||||
return (
|
||||
<TooltipContainer>
|
||||
<Tooltip>
|
||||
<Icon type="info-circle" size="small" className="tooltip" />
|
||||
</Tooltip>
|
||||
{statusKey && (
|
||||
<TooltipText key="status-tooltip">
|
||||
{t(`editor.editorToolbar.${statusToLocaleKey[statusKey]}`)}
|
||||
</TooltipText>
|
||||
)}
|
||||
</TooltipContainer>
|
||||
);
|
||||
};
|
||||
|
||||
renderWorkflowStatusControls = () => {
|
||||
const { isUpdatingStatus, onChangeStatus, currentStatus, t, useOpenAuthoring } = this.props;
|
||||
|
||||
const statusToTranslation = {
|
||||
[status.get('DRAFT')]: t('editor.editorToolbar.draft'),
|
||||
[status.get('PENDING_REVIEW')]: t('editor.editorToolbar.inReview'),
|
||||
[status.get('PENDING_PUBLISH')]: t('editor.editorToolbar.ready'),
|
||||
};
|
||||
|
||||
const buttonText = isUpdatingStatus
|
||||
? t('editor.editorToolbar.updating')
|
||||
: t('editor.editorToolbar.status', { status: statusToTranslation[currentStatus] });
|
||||
|
||||
return (
|
||||
<>
|
||||
<ToolbarDropdown
|
||||
dropdownTopOverlap="40px"
|
||||
dropdownWidth="120px"
|
||||
renderButton={() => <StatusButton>{buttonText}</StatusButton>}
|
||||
>
|
||||
<StatusDropdownItem
|
||||
label={t('editor.editorToolbar.draft')}
|
||||
onClick={() => onChangeStatus('DRAFT')}
|
||||
icon={currentStatus === status.get('DRAFT') ? 'check' : null}
|
||||
/>
|
||||
<StatusDropdownItem
|
||||
label={t('editor.editorToolbar.inReview')}
|
||||
onClick={() => onChangeStatus('PENDING_REVIEW')}
|
||||
icon={currentStatus === status.get('PENDING_REVIEW') ? 'check' : null}
|
||||
/>
|
||||
{useOpenAuthoring ? (
|
||||
''
|
||||
) : (
|
||||
<StatusDropdownItem
|
||||
key="workflow-status-pending-publish"
|
||||
label={t('editor.editorToolbar.ready')}
|
||||
onClick={() => onChangeStatus('PENDING_PUBLISH')}
|
||||
icon={currentStatus === status.get('PENDING_PUBLISH') ? 'check' : null}
|
||||
/>
|
||||
)}
|
||||
</ToolbarDropdown>
|
||||
{useOpenAuthoring && this.renderStatusInfoTooltip()}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
renderNewEntryWorkflowPublishControls = ({ canCreate, canPublish }) => {
|
||||
const { isPublishing, onPublish, onPublishAndNew, onPublishAndDuplicate, t } = this.props;
|
||||
|
||||
return canPublish ? (
|
||||
<ToolbarDropdown
|
||||
key="workflow-new-publish-controls"
|
||||
dropdownTopOverlap="40px"
|
||||
dropdownWidth="150px"
|
||||
renderButton={() => (
|
||||
<PublishButton>
|
||||
{isPublishing
|
||||
? t('editor.editorToolbar.publishing')
|
||||
: t('editor.editorToolbar.publish')}
|
||||
</PublishButton>
|
||||
)}
|
||||
>
|
||||
<DropdownItem
|
||||
label={t('editor.editorToolbar.publishNow')}
|
||||
icon="arrow"
|
||||
iconDirection="right"
|
||||
onClick={onPublish}
|
||||
/>
|
||||
{canCreate ? (
|
||||
<>
|
||||
<DropdownItem
|
||||
label={t('editor.editorToolbar.publishAndCreateNew')}
|
||||
icon="add"
|
||||
onClick={onPublishAndNew}
|
||||
/>
|
||||
<DropdownItem
|
||||
label={t('editor.editorToolbar.publishAndDuplicate')}
|
||||
icon="add"
|
||||
onClick={onPublishAndDuplicate}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</ToolbarDropdown>
|
||||
) : (
|
||||
''
|
||||
);
|
||||
};
|
||||
|
||||
renderExistingEntryWorkflowPublishControls = ({ canCreate, canPublish, canDelete }) => {
|
||||
const { unPublish, onDuplicate, isPersisting, t } = this.props;
|
||||
|
||||
return canPublish || canCreate ? (
|
||||
<ToolbarDropdown
|
||||
key="workflow-existing-publish-controls"
|
||||
dropdownTopOverlap="40px"
|
||||
dropdownWidth="150px"
|
||||
renderButton={() => (
|
||||
<PublishedToolbarButton>
|
||||
{isPersisting
|
||||
? t('editor.editorToolbar.unpublishing')
|
||||
: t('editor.editorToolbar.published')}
|
||||
</PublishedToolbarButton>
|
||||
)}
|
||||
>
|
||||
{canDelete && canPublish && (
|
||||
<DropdownItem
|
||||
label={t('editor.editorToolbar.unpublish')}
|
||||
icon="arrow"
|
||||
iconDirection="right"
|
||||
onClick={unPublish}
|
||||
/>
|
||||
)}
|
||||
{canCreate && (
|
||||
<DropdownItem
|
||||
label={t('editor.editorToolbar.duplicate')}
|
||||
icon="add"
|
||||
onClick={onDuplicate}
|
||||
/>
|
||||
)}
|
||||
</ToolbarDropdown>
|
||||
) : (
|
||||
''
|
||||
);
|
||||
};
|
||||
|
||||
renderExistingEntrySimplePublishControls = ({ canCreate }) => {
|
||||
const { onDuplicate, t } = this.props;
|
||||
return canCreate ? (
|
||||
<ToolbarDropdown
|
||||
key="simple-existing-publish-controls"
|
||||
dropdownTopOverlap="40px"
|
||||
dropdownWidth="150px"
|
||||
renderButton={() => (
|
||||
<PublishedToolbarButton>{t('editor.editorToolbar.published')}</PublishedToolbarButton>
|
||||
)}
|
||||
>
|
||||
{
|
||||
<DropdownItem
|
||||
label={t('editor.editorToolbar.duplicate')}
|
||||
icon="add"
|
||||
onClick={onDuplicate}
|
||||
/>
|
||||
}
|
||||
</ToolbarDropdown>
|
||||
) : (
|
||||
<PublishedButton>{t('editor.editorToolbar.published')}</PublishedButton>
|
||||
);
|
||||
};
|
||||
|
||||
renderNewEntrySimplePublishControls = ({ canCreate }) => {
|
||||
const { onPersist, onPersistAndNew, onPersistAndDuplicate, isPersisting, t } = this.props;
|
||||
|
||||
return (
|
||||
<div key="simple-new-publish-controls">
|
||||
<ToolbarDropdown
|
||||
dropdownTopOverlap="40px"
|
||||
dropdownWidth="150px"
|
||||
renderButton={() => (
|
||||
<PublishButton>
|
||||
{isPersisting
|
||||
? t('editor.editorToolbar.publishing')
|
||||
: t('editor.editorToolbar.publish')}
|
||||
</PublishButton>
|
||||
)}
|
||||
>
|
||||
<DropdownItem
|
||||
label={t('editor.editorToolbar.publishNow')}
|
||||
icon="arrow"
|
||||
iconDirection="right"
|
||||
onClick={onPersist}
|
||||
/>
|
||||
{canCreate ? (
|
||||
<>
|
||||
<DropdownItem
|
||||
label={t('editor.editorToolbar.publishAndCreateNew')}
|
||||
icon="add"
|
||||
onClick={onPersistAndNew}
|
||||
/>
|
||||
<DropdownItem
|
||||
label={t('editor.editorToolbar.publishAndDuplicate')}
|
||||
icon="add"
|
||||
onClick={onPersistAndDuplicate}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</ToolbarDropdown>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
renderSimpleDeployPreviewControls = () => {
|
||||
const { hasChanged, isNewEntry, t } = this.props;
|
||||
|
||||
if (!isNewEntry && !hasChanged) {
|
||||
return this.renderDeployPreviewControls(t('editor.editorToolbar.deployButtonLabel'));
|
||||
}
|
||||
};
|
||||
|
||||
renderWorkflowControls = () => {
|
||||
const {
|
||||
onPersist,
|
||||
onDelete,
|
||||
onDeleteUnpublishedChanges,
|
||||
showDelete,
|
||||
hasChanged,
|
||||
hasUnpublishedChanges,
|
||||
useOpenAuthoring,
|
||||
isPersisting,
|
||||
isDeleting,
|
||||
isNewEntry,
|
||||
isModification,
|
||||
currentStatus,
|
||||
collection,
|
||||
t,
|
||||
} = this.props;
|
||||
|
||||
const canCreate = collection.get('create');
|
||||
const canPublish = collection.get('publish') && !useOpenAuthoring;
|
||||
const canDelete = collection.get('delete', true);
|
||||
|
||||
const deleteLabel =
|
||||
(hasUnpublishedChanges &&
|
||||
isModification &&
|
||||
t('editor.editorToolbar.deleteUnpublishedChanges')) ||
|
||||
(hasUnpublishedChanges &&
|
||||
(isNewEntry || !isModification) &&
|
||||
t('editor.editorToolbar.deleteUnpublishedEntry')) ||
|
||||
(!hasUnpublishedChanges && !isModification && t('editor.editorToolbar.deletePublishedEntry'));
|
||||
|
||||
return [
|
||||
<SaveButton
|
||||
disabled={!hasChanged}
|
||||
key="save-button"
|
||||
onClick={() => hasChanged && onPersist()}
|
||||
>
|
||||
{isPersisting ? t('editor.editorToolbar.saving') : t('editor.editorToolbar.save')}
|
||||
</SaveButton>,
|
||||
currentStatus
|
||||
? [
|
||||
this.renderWorkflowStatusControls(),
|
||||
this.renderNewEntryWorkflowPublishControls({ canCreate, canPublish }),
|
||||
]
|
||||
: !isNewEntry &&
|
||||
this.renderExistingEntryWorkflowPublishControls({ canCreate, canPublish, canDelete }),
|
||||
(!showDelete || useOpenAuthoring) && !hasUnpublishedChanges && !isModification ? null : (
|
||||
<DeleteButton
|
||||
key="delete-button"
|
||||
onClick={hasUnpublishedChanges ? onDeleteUnpublishedChanges : onDelete}
|
||||
>
|
||||
{isDeleting ? t('editor.editorToolbar.deleting') : deleteLabel}
|
||||
</DeleteButton>
|
||||
),
|
||||
];
|
||||
};
|
||||
|
||||
renderWorkflowDeployPreviewControls = () => {
|
||||
const { currentStatus, isNewEntry, t } = this.props;
|
||||
|
||||
if (currentStatus) {
|
||||
return this.renderDeployPreviewControls(t('editor.editorToolbar.deployPreviewButtonLabel'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish control for published workflow entry.
|
||||
*/
|
||||
if (!isNewEntry) {
|
||||
return this.renderDeployPreviewControls(t('editor.editorToolbar.deployButtonLabel'));
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
user,
|
||||
hasChanged,
|
||||
displayUrl,
|
||||
collection,
|
||||
hasWorkflow,
|
||||
onLogoutClick,
|
||||
t,
|
||||
editorBackLink,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<ToolbarContainer>
|
||||
<ToolbarSectionBackLink to={editorBackLink}>
|
||||
<BackArrow>←</BackArrow>
|
||||
<div>
|
||||
<BackCollection>
|
||||
{t('editor.editorToolbar.backCollection', {
|
||||
collectionLabel: collection.get('label'),
|
||||
})}
|
||||
</BackCollection>
|
||||
{hasChanged ? (
|
||||
<BackStatusChanged key="back-changed">{t('editor.editorToolbar.unsavedChanges')}</BackStatusChanged>
|
||||
) : (
|
||||
<BackStatusUnchanged key="back-unchanged">{t('editor.editorToolbar.changesSaved')}</BackStatusUnchanged>
|
||||
)}
|
||||
</div>
|
||||
</ToolbarSectionBackLink>
|
||||
<ToolbarSectionMain>
|
||||
<ToolbarSubSectionFirst>
|
||||
{hasWorkflow ? this.renderWorkflowControls() : this.renderSimpleControls()}
|
||||
</ToolbarSubSectionFirst>
|
||||
<ToolbarSubSectionLast>
|
||||
{hasWorkflow
|
||||
? this.renderWorkflowDeployPreviewControls()
|
||||
: this.renderSimpleDeployPreviewControls()}
|
||||
</ToolbarSubSectionLast>
|
||||
</ToolbarSectionMain>
|
||||
<ToolbarSectionMeta>
|
||||
<SettingsDropdown
|
||||
displayUrl={displayUrl}
|
||||
imageUrl={user?.avatar_url}
|
||||
onLogoutClick={onLogoutClick}
|
||||
/>
|
||||
</ToolbarSectionMeta>
|
||||
</ToolbarContainer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default translate()(EditorToolbar);
|
61
src/components/Editor/withWorkflow.js
Normal file
61
src/components/Editor/withWorkflow.js
Normal file
@ -0,0 +1,61 @@
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { loadUnpublishedEntry, persistUnpublishedEntry } from '../../actions/editorialWorkflow';
|
||||
import { EDITORIAL_WORKFLOW } from '../../constants/publishModes';
|
||||
import { selectUnpublishedEntry } from '../../reducers';
|
||||
import { selectAllowDeletion } from '../../reducers/collections';
|
||||
|
||||
function mapStateToProps(state, ownProps) {
|
||||
const { collections } = state;
|
||||
const isEditorialWorkflow = state.config.publish_mode === EDITORIAL_WORKFLOW;
|
||||
const collection = collections.get(ownProps.match.params.name);
|
||||
const returnObj = {
|
||||
isEditorialWorkflow,
|
||||
showDelete: !ownProps.newEntry && selectAllowDeletion(collection),
|
||||
};
|
||||
if (isEditorialWorkflow) {
|
||||
const slug = ownProps.match.params[0];
|
||||
const unpublishedEntry = selectUnpublishedEntry(state, collection.get('name'), slug);
|
||||
if (unpublishedEntry) {
|
||||
returnObj.unpublishedEntry = true;
|
||||
returnObj.entry = unpublishedEntry;
|
||||
}
|
||||
}
|
||||
return returnObj;
|
||||
}
|
||||
|
||||
function mergeProps(stateProps, dispatchProps, ownProps) {
|
||||
const { isEditorialWorkflow, unpublishedEntry } = stateProps;
|
||||
const { dispatch } = dispatchProps;
|
||||
const returnObj = {};
|
||||
|
||||
if (isEditorialWorkflow) {
|
||||
// Overwrite loadEntry to loadUnpublishedEntry
|
||||
returnObj.loadEntry = (collection, slug) => dispatch(loadUnpublishedEntry(collection, slug));
|
||||
|
||||
// Overwrite persistEntry to persistUnpublishedEntry
|
||||
returnObj.persistEntry = collection =>
|
||||
dispatch(persistUnpublishedEntry(collection, unpublishedEntry));
|
||||
}
|
||||
|
||||
return {
|
||||
...ownProps,
|
||||
...stateProps,
|
||||
...returnObj,
|
||||
};
|
||||
}
|
||||
|
||||
export default function withWorkflow(Editor) {
|
||||
return connect(
|
||||
mapStateToProps,
|
||||
null,
|
||||
mergeProps,
|
||||
)(
|
||||
class WorkflowEditor extends React.Component {
|
||||
render() {
|
||||
return <Editor {...this.props} />;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
17
src/components/EditorWidgets/Unknown/UnknownControl.js
Normal file
17
src/components/EditorWidgets/Unknown/UnknownControl.js
Normal file
@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
function UnknownControl({ field, t }) {
|
||||
return (
|
||||
<div>{t('editor.editorWidgets.unknownControl.noControl', { widget: field.get('widget') })}</div>
|
||||
);
|
||||
}
|
||||
|
||||
UnknownControl.propTypes = {
|
||||
field: ImmutablePropTypes.map,
|
||||
t: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default translate()(UnknownControl);
|
19
src/components/EditorWidgets/Unknown/UnknownPreview.js
Normal file
19
src/components/EditorWidgets/Unknown/UnknownPreview.js
Normal file
@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
function UnknownPreview({ field, t }) {
|
||||
return (
|
||||
<div className="nc-widgetPreview">
|
||||
{t('editor.editorWidgets.unknownPreview.noPreview', { widget: field.get('widget') })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
UnknownPreview.propTypes = {
|
||||
field: ImmutablePropTypes.map,
|
||||
t: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default translate()(UnknownPreview);
|
5
src/components/EditorWidgets/index.js
Normal file
5
src/components/EditorWidgets/index.js
Normal file
@ -0,0 +1,5 @@
|
||||
import { registerWidget } from '../../lib/registry';
|
||||
import UnknownControl from './Unknown/UnknownControl';
|
||||
import UnknownPreview from './Unknown/UnknownPreview';
|
||||
|
||||
registerWidget('unknown', UnknownControl, UnknownPreview);
|
29
src/components/MediaLibrary/EmptyMessage.js
Normal file
29
src/components/MediaLibrary/EmptyMessage.js
Normal file
@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { colors } from '../../ui';
|
||||
|
||||
const EmptyMessageContainer = styled.div`
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: ${props => props.isPrivate && colors.textFieldBorder};
|
||||
`;
|
||||
|
||||
function EmptyMessage({ content, isPrivate }) {
|
||||
return (
|
||||
<EmptyMessageContainer isPrivate={isPrivate}>
|
||||
<h1>{content}</h1>
|
||||
</EmptyMessageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
EmptyMessage.propTypes = {
|
||||
content: PropTypes.string.isRequired,
|
||||
isPrivate: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default EmptyMessage;
|
404
src/components/MediaLibrary/MediaLibrary.js
Normal file
404
src/components/MediaLibrary/MediaLibrary.js
Normal file
@ -0,0 +1,404 @@
|
||||
import fuzzy from 'fuzzy';
|
||||
import { map, orderBy } from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { translate } from 'react-polyglot';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import {
|
||||
closeMediaLibrary as closeMediaLibraryAction,
|
||||
deleteMedia as deleteMediaAction,
|
||||
insertMedia as insertMediaAction,
|
||||
loadMedia as loadMediaAction,
|
||||
loadMediaDisplayURL as loadMediaDisplayURLAction,
|
||||
persistMedia as persistMediaAction,
|
||||
} from '../../actions/mediaLibrary';
|
||||
import { fileExtension } from '../../lib/util';
|
||||
import { selectMediaFiles } from '../../reducers/mediaLibrary';
|
||||
import alert from '../UI/Alert';
|
||||
import confirm from '../UI/Confirm';
|
||||
import MediaLibraryModal, { fileShape } from './MediaLibraryModal';
|
||||
|
||||
/**
|
||||
* Extensions used to determine which files to show when the media library is
|
||||
* accessed from an image insertion field.
|
||||
*/
|
||||
const IMAGE_EXTENSIONS_VIEWABLE = [
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'webp',
|
||||
'gif',
|
||||
'png',
|
||||
'bmp',
|
||||
'tiff',
|
||||
'svg',
|
||||
'avif',
|
||||
];
|
||||
const IMAGE_EXTENSIONS = [...IMAGE_EXTENSIONS_VIEWABLE];
|
||||
|
||||
class MediaLibrary extends React.Component {
|
||||
static propTypes = {
|
||||
isVisible: PropTypes.bool,
|
||||
loadMediaDisplayURL: PropTypes.func,
|
||||
displayURLs: ImmutablePropTypes.map,
|
||||
canInsert: PropTypes.bool,
|
||||
files: PropTypes.arrayOf(PropTypes.shape(fileShape)).isRequired,
|
||||
dynamicSearch: PropTypes.bool,
|
||||
dynamicSearchActive: PropTypes.bool,
|
||||
forImage: PropTypes.bool,
|
||||
isLoading: PropTypes.bool,
|
||||
isPersisting: PropTypes.bool,
|
||||
isDeleting: PropTypes.bool,
|
||||
hasNextPage: PropTypes.bool,
|
||||
isPaginating: PropTypes.bool,
|
||||
privateUpload: PropTypes.bool,
|
||||
config: ImmutablePropTypes.map,
|
||||
loadMedia: PropTypes.func.isRequired,
|
||||
dynamicSearchQuery: PropTypes.string,
|
||||
page: PropTypes.number,
|
||||
persistMedia: PropTypes.func.isRequired,
|
||||
deleteMedia: PropTypes.func.isRequired,
|
||||
insertMedia: PropTypes.func.isRequired,
|
||||
closeMediaLibrary: PropTypes.func.isRequired,
|
||||
t: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
files: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* The currently selected file and query are tracked in component state as
|
||||
* they do not impact the rest of the application.
|
||||
*/
|
||||
state = {
|
||||
selectedFile: {},
|
||||
query: '',
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.props.loadMedia();
|
||||
}
|
||||
|
||||
UNSAFE_componentWillReceiveProps(nextProps) {
|
||||
/**
|
||||
* We clear old state from the media library when it's being re-opened
|
||||
* because, when doing so on close, the state is cleared while the media
|
||||
* library is still fading away.
|
||||
*/
|
||||
const isOpening = !this.props.isVisible && nextProps.isVisible;
|
||||
if (isOpening) {
|
||||
this.setState({ selectedFile: {}, query: '' });
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const isOpening = !prevProps.isVisible && this.props.isVisible;
|
||||
|
||||
if (isOpening && prevProps.privateUpload !== this.props.privateUpload) {
|
||||
this.props.loadMedia({ privateUpload: this.props.privateUpload });
|
||||
}
|
||||
}
|
||||
|
||||
loadDisplayURL = file => {
|
||||
const { loadMediaDisplayURL } = this.props;
|
||||
loadMediaDisplayURL(file);
|
||||
};
|
||||
|
||||
/**
|
||||
* Filter an array of file data to include only images.
|
||||
*/
|
||||
filterImages = files => {
|
||||
return files.filter(file => {
|
||||
const ext = fileExtension(file.name).toLowerCase();
|
||||
return IMAGE_EXTENSIONS.includes(ext);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Transform file data for table display.
|
||||
*/
|
||||
toTableData = files => {
|
||||
const tableData =
|
||||
files &&
|
||||
files.map(({ key, name, id, size, path, queryOrder, displayURL, draft }) => {
|
||||
const ext = fileExtension(name).toLowerCase();
|
||||
return {
|
||||
key,
|
||||
id,
|
||||
name,
|
||||
path,
|
||||
type: ext.toUpperCase(),
|
||||
size,
|
||||
queryOrder,
|
||||
displayURL,
|
||||
draft,
|
||||
isImage: IMAGE_EXTENSIONS.includes(ext),
|
||||
isViewableImage: IMAGE_EXTENSIONS_VIEWABLE.includes(ext),
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Get the sort order for use with `lodash.orderBy`, and always add the
|
||||
* `queryOrder` sort as the lowest priority sort order.
|
||||
*/
|
||||
const { sortFields } = this.state;
|
||||
const fieldNames = map(sortFields, 'fieldName').concat('queryOrder');
|
||||
const directions = map(sortFields, 'direction').concat('asc');
|
||||
return orderBy(tableData, fieldNames, directions);
|
||||
};
|
||||
|
||||
handleClose = () => {
|
||||
this.props.closeMediaLibrary();
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggle asset selection on click.
|
||||
*/
|
||||
handleAssetClick = asset => {
|
||||
const selectedFile = this.state.selectedFile.key === asset.key ? {} : asset;
|
||||
this.setState({ selectedFile });
|
||||
};
|
||||
|
||||
/**
|
||||
* Upload a file.
|
||||
*/
|
||||
handlePersist = async event => {
|
||||
/**
|
||||
* Stop the browser from automatically handling the file input click, and
|
||||
* get the file for upload, and retain the synthetic event for access after
|
||||
* the asynchronous persist operation.
|
||||
*/
|
||||
event.persist();
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
const { persistMedia, privateUpload, config, field } = this.props;
|
||||
const { files: fileList } = event.dataTransfer || event.target;
|
||||
const files = [...fileList];
|
||||
const file = files[0];
|
||||
const maxFileSize = config.get('max_file_size');
|
||||
|
||||
if (maxFileSize && file.size > maxFileSize) {
|
||||
alert({
|
||||
title: 'mediaLibrary.mediaLibrary.fileTooLargeTitle',
|
||||
body: {
|
||||
key: 'mediaLibrary.mediaLibrary.fileTooLargeBody',
|
||||
options: {
|
||||
size: Math.floor(maxFileSize / 1000),
|
||||
},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await persistMedia(file, { privateUpload, field });
|
||||
|
||||
this.setState({ selectedFile: this.props.files[0] });
|
||||
|
||||
this.scrollToTop();
|
||||
}
|
||||
|
||||
event.target.value = null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Stores the public path of the file in the application store, where the
|
||||
* editor field that launched the media library can retrieve it.
|
||||
*/
|
||||
handleInsert = () => {
|
||||
const { selectedFile } = this.state;
|
||||
const { path } = selectedFile;
|
||||
const { insertMedia, field } = this.props;
|
||||
insertMedia(path, field);
|
||||
this.handleClose();
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes the selected file from the backend.
|
||||
*/
|
||||
handleDelete = async () => {
|
||||
const { selectedFile } = this.state;
|
||||
const { files, deleteMedia, privateUpload, t } = this.props;
|
||||
if (
|
||||
!(await confirm({
|
||||
title: 'mediaLibrary.mediaLibrary.onDeleteTitle',
|
||||
body: 'mediaLibrary.mediaLibrary.onDeleteBody',
|
||||
color: 'error',
|
||||
}))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const file = files.find(file => selectedFile.key === file.key);
|
||||
deleteMedia(file, { privateUpload }).then(() => {
|
||||
this.setState({ selectedFile: {} });
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Downloads the selected file.
|
||||
*/
|
||||
handleDownload = () => {
|
||||
const { selectedFile } = this.state;
|
||||
const { displayURLs } = this.props;
|
||||
const url = displayURLs.getIn([selectedFile.id, 'url']) || selectedFile.url;
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filename = selectedFile.name;
|
||||
|
||||
const element = document.createElement('a');
|
||||
element.setAttribute('href', url);
|
||||
element.setAttribute('download', filename);
|
||||
|
||||
element.style.display = 'none';
|
||||
document.body.appendChild(element);
|
||||
|
||||
element.click();
|
||||
|
||||
document.body.removeChild(element);
|
||||
this.setState({ selectedFile: {} });
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
|
||||
handleLoadMore = () => {
|
||||
const { loadMedia, dynamicSearchQuery, page, privateUpload } = this.props;
|
||||
loadMedia({ query: dynamicSearchQuery, page: page + 1, privateUpload });
|
||||
};
|
||||
|
||||
/**
|
||||
* Executes media library search for implementations that support dynamic
|
||||
* search via request. For these implementations, the Enter key must be
|
||||
* pressed to execute search. If assets are being stored directly through
|
||||
* the GitHub backend, search is in-memory and occurs as the query is typed,
|
||||
* so this handler has no impact.
|
||||
*/
|
||||
handleSearchKeyDown = async event => {
|
||||
const { dynamicSearch, loadMedia, privateUpload } = this.props;
|
||||
if (event.key === 'Enter' && dynamicSearch) {
|
||||
await loadMedia({ query: this.state.query, privateUpload });
|
||||
this.scrollToTop();
|
||||
}
|
||||
};
|
||||
|
||||
scrollToTop = () => {
|
||||
this.scrollContainerRef.scrollTop = 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates query state as the user types in the search field.
|
||||
*/
|
||||
handleSearchChange = event => {
|
||||
this.setState({ query: event.target.value });
|
||||
};
|
||||
|
||||
/**
|
||||
* Filters files that do not match the query. Not used for dynamic search.
|
||||
*/
|
||||
queryFilter = (query, files) => {
|
||||
/**
|
||||
* Because file names don't have spaces, typing a space eliminates all
|
||||
* potential matches, so we strip them all out internally before running the
|
||||
* query.
|
||||
*/
|
||||
const strippedQuery = query.replace(/ /g, '');
|
||||
const matches = fuzzy.filter(strippedQuery, files, { extract: file => file.name });
|
||||
const matchFiles = matches.map((match, queryIndex) => {
|
||||
const file = files[match.index];
|
||||
return { ...file, queryIndex };
|
||||
});
|
||||
return matchFiles;
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
isVisible,
|
||||
canInsert,
|
||||
files,
|
||||
dynamicSearch,
|
||||
dynamicSearchActive,
|
||||
forImage,
|
||||
isLoading,
|
||||
isPersisting,
|
||||
isDeleting,
|
||||
hasNextPage,
|
||||
isPaginating,
|
||||
privateUpload,
|
||||
displayURLs,
|
||||
t,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<MediaLibraryModal
|
||||
isVisible={isVisible}
|
||||
canInsert={canInsert}
|
||||
files={files}
|
||||
dynamicSearch={dynamicSearch}
|
||||
dynamicSearchActive={dynamicSearchActive}
|
||||
forImage={forImage}
|
||||
isLoading={isLoading}
|
||||
isPersisting={isPersisting}
|
||||
isDeleting={isDeleting}
|
||||
hasNextPage={hasNextPage}
|
||||
isPaginating={isPaginating}
|
||||
privateUpload={privateUpload}
|
||||
query={this.state.query}
|
||||
selectedFile={this.state.selectedFile}
|
||||
handleFilter={this.filterImages}
|
||||
handleQuery={this.queryFilter}
|
||||
toTableData={this.toTableData}
|
||||
handleClose={this.handleClose}
|
||||
handleSearchChange={this.handleSearchChange}
|
||||
handleSearchKeyDown={this.handleSearchKeyDown}
|
||||
handlePersist={this.handlePersist}
|
||||
handleDelete={this.handleDelete}
|
||||
handleInsert={this.handleInsert}
|
||||
handleDownload={this.handleDownload}
|
||||
setScrollContainerRef={ref => (this.scrollContainerRef = ref)}
|
||||
handleAssetClick={this.handleAssetClick}
|
||||
handleLoadMore={this.handleLoadMore}
|
||||
displayURLs={displayURLs}
|
||||
loadDisplayURL={this.loadDisplayURL}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state) {
|
||||
const { mediaLibrary } = state;
|
||||
const field = mediaLibrary.get('field');
|
||||
const mediaLibraryProps = {
|
||||
isVisible: mediaLibrary.get('isVisible'),
|
||||
canInsert: mediaLibrary.get('canInsert'),
|
||||
files: selectMediaFiles(state, field),
|
||||
displayURLs: mediaLibrary.get('displayURLs'),
|
||||
dynamicSearch: mediaLibrary.get('dynamicSearch'),
|
||||
dynamicSearchActive: mediaLibrary.get('dynamicSearchActive'),
|
||||
dynamicSearchQuery: mediaLibrary.get('dynamicSearchQuery'),
|
||||
forImage: mediaLibrary.get('forImage'),
|
||||
isLoading: mediaLibrary.get('isLoading'),
|
||||
isPersisting: mediaLibrary.get('isPersisting'),
|
||||
isDeleting: mediaLibrary.get('isDeleting'),
|
||||
privateUpload: mediaLibrary.get('privateUpload'),
|
||||
config: mediaLibrary.get('config'),
|
||||
page: mediaLibrary.get('page'),
|
||||
hasNextPage: mediaLibrary.get('hasNextPage'),
|
||||
isPaginating: mediaLibrary.get('isPaginating'),
|
||||
field,
|
||||
};
|
||||
return { ...mediaLibraryProps };
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
loadMedia: loadMediaAction,
|
||||
persistMedia: persistMediaAction,
|
||||
deleteMedia: deleteMediaAction,
|
||||
insertMedia: insertMediaAction,
|
||||
loadMediaDisplayURL: loadMediaDisplayURLAction,
|
||||
closeMediaLibrary: closeMediaLibraryAction,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(translate()(MediaLibrary));
|
136
src/components/MediaLibrary/MediaLibraryButtons.js
Normal file
136
src/components/MediaLibrary/MediaLibraryButtons.js
Normal file
@ -0,0 +1,136 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { css } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import copyToClipboard from 'copy-text-to-clipboard';
|
||||
|
||||
import { buttons, shadows, zIndex } from '../../ui';
|
||||
import { isAbsolutePath } from '../../lib/util';
|
||||
import { FileUploadButton } from '../UI';
|
||||
|
||||
const styles = {
|
||||
button: css`
|
||||
${buttons.button};
|
||||
${buttons.default};
|
||||
display: inline-block;
|
||||
margin-left: 15px;
|
||||
margin-right: 2px;
|
||||
|
||||
&[disabled] {
|
||||
${buttons.disabled};
|
||||
cursor: default;
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
export const UploadButton = styled(FileUploadButton)`
|
||||
${styles.button};
|
||||
${buttons.gray};
|
||||
${shadows.dropMain};
|
||||
margin-bottom: 0;
|
||||
|
||||
span {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
input {
|
||||
height: 0.1px;
|
||||
width: 0.1px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
z-index: ${zIndex.zIndex0};
|
||||
outline: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export const DeleteButton = styled.button`
|
||||
${styles.button};
|
||||
${buttons.lightRed};
|
||||
`;
|
||||
|
||||
export const InsertButton = styled.button`
|
||||
${styles.button};
|
||||
${buttons.green};
|
||||
`;
|
||||
|
||||
const ActionButton = styled.button`
|
||||
${styles.button};
|
||||
${props =>
|
||||
!props.disabled &&
|
||||
css`
|
||||
${buttons.gray}
|
||||
`}
|
||||
`;
|
||||
|
||||
export const DownloadButton = ActionButton;
|
||||
|
||||
export class CopyToClipBoardButton extends React.Component {
|
||||
mounted = false;
|
||||
timeout;
|
||||
|
||||
state = {
|
||||
copied: false,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.mounted = true;
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.mounted = false;
|
||||
}
|
||||
|
||||
handleCopy = () => {
|
||||
clearTimeout(this.timeout);
|
||||
const { path, draft, name } = this.props;
|
||||
copyToClipboard(isAbsolutePath(path) || !draft ? path : name);
|
||||
this.setState({ copied: true });
|
||||
this.timeout = setTimeout(() => this.mounted && this.setState({ copied: false }), 1500);
|
||||
};
|
||||
|
||||
getTitle = () => {
|
||||
const { t, path, draft } = this.props;
|
||||
if (this.state.copied) {
|
||||
return t('mediaLibrary.mediaLibraryCard.copied');
|
||||
}
|
||||
|
||||
if (!path) {
|
||||
return t('mediaLibrary.mediaLibraryCard.copy');
|
||||
}
|
||||
|
||||
if (isAbsolutePath(path)) {
|
||||
return t('mediaLibrary.mediaLibraryCard.copyUrl');
|
||||
}
|
||||
|
||||
if (draft) {
|
||||
return t('mediaLibrary.mediaLibraryCard.copyName');
|
||||
}
|
||||
|
||||
return t('mediaLibrary.mediaLibraryCard.copyPath');
|
||||
};
|
||||
|
||||
render() {
|
||||
const { disabled } = this.props;
|
||||
|
||||
return (
|
||||
<ActionButton disabled={disabled} onClick={this.handleCopy}>
|
||||
{this.getTitle()}
|
||||
</ActionButton>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
CopyToClipBoardButton.propTypes = {
|
||||
disabled: PropTypes.bool.isRequired,
|
||||
draft: PropTypes.bool,
|
||||
path: PropTypes.string,
|
||||
name: PropTypes.string,
|
||||
t: PropTypes.func.isRequired,
|
||||
};
|
129
src/components/MediaLibrary/MediaLibraryCard.js
Normal file
129
src/components/MediaLibrary/MediaLibraryCard.js
Normal file
@ -0,0 +1,129 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { colors, borders, lengths, shadows, effects } from '../../ui';
|
||||
|
||||
const IMAGE_HEIGHT = 160;
|
||||
|
||||
const Card = styled.div`
|
||||
width: ${props => props.width};
|
||||
height: ${props => props.height};
|
||||
margin: ${props => props.margin};
|
||||
border: ${borders.textField};
|
||||
border-color: ${props => props.isSelected && colors.active};
|
||||
border-radius: ${lengths.borderRadius};
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
background-color: ${props => props.isPrivate && colors.textFieldBorder};
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const CardImageWrapper = styled.div`
|
||||
height: ${IMAGE_HEIGHT + 2}px;
|
||||
${effects.checkerboard};
|
||||
${shadows.inset};
|
||||
border-bottom: solid ${lengths.borderWidth} ${colors.textFieldBorder};
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const CardImage = styled.img`
|
||||
width: 100%;
|
||||
height: ${IMAGE_HEIGHT}px;
|
||||
object-fit: contain;
|
||||
border-radius: 2px 2px 0 0;
|
||||
`;
|
||||
|
||||
const CardFileIcon = styled.div`
|
||||
width: 100%;
|
||||
height: 160px;
|
||||
object-fit: cover;
|
||||
border-radius: 2px 2px 0 0;
|
||||
padding: 1em;
|
||||
font-size: 3em;
|
||||
`;
|
||||
|
||||
const CardText = styled.p`
|
||||
color: ${colors.text};
|
||||
padding: 8px;
|
||||
margin-top: 20px;
|
||||
overflow-wrap: break-word;
|
||||
line-height: 1.3 !important;
|
||||
`;
|
||||
|
||||
const DraftText = styled.p`
|
||||
color: ${colors.mediaDraftText};
|
||||
background-color: ${colors.mediaDraftBackground};
|
||||
position: absolute;
|
||||
padding: 8px;
|
||||
border-radius: ${lengths.borderRadius} 0 ${lengths.borderRadius} 0;
|
||||
`;
|
||||
|
||||
class MediaLibraryCard extends React.Component {
|
||||
render() {
|
||||
const {
|
||||
isSelected,
|
||||
displayURL,
|
||||
text,
|
||||
onClick,
|
||||
draftText,
|
||||
width,
|
||||
height,
|
||||
margin,
|
||||
isPrivate,
|
||||
type,
|
||||
isViewableImage,
|
||||
isDraft,
|
||||
} = this.props;
|
||||
const url = displayURL.get('url');
|
||||
return (
|
||||
<Card
|
||||
isSelected={isSelected}
|
||||
onClick={onClick}
|
||||
width={width}
|
||||
height={height}
|
||||
margin={margin}
|
||||
tabIndex="-1"
|
||||
isPrivate={isPrivate}
|
||||
>
|
||||
<CardImageWrapper>
|
||||
{isDraft ? <DraftText data-testid="draft-text">{draftText}</DraftText> : null}
|
||||
{url && isViewableImage ? (
|
||||
<CardImage src={url} />
|
||||
) : (
|
||||
<CardFileIcon data-testid="card-file-icon">{type}</CardFileIcon>
|
||||
)}
|
||||
</CardImageWrapper>
|
||||
<CardText>{text}</CardText>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
componentDidMount() {
|
||||
const { displayURL, loadDisplayURL } = this.props;
|
||||
if (!displayURL.get('url')) {
|
||||
loadDisplayURL();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MediaLibraryCard.propTypes = {
|
||||
isSelected: PropTypes.bool,
|
||||
displayURL: ImmutablePropTypes.map.isRequired,
|
||||
text: PropTypes.string.isRequired,
|
||||
onClick: PropTypes.func.isRequired,
|
||||
draftText: PropTypes.string.isRequired,
|
||||
width: PropTypes.string.isRequired,
|
||||
height: PropTypes.string.isRequired,
|
||||
margin: PropTypes.string.isRequired,
|
||||
isPrivate: PropTypes.bool,
|
||||
type: PropTypes.string,
|
||||
isViewableImage: PropTypes.bool.isRequired,
|
||||
loadDisplayURL: PropTypes.func.isRequired,
|
||||
isDraft: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default MediaLibraryCard;
|
198
src/components/MediaLibrary/MediaLibraryCardGrid.js
Normal file
198
src/components/MediaLibrary/MediaLibraryCardGrid.js
Normal file
@ -0,0 +1,198 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import styled from '@emotion/styled';
|
||||
import { Waypoint } from 'react-waypoint';
|
||||
import { Map } from 'immutable';
|
||||
import { FixedSizeGrid as Grid } from 'react-window';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
|
||||
import { colors } from '../../ui';
|
||||
import MediaLibraryCard from './MediaLibraryCard';
|
||||
|
||||
function CardWrapper(props) {
|
||||
const {
|
||||
rowIndex,
|
||||
columnIndex,
|
||||
style,
|
||||
data: {
|
||||
mediaItems,
|
||||
isSelectedFile,
|
||||
onAssetClick,
|
||||
cardDraftText,
|
||||
cardWidth,
|
||||
cardHeight,
|
||||
isPrivate,
|
||||
displayURLs,
|
||||
loadDisplayURL,
|
||||
columnCount,
|
||||
gutter,
|
||||
},
|
||||
} = props;
|
||||
const index = rowIndex * columnCount + columnIndex;
|
||||
if (index >= mediaItems.length) {
|
||||
return null;
|
||||
}
|
||||
const file = mediaItems[index];
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
...style,
|
||||
left: style.left + gutter * columnIndex,
|
||||
top: style.top + gutter,
|
||||
width: style.width - gutter,
|
||||
height: style.height - gutter,
|
||||
}}
|
||||
>
|
||||
<MediaLibraryCard
|
||||
key={file.key}
|
||||
isSelected={isSelectedFile(file)}
|
||||
text={file.name}
|
||||
onClick={() => onAssetClick(file)}
|
||||
isDraft={file.draft}
|
||||
draftText={cardDraftText}
|
||||
width={cardWidth}
|
||||
height={cardHeight}
|
||||
margin={'0px'}
|
||||
isPrivate={isPrivate}
|
||||
displayURL={displayURLs.get(file.id, file.url ? Map({ url: file.url }) : Map())}
|
||||
loadDisplayURL={() => loadDisplayURL(file)}
|
||||
type={file.type}
|
||||
isViewableImage={file.isViewableImage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VirtualizedGrid(props) {
|
||||
const { mediaItems, setScrollContainerRef } = props;
|
||||
|
||||
return (
|
||||
<CardGridContainer ref={setScrollContainerRef}>
|
||||
<AutoSizer>
|
||||
{({ height, width }) => {
|
||||
const cardWidth = parseInt(props.cardWidth, 10);
|
||||
const cardHeight = parseInt(props.cardHeight, 10);
|
||||
const gutter = parseInt(props.cardMargin, 10);
|
||||
const columnWidth = cardWidth + gutter;
|
||||
const rowHeight = cardHeight + gutter;
|
||||
const columnCount = Math.floor(width / columnWidth);
|
||||
const rowCount = Math.ceil(mediaItems.length / columnCount);
|
||||
return (
|
||||
<Grid
|
||||
columnCount={columnCount}
|
||||
columnWidth={columnWidth}
|
||||
rowCount={rowCount}
|
||||
rowHeight={rowHeight}
|
||||
width={width}
|
||||
height={height}
|
||||
itemData={{ ...props, gutter, columnCount }}
|
||||
>
|
||||
{CardWrapper}
|
||||
</Grid>
|
||||
);
|
||||
}}
|
||||
</AutoSizer>
|
||||
</CardGridContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function PaginatedGrid({
|
||||
setScrollContainerRef,
|
||||
mediaItems,
|
||||
isSelectedFile,
|
||||
onAssetClick,
|
||||
cardDraftText,
|
||||
cardWidth,
|
||||
cardHeight,
|
||||
cardMargin,
|
||||
isPrivate,
|
||||
displayURLs,
|
||||
loadDisplayURL,
|
||||
canLoadMore,
|
||||
onLoadMore,
|
||||
isPaginating,
|
||||
paginatingMessage,
|
||||
}) {
|
||||
return (
|
||||
<CardGridContainer ref={setScrollContainerRef}>
|
||||
<CardGrid>
|
||||
{mediaItems.map(file => (
|
||||
<MediaLibraryCard
|
||||
key={file.key}
|
||||
isSelected={isSelectedFile(file)}
|
||||
text={file.name}
|
||||
onClick={() => onAssetClick(file)}
|
||||
isDraft={file.draft}
|
||||
draftText={cardDraftText}
|
||||
width={cardWidth}
|
||||
height={cardHeight}
|
||||
margin={cardMargin}
|
||||
isPrivate={isPrivate}
|
||||
displayURL={displayURLs.get(file.id, file.url ? Map({ url: file.url }) : Map())}
|
||||
loadDisplayURL={() => loadDisplayURL(file)}
|
||||
type={file.type}
|
||||
isViewableImage={file.isViewableImage}
|
||||
/>
|
||||
))}
|
||||
{!canLoadMore ? null : <Waypoint onEnter={onLoadMore} />}
|
||||
</CardGrid>
|
||||
{!isPaginating ? null : (
|
||||
<PaginatingMessage isPrivate={isPrivate}>{paginatingMessage}</PaginatingMessage>
|
||||
)}
|
||||
</CardGridContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const CardGridContainer = styled.div`
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
`;
|
||||
|
||||
const CardGrid = styled.div`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
margin-left: -10px;
|
||||
margin-right: -10px;
|
||||
`;
|
||||
|
||||
const PaginatingMessage = styled.h1`
|
||||
color: ${props => props.isPrivate && colors.textFieldBorder};
|
||||
`;
|
||||
|
||||
function MediaLibraryCardGrid(props) {
|
||||
const { canLoadMore, isPaginating } = props;
|
||||
if (canLoadMore || isPaginating) {
|
||||
return <PaginatedGrid {...props} />;
|
||||
}
|
||||
return <VirtualizedGrid {...props} />;
|
||||
}
|
||||
|
||||
MediaLibraryCardGrid.propTypes = {
|
||||
setScrollContainerRef: PropTypes.func.isRequired,
|
||||
mediaItems: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
displayURL: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
|
||||
id: PropTypes.string.isRequired,
|
||||
key: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
type: PropTypes.string.isRequired,
|
||||
draft: PropTypes.bool,
|
||||
}),
|
||||
).isRequired,
|
||||
isSelectedFile: PropTypes.func.isRequired,
|
||||
onAssetClick: PropTypes.func.isRequired,
|
||||
canLoadMore: PropTypes.bool,
|
||||
onLoadMore: PropTypes.func.isRequired,
|
||||
isPaginating: PropTypes.bool,
|
||||
paginatingMessage: PropTypes.string,
|
||||
cardDraftText: PropTypes.string.isRequired,
|
||||
cardWidth: PropTypes.string.isRequired,
|
||||
cardMargin: PropTypes.string.isRequired,
|
||||
loadDisplayURL: PropTypes.func.isRequired,
|
||||
isPrivate: PropTypes.bool,
|
||||
displayURLs: PropTypes.instanceOf(Map).isRequired,
|
||||
};
|
||||
|
||||
export default MediaLibraryCardGrid;
|
49
src/components/MediaLibrary/MediaLibraryHeader.js
Normal file
49
src/components/MediaLibrary/MediaLibraryHeader.js
Normal file
@ -0,0 +1,49 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { Icon, shadows, colors, buttons } from '../../ui';
|
||||
|
||||
const CloseButton = styled.button`
|
||||
${buttons.button};
|
||||
${shadows.dropMiddle};
|
||||
position: absolute;
|
||||
margin-right: -40px;
|
||||
left: -40px;
|
||||
top: -40px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background-color: white;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const LibraryTitle = styled.h1`
|
||||
line-height: 36px;
|
||||
font-size: 22px;
|
||||
text-align: left;
|
||||
margin-bottom: 25px;
|
||||
color: ${props => props.isPrivate && colors.textFieldBorder};
|
||||
`;
|
||||
|
||||
function MediaLibraryHeader({ onClose, title, isPrivate }) {
|
||||
return (
|
||||
<div>
|
||||
<CloseButton onClick={onClose}>
|
||||
<Icon type="close" />
|
||||
</CloseButton>
|
||||
<LibraryTitle isPrivate={isPrivate}>{title}</LibraryTitle>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
MediaLibraryHeader.propTypes = {
|
||||
onClose: PropTypes.func.isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
isPrivate: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default MediaLibraryHeader;
|
200
src/components/MediaLibrary/MediaLibraryModal.js
Normal file
200
src/components/MediaLibrary/MediaLibraryModal.js
Normal file
@ -0,0 +1,200 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import styled from '@emotion/styled';
|
||||
import { Map } from 'immutable';
|
||||
import { isEmpty } from 'lodash';
|
||||
import { translate } from 'react-polyglot';
|
||||
|
||||
import { colors } from '../../ui';
|
||||
import { Modal } from '../UI';
|
||||
import MediaLibraryTop from './MediaLibraryTop';
|
||||
import MediaLibraryCardGrid from './MediaLibraryCardGrid';
|
||||
import EmptyMessage from './EmptyMessage';
|
||||
|
||||
/**
|
||||
* Responsive styling needs to be overhauled. Current setup requires specifying
|
||||
* widths per breakpoint.
|
||||
*/
|
||||
const cardWidth = `280px`;
|
||||
const cardHeight = `240px`;
|
||||
const cardMargin = `10px`;
|
||||
|
||||
/**
|
||||
* cardWidth + cardMargin * 2 = cardOutsideWidth
|
||||
* (not using calc because this will be nested in other calcs)
|
||||
*/
|
||||
const cardOutsideWidth = `300px`;
|
||||
|
||||
const StyledModal = styled(Modal)`
|
||||
display: grid;
|
||||
grid-template-rows: 120px auto;
|
||||
width: calc(${cardOutsideWidth} + 20px);
|
||||
background-color: ${props => props.isPrivate && colors.grayDark};
|
||||
|
||||
@media (min-width: 800px) {
|
||||
width: calc(${cardOutsideWidth} * 2 + 20px);
|
||||
}
|
||||
|
||||
@media (min-width: 1120px) {
|
||||
width: calc(${cardOutsideWidth} * 3 + 20px);
|
||||
}
|
||||
|
||||
@media (min-width: 1440px) {
|
||||
width: calc(${cardOutsideWidth} * 4 + 20px);
|
||||
}
|
||||
|
||||
@media (min-width: 1760px) {
|
||||
width: calc(${cardOutsideWidth} * 5 + 20px);
|
||||
}
|
||||
|
||||
@media (min-width: 2080px) {
|
||||
width: calc(${cardOutsideWidth} * 6 + 20px);
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: ${props => props.isPrivate && colors.textFieldBorder};
|
||||
}
|
||||
|
||||
button:disabled,
|
||||
label[disabled] {
|
||||
background-color: ${props => props.isPrivate && `rgba(217, 217, 217, 0.15)`};
|
||||
}
|
||||
`;
|
||||
|
||||
function MediaLibraryModal({
|
||||
isVisible,
|
||||
canInsert,
|
||||
files,
|
||||
dynamicSearch,
|
||||
dynamicSearchActive,
|
||||
forImage,
|
||||
isLoading,
|
||||
isPersisting,
|
||||
isDeleting,
|
||||
hasNextPage,
|
||||
isPaginating,
|
||||
privateUpload,
|
||||
query,
|
||||
selectedFile,
|
||||
handleFilter,
|
||||
handleQuery,
|
||||
toTableData,
|
||||
handleClose,
|
||||
handleSearchChange,
|
||||
handleSearchKeyDown,
|
||||
handlePersist,
|
||||
handleDelete,
|
||||
handleInsert,
|
||||
handleDownload,
|
||||
setScrollContainerRef,
|
||||
handleAssetClick,
|
||||
handleLoadMore,
|
||||
loadDisplayURL,
|
||||
displayURLs,
|
||||
t,
|
||||
}) {
|
||||
const filteredFiles = forImage ? handleFilter(files) : files;
|
||||
const queriedFiles = !dynamicSearch && query ? handleQuery(query, filteredFiles) : filteredFiles;
|
||||
const tableData = toTableData(queriedFiles);
|
||||
const hasFiles = files && !!files.length;
|
||||
const hasFilteredFiles = filteredFiles && !!filteredFiles.length;
|
||||
const hasSearchResults = queriedFiles && !!queriedFiles.length;
|
||||
const hasMedia = hasSearchResults;
|
||||
const shouldShowEmptyMessage = !hasMedia;
|
||||
const emptyMessage =
|
||||
(isLoading && !hasMedia && t('mediaLibrary.mediaLibraryModal.loading')) ||
|
||||
(dynamicSearchActive && t('mediaLibrary.mediaLibraryModal.noResults')) ||
|
||||
(!hasFiles && t('mediaLibrary.mediaLibraryModal.noAssetsFound')) ||
|
||||
(!hasFilteredFiles && t('mediaLibrary.mediaLibraryModal.noImagesFound')) ||
|
||||
(!hasSearchResults && t('mediaLibrary.mediaLibraryModal.noResults'));
|
||||
|
||||
const hasSelection = hasMedia && !isEmpty(selectedFile);
|
||||
|
||||
return (
|
||||
<StyledModal isOpen={isVisible} onClose={handleClose} isPrivate={privateUpload}>
|
||||
<MediaLibraryTop
|
||||
t={t}
|
||||
onClose={handleClose}
|
||||
privateUpload={privateUpload}
|
||||
forImage={forImage}
|
||||
onDownload={handleDownload}
|
||||
onUpload={handlePersist}
|
||||
query={query}
|
||||
onSearchChange={handleSearchChange}
|
||||
onSearchKeyDown={handleSearchKeyDown}
|
||||
searchDisabled={!dynamicSearchActive && !hasFilteredFiles}
|
||||
onDelete={handleDelete}
|
||||
canInsert={canInsert}
|
||||
onInsert={handleInsert}
|
||||
hasSelection={hasSelection}
|
||||
isPersisting={isPersisting}
|
||||
isDeleting={isDeleting}
|
||||
selectedFile={selectedFile}
|
||||
/>
|
||||
{!shouldShowEmptyMessage ? null : (
|
||||
<EmptyMessage content={emptyMessage} isPrivate={privateUpload} />
|
||||
)}
|
||||
<MediaLibraryCardGrid
|
||||
setScrollContainerRef={setScrollContainerRef}
|
||||
mediaItems={tableData}
|
||||
isSelectedFile={file => selectedFile.key === file.key}
|
||||
onAssetClick={handleAssetClick}
|
||||
canLoadMore={hasNextPage}
|
||||
onLoadMore={handleLoadMore}
|
||||
isPaginating={isPaginating}
|
||||
paginatingMessage={t('mediaLibrary.mediaLibraryModal.loading')}
|
||||
cardDraftText={t('mediaLibrary.mediaLibraryCard.draft')}
|
||||
cardWidth={cardWidth}
|
||||
cardHeight={cardHeight}
|
||||
cardMargin={cardMargin}
|
||||
isPrivate={privateUpload}
|
||||
loadDisplayURL={loadDisplayURL}
|
||||
displayURLs={displayURLs}
|
||||
/>
|
||||
</StyledModal>
|
||||
);
|
||||
}
|
||||
|
||||
export const fileShape = {
|
||||
displayURL: PropTypes.oneOfType([PropTypes.string, PropTypes.object]).isRequired,
|
||||
id: PropTypes.string.isRequired,
|
||||
key: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
queryOrder: PropTypes.number,
|
||||
size: PropTypes.number,
|
||||
path: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
MediaLibraryModal.propTypes = {
|
||||
isVisible: PropTypes.bool,
|
||||
canInsert: PropTypes.bool,
|
||||
files: PropTypes.arrayOf(PropTypes.shape(fileShape)).isRequired,
|
||||
dynamicSearch: PropTypes.bool,
|
||||
dynamicSearchActive: PropTypes.bool,
|
||||
forImage: PropTypes.bool,
|
||||
isLoading: PropTypes.bool,
|
||||
isPersisting: PropTypes.bool,
|
||||
isDeleting: PropTypes.bool,
|
||||
hasNextPage: PropTypes.bool,
|
||||
isPaginating: PropTypes.bool,
|
||||
privateUpload: PropTypes.bool,
|
||||
query: PropTypes.string,
|
||||
selectedFile: PropTypes.oneOfType([PropTypes.shape(fileShape), PropTypes.shape({})]),
|
||||
handleFilter: PropTypes.func.isRequired,
|
||||
handleQuery: PropTypes.func.isRequired,
|
||||
toTableData: PropTypes.func.isRequired,
|
||||
handleClose: PropTypes.func.isRequired,
|
||||
handleSearchChange: PropTypes.func.isRequired,
|
||||
handleSearchKeyDown: PropTypes.func.isRequired,
|
||||
handlePersist: PropTypes.func.isRequired,
|
||||
handleDelete: PropTypes.func.isRequired,
|
||||
handleInsert: PropTypes.func.isRequired,
|
||||
setScrollContainerRef: PropTypes.func.isRequired,
|
||||
handleAssetClick: PropTypes.func.isRequired,
|
||||
handleLoadMore: PropTypes.func.isRequired,
|
||||
loadDisplayURL: PropTypes.func.isRequired,
|
||||
t: PropTypes.func.isRequired,
|
||||
displayURLs: PropTypes.instanceOf(Map).isRequired,
|
||||
};
|
||||
|
||||
export default translate()(MediaLibraryModal);
|
62
src/components/MediaLibrary/MediaLibrarySearch.js
Normal file
62
src/components/MediaLibrary/MediaLibrarySearch.js
Normal file
@ -0,0 +1,62 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { Icon, lengths, colors, zIndex } from '../../ui';
|
||||
|
||||
const SearchContainer = styled.div`
|
||||
height: 37px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
width: 400px;
|
||||
`;
|
||||
|
||||
const SearchInput = styled.input`
|
||||
background-color: #eff0f4;
|
||||
border-radius: ${lengths.borderRadius};
|
||||
|
||||
font-size: 14px;
|
||||
padding: 10px 6px 10px 32px;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
z-index: ${zIndex.zIndex1};
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
box-shadow: inset 0 0 0 2px ${colors.active};
|
||||
}
|
||||
`;
|
||||
|
||||
const SearchIcon = styled(Icon)`
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 6px;
|
||||
z-index: ${zIndex.zIndex2};
|
||||
transform: translate(0, -50%);
|
||||
`;
|
||||
|
||||
function MediaLibrarySearch({ value, onChange, onKeyDown, placeholder, disabled }) {
|
||||
return (
|
||||
<SearchContainer>
|
||||
<SearchIcon type="search" size="small" />
|
||||
<SearchInput
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</SearchContainer>
|
||||
);
|
||||
}
|
||||
|
||||
MediaLibrarySearch.propTypes = {
|
||||
value: PropTypes.string,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onKeyDown: PropTypes.func.isRequired,
|
||||
placeholder: PropTypes.string.isRequired,
|
||||
disabled: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default MediaLibrarySearch;
|
143
src/components/MediaLibrary/MediaLibraryTop.js
Normal file
143
src/components/MediaLibrary/MediaLibraryTop.js
Normal file
@ -0,0 +1,143 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import MediaLibrarySearch from './MediaLibrarySearch';
|
||||
import MediaLibraryHeader from './MediaLibraryHeader';
|
||||
import {
|
||||
UploadButton,
|
||||
DeleteButton,
|
||||
DownloadButton,
|
||||
CopyToClipBoardButton,
|
||||
InsertButton,
|
||||
} from './MediaLibraryButtons';
|
||||
|
||||
const LibraryTop = styled.div`
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
const RowContainer = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
`;
|
||||
|
||||
const ButtonsContainer = styled.div`
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
function MediaLibraryTop({
|
||||
t,
|
||||
onClose,
|
||||
privateUpload,
|
||||
forImage,
|
||||
onDownload,
|
||||
onUpload,
|
||||
query,
|
||||
onSearchChange,
|
||||
onSearchKeyDown,
|
||||
searchDisabled,
|
||||
onDelete,
|
||||
canInsert,
|
||||
onInsert,
|
||||
hasSelection,
|
||||
isPersisting,
|
||||
isDeleting,
|
||||
selectedFile,
|
||||
}) {
|
||||
const shouldShowButtonLoader = isPersisting || isDeleting;
|
||||
const uploadEnabled = !shouldShowButtonLoader;
|
||||
const deleteEnabled = !shouldShowButtonLoader && hasSelection;
|
||||
|
||||
const uploadButtonLabel = isPersisting
|
||||
? t('mediaLibrary.mediaLibraryModal.uploading')
|
||||
: t('mediaLibrary.mediaLibraryModal.upload');
|
||||
const deleteButtonLabel = isDeleting
|
||||
? t('mediaLibrary.mediaLibraryModal.deleting')
|
||||
: t('mediaLibrary.mediaLibraryModal.deleteSelected');
|
||||
const downloadButtonLabel = t('mediaLibrary.mediaLibraryModal.download');
|
||||
const insertButtonLabel = t('mediaLibrary.mediaLibraryModal.chooseSelected');
|
||||
|
||||
return (
|
||||
<LibraryTop>
|
||||
<RowContainer>
|
||||
<MediaLibraryHeader
|
||||
onClose={onClose}
|
||||
title={`${privateUpload ? t('mediaLibrary.mediaLibraryModal.private') : ''}${
|
||||
forImage
|
||||
? t('mediaLibrary.mediaLibraryModal.images')
|
||||
: t('mediaLibrary.mediaLibraryModal.mediaAssets')
|
||||
}`}
|
||||
isPrivate={privateUpload}
|
||||
/>
|
||||
<ButtonsContainer>
|
||||
<CopyToClipBoardButton
|
||||
disabled={!hasSelection}
|
||||
path={selectedFile.path}
|
||||
name={selectedFile.name}
|
||||
draft={selectedFile.draft}
|
||||
t={t}
|
||||
/>
|
||||
<DownloadButton onClick={onDownload} disabled={!hasSelection}>
|
||||
{downloadButtonLabel}
|
||||
</DownloadButton>
|
||||
<UploadButton
|
||||
label={uploadButtonLabel}
|
||||
imagesOnly={forImage}
|
||||
onChange={onUpload}
|
||||
disabled={!uploadEnabled}
|
||||
/>
|
||||
</ButtonsContainer>
|
||||
</RowContainer>
|
||||
<RowContainer>
|
||||
<MediaLibrarySearch
|
||||
value={query}
|
||||
onChange={onSearchChange}
|
||||
onKeyDown={onSearchKeyDown}
|
||||
placeholder={t('mediaLibrary.mediaLibraryModal.search')}
|
||||
disabled={searchDisabled}
|
||||
/>
|
||||
<ButtonsContainer>
|
||||
<DeleteButton onClick={onDelete} disabled={!deleteEnabled}>
|
||||
{deleteButtonLabel}
|
||||
</DeleteButton>
|
||||
{!canInsert ? null : (
|
||||
<InsertButton onClick={onInsert} disabled={!hasSelection}>
|
||||
{insertButtonLabel}
|
||||
</InsertButton>
|
||||
)}
|
||||
</ButtonsContainer>
|
||||
</RowContainer>
|
||||
</LibraryTop>
|
||||
);
|
||||
}
|
||||
|
||||
MediaLibraryTop.propTypes = {
|
||||
t: PropTypes.func.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
privateUpload: PropTypes.bool,
|
||||
forImage: PropTypes.bool,
|
||||
onDownload: PropTypes.func.isRequired,
|
||||
onUpload: PropTypes.func.isRequired,
|
||||
query: PropTypes.string,
|
||||
onSearchChange: PropTypes.func.isRequired,
|
||||
onSearchKeyDown: PropTypes.func.isRequired,
|
||||
searchDisabled: PropTypes.bool.isRequired,
|
||||
onDelete: PropTypes.func.isRequired,
|
||||
canInsert: PropTypes.bool,
|
||||
onInsert: PropTypes.func.isRequired,
|
||||
hasSelection: PropTypes.bool.isRequired,
|
||||
isPersisting: PropTypes.bool,
|
||||
isDeleting: PropTypes.bool,
|
||||
selectedFile: PropTypes.oneOfType([
|
||||
PropTypes.shape({
|
||||
path: PropTypes.string.isRequired,
|
||||
draft: PropTypes.bool.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
}),
|
||||
PropTypes.shape({}),
|
||||
]),
|
||||
};
|
||||
|
||||
export default MediaLibraryTop;
|
@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
|
||||
import { CopyToClipBoardButton } from '../MediaLibraryButtons';
|
||||
|
||||
describe('CopyToClipBoardButton', () => {
|
||||
const props = {
|
||||
disabled: false,
|
||||
t: jest.fn(key => key),
|
||||
};
|
||||
|
||||
it('should use copy text when no path is defined', () => {
|
||||
const { container } = render(<CopyToClipBoardButton {...props} />);
|
||||
|
||||
expect(container).toHaveTextContent('mediaLibrary.mediaLibraryCard.copy');
|
||||
});
|
||||
|
||||
it('should use copyUrl text when path is absolute and is draft', () => {
|
||||
const { container } = render(
|
||||
<CopyToClipBoardButton {...props} path="https://www.images.com/image.png" draft />,
|
||||
);
|
||||
|
||||
expect(container).toHaveTextContent('mediaLibrary.mediaLibraryCard.copyUrl');
|
||||
});
|
||||
|
||||
it('should use copyUrl text when path is absolute and is not draft', () => {
|
||||
const { container } = render(
|
||||
<CopyToClipBoardButton {...props} path="https://www.images.com/image.png" />,
|
||||
);
|
||||
|
||||
expect(container).toHaveTextContent('mediaLibrary.mediaLibraryCard.copyUrl');
|
||||
});
|
||||
|
||||
it('should use copyName when path is not absolute and is draft', () => {
|
||||
const { container } = render(<CopyToClipBoardButton {...props} path="image.png" draft />);
|
||||
|
||||
expect(container).toHaveTextContent('mediaLibrary.mediaLibraryCard.copyName');
|
||||
});
|
||||
|
||||
it('should use copyPath when path is not absolute and is not draft', () => {
|
||||
const { container } = render(<CopyToClipBoardButton {...props} path="image.png" />);
|
||||
|
||||
expect(container).toHaveTextContent('mediaLibrary.mediaLibraryCard.copyPath');
|
||||
});
|
||||
});
|
@ -0,0 +1,49 @@
|
||||
import React from 'react';
|
||||
import { Map } from 'immutable';
|
||||
import { render } from '@testing-library/react';
|
||||
|
||||
import MediaLibraryCard from '../MediaLibraryCard';
|
||||
|
||||
describe('MediaLibraryCard', () => {
|
||||
const props = {
|
||||
displayURL: Map({ url: 'url' }),
|
||||
text: 'image.png',
|
||||
onClick: jest.fn(),
|
||||
draftText: 'Draft',
|
||||
width: '100px',
|
||||
height: '240px',
|
||||
margin: '10px',
|
||||
isViewableImage: true,
|
||||
loadDisplayURL: jest.fn(),
|
||||
};
|
||||
|
||||
it('should match snapshot for non draft image', () => {
|
||||
const { asFragment, queryByTestId } = render(<MediaLibraryCard {...props} />);
|
||||
|
||||
expect(queryByTestId('draft-text')).toBeNull();
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should match snapshot for draft image', () => {
|
||||
const { asFragment, getByTestId } = render(<MediaLibraryCard {...props} isDraft={true} />);
|
||||
expect(getByTestId('draft-text')).toHaveTextContent('Draft');
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should match snapshot for non viewable image', () => {
|
||||
const { asFragment, getByTestId } = render(
|
||||
<MediaLibraryCard {...props} isViewableImage={false} type="Not Viewable" />,
|
||||
);
|
||||
expect(getByTestId('card-file-icon')).toHaveTextContent('Not Viewable');
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should call loadDisplayURL on mount when url is empty', () => {
|
||||
const loadDisplayURL = jest.fn();
|
||||
render(
|
||||
<MediaLibraryCard {...props} loadDisplayURL={loadDisplayURL} displayURL={Map({ url: '' })} />,
|
||||
);
|
||||
|
||||
expect(loadDisplayURL).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
@ -0,0 +1,214 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`MediaLibraryCard should match snapshot for draft image 1`] = `
|
||||
<DocumentFragment>
|
||||
.emotion-8 {
|
||||
width: 100px;
|
||||
height: 240px;
|
||||
margin: 10px;
|
||||
border: solid 2px #dfdfe3;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.emotion-8:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.emotion-4 {
|
||||
height: 162px;
|
||||
background-color: #f2f2f2;
|
||||
background-size: 16px 16px;
|
||||
background-position: 0 0,8px 8px;
|
||||
background-image: linear-gradient( 45deg, #e6e6e6 25%, transparent 25%, transparent 75%, #e6e6e6 75%, #e6e6e6 ) , linear-gradient( 45deg, #e6e6e6 25%, transparent 25%, transparent 75%, #e6e6e6 75%, #e6e6e6 );
|
||||
box-shadow: inset 0 0 4px rgba(68,74,87,0.3);
|
||||
border-bottom: solid 2px #dfdfe3;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.emotion-2 {
|
||||
width: 100%;
|
||||
height: 160px;
|
||||
object-fit: contain;
|
||||
border-radius: 2px 2px 0 0;
|
||||
}
|
||||
|
||||
.emotion-6 {
|
||||
color: #798291;
|
||||
padding: 8px;
|
||||
margin-top: 20px;
|
||||
overflow-wrap: break-word;
|
||||
line-height: 1.3 !important;
|
||||
}
|
||||
|
||||
.emotion-0 {
|
||||
color: #70399f;
|
||||
background-color: #f6d8ff;
|
||||
position: absolute;
|
||||
padding: 8px;
|
||||
border-radius: 5px 0 5px 0;
|
||||
}
|
||||
|
||||
<div
|
||||
class="emotion-8 emotion-9"
|
||||
height="240px"
|
||||
tabindex="-1"
|
||||
width="100px"
|
||||
>
|
||||
<div
|
||||
class="emotion-4 emotion-5"
|
||||
>
|
||||
<p
|
||||
class="emotion-0 emotion-1"
|
||||
data-testid="draft-text"
|
||||
>
|
||||
Draft
|
||||
</p>
|
||||
<img
|
||||
class="emotion-2 emotion-3"
|
||||
src="url"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
class="emotion-6 emotion-7"
|
||||
>
|
||||
image.png
|
||||
</p>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`MediaLibraryCard should match snapshot for non draft image 1`] = `
|
||||
<DocumentFragment>
|
||||
.emotion-6 {
|
||||
width: 100px;
|
||||
height: 240px;
|
||||
margin: 10px;
|
||||
border: solid 2px #dfdfe3;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.emotion-6:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.emotion-2 {
|
||||
height: 162px;
|
||||
background-color: #f2f2f2;
|
||||
background-size: 16px 16px;
|
||||
background-position: 0 0,8px 8px;
|
||||
background-image: linear-gradient( 45deg, #e6e6e6 25%, transparent 25%, transparent 75%, #e6e6e6 75%, #e6e6e6 ) , linear-gradient( 45deg, #e6e6e6 25%, transparent 25%, transparent 75%, #e6e6e6 75%, #e6e6e6 );
|
||||
box-shadow: inset 0 0 4px rgba(68,74,87,0.3);
|
||||
border-bottom: solid 2px #dfdfe3;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.emotion-0 {
|
||||
width: 100%;
|
||||
height: 160px;
|
||||
object-fit: contain;
|
||||
border-radius: 2px 2px 0 0;
|
||||
}
|
||||
|
||||
.emotion-4 {
|
||||
color: #798291;
|
||||
padding: 8px;
|
||||
margin-top: 20px;
|
||||
overflow-wrap: break-word;
|
||||
line-height: 1.3 !important;
|
||||
}
|
||||
|
||||
<div
|
||||
class="emotion-6 emotion-7"
|
||||
height="240px"
|
||||
tabindex="-1"
|
||||
width="100px"
|
||||
>
|
||||
<div
|
||||
class="emotion-2 emotion-3"
|
||||
>
|
||||
<img
|
||||
class="emotion-0 emotion-1"
|
||||
src="url"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
class="emotion-4 emotion-5"
|
||||
>
|
||||
image.png
|
||||
</p>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`MediaLibraryCard should match snapshot for non viewable image 1`] = `
|
||||
<DocumentFragment>
|
||||
.emotion-6 {
|
||||
width: 100px;
|
||||
height: 240px;
|
||||
margin: 10px;
|
||||
border: solid 2px #dfdfe3;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.emotion-6:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.emotion-2 {
|
||||
height: 162px;
|
||||
background-color: #f2f2f2;
|
||||
background-size: 16px 16px;
|
||||
background-position: 0 0,8px 8px;
|
||||
background-image: linear-gradient( 45deg, #e6e6e6 25%, transparent 25%, transparent 75%, #e6e6e6 75%, #e6e6e6 ) , linear-gradient( 45deg, #e6e6e6 25%, transparent 25%, transparent 75%, #e6e6e6 75%, #e6e6e6 );
|
||||
box-shadow: inset 0 0 4px rgba(68,74,87,0.3);
|
||||
border-bottom: solid 2px #dfdfe3;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.emotion-4 {
|
||||
color: #798291;
|
||||
padding: 8px;
|
||||
margin-top: 20px;
|
||||
overflow-wrap: break-word;
|
||||
line-height: 1.3 !important;
|
||||
}
|
||||
|
||||
.emotion-0 {
|
||||
width: 100%;
|
||||
height: 160px;
|
||||
object-fit: cover;
|
||||
border-radius: 2px 2px 0 0;
|
||||
padding: 1em;
|
||||
font-size: 3em;
|
||||
}
|
||||
|
||||
<div
|
||||
class="emotion-6 emotion-7"
|
||||
height="240px"
|
||||
tabindex="-1"
|
||||
width="100px"
|
||||
>
|
||||
<div
|
||||
class="emotion-2 emotion-3"
|
||||
>
|
||||
<div
|
||||
class="emotion-0 emotion-1"
|
||||
data-testid="card-file-icon"
|
||||
>
|
||||
Not Viewable
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
class="emotion-4 emotion-5"
|
||||
>
|
||||
image.png
|
||||
</p>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
103
src/components/UI/Alert.tsx
Normal file
103
src/components/UI/Alert.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
import Button from '@mui/material/Button';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogContentText from '@mui/material/DialogContentText';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { translate, TranslateProps } from 'react-polyglot';
|
||||
|
||||
import AlertEvent from '../../lib/util/events/AlertEvent';
|
||||
import { useWindowEvent } from '../../lib/util/window.util';
|
||||
|
||||
interface AlertProps {
|
||||
title: string | { key: string; options?: any };
|
||||
body: string | { key: string; options?: any };
|
||||
okay?: string | { key: string; options?: any };
|
||||
color?: 'success' | 'error' | 'primary';
|
||||
}
|
||||
|
||||
export interface AlertDialogProps extends AlertProps {
|
||||
resolve: () => void;
|
||||
}
|
||||
|
||||
const AlertDialog = ({ t }: TranslateProps) => {
|
||||
const [detail, setDetail] = useState<AlertDialogProps | null>(null);
|
||||
const {
|
||||
resolve,
|
||||
title: rawTitle,
|
||||
body: rawBody,
|
||||
okay: rawOkay = 'ui.common.okay',
|
||||
color = 'primary',
|
||||
} = detail ?? {};
|
||||
|
||||
const onAlertMessage = useCallback((event: AlertEvent) => {
|
||||
setDetail(event.detail);
|
||||
}, []);
|
||||
|
||||
useWindowEvent('alert', onAlertMessage);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setDetail(null);
|
||||
resolve?.();
|
||||
}, [resolve]);
|
||||
|
||||
const title = useMemo(() => {
|
||||
if (!rawTitle) {
|
||||
return '';
|
||||
}
|
||||
return typeof rawTitle === 'string' ? t(rawTitle) : t(rawTitle.key, rawTitle.options);
|
||||
}, [rawTitle]);
|
||||
|
||||
const body = useMemo(() => {
|
||||
if (!rawBody) {
|
||||
return '';
|
||||
}
|
||||
return typeof rawBody === 'string' ? t(rawBody) : t(rawBody.key, rawBody.options);
|
||||
}, [rawBody]);
|
||||
|
||||
const okay = useMemo(
|
||||
() => (typeof rawOkay === 'string' ? t(rawOkay) : t(rawOkay.key, rawOkay.options)),
|
||||
[rawOkay],
|
||||
);
|
||||
|
||||
if (!detail) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Dialog
|
||||
open
|
||||
onClose={handleClose}
|
||||
aria-labelledby="alert-dialog-title"
|
||||
aria-describedby="alert-dialog-description"
|
||||
>
|
||||
<DialogTitle id="alert-dialog-title">{t(title)}</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText id="alert-dialog-description">{body}</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose} variant="contained" color={color}>
|
||||
{okay}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Alert = translate()(AlertDialog);
|
||||
|
||||
const alert = (props: AlertProps) => {
|
||||
return new Promise<void>(resolve => {
|
||||
window.dispatchEvent(
|
||||
new AlertEvent({
|
||||
...props,
|
||||
resolve,
|
||||
}),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export default alert;
|
122
src/components/UI/Confirm.tsx
Normal file
122
src/components/UI/Confirm.tsx
Normal file
@ -0,0 +1,122 @@
|
||||
import Button from '@mui/material/Button';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogContentText from '@mui/material/DialogContentText';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { translate, TranslateProps } from 'react-polyglot';
|
||||
|
||||
import ConfirmEvent from '../../lib/util/events/ConfirmEvent';
|
||||
import { useWindowEvent } from '../../lib/util/window.util';
|
||||
|
||||
interface ConfirmProps {
|
||||
title: string | { key: string; options?: any };
|
||||
body: string | { key: string; options?: any };
|
||||
cancel?: string | { key: string; options?: any };
|
||||
confirm?: string | { key: string; options?: any };
|
||||
color?: 'success' | 'error' | 'primary';
|
||||
}
|
||||
|
||||
export interface ConfirmDialogProps extends ConfirmProps {
|
||||
resolve: (value: boolean | PromiseLike<boolean>) => void;
|
||||
}
|
||||
|
||||
const ConfirmDialog = ({ t }: TranslateProps) => {
|
||||
const [detail, setDetail] = useState<ConfirmDialogProps | null>(null);
|
||||
const {
|
||||
resolve,
|
||||
title: rawTitle,
|
||||
body: rawBody,
|
||||
cancel: rawCancel = 'ui.common.no',
|
||||
confirm: rawConfirm = 'ui.common.yes',
|
||||
color = 'primary',
|
||||
} = detail ?? {};
|
||||
|
||||
const onConfirmMessage = useCallback((event: ConfirmEvent) => {
|
||||
setDetail(event.detail);
|
||||
}, []);
|
||||
|
||||
useWindowEvent('confirm', onConfirmMessage);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setDetail(null);
|
||||
}, []);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
resolve?.(false);
|
||||
handleClose();
|
||||
}, [handleClose, resolve]);
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
resolve?.(true);
|
||||
handleClose();
|
||||
}, [handleClose, resolve]);
|
||||
|
||||
const title = useMemo(() => {
|
||||
if (!rawTitle) {
|
||||
return '';
|
||||
}
|
||||
return typeof rawTitle === 'string' ? t(rawTitle) : t(rawTitle.key, rawTitle.options);
|
||||
}, [rawTitle]);
|
||||
|
||||
const body = useMemo(() => {
|
||||
if (!rawBody) {
|
||||
return '';
|
||||
}
|
||||
return typeof rawBody === 'string' ? t(rawBody) : t(rawBody.key, rawBody.options);
|
||||
}, [rawBody]);
|
||||
|
||||
const cancel = useMemo(
|
||||
() => (typeof rawCancel === 'string' ? t(rawCancel) : t(rawCancel.key, rawCancel.options)),
|
||||
[rawCancel],
|
||||
);
|
||||
|
||||
const confirm = useMemo(
|
||||
() => (typeof rawConfirm === 'string' ? t(rawConfirm) : t(rawConfirm.key, rawConfirm.options)),
|
||||
[rawConfirm],
|
||||
);
|
||||
|
||||
if (!detail) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Dialog
|
||||
open
|
||||
onClose={handleCancel}
|
||||
aria-labelledby="confirm-dialog-title"
|
||||
aria-describedby="confirm-dialog-description"
|
||||
>
|
||||
<DialogTitle id="confirm-dialog-title">{title}</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText id="confirm-dialog-description">{body}</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleCancel} color="inherit">
|
||||
{cancel}
|
||||
</Button>
|
||||
<Button onClick={handleConfirm} variant="contained" color={color}>
|
||||
{confirm}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Confirm = translate()(ConfirmDialog);
|
||||
|
||||
const confirm = (props: ConfirmProps) => {
|
||||
return new Promise<boolean>(resolve => {
|
||||
window.dispatchEvent(
|
||||
new ConfirmEvent({
|
||||
...props,
|
||||
resolve,
|
||||
}),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export default confirm;
|
66
src/components/UI/DragDrop.js
Normal file
66
src/components/UI/DragDrop.js
Normal file
@ -0,0 +1,66 @@
|
||||
import { HTML5Backend as ReactDNDHTML5Backend } from 'react-dnd-html5-backend';
|
||||
import {
|
||||
DndProvider as ReactDNDProvider,
|
||||
DragSource as ReactDNDDragSource,
|
||||
DropTarget as ReactDNDDropTarget,
|
||||
} from 'react-dnd';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
export function DragSource({ namespace, ...props }) {
|
||||
const DragComponent = ReactDNDDragSource(
|
||||
namespace,
|
||||
{
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
beginDrag({ children, isDragging, connectDragComponent, ...ownProps }) {
|
||||
// We return the rest of the props as the ID of the element being dragged.
|
||||
return ownProps;
|
||||
},
|
||||
},
|
||||
connect => ({
|
||||
connectDragComponent: connect.dragSource(),
|
||||
}),
|
||||
)(({ children, connectDragComponent }) => children(connectDragComponent));
|
||||
|
||||
return React.createElement(DragComponent, props, props.children);
|
||||
}
|
||||
|
||||
DragSource.propTypes = {
|
||||
namespace: PropTypes.any.isRequired,
|
||||
children: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export function DropTarget({ onDrop, namespace, ...props }) {
|
||||
const DropComponent = ReactDNDDropTarget(
|
||||
namespace,
|
||||
{
|
||||
drop(ownProps, monitor) {
|
||||
onDrop(monitor.getItem());
|
||||
},
|
||||
},
|
||||
(connect, monitor) => ({
|
||||
connectDropTarget: connect.dropTarget(),
|
||||
isHovered: monitor.isOver(),
|
||||
}),
|
||||
)(({ children, connectDropTarget, isHovered }) => children(connectDropTarget, { isHovered }));
|
||||
|
||||
return React.createElement(DropComponent, props, props.children);
|
||||
}
|
||||
|
||||
DropTarget.propTypes = {
|
||||
onDrop: PropTypes.func.isRequired,
|
||||
namespace: PropTypes.any.isRequired,
|
||||
children: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export function HTML5DragDrop(WrappedComponent) {
|
||||
return class HTML5DragDrop extends React.Component {
|
||||
render() {
|
||||
return (
|
||||
<ReactDNDProvider backend={ReactDNDHTML5Backend}>
|
||||
<WrappedComponent {...this.props} />
|
||||
</ReactDNDProvider>
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
206
src/components/UI/ErrorBoundary.js
Normal file
206
src/components/UI/ErrorBoundary.js
Normal file
@ -0,0 +1,206 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { translate } from 'react-polyglot';
|
||||
import styled from '@emotion/styled';
|
||||
import yaml from 'yaml';
|
||||
import { truncate } from 'lodash';
|
||||
import copyToClipboard from 'copy-text-to-clipboard';
|
||||
import cleanStack from 'clean-stack';
|
||||
|
||||
import { buttons, colors } from '../../ui';
|
||||
import { localForage } from '../../lib/util';
|
||||
|
||||
const ISSUE_URL = 'https://github.com/SimpleCMS/simple-cms/issues/new?';
|
||||
|
||||
function getIssueTemplate({ version, provider, browser, config }) {
|
||||
return `
|
||||
**Describe the bug**
|
||||
|
||||
**To Reproduce**
|
||||
|
||||
**Expected behavior**
|
||||
|
||||
**Screenshots**
|
||||
|
||||
**Applicable Versions:**
|
||||
- Simple CMS version: \`${version}\`
|
||||
- Git provider: \`${provider}\`
|
||||
- Browser version: \`${browser}\`
|
||||
|
||||
**CMS configuration**
|
||||
\`\`\`
|
||||
${config}
|
||||
\`\`\`
|
||||
|
||||
**Additional context**
|
||||
`;
|
||||
}
|
||||
|
||||
function buildIssueTemplate({ config }) {
|
||||
let version = '';
|
||||
if (typeof SIMPLE_CMS_CORE_VERSION === 'string') {
|
||||
version = `simple-cms@${SIMPLE_CMS_CORE_VERSION}`;
|
||||
}
|
||||
const template = getIssueTemplate({
|
||||
version,
|
||||
provider: config.backend.name,
|
||||
browser: navigator.userAgent,
|
||||
config: yaml.stringify(config),
|
||||
});
|
||||
|
||||
return template;
|
||||
}
|
||||
|
||||
function buildIssueUrl({ title, config }) {
|
||||
try {
|
||||
const body = buildIssueTemplate({ config });
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.append('title', truncate(title, { length: 100 }));
|
||||
params.append('body', truncate(body, { length: 4000, omission: '\n...' }));
|
||||
params.append('labels', 'type: bug');
|
||||
|
||||
return `${ISSUE_URL}${params.toString()}`;
|
||||
} catch (e) {
|
||||
console.info(e);
|
||||
return `${ISSUE_URL}template=bug_report.md`;
|
||||
}
|
||||
}
|
||||
|
||||
const ErrorBoundaryContainer = styled.div`
|
||||
padding: 40px;
|
||||
|
||||
h1 {
|
||||
font-size: 28px;
|
||||
color: ${colors.text};
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: ${colors.textLead};
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
hr {
|
||||
width: 200px;
|
||||
margin: 30px 0;
|
||||
border: 0;
|
||||
height: 1px;
|
||||
background-color: ${colors.text};
|
||||
}
|
||||
|
||||
a {
|
||||
color: ${colors.active};
|
||||
}
|
||||
`;
|
||||
|
||||
const PrivacyWarning = styled.span`
|
||||
color: ${colors.text};
|
||||
`;
|
||||
|
||||
const CopyButton = styled.button`
|
||||
${buttons.button};
|
||||
${buttons.default};
|
||||
${buttons.gray};
|
||||
display: block;
|
||||
margin: 12px 0;
|
||||
`;
|
||||
|
||||
function RecoveredEntry({ entry, t }) {
|
||||
console.info(entry);
|
||||
return (
|
||||
<>
|
||||
<hr />
|
||||
<h2>{t('ui.errorBoundary.recoveredEntry.heading')}</h2>
|
||||
<strong>{t('ui.errorBoundary.recoveredEntry.warning')}</strong>
|
||||
<CopyButton onClick={() => copyToClipboard(entry)}>
|
||||
{t('ui.errorBoundary.recoveredEntry.copyButtonLabel')}
|
||||
</CopyButton>
|
||||
<pre>
|
||||
<code>{entry}</code>
|
||||
</pre>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends React.Component {
|
||||
static propTypes = {
|
||||
children: PropTypes.node,
|
||||
t: PropTypes.func.isRequired,
|
||||
config: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
state = {
|
||||
hasError: false,
|
||||
errorMessage: '',
|
||||
errorTitle: '',
|
||||
backup: '',
|
||||
};
|
||||
|
||||
static getDerivedStateFromError(error) {
|
||||
console.error(error);
|
||||
return {
|
||||
hasError: true,
|
||||
errorMessage: cleanStack(error.stack, { basePath: window.location.origin || '' }),
|
||||
errorTitle: error.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps, nextState) {
|
||||
if (this.props.showBackup) {
|
||||
return (
|
||||
this.state.errorMessage !== nextState.errorMessage || this.state.backup !== nextState.backup
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async componentDidUpdate() {
|
||||
if (this.props.showBackup) {
|
||||
const backup = await localForage.getItem('backup');
|
||||
backup && console.info(backup);
|
||||
this.setState({ backup });
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { hasError, errorMessage, backup, errorTitle } = this.state;
|
||||
const { showBackup, t } = this.props;
|
||||
if (!hasError) {
|
||||
return this.props.children;
|
||||
}
|
||||
return (
|
||||
<ErrorBoundaryContainer key="error-boundary-container">
|
||||
<h1>{t('ui.errorBoundary.title')}</h1>
|
||||
<p>
|
||||
<span>{t('ui.errorBoundary.details')}</span>
|
||||
<a
|
||||
href={buildIssueUrl({ title: errorTitle, config: this.props.config })}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-testid="issue-url"
|
||||
>
|
||||
{t('ui.errorBoundary.reportIt')}
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
{t('ui.errorBoundary.privacyWarning')
|
||||
.split('\n')
|
||||
.map((item, index) => [
|
||||
<PrivacyWarning key={`private-warning-${index}`}>{item}</PrivacyWarning>,
|
||||
<br key={`break-${index}`} />,
|
||||
])}
|
||||
</p>
|
||||
<hr />
|
||||
<h2>{t('ui.errorBoundary.detailsHeading')}</h2>
|
||||
<p>{errorMessage}</p>
|
||||
{backup && showBackup && <RecoveredEntry key="backup" entry={backup} t={t} />}
|
||||
</ErrorBoundaryContainer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default translate()(ErrorBoundary);
|
24
src/components/UI/FileUploadButton.js
Normal file
24
src/components/UI/FileUploadButton.js
Normal file
@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
export function FileUploadButton({ label, imagesOnly, onChange, disabled, className }) {
|
||||
return (
|
||||
<label className={`nc-fileUploadButton ${className || ''}`}>
|
||||
<span>{label}</span>
|
||||
<input
|
||||
type="file"
|
||||
accept={imagesOnly ? 'image/*' : '*/*'}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
FileUploadButton.propTypes = {
|
||||
className: PropTypes.string,
|
||||
label: PropTypes.string.isRequired,
|
||||
imagesOnly: PropTypes.bool,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
disabled: PropTypes.bool,
|
||||
};
|
110
src/components/UI/Modal.js
Normal file
110
src/components/UI/Modal.js
Normal file
@ -0,0 +1,110 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { css, Global, ClassNames } from '@emotion/react';
|
||||
import ReactModal from 'react-modal';
|
||||
|
||||
import { transitions, shadows, lengths, zIndex } from '../../ui';
|
||||
|
||||
function ReactModalGlobalStyles() {
|
||||
return (
|
||||
<Global
|
||||
styles={css`
|
||||
.ReactModal__Body--open {
|
||||
overflow: hidden;
|
||||
}
|
||||
`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const styleStrings = {
|
||||
modalBody: `
|
||||
${shadows.dropDeep};
|
||||
background-color: #fff;
|
||||
border-radius: ${lengths.borderRadius};
|
||||
height: 80%;
|
||||
text-align: center;
|
||||
max-width: 2200px;
|
||||
padding: 20px;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
`,
|
||||
overlay: `
|
||||
z-index: ${zIndex.zIndex1002};
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
opacity: 0;
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
transition: background-color ${transitions.main}, opacity ${transitions.main};
|
||||
`,
|
||||
overlayAfterOpen: `
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
opacity: 1;
|
||||
`,
|
||||
overlayBeforeClose: `
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
opacity: 0;
|
||||
`,
|
||||
};
|
||||
|
||||
export class Modal extends React.Component {
|
||||
static propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
className: PropTypes.string,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
ReactModal.setAppElement('#nc-root');
|
||||
}
|
||||
|
||||
render() {
|
||||
const { isOpen, children, className, onClose } = this.props;
|
||||
return (
|
||||
<>
|
||||
<ReactModalGlobalStyles />
|
||||
<ClassNames>
|
||||
{({ css, cx }) => (
|
||||
<ReactModal
|
||||
isOpen={isOpen}
|
||||
onRequestClose={onClose}
|
||||
closeTimeoutMS={300}
|
||||
className={{
|
||||
base: cx(
|
||||
css`
|
||||
${styleStrings.modalBody};
|
||||
`,
|
||||
className,
|
||||
),
|
||||
afterOpen: '',
|
||||
beforeClose: '',
|
||||
}}
|
||||
overlayClassName={{
|
||||
base: css`
|
||||
${styleStrings.overlay};
|
||||
`,
|
||||
afterOpen: css`
|
||||
${styleStrings.overlayAfterOpen};
|
||||
`,
|
||||
beforeClose: css`
|
||||
${styleStrings.overlayBeforeClose};
|
||||
`,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ReactModal>
|
||||
)}
|
||||
</ClassNames>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
103
src/components/UI/SettingsDropdown.js
Normal file
103
src/components/UI/SettingsDropdown.js
Normal file
@ -0,0 +1,103 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { css } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { translate } from 'react-polyglot';
|
||||
|
||||
import { Icon, Dropdown, DropdownItem, DropdownButton, colors } from '../../ui';
|
||||
import { stripProtocol } from '../../lib/urlHelper';
|
||||
|
||||
const styles = {
|
||||
avatarImage: css`
|
||||
width: 32px;
|
||||
border-radius: 32px;
|
||||
`,
|
||||
};
|
||||
|
||||
const AvatarDropdownButton = styled(DropdownButton)`
|
||||
display: inline-block;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
color: #1e2532;
|
||||
background-color: transparent;
|
||||
`;
|
||||
|
||||
const AvatarImage = styled.img`
|
||||
${styles.avatarImage};
|
||||
`;
|
||||
|
||||
const AvatarPlaceholderIcon = styled(Icon)`
|
||||
${styles.avatarImage};
|
||||
height: 32px;
|
||||
color: #1e2532;
|
||||
background-color: ${colors.textFieldBorder};
|
||||
`;
|
||||
|
||||
const AppHeaderSiteLink = styled.a`
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: #7b8290;
|
||||
padding: 10px 16px;
|
||||
`;
|
||||
|
||||
const AppHeaderTestRepoIndicator = styled.a`
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: #7b8290;
|
||||
padding: 10px 16px;
|
||||
`;
|
||||
|
||||
function Avatar({ imageUrl }) {
|
||||
return imageUrl ? (
|
||||
<AvatarImage src={imageUrl} />
|
||||
) : (
|
||||
<AvatarPlaceholderIcon type="user" size="large" />
|
||||
);
|
||||
}
|
||||
|
||||
Avatar.propTypes = {
|
||||
imageUrl: PropTypes.string,
|
||||
};
|
||||
|
||||
function SettingsDropdown({ displayUrl, isTestRepo, imageUrl, onLogoutClick, t }) {
|
||||
return (
|
||||
<React.Fragment>
|
||||
{isTestRepo && (
|
||||
<AppHeaderTestRepoIndicator
|
||||
href="https://www.netlifycms.org/docs/test-backend"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Test Backend ↗
|
||||
</AppHeaderTestRepoIndicator>
|
||||
)}
|
||||
{displayUrl ? (
|
||||
<AppHeaderSiteLink href={displayUrl} target="_blank">
|
||||
{stripProtocol(displayUrl)}
|
||||
</AppHeaderSiteLink>
|
||||
) : null}
|
||||
<Dropdown
|
||||
dropdownTopOverlap="50px"
|
||||
dropdownWidth="100px"
|
||||
dropdownPosition="right"
|
||||
renderButton={() => (
|
||||
<AvatarDropdownButton>
|
||||
<Avatar imageUrl={imageUrl} />
|
||||
</AvatarDropdownButton>
|
||||
)}
|
||||
>
|
||||
<DropdownItem label={t('ui.settingsDropdown.logOut')} onClick={onLogoutClick} />
|
||||
</Dropdown>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
SettingsDropdown.propTypes = {
|
||||
displayUrl: PropTypes.string,
|
||||
isTestRepo: PropTypes.bool,
|
||||
imageUrl: PropTypes.string,
|
||||
onLogoutClick: PropTypes.func.isRequired,
|
||||
t: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default translate()(SettingsDropdown);
|
5
src/components/UI/index.js
Normal file
5
src/components/UI/index.js
Normal file
@ -0,0 +1,5 @@
|
||||
export { DragSource, DropTarget, HTML5DragDrop } from './DragDrop';
|
||||
export { default as ErrorBoundary } from './ErrorBoundary';
|
||||
export { FileUploadButton } from './FileUploadButton';
|
||||
export { Modal } from './Modal';
|
||||
export { default as SettingsDropdown } from './SettingsDropdown';
|
166
src/components/Workflow/Workflow.js
Normal file
166
src/components/Workflow/Workflow.js
Normal file
@ -0,0 +1,166 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import styled from '@emotion/styled';
|
||||
import { OrderedMap } from 'immutable';
|
||||
import { translate } from 'react-polyglot';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
StyledDropdownButton,
|
||||
Loader,
|
||||
lengths,
|
||||
components,
|
||||
shadows,
|
||||
} from '../../ui';
|
||||
import { createNewEntry } from '../../actions/collections';
|
||||
import {
|
||||
loadUnpublishedEntries,
|
||||
updateUnpublishedEntryStatus,
|
||||
publishUnpublishedEntry,
|
||||
deleteUnpublishedEntry,
|
||||
} from '../../actions/editorialWorkflow';
|
||||
import { selectUnpublishedEntriesByStatus } from '../../reducers';
|
||||
import { EDITORIAL_WORKFLOW, status } from '../../constants/publishModes';
|
||||
import WorkflowList from './WorkflowList';
|
||||
|
||||
const WorkflowContainer = styled.div`
|
||||
padding: ${lengths.pageMargin} 0;
|
||||
height: calc(100vh - 56px);
|
||||
`;
|
||||
|
||||
const WorkflowTop = styled.div`
|
||||
${components.cardTop};
|
||||
`;
|
||||
|
||||
const WorkflowTopRow = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
span[role='button'] {
|
||||
${shadows.dropDeep};
|
||||
}
|
||||
`;
|
||||
|
||||
const WorkflowTopHeading = styled.h1`
|
||||
${components.cardTopHeading};
|
||||
`;
|
||||
|
||||
const WorkflowTopDescription = styled.p`
|
||||
${components.cardTopDescription};
|
||||
`;
|
||||
|
||||
class Workflow extends Component {
|
||||
static propTypes = {
|
||||
collections: ImmutablePropTypes.map.isRequired,
|
||||
isEditorialWorkflow: PropTypes.bool.isRequired,
|
||||
isOpenAuthoring: PropTypes.bool,
|
||||
isFetching: PropTypes.bool,
|
||||
unpublishedEntries: ImmutablePropTypes.map,
|
||||
loadUnpublishedEntries: PropTypes.func.isRequired,
|
||||
updateUnpublishedEntryStatus: PropTypes.func.isRequired,
|
||||
publishUnpublishedEntry: PropTypes.func.isRequired,
|
||||
deleteUnpublishedEntry: PropTypes.func.isRequired,
|
||||
t: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
const { loadUnpublishedEntries, isEditorialWorkflow, collections } = this.props;
|
||||
if (isEditorialWorkflow) {
|
||||
loadUnpublishedEntries(collections);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
isEditorialWorkflow,
|
||||
isOpenAuthoring,
|
||||
isFetching,
|
||||
unpublishedEntries,
|
||||
updateUnpublishedEntryStatus,
|
||||
publishUnpublishedEntry,
|
||||
deleteUnpublishedEntry,
|
||||
collections,
|
||||
t,
|
||||
} = this.props;
|
||||
|
||||
if (!isEditorialWorkflow) return null;
|
||||
if (isFetching) return <Loader active>{t('workflow.workflow.loading')}</Loader>;
|
||||
const reviewCount = unpublishedEntries.get('pending_review').size;
|
||||
const readyCount = unpublishedEntries.get('pending_publish').size;
|
||||
|
||||
return (
|
||||
<WorkflowContainer>
|
||||
<WorkflowTop>
|
||||
<WorkflowTopRow>
|
||||
<WorkflowTopHeading>{t('workflow.workflow.workflowHeading')}</WorkflowTopHeading>
|
||||
<Dropdown
|
||||
dropdownWidth="160px"
|
||||
dropdownPosition="left"
|
||||
dropdownTopOverlap="40px"
|
||||
renderButton={() => (
|
||||
<StyledDropdownButton>{t('workflow.workflow.newPost')}</StyledDropdownButton>
|
||||
)}
|
||||
>
|
||||
{collections
|
||||
.filter(collection => collection.get('create'))
|
||||
.toList()
|
||||
.map(collection => (
|
||||
<DropdownItem
|
||||
key={collection.get('name')}
|
||||
label={collection.get('label')}
|
||||
onClick={() => createNewEntry(collection.get('name'))}
|
||||
/>
|
||||
))}
|
||||
</Dropdown>
|
||||
</WorkflowTopRow>
|
||||
<WorkflowTopDescription>
|
||||
{t('workflow.workflow.description', {
|
||||
smart_count: reviewCount,
|
||||
readyCount,
|
||||
})}
|
||||
</WorkflowTopDescription>
|
||||
</WorkflowTop>
|
||||
<WorkflowList
|
||||
entries={unpublishedEntries}
|
||||
handleChangeStatus={updateUnpublishedEntryStatus}
|
||||
handlePublish={publishUnpublishedEntry}
|
||||
handleDelete={deleteUnpublishedEntry}
|
||||
isOpenAuthoring={isOpenAuthoring}
|
||||
collections={collections}
|
||||
/>
|
||||
</WorkflowContainer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state) {
|
||||
const { collections, config, globalUI } = state;
|
||||
const isEditorialWorkflow = config.publish_mode === EDITORIAL_WORKFLOW;
|
||||
const isOpenAuthoring = globalUI.useOpenAuthoring;
|
||||
const returnObj = { collections, isEditorialWorkflow, isOpenAuthoring };
|
||||
|
||||
if (isEditorialWorkflow) {
|
||||
returnObj.isFetching = state.editorialWorkflow.getIn(['pages', 'isFetching'], false);
|
||||
|
||||
/*
|
||||
* Generates an ordered Map of the available status as keys.
|
||||
* Each key containing a Sequence of available unpubhlished entries
|
||||
* Eg.: OrderedMap{'draft':Seq(), 'pending_review':Seq(), 'pending_publish':Seq()}
|
||||
*/
|
||||
returnObj.unpublishedEntries = status.reduce((acc, currStatus) => {
|
||||
const entries = selectUnpublishedEntriesByStatus(state, currStatus);
|
||||
return acc.set(currStatus, entries);
|
||||
}, OrderedMap());
|
||||
}
|
||||
return returnObj;
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, {
|
||||
loadUnpublishedEntries,
|
||||
updateUnpublishedEntryStatus,
|
||||
publishUnpublishedEntry,
|
||||
deleteUnpublishedEntry,
|
||||
})(translate()(Workflow));
|
178
src/components/Workflow/WorkflowCard.js
Normal file
178
src/components/Workflow/WorkflowCard.js
Normal file
@ -0,0 +1,178 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { css } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { translate } from 'react-polyglot';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { components, colors, colorsRaw, transitions, buttons } from '../../ui';
|
||||
|
||||
const styles = {
|
||||
text: css`
|
||||
font-size: 13px;
|
||||
font-weight: normal;
|
||||
margin-top: 4px;
|
||||
`,
|
||||
button: css`
|
||||
${buttons.button};
|
||||
width: auto;
|
||||
flex: 1 0 0;
|
||||
font-size: 13px;
|
||||
padding: 6px 0;
|
||||
`,
|
||||
};
|
||||
|
||||
const WorkflowLink = styled(Link)`
|
||||
display: block;
|
||||
padding: 0 18px 18px;
|
||||
height: 200px;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const CardCollection = styled.div`
|
||||
font-size: 14px;
|
||||
color: ${colors.textLead};
|
||||
text-transform: uppercase;
|
||||
margin-top: 12px;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const CardTitle = styled.h2`
|
||||
margin: 28px 0 0;
|
||||
color: ${colors.textLead};
|
||||
`;
|
||||
|
||||
const CardDateContainer = styled.div`
|
||||
${styles.text};
|
||||
`;
|
||||
|
||||
const CardBody = styled.p`
|
||||
${styles.text};
|
||||
color: ${colors.text};
|
||||
margin: 24px 0 0;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
hyphens: auto;
|
||||
`;
|
||||
|
||||
const CardButtonContainer = styled.div`
|
||||
background-color: ${colors.foreground};
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
padding: 12px 18px;
|
||||
display: flex;
|
||||
opacity: 0;
|
||||
transition: opacity ${transitions.main};
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
const DeleteButton = styled.button`
|
||||
${styles.button};
|
||||
background-color: ${colorsRaw.redLight};
|
||||
color: ${colorsRaw.red};
|
||||
margin-right: 6px;
|
||||
`;
|
||||
|
||||
const PublishButton = styled.button`
|
||||
${styles.button};
|
||||
background-color: ${colorsRaw.teal};
|
||||
color: ${colors.textLight};
|
||||
margin-left: 6px;
|
||||
|
||||
&[disabled] {
|
||||
${buttons.disabled};
|
||||
}
|
||||
`;
|
||||
|
||||
const WorkflowCardContainer = styled.div`
|
||||
${components.card};
|
||||
margin-bottom: 24px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&:hover ${CardButtonContainer} {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
function lastChangePhraseKey(date, author) {
|
||||
if (date && author) {
|
||||
return 'lastChange';
|
||||
} else if (date) {
|
||||
return 'lastChangeNoAuthor';
|
||||
} else if (author) {
|
||||
return 'lastChangeNoDate';
|
||||
}
|
||||
}
|
||||
|
||||
const CardDate = translate()(({ t, date, author }) => {
|
||||
const key = lastChangePhraseKey(date, author);
|
||||
if (key) {
|
||||
return (
|
||||
<CardDateContainer>{t(`workflow.workflowCard.${key}`, { date, author })}</CardDateContainer>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
function WorkflowCard({
|
||||
collectionLabel,
|
||||
title,
|
||||
authorLastChange,
|
||||
body,
|
||||
isModification,
|
||||
editLink,
|
||||
timestamp,
|
||||
onDelete,
|
||||
allowPublish,
|
||||
canPublish,
|
||||
onPublish,
|
||||
postAuthor,
|
||||
t,
|
||||
}) {
|
||||
return (
|
||||
<WorkflowCardContainer>
|
||||
<WorkflowLink to={editLink}>
|
||||
<CardCollection>{collectionLabel}</CardCollection>
|
||||
{postAuthor}
|
||||
<CardTitle>{title}</CardTitle>
|
||||
{(timestamp || authorLastChange) && <CardDate date={timestamp} author={authorLastChange} />}
|
||||
<CardBody>{body}</CardBody>
|
||||
</WorkflowLink>
|
||||
<CardButtonContainer>
|
||||
<DeleteButton onClick={onDelete}>
|
||||
{isModification
|
||||
? t('workflow.workflowCard.deleteChanges')
|
||||
: t('workflow.workflowCard.deleteNewEntry')}
|
||||
</DeleteButton>
|
||||
{allowPublish && (
|
||||
<PublishButton disabled={!canPublish} onClick={onPublish}>
|
||||
{isModification
|
||||
? t('workflow.workflowCard.publishChanges')
|
||||
: t('workflow.workflowCard.publishNewEntry')}
|
||||
</PublishButton>
|
||||
)}
|
||||
</CardButtonContainer>
|
||||
</WorkflowCardContainer>
|
||||
);
|
||||
}
|
||||
|
||||
WorkflowCard.propTypes = {
|
||||
collectionLabel: PropTypes.string.isRequired,
|
||||
title: PropTypes.string,
|
||||
authorLastChange: PropTypes.string,
|
||||
body: PropTypes.string,
|
||||
isModification: PropTypes.bool,
|
||||
editLink: PropTypes.string.isRequired,
|
||||
timestamp: PropTypes.string.isRequired,
|
||||
onDelete: PropTypes.func.isRequired,
|
||||
allowPublish: PropTypes.bool.isRequired,
|
||||
canPublish: PropTypes.bool.isRequired,
|
||||
onPublish: PropTypes.func.isRequired,
|
||||
postAuthor: PropTypes.string,
|
||||
t: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default translate()(WorkflowCard);
|
284
src/components/Workflow/WorkflowList.js
Normal file
284
src/components/Workflow/WorkflowList.js
Normal file
@ -0,0 +1,284 @@
|
||||
import { css } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import moment from 'moment';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { translate } from 'react-polyglot';
|
||||
|
||||
import { status } from '../../constants/publishModes';
|
||||
import { selectEntryCollectionTitle } from '../../reducers/collections';
|
||||
import { colors, lengths } from '../../ui';
|
||||
import { DragSource, DropTarget, HTML5DragDrop } from '../UI';
|
||||
import alert from '../UI/Alert';
|
||||
import confirm from '../UI/Confirm';
|
||||
import WorkflowCard from './WorkflowCard';
|
||||
|
||||
const WorkflowListContainer = styled.div`
|
||||
min-height: 60%;
|
||||
display: grid;
|
||||
grid-template-columns: 33.3% 33.3% 33.3%;
|
||||
`;
|
||||
|
||||
const WorkflowListContainerOpenAuthoring = styled.div`
|
||||
min-height: 60%;
|
||||
display: grid;
|
||||
grid-template-columns: 50% 50% 0%;
|
||||
`;
|
||||
|
||||
const styles = {
|
||||
columnPosition: idx =>
|
||||
(idx === 0 &&
|
||||
css`
|
||||
margin-left: 0;
|
||||
`) ||
|
||||
(idx === 2 &&
|
||||
css`
|
||||
margin-right: 0;
|
||||
`) ||
|
||||
css`
|
||||
&:before,
|
||||
&:after {
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
width: 2px;
|
||||
height: 80%;
|
||||
top: 76px;
|
||||
background-color: ${colors.textFieldBorder};
|
||||
}
|
||||
|
||||
&:before {
|
||||
left: -23px;
|
||||
}
|
||||
|
||||
&:after {
|
||||
right: -23px;
|
||||
}
|
||||
`,
|
||||
column: css`
|
||||
margin: 0 20px;
|
||||
transition: background-color 0.5s ease;
|
||||
border: 2px dashed transparent;
|
||||
border-radius: 4px;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
`,
|
||||
columnHovered: css`
|
||||
border-color: ${colors.active};
|
||||
`,
|
||||
hiddenColumn: css`
|
||||
display: none;
|
||||
`,
|
||||
hiddenRightBorder: css`
|
||||
&:not(:first-child):not(:last-child) {
|
||||
&:after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
const ColumnHeader = styled.h2`
|
||||
font-size: 20px;
|
||||
font-weight: normal;
|
||||
padding: 4px 14px;
|
||||
border-radius: ${lengths.borderRadius};
|
||||
margin-bottom: 28px;
|
||||
|
||||
${props =>
|
||||
props.name === 'draft' &&
|
||||
css`
|
||||
background-color: ${colors.statusDraftBackground};
|
||||
color: ${colors.statusDraftText};
|
||||
`}
|
||||
|
||||
${props =>
|
||||
props.name === 'pending_review' &&
|
||||
css`
|
||||
background-color: ${colors.statusReviewBackground};
|
||||
color: ${colors.statusReviewText};
|
||||
`}
|
||||
|
||||
${props =>
|
||||
props.name === 'pending_publish' &&
|
||||
css`
|
||||
background-color: ${colors.statusReadyBackground};
|
||||
color: ${colors.statusReadyText};
|
||||
`}
|
||||
`;
|
||||
|
||||
const ColumnCount = styled.p`
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: ${colors.text};
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 6px;
|
||||
`;
|
||||
|
||||
// This is a namespace so that we can only drop these elements on a DropTarget with the same
|
||||
const DNDNamespace = 'cms-workflow';
|
||||
|
||||
function getColumnHeaderText(columnName, t) {
|
||||
switch (columnName) {
|
||||
case 'draft':
|
||||
return t('workflow.workflowList.draftHeader');
|
||||
case 'pending_review':
|
||||
return t('workflow.workflowList.inReviewHeader');
|
||||
case 'pending_publish':
|
||||
return t('workflow.workflowList.readyHeader');
|
||||
}
|
||||
}
|
||||
|
||||
class WorkflowList extends React.Component {
|
||||
static propTypes = {
|
||||
entries: ImmutablePropTypes.orderedMap,
|
||||
handleChangeStatus: PropTypes.func.isRequired,
|
||||
handlePublish: PropTypes.func.isRequired,
|
||||
handleDelete: PropTypes.func.isRequired,
|
||||
t: PropTypes.func.isRequired,
|
||||
isOpenAuthoring: PropTypes.bool,
|
||||
collections: ImmutablePropTypes.map.isRequired,
|
||||
};
|
||||
|
||||
handleChangeStatus = (newStatus, dragProps) => {
|
||||
const slug = dragProps.slug;
|
||||
const collection = dragProps.collection;
|
||||
const oldStatus = dragProps.ownStatus;
|
||||
this.props.handleChangeStatus(collection, slug, oldStatus, newStatus);
|
||||
};
|
||||
|
||||
requestDelete = async (collection, slug, ownStatus) => {
|
||||
if (
|
||||
await confirm({
|
||||
title: 'workflow.workflowList.onDeleteEntryTitle',
|
||||
body: 'workflow.workflowList.onDeleteEntryBody',
|
||||
})
|
||||
) {
|
||||
this.props.handleDelete(collection, slug, ownStatus);
|
||||
}
|
||||
};
|
||||
|
||||
requestPublish = async (collection, slug, ownStatus) => {
|
||||
if (ownStatus !== status.last()) {
|
||||
alert({
|
||||
title: 'workflow.workflowList.onPublishingNotReadyEntryTitle',
|
||||
body: 'workflow.workflowList.onPublishingNotReadyEntryBody',
|
||||
});
|
||||
return;
|
||||
} else if (
|
||||
!(await confirm({
|
||||
title: 'workflow.workflowList.onPublishEntryTitle',
|
||||
body: 'workflow.workflowList.onPublishEntryBody',
|
||||
}))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.props.handlePublish(collection, slug);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react/display-name
|
||||
renderColumns = (entries, column) => {
|
||||
const { isOpenAuthoring, collections, t } = this.props;
|
||||
if (!entries) return null;
|
||||
|
||||
if (!column) {
|
||||
return entries.entrySeq().map(([currColumn, currEntries], idx) => (
|
||||
<DropTarget
|
||||
namespace={DNDNamespace}
|
||||
key={currColumn}
|
||||
onDrop={this.handleChangeStatus.bind(this, currColumn)}
|
||||
>
|
||||
{(connect, { isHovered }) =>
|
||||
connect(
|
||||
<div style={{ height: '100%' }}>
|
||||
<div
|
||||
css={[
|
||||
styles.column,
|
||||
styles.columnPosition(idx),
|
||||
isHovered && styles.columnHovered,
|
||||
isOpenAuthoring && currColumn === 'pending_publish' && styles.hiddenColumn,
|
||||
isOpenAuthoring && currColumn === 'pending_review' && styles.hiddenRightBorder,
|
||||
]}
|
||||
>
|
||||
<ColumnHeader name={currColumn}>
|
||||
{getColumnHeaderText(currColumn, this.props.t)}
|
||||
</ColumnHeader>
|
||||
<ColumnCount>
|
||||
{this.props.t('workflow.workflowList.currentEntries', {
|
||||
smart_count: currEntries.size,
|
||||
})}
|
||||
</ColumnCount>
|
||||
{this.renderColumns(currEntries, currColumn)}
|
||||
</div>
|
||||
</div>,
|
||||
)
|
||||
}
|
||||
</DropTarget>
|
||||
));
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
{entries.map(entry => {
|
||||
const timestamp = moment(entry.get('updatedOn')).format(
|
||||
t('workflow.workflow.dateFormat'),
|
||||
);
|
||||
const slug = entry.get('slug');
|
||||
const collectionName = entry.get('collection');
|
||||
const editLink = `collections/${collectionName}/entries/${slug}?ref=workflow`;
|
||||
const ownStatus = entry.get('status');
|
||||
const collection = collections.find(
|
||||
collection => collection.get('name') === collectionName,
|
||||
);
|
||||
const collectionLabel = collection?.get('label');
|
||||
const isModification = entry.get('isModification');
|
||||
|
||||
const allowPublish = collection?.get('publish');
|
||||
const canPublish = ownStatus === status.last() && !entry.get('isPersisting', false);
|
||||
const postAuthor = entry.get('author');
|
||||
|
||||
return (
|
||||
<DragSource
|
||||
namespace={DNDNamespace}
|
||||
key={`${collectionName}-${slug}`}
|
||||
slug={slug}
|
||||
collection={collectionName}
|
||||
ownStatus={ownStatus}
|
||||
>
|
||||
{connect =>
|
||||
connect(
|
||||
<div>
|
||||
<WorkflowCard
|
||||
collectionLabel={collectionLabel || collectionName}
|
||||
title={selectEntryCollectionTitle(collection, entry)}
|
||||
authorLastChange={entry.getIn(['metaData', 'user'])}
|
||||
body={entry.getIn(['data', 'body'])}
|
||||
isModification={isModification}
|
||||
editLink={editLink}
|
||||
timestamp={timestamp}
|
||||
onDelete={this.requestDelete.bind(this, collectionName, slug, ownStatus)}
|
||||
allowPublish={allowPublish}
|
||||
canPublish={canPublish}
|
||||
onPublish={this.requestPublish.bind(this, collectionName, slug, ownStatus)}
|
||||
postAuthor={postAuthor}
|
||||
/>
|
||||
</div>,
|
||||
)
|
||||
}
|
||||
</DragSource>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const columns = this.renderColumns(this.props.entries);
|
||||
const ListContainer = this.props.isOpenAuthoring
|
||||
? WorkflowListContainerOpenAuthoring
|
||||
: WorkflowListContainer;
|
||||
return <ListContainer>{columns}</ListContainer>;
|
||||
}
|
||||
}
|
||||
|
||||
export default HTML5DragDrop(translate()(WorkflowList));
|
106
src/components/page/Page.tsx
Normal file
106
src/components/page/Page.tsx
Normal file
@ -0,0 +1,106 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React, { useMemo } from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { getAdditionalLink } from '../../lib/registry';
|
||||
import { Collections, State } from '../../types/redux';
|
||||
import { lengths } from '../../ui';
|
||||
import Sidebar from '../Collection/Sidebar';
|
||||
|
||||
const StylePage = styled('div')`
|
||||
margin: ${lengths.pageMargin};
|
||||
`;
|
||||
|
||||
const StyledPageContent = styled('div')`
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
interface PageProps {
|
||||
match: any;
|
||||
}
|
||||
|
||||
interface ConnectedPageProps extends PageProps {
|
||||
collections: Collections;
|
||||
isSearchEnabled: boolean;
|
||||
searchTerm: string;
|
||||
filterTerm: string;
|
||||
}
|
||||
|
||||
const Page = ({
|
||||
match,
|
||||
collections,
|
||||
isSearchEnabled,
|
||||
searchTerm,
|
||||
filterTerm,
|
||||
}: ConnectedPageProps) => {
|
||||
const { id } = match.params;
|
||||
const Content = useMemo(() => {
|
||||
const page = getAdditionalLink(id);
|
||||
if (!page) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return page.data;
|
||||
}, []);
|
||||
|
||||
const pageContent = useMemo(() => {
|
||||
if (!Content) {
|
||||
return <StyledPageContent>Page not found</StyledPageContent>;
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledPageContent>
|
||||
<Content />
|
||||
</StyledPageContent>
|
||||
);
|
||||
}, [Content]);
|
||||
|
||||
return (
|
||||
<StylePage>
|
||||
<Sidebar
|
||||
collections={collections}
|
||||
collection={false}
|
||||
isSearchEnabled={isSearchEnabled}
|
||||
searchTerm={searchTerm}
|
||||
filterTerm={filterTerm}
|
||||
/>
|
||||
{pageContent}
|
||||
</StylePage>
|
||||
);
|
||||
};
|
||||
|
||||
function mapStateToProps(state: State, ownProps: PageProps) {
|
||||
const { collections } = state;
|
||||
const isSearchEnabled = state.config && state.config.search != false;
|
||||
const { match } = ownProps;
|
||||
const { searchTerm = '', filterTerm = '' } = match.params;
|
||||
|
||||
return {
|
||||
collections,
|
||||
isSearchEnabled,
|
||||
searchTerm,
|
||||
filterTerm,
|
||||
};
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {};
|
||||
|
||||
function mergeProps(
|
||||
stateProps: ReturnType<typeof mapStateToProps>,
|
||||
dispatchProps: typeof mapDispatchToProps,
|
||||
ownProps: PageProps,
|
||||
) {
|
||||
return {
|
||||
...stateProps,
|
||||
...dispatchProps,
|
||||
...ownProps,
|
||||
};
|
||||
}
|
||||
|
||||
const ConnectedPage = connect(mapStateToProps, mapDispatchToProps, mergeProps)(Page);
|
||||
|
||||
export default translate()(ConnectedPage);
|
84
src/components/snackbar/Snackbars.tsx
Normal file
84
src/components/snackbar/Snackbars.tsx
Normal file
@ -0,0 +1,84 @@
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Snackbar from '@mui/material/Snackbar';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
|
||||
import { useAppDispatch, useAppSelector } from '../../store/hooks';
|
||||
import { removeSnackbarById, selectSnackbars } from '../../store/slices/snackbars';
|
||||
|
||||
import type { TranslatedProps } from '../../interface';
|
||||
import type { SnackbarMessage } from '../../store/slices/snackbars';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||
interface SnackbarsProps {}
|
||||
|
||||
const Snackbars = ({ t }: TranslatedProps<SnackbarsProps>) => {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [messageInfo, setMessageInfo] = useState<SnackbarMessage | undefined>(undefined);
|
||||
|
||||
const snackbars = useAppSelector(selectSnackbars);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
if (snackbars.length && !messageInfo) {
|
||||
// Set a new snack when we don't have an active one
|
||||
const snackbar = { ...snackbars[0] };
|
||||
setMessageInfo(snackbar);
|
||||
dispatch(removeSnackbarById(snackbar.id));
|
||||
setOpen(true);
|
||||
} else if (snackbars.length && messageInfo && open) {
|
||||
// Close an active snack when a new one is added
|
||||
setOpen(false);
|
||||
}
|
||||
}, [snackbars, messageInfo, open]);
|
||||
|
||||
const handleClose = useCallback((_event?: React.SyntheticEvent | Event, reason?: string) => {
|
||||
if (reason === 'clickaway') {
|
||||
return;
|
||||
}
|
||||
|
||||
setOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleExited = () => {
|
||||
setMessageInfo(undefined);
|
||||
};
|
||||
|
||||
const renderAlert = useCallback(
|
||||
(data: SnackbarMessage) => {
|
||||
const {
|
||||
type,
|
||||
message: { key, ...options },
|
||||
} = data;
|
||||
|
||||
return (
|
||||
<Alert key="message" onClose={handleClose} severity={type} sx={{ width: '100%' }}>
|
||||
{t(key, options)}
|
||||
</Alert>
|
||||
);
|
||||
},
|
||||
[handleClose, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<Snackbar
|
||||
key={messageInfo ? messageInfo.id : undefined}
|
||||
open={open}
|
||||
autoHideDuration={6000}
|
||||
onClose={handleClose}
|
||||
TransitionProps={{ onExited: handleExited }}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
action={
|
||||
<IconButton aria-label="close" color="inherit" sx={{ p: 0.5 }} onClick={handleClose}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
{messageInfo ? renderAlert(messageInfo) : undefined}
|
||||
</Snackbar>
|
||||
);
|
||||
};
|
||||
|
||||
export default translate()(Snackbars);
|
Reference in New Issue
Block a user