Feature/rebrand (#3)

This commit is contained in:
Daniel Lautzenheiser
2022-09-30 06:13:47 -06:00
committed by GitHub
parent 213e51c52d
commit 8acda23acc
416 changed files with 2818 additions and 8793 deletions

353
src/components/App/App.js Normal file
View 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)));

View 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));

View 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);

View 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);

View 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;

View 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);

View 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);

View 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>
);
}

View 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);

View 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);

View 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);

View 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;

View 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>
);
}
}

View 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);

View 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);

View 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);

View 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);

View 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);

View 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;

View 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)));

View 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;

View 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,
};

View 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,
});
}
}

View 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;

View 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,
};

View File

@ -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;

View 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);

View 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;

View 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);

View 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} />;
}
},
);
}

View 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);

View 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);

View File

@ -0,0 +1,5 @@
import { registerWidget } from '../../lib/registry';
import UnknownControl from './Unknown/UnknownControl';
import UnknownPreview from './Unknown/UnknownPreview';
registerWidget('unknown', UnknownControl, UnknownPreview);

View 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;

View 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));

View 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,
};

View 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;

View 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;

View 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;

View 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);

View 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;

View 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;

View File

@ -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');
});
});

View File

@ -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);
});
});

View File

@ -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
View 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;

View 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;

View 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>
);
}
};
}

View 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);

View 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
View 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>
</>
);
}
}

View 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);

View 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';

View 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));

View 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);

View 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));

View 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);

View 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);