UI updates (#151)

* infer card title

* Infer entry body & image

* infer image

* Better terminology: EntryListing accept a single Collection

* remove log

* Refactored Collections VO into selectors

* use selectors when showning card

* fixed size cards

* Added 'bio' and 'biography' to collection description inference synonyms

* Removed unused card file

* throw error instance

* bugfix for file based collections

* lint

* moved components with css to own folder

* Search Bugfix: More than one collection might be returned

* Changed sidebar implementation. Closes #104 & #152

* Show spinning loading for unpublished entries

* Refactored Sidebar into a separate container

* Make preview widgets more robust
This commit is contained in:
Cássio Souza 2016-11-11 17:54:58 -02:00 committed by GitHub
parent 3420273691
commit 2a2497072d
42 changed files with 490 additions and 603 deletions

View File

@ -117,6 +117,7 @@
"react-redux": "^4.4.0",
"react-router": "^2.5.1",
"react-router-redux": "^4.0.5",
"react-sidebar": "^2.2.1",
"react-simple-dnd": "^0.1.2",
"react-sortable": "^1.2.0",
"react-toolbox": "^1.2.1",

13
src/actions/globalUI.js Normal file
View File

@ -0,0 +1,13 @@
export const TOGGLE_SIDEBAR = 'TOGGLE_SIDEBAR';
export const OPEN_SIDEBAR = 'OPEN_SIDEBAR';
export function toggleSidebar() {
return { type: TOGGLE_SIDEBAR };
}
export function openSidebar(open = false) {
return {
type: OPEN_SIDEBAR,
payload: { open },
};
}

View File

@ -2,8 +2,8 @@ import TestRepoBackend from './test-repo/implementation';
import GitHubBackend from './github/implementation';
import NetlifyGitBackend from './netlify-git/implementation';
import { resolveFormat } from '../formats/formats';
import { selectListMethod, selectEntrySlug, selectEntryPath, selectAllowNewEntries } from '../reducers/collections';
import { createEntry } from '../valueObjects/Entry';
import Collection from '../valueObjects/Collection';
class LocalStorageAuthStore {
storageKey = 'nf-cms-user';
@ -80,13 +80,12 @@ class Backend {
}
listEntries(collection) {
const collectionModel = new Collection(collection);
const listMethod = this.implementation[collectionModel.listMethod()];
const listMethod = this.implementation[selectListMethod(collection)];
return listMethod.call(this.implementation, collection)
.then(loadedEntries => (
loadedEntries.map(loadedEntry => createEntry(
collection.get('name'),
collectionModel.entrySlug(loadedEntry.file.path),
selectEntrySlug(collection, loadedEntry.file.path),
loadedEntry.file.path,
{ raw: loadedEntry.data, label: loadedEntry.file.label }
))
@ -99,7 +98,7 @@ class Backend {
}
getEntry(collection, slug) {
return this.implementation.getEntry(collection, slug, new Collection(collection).entryPath(slug))
return this.implementation.getEntry(collection, slug, selectEntryPath(collection, slug))
.then(loadedEntry => this.entryWithFormat(collection, slug)(createEntry(
collection.get('name'),
slug,
@ -150,7 +149,6 @@ class Backend {
}
persistEntry(config, collection, entryDraft, MediaFiles, options) {
const collectionModel = new Collection(collection);
const newEntry = entryDraft.getIn(['entry', 'newRecord']) || false;
const parsedData = {
@ -161,11 +159,11 @@ class Backend {
const entryData = entryDraft.getIn(['entry', 'data']).toJS();
let entryObj;
if (newEntry) {
if (!collectionModel.allowNewEntries()) {
if (!selectAllowNewEntries(collection)) {
throw (new Error('Not allowed to create new entries in this collection'));
}
const slug = slugFormatter(collection.get('slug'), entryDraft.getIn(['entry', 'data']));
const path = collectionModel.entryPath(slug);
const path = selectEntryPath(collection, slug);
entryObj = {
path,
slug,

View File

@ -17,7 +17,7 @@ export default class AppHeader extends React.Component {
commands: PropTypes.array.isRequired, // eslint-disable-line
defaultCommands: PropTypes.array.isRequired, // eslint-disable-line
runCommand: PropTypes.func.isRequired,
toggleNavDrawer: PropTypes.func.isRequired,
toggleDrawer: PropTypes.func.isRequired,
onCreateEntryClick: PropTypes.func.isRequired,
onLogoutClick: PropTypes.func.isRequired,
};
@ -59,7 +59,7 @@ export default class AppHeader extends React.Component {
commands,
defaultCommands,
runCommand,
toggleNavDrawer,
toggleDrawer,
onLogoutClick,
} = this.props;
@ -88,7 +88,7 @@ export default class AppHeader extends React.Component {
</Menu>
</div>
}
onLeftIconClick={toggleNavDrawer}
onLeftIconClick={toggleDrawer}
onRightIconClick={this.handleRightIconClick}
>
<IndexLink to="/">

View File

@ -1,11 +0,0 @@
import UnknownCard from './Cards/UnknownCard';
import ImageCard from './Cards/ImageCard';
import AlltypeCard from './Cards/AlltypeCard';
const Cards = {
unknown: UnknownCard,
image: ImageCard,
alltype: AlltypeCard,
};
export default Cards;

View File

@ -1,5 +0,0 @@
.cardContent {
white-space: nowrap;
text-align: center;
font-weight: 500;
}

View File

@ -1,81 +0,0 @@
import React, { PropTypes } from 'react';
import { Card } from '../UI';
import ScaledLine from './ScaledLine';
import styles from './AlltypeCard.css';
export default class AlltypeCard extends React.Component {
// Based on the Slabtype Algorithm by Erik Loyer
// http://erikloyer.com/index.php/blog/the_slabtype_algorithm_part_1_background/
renderInscription(inscription) {
const idealCharPerLine = 22;
// segment the text into lines
const words = inscription.split(' ');
let preText, postText, finalText;
let preDiff, postDiff;
let wordIndex = 0;
const lineText = [];
// while we still have words left, build the next line
while (wordIndex < words.length) {
postText = '';
// build two strings (preText and postText) word by word, with one
// string always one word behind the other, until
// the length of one string is less than the ideal number of characters
// per line, while the length of the other is greater than that ideal
while (postText.length < idealCharPerLine) {
preText = postText;
postText += words[wordIndex] + ' ';
wordIndex++;
if (wordIndex >= words.length) {
break;
}
}
// calculate the character difference between the two strings and the
// ideal number of characters per line
preDiff = idealCharPerLine - preText.length;
postDiff = postText.length - idealCharPerLine;
// if the smaller string is closer to the length of the ideal than
// the longer string, and doesnt contain just a single space, then
// use that one for the line
if ((preDiff < postDiff) && (preText.length > 2)) {
finalText = preText;
wordIndex--;
// otherwise, use the longer string for the line
} else {
finalText = postText;
}
lineText.push(finalText.substr(0, finalText.length - 1));
}
return lineText.map(text => (
<ScaledLine key={text.trim().replace(/[^a-z0-9]+/gi, '-')} toWidth={216}>
{text}
</ScaledLine>
));
}
render() {
const { onClick, text } = this.props;
return (
<Card onClick={onClick}>
<div className={styles.cardContent}>{this.renderInscription(text)}</div>
</Card>
);
}
}
AlltypeCard.propTypes = {
onClick: PropTypes.func,
text: PropTypes.string.isRequired
};
AlltypeCard.defaultProps = {
onClick: function() {},
};

View File

@ -1,4 +0,0 @@
.root {
width: 240px;
cursor: pointer;
}

View File

@ -1,49 +0,0 @@
import React, { PropTypes } from 'react';
import { Card, CardMedia, CardTitle, CardText } from 'react-toolbox/lib/card';
import styles from './ImageCard.css';
const ImageCard = (
{
author,
description,
image,
text,
onClick,
onImageLoaded,
}) => (
<Card
onClick={onClick}
className={styles.root}
>
<CardTitle
title={text}
/>
{
image && <CardMedia aspectRatio="wide">
<img
src={image}
alt={text}
onLoad={onImageLoaded}
/>
</CardMedia>
}
{ description && <CardText>{ description }</CardText> }
</Card>
);
ImageCard.propTypes = {
image: PropTypes.string,
onClick: PropTypes.func,
onImageLoaded: PropTypes.func,
text: PropTypes.string.isRequired,
description: PropTypes.string,
};
ImageCard.defaultProps = {
onClick: () => {
},
onImageLoaded: () => {
},
};
export default ImageCard;

View File

@ -1,39 +0,0 @@
import React, { PropTypes } from 'react';
export default class ScaledLine extends React.Component {
constructor(props) {
super(props);
this._content = null;
this.state = {
ratio: 1,
};
}
componentDidMount() {
const actualContent = this._content.children[0];
this.setState({
ratio: this.props.toWidth / actualContent.offsetWidth,
});
}
render() {
const { ratio } = this.state;
const { children } = this.props;
const styles = {
fontSize: ratio.toFixed(3) + 'em'
};
return (
<div ref={(c) => this._content = c} style={styles}>
<span>{children}</span>
</div>
);
}
}
ScaledLine.propTypes = {
children: PropTypes.node.isRequired,
toWidth: PropTypes.number.isRequired
};

View File

@ -1,15 +0,0 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { Card } from '../UI';
export default function UnknownCard({ collection }) {
return (
<Card>
<p>No card of type {collection.getIn(['card', 'type'])}.</p>
</Card>
);
}
UnknownCard.propTypes = {
collection: ImmutablePropTypes.map,
};

View File

@ -3,6 +3,10 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import { resolveWidget } from '../Widgets';
import styles from './ControlPane.css';
function isHidden(field) {
return field.get('widget') === 'hidden';
}
export default class ControlPane extends Component {
controlFor(field) {
@ -38,7 +42,7 @@ export default class ControlPane extends Component {
<div>
{
fields.map(field =>
<div
isHidden(field) ? null : <div
key={field.get('name')}
className={styles.widget}
>

View File

@ -1,127 +0,0 @@
import React, { PropTypes } from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { Map } from 'immutable';
import { throttle } from 'lodash';
import bricks from 'bricks.js';
import Waypoint from 'react-waypoint';
import history from '../routing/history';
import Cards from './Cards';
export default class EntryListing extends React.Component {
static propTypes = {
children: PropTypes.node.isRequired,
collections: PropTypes.oneOfType([
ImmutablePropTypes.map,
ImmutablePropTypes.iterable,
]).isRequired,
entries: ImmutablePropTypes.list,
onPaginate: PropTypes.func.isRequired,
page: PropTypes.number,
};
constructor(props) {
super(props);
this.bricksInstance = null;
this.bricksConfig = {
packed: 'data-packed',
sizes: [
{ columns: 1, gutter: 15 },
{ mq: '495px', columns: 2, gutter: 15 },
{ mq: '750px', columns: 3, gutter: 15 },
{ mq: '1005px', columns: 4, gutter: 15 },
{ mq: '1515px', columns: 5, gutter: 15 },
{ mq: '1770px', columns: 6, gutter: 15 },
],
};
this.updateBricks = throttle(this.updateBricks.bind(this), 30);
this.handleLoadMore = this.handleLoadMore.bind(this);
}
componentDidMount() {
this.bricksInstance = bricks({
container: this.containerNode,
packed: this.bricksConfig.packed,
sizes: this.bricksConfig.sizes,
});
this.bricksInstance.resize(true);
if (this.props.entries && this.props.entries.size > 0) {
setTimeout(() => {
this.bricksInstance.pack();
}, 0);
}
}
componentDidUpdate(prevProps) {
if ((prevProps.entries === undefined || prevProps.entries.size === 0)
&& this.props.entries.size === 0) {
return;
}
this.bricksInstance.pack();
}
componengWillUnmount() {
this.bricksInstance.resize(false);
}
updateBricks() {
this.bricksInstance.pack();
}
cardFor(collection, entry, link) {
const cardType = collection.getIn(['card', 'type']) || 'alltype';
const card = Cards[cardType] || Cards.unknown;
return React.createElement(card, {
key: entry.get('slug'),
collection,
description: entry.getIn(['data', collection.getIn(['card', 'description'])]),
image: entry.getIn(['data', collection.getIn(['card', 'image'])]),
link,
text: entry.get('label') ? entry.get('label') : entry.getIn(['data', collection.getIn(['card', 'text'])]),
onClick: history.push.bind(this, link),
onImageLoaded: this.updateBricks,
});
}
handleLoadMore() {
this.props.onPaginate(this.props.page + 1);
}
handleRef = (node) => {
this.containerNode = node;
};
renderCards = () => {
const { collections, entries } = this.props;
if (Map.isMap(collections)) {
const collectionName = collections.get('name');
return entries.map((entry) => {
const path = `/collections/${ collectionName }/entries/${ entry.get('slug') }`;
return this.cardFor(collections, entry, path);
});
}
return entries.map((entry) => {
const collection = collections
.filter(collection => collection.get('name') === entry.get('collection')).first();
const path = `/collections/${ collection.get('name') }/entries/${ entry.get('slug') }`;
return this.cardFor(collection, entry, path);
});
};
render() {
const { children } = this.props;
return (
<div>
<h1>{children}</h1>
<div ref={this.handleRef}>
{ this.renderCards() }
<Waypoint onEnter={this.handleLoadMore} />
</div>
</div>
);
}
}

View File

@ -0,0 +1,21 @@
.card {
overflow: hidden;
margin-bottom: 10px;
max-height: 290px;
width: 240px;
cursor: pointer;
}
.cardImage {
width: 240px;
height: 135px;
background-position: center center;
background-size: cover;
background-repeat: no-repeat;
}
.cardsGrid {
display: flex;
flex-flow: row wrap;
justify-content: space-around;
}

View File

@ -0,0 +1,89 @@
import React, { PropTypes } from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Waypoint from 'react-waypoint';
import { Map } from 'immutable';
import history from '../../routing/history';
import { selectFields, selectInferedField } from '../../reducers/collections';
import { Card } from '../UI';
import styles from './EntryListing.css';
export default class EntryListing extends React.Component {
static propTypes = {
children: PropTypes.node.isRequired,
collections: PropTypes.oneOfType([
ImmutablePropTypes.map,
ImmutablePropTypes.iterable,
]).isRequired,
entries: ImmutablePropTypes.list,
onPaginate: PropTypes.func.isRequired,
page: PropTypes.number,
};
handleLoadMore = () => {
this.props.onPaginate(this.props.page + 1);
};
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 };
}
renderCard(collection, entry, inferedFields) {
const path = `/collections/${ collection.get('name') }/entries/${ entry.get('slug') }`;
const label = entry.get('label');
const title = label || entry.getIn(['data', inferedFields.titleField]);
const image = entry.getIn(['data', inferedFields.imageField]);
return (
<Card
key={entry.get('slug')}
onClick={history.push.bind(this, path)} // eslint-disable-line
className={styles.card}
>
{ image &&
<header className={styles.cardImage} style={{ backgroundImage: `url(${ image })` }} />
}
<h1>{title}</h1>
{inferedFields.descriptionField ?
<p>{entry.getIn(['data', inferedFields.descriptionField])}</p>
: inferedFields.remainingFields && inferedFields.remainingFields.map(f => (
<p key={f.get('name')}>
<strong>{f.get('label')}:</strong> {entry.getIn(['data', f.get('name')])}
</p>
))
}
</Card>
);
}
renderCards = () => {
const { collections, entries } = this.props;
if (Map.isMap(collections)) {
const inferedFields = this.inferFields(collections);
return entries.map(entry => this.renderCard(collections, entry, inferedFields));
}
return entries.map((entry) => {
const collection = collections
.filter(collection => collection.get('name') === entry.get('collection')).first();
const inferedFields = this.inferFields(collection);
return this.renderCard(collection, entry, inferedFields);
});
};
render() {
const { children } = this.props;
return (
<div>
<h1>{children}</h1>
<div className={styles.cardsGrid}>
{ this.renderCards() }
<Waypoint onEnter={this.handleLoadMore} />
</div>
</div>
);
}
}

View File

@ -1,13 +1,17 @@
import React, { PropTypes } from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
function isVisible(field) {
return field.get('widget') !== 'hidden';
}
export default function Preview({ collection, fields, widgetFor }) {
if (!collection || !fields) {
return null;
}
return (
<div>
{fields.map(field => widgetFor(field.get('name')))}
{fields.filter(isVisible).map(field => widgetFor(field.get('name')))}
</div>
);
}

View File

@ -3,13 +3,12 @@ import ReactDOM from 'react-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { ScrollSyncPane } from '../ScrollSync';
import registry from '../../lib/registry';
import Collection from '../../valueObjects/Collection';
import { resolveWidget } from '../Widgets';
import { selectTemplateName } from '../../reducers/collections';
import Preview from './Preview';
import styles from './PreviewPane.css';
export default class PreviewPane extends React.Component {
componentDidUpdate() {
this.renderPreview();
}
@ -28,8 +27,8 @@ export default class PreviewPane extends React.Component {
renderPreview() {
const { entry, collection } = this.props;
const collectionModel = new Collection(collection);
const component = registry.getPreviewTemplate(collectionModel.templateName(entry.get('slug'))) || Preview;
const component = registry.getPreviewTemplate(selectTemplateName(collection, entry.get('slug'))) || Preview;
const previewProps = {
...this.props,
widgetFor: this.widgetFor,

View File

@ -1,4 +1,4 @@
@import "../theme.css";
@import '../theme.css';
.card {
composes: base container rounded depth;
@ -7,8 +7,8 @@
}
.card > *:not(iframe, video, img, header, footer) {
margin-left: 10px;
margin-right: 10px;
margin-left: 10px;
}
.card > *:not(iframe, video, img, header, footer):first-child {
@ -19,6 +19,16 @@
margin-bottom: 10px;
}
.card > iframe, .card > video, .card > img {
.card > iframe,
.card > video,
.card > img {
max-width: 100%;
}
.card h1 {
border: none;
color: var(--defaultColor);
font-size: 18px;
margin: 15px 0;
padding: 0;
}

View File

@ -5,7 +5,7 @@ import { Link } from 'react-router';
import moment from 'moment';
import { Card, CardTitle, CardText, CardActions } from 'react-toolbox/lib/card';
import Button from 'react-toolbox/lib/button';
import { status, statusDescriptions } from '../constants/publishModes';
import { status, statusDescriptions } from '../../constants/publishModes';
import styles from './UnpublishedListing.css';
class UnpublishedListing extends React.Component {

View File

@ -1,9 +1,9 @@
import React, { PropTypes } from 'react';
export default function StringPreview({ value }) {
return <span>{value}</span>;
export default function DatePreview({ value }) {
return <span>{value ? value.toString() : null}</span>;
}
StringPreview.propTypes = {
DatePreview.propTypes = {
value: PropTypes.node,
};

View File

@ -1,7 +1,7 @@
import React, { PropTypes, Component } from 'react';
import { resolveWidget } from '../Widgets';
export default class ObjectPreview extends Component {
export default class ListPreview extends Component {
widgetFor = (field, value) => {
const { getMedia } = this.props;
const widget = resolveWidget(field.get('widget'));
@ -22,11 +22,11 @@ export default class ObjectPreview extends Component {
</div>)}</div>) : null;
}
return value ? value.join(', ') : null;
return <span>{value ? value.join(', ') : null}</span>;
}
}
ObjectPreview.propTypes = {
ListPreview.propTypes = {
value: PropTypes.node,
field: PropTypes.node,
getMedia: PropTypes.func.isRequired,

View File

@ -1,9 +1,9 @@
import React, { PropTypes } from 'react';
export default function StringPreview({ value }) {
return <span>{value}</span>;
export default function NumberPreview({ value }) {
return <span>{value ? value.toString() : null}</span>;
}
StringPreview.propTypes = {
NumberPreview.propTypes = {
value: PropTypes.node,
};

View File

@ -17,7 +17,7 @@ export default class ObjectPreview extends Component {
const { field } = this.props;
const fields = field && field.get('fields');
return <div>{fields && fields.map(f => this.widgetFor(f))}</div>;
return <div>{fields ? fields.map(f => this.widgetFor(f)) : null}</div>;
}
}

View File

@ -1,7 +1,7 @@
import React, { PropTypes } from 'react';
export default function StringPreview({ value }) {
return <span>{value}</span>;
return <span>{value ? value.toString() : null}</span>;
}
StringPreview.propTypes = {

View File

@ -1,4 +1,9 @@
import StringPreview from './StringPreview';
import React, { PropTypes } from 'react';
export default class TextPreview extends StringPreview {
export default function TextPreview({ value }) {
return <span>{value ? value.toString() : null}</span>;
}
TextPreview.propTypes = {
value: PropTypes.node,
};

View File

@ -1,28 +1,8 @@
@import '../components/UI/theme.css';
.nav {
display: block;
padding: 1rem;
& .heading {
border: none;
}
.root {
margin-top: 64px;
}
.navDrawer {
max-width: 240px !important;
& .drawerContent {
max-width: 240px !important;
}
}
.notifsContainer {
position: fixed;
top: 60px;
right: 0;
bottom: 60px;
z-index: var(--topmostZindex);
width: 360px;
pointer-events: none;
.sidebar {
width: 200px;
background-color: #fff;
}

View File

@ -2,13 +2,15 @@ import React, { PropTypes } from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import pluralize from 'pluralize';
import { connect } from 'react-redux';
import { Layout, Panel, NavDrawer } from 'react-toolbox/lib/layout';
import { Layout, Panel } from 'react-toolbox/lib/layout';
import { Navigation } from 'react-toolbox/lib/navigation';
import { Link } from 'react-toolbox/lib/link';
import { Notifs } from 'redux-notifications';
import TopBarProgress from 'react-topbar-progress-indicator';
import Sidebar from './Sidebar';
import { loadConfig } from '../actions/config';
import { loginUser, logoutUser } from '../actions/auth';
import { toggleSidebar } from '../actions/globalUI';
import { currentBackend } from '../backends/backend';
import {
SHOW_COLLECTION,
@ -42,6 +44,7 @@ class App extends React.Component {
createNewEntryInCollection: PropTypes.func.isRequired,
logoutUser: PropTypes.func.isRequired,
dispatch: PropTypes.func.isRequired,
toggleSidebar: PropTypes.func.isRequired,
navigateToCollection: PropTypes.func.isRequired,
user: ImmutablePropTypes.map,
runCommand: PropTypes.func.isRequired,
@ -59,10 +62,6 @@ class App extends React.Component {
</div>);
}
state = {
navDrawerIsVisible: true,
};
componentDidMount() {
this.props.dispatch(loadConfig());
}
@ -123,19 +122,13 @@ class App extends React.Component {
return { commands, defaultCommands };
}
toggleNavDrawer = () => {
this.setState({
navDrawerIsVisible: !this.state.navDrawerIsVisible,
});
};
render() {
const { navDrawerIsVisible } = this.state;
const {
user,
config,
children,
collections,
toggleSidebar,
runCommand,
navigateToCollection,
createNewEntryInCollection,
@ -160,20 +153,7 @@ class App extends React.Component {
}
const { commands, defaultCommands } = this.generateFindBarCommands();
return (
<Layout theme={styles}>
<Notifs
className={styles.notifsContainer}
CustomComponent={Toast}
/>
<NavDrawer
active={navDrawerIsVisible}
scrollY
permanentAt={navDrawerIsVisible ? 'lg' : null}
onOverlayClick={this.toggleNavDrawer} // eslint-disable-line
theme={styles}
>
const sidebarContent = (
<nav className={styles.nav}>
<h1 className={styles.heading}>Collections</h1>
<Navigation type="vertical">
@ -189,7 +169,15 @@ class App extends React.Component {
}
</Navigation>
</nav>
</NavDrawer>
);
return (
<Sidebar content={sidebarContent}>
<Layout theme={styles}>
<Notifs
className={styles.notifsContainer}
CustomComponent={Toast}
/>
<AppHeader
user={user}
collections={collections}
@ -198,7 +186,7 @@ class App extends React.Component {
runCommand={runCommand}
onCreateEntryClick={createNewEntryInCollection}
onLogoutClick={logoutUser}
toggleNavDrawer={this.toggleNavDrawer}
toggleDrawer={toggleSidebar}
/>
<Panel scrollY>
{ isFetching && <TopBarProgress /> }
@ -206,21 +194,24 @@ class App extends React.Component {
{children}
</div>
</Panel>
</Layout>
</Sidebar>
);
}
}
function mapStateToProps(state) {
const { auth, config, collections, global } = state;
const { auth, config, collections, globalUI } = state;
const user = auth && auth.get('user');
const { isFetching } = global;
const isFetching = globalUI.get('isFetching');
return { auth, config, collections, user, isFetching };
}
function mapDispatchToProps(dispatch) {
return {
dispatch,
toggleSidebar: () => dispatch(toggleSidebar()),
runCommand: (type, payload) => {
dispatch(runCommand(type, payload));
},

View File

@ -4,7 +4,7 @@ import { connect } from 'react-redux';
import { loadEntries } from '../actions/entries';
import { selectEntries } from '../reducers';
import { Loader } from '../components/UI';
import EntryListing from '../components/EntryListing';
import EntryListing from '../components/EntryListing/EntryListing';
import styles from './breakpoints.css';
class CollectionPage extends React.Component {

View File

@ -11,8 +11,9 @@ import {
} from '../actions/entries';
import { cancelEdit } from '../actions/editor';
import { addMedia, removeMedia } from '../actions/media';
import { openSidebar } from '../actions/globalUI';
import { selectEntry, getMedia } from '../reducers';
import Collection from '../valueObjects/Collection';
import { selectFields } from '../reducers/collections';
import EntryEditor from '../components/EntryEditor/EntryEditor';
import entryPageHOC from './editorialWorkflow/EntryPageHOC';
import { Loader } from '../components/UI';
@ -32,6 +33,7 @@ class EntryPage extends React.Component {
persistEntry: PropTypes.func.isRequired,
removeMedia: PropTypes.func.isRequired,
cancelEdit: PropTypes.func.isRequired,
openSidebar: PropTypes.func.isRequired,
fields: ImmutablePropTypes.list.isRequired,
slug: PropTypes.string,
newEntry: PropTypes.bool.isRequired,
@ -39,6 +41,7 @@ class EntryPage extends React.Component {
componentDidMount() {
const { entry, newEntry, collection, slug, loadEntry } = this.props;
this.props.openSidebar();
if (newEntry) {
createEmptyDraft(collection);
} else {
@ -108,9 +111,8 @@ function mapStateToProps(state, ownProps) {
const { collections, entryDraft } = state;
const slug = ownProps.params.slug;
const collection = collections.get(ownProps.params.name);
const collectionModel = new Collection(collection);
const newEntry = ownProps.route && ownProps.route.newRecord === true;
const fields = selectFields(collection, slug);
const entry = newEntry ? null : selectEntry(state, collection.get('name'), slug);
const boundGetMedia = getMedia.bind(null, state);
return {
@ -119,7 +121,7 @@ function mapStateToProps(state, ownProps) {
newEntry,
entryDraft,
boundGetMedia,
fields: collectionModel.entryFields(slug),
fields,
slug,
entry,
};
@ -137,5 +139,6 @@ export default connect(
discardDraft,
persistEntry,
cancelEdit,
openSidebar,
}
)(entryPageHOC(EntryPage));

View File

@ -4,7 +4,7 @@ import { connect } from 'react-redux';
import { selectSearchedEntries } from '../reducers';
import { searchEntries } from '../actions/entries';
import { Loader } from '../components/UI';
import EntryListing from '../components/EntryListing';
import EntryListing from '../components/EntryListing/EntryListing';
import styles from './breakpoints.css';
class SearchPage extends React.Component {

View File

@ -0,0 +1,8 @@
.root {
margin-top: 64px;
}
.sidebar {
width: 200px;
background-color: #fff
}

63
src/containers/Sidebar.js Normal file
View File

@ -0,0 +1,63 @@
import React, { PropTypes } from 'react';
import { connect } from 'react-redux';
import ReactSidebar from 'react-sidebar';
import _ from 'lodash';
import { openSidebar } from '../actions/globalUI';
import styles from './Sidebar.css';
class Sidebar extends React.Component {
static propTypes = {
children: PropTypes.node.isRequired,
content: PropTypes.node.isRequired,
sidebarIsOpen: PropTypes.bool.isRequired,
openSidebar: PropTypes.func.isRequired,
};
state = { sidebarDocked: false };
componentWillMount() {
this.mql = window.matchMedia('(min-width: 1200px)');
this.mql.addListener(this.mediaQueryChanged);
this.setState({ sidebarDocked: this.mql.matches });
}
componentWillUnmount() {
this.mql.removeListener(this.mediaQueryChanged);
}
mediaQueryChanged = _.throttle(() => {
this.setState({ sidebarDocked: this.mql.matches });
}, 500);
render() {
const {
children,
content,
sidebarIsOpen,
openSidebar,
} = this.props;
return (
<ReactSidebar
sidebar={content}
rootClassName={styles.root}
sidebarClassName={styles.sidebar}
docked={sidebarIsOpen && this.state.sidebarDocked} // ALWAYS can hide sidebar
open={sidebarIsOpen}
onSetOpen={openSidebar}
>
{children}
</ReactSidebar>
);
}
}
function mapStateToProps(state) {
const { globalUI } = state;
const sidebarIsOpen = globalUI.get('sidebarIsOpen');
return { sidebarIsOpen };
}
export default connect(mapStateToProps, { openSidebar })(Sidebar);

View File

@ -5,11 +5,13 @@ import { connect } from 'react-redux';
import { loadUnpublishedEntries, updateUnpublishedEntryStatus, publishUnpublishedEntry } from '../../actions/editorialWorkflow';
import { selectUnpublishedEntries } from '../../reducers';
import { EDITORIAL_WORKFLOW, status } from '../../constants/publishModes';
import UnpublishedListing from '../../components/UnpublishedListing';
import UnpublishedListing from '../../components/UnpublishedListing/UnpublishedListing';
import { Loader } from '../../components/UI';
class unpublishedEntriesPanel extends Component {
static propTypes = {
isEditorialWorkflow: PropTypes.bool.isRequired,
isFetching: PropTypes.bool.isRequired,
unpublishedEntries: ImmutablePropTypes.map,
loadUnpublishedEntries: PropTypes.func.isRequired,
updateUnpublishedEntryStatus: PropTypes.func.isRequired,
@ -24,9 +26,9 @@ class unpublishedEntriesPanel extends Component {
}
render() {
const { isEditorialWorkflow, unpublishedEntries, updateUnpublishedEntryStatus, publishUnpublishedEntry } = this.props;
const { isEditorialWorkflow, isFetching, unpublishedEntries, updateUnpublishedEntryStatus, publishUnpublishedEntry } = this.props;
if (!isEditorialWorkflow) return null;
if (isFetching) return <Loader active>Loading Editorial Workflow Entries</Loader>;
return (
<UnpublishedListing
entries={unpublishedEntries}
@ -42,6 +44,8 @@ function mapStateToProps(state) {
const returnObj = { isEditorialWorkflow };
if (isEditorialWorkflow) {
returnObj.isFetching = state.editorialWorkflow.getIn(['pages', 'isFetching'], false);
/*
* Generates an ordered Map of the available status as keys.
* Each key containing a List of available unpubhlished entries

View File

@ -1,6 +1,6 @@
import { createEntry } from '../../../valueObjects/Entry';
import _ from 'lodash';
import Collection from '../../../valueObjects/Collection';
import { createEntry } from '../../../valueObjects/Entry';
import { selectEntrySlug } from '../../../reducers/collections';
function getSlug(path) {
const m = path.match(/([^\/]+?)(\.[^\/\.]+)?$/);
@ -102,12 +102,11 @@ export default class Algolia {
if (this.entriesCache.collection === collection && this.entriesCache.page === page) {
return Promise.resolve({ page: this.entriesCache.page, entries: this.entriesCache.entries });
} else {
const collectionModel = new Collection(collection);
return this.request(`${ this.searchURL }/indexes/${ collection.get('name') }`, {
params: { page },
}).then((response) => {
const entries = response.hits.map((hit) => {
const slug = collectionModel.entrySlug(hit.path);
const slug = selectEntrySlug(collection, hit.path);
return createEntry(collection.get('name'), slug, hit.path, { data: hit.data, partial: true });
});
this.entriesCache = { collection, pagination: response.page, entries };

8
src/lib/consoleError.js Normal file
View File

@ -0,0 +1,8 @@
export default function consoleError(title, description) {
console.error(
`%c ⛔ ${ title }\n` + `%c${ description }\n\n`,
'color: black; font-weight: bold; font-size: 16px; line-height: 50px;',
'color: black;'
);
}

View File

@ -1,19 +1,22 @@
import { OrderedMap, fromJS } from 'immutable';
import consoleError from '../lib/consoleError';
import { CONFIG_SUCCESS } from '../actions/config';
import { FILES, FOLDER } from '../constants/collectionTypes';
const hasProperty = (config, property) => ({}.hasOwnProperty.call(config, property));
const collections = (state = null, action) => {
const configCollections = action.payload && action.payload.collections;
switch (action.type) {
case CONFIG_SUCCESS:
const configCollections = action.payload && action.payload.collections;
return OrderedMap().withMutations((map) => {
(configCollections || []).forEach((configCollection) => {
if (hasProperty(configCollection, 'folder')) {
configCollection.type = FOLDER; // eslint-disable-line no-param-reassign
} else if (hasProperty(configCollection, 'files')) {
configCollection.type = FILES; // eslint-disable-line no-param-reassign
} else {
throw new Error('Unknown collection type. Collections can be either Folder based or File based. Please verify your site configuration');
}
map.set(configCollection.name, fromJS(configCollection));
});
@ -23,4 +26,126 @@ const collections = (state = null, action) => {
}
};
const formatToExtension = format => ({
markdown: 'md',
yaml: 'yml',
json: 'json',
html: 'html',
}[format]);
const inferables = {
title: {
type: 'string',
secondaryTypes: [],
synonyms: ['title', 'name', 'label', 'headline'],
fallbackToFirstField: true,
showError: true,
},
description: {
type: 'string',
secondaryTypes: ['text', 'markdown'],
synonyms: ['shortDescription', 'short_description', 'shortdescription', 'description', 'brief', 'body', 'content', 'biography', 'bio'],
fallbackToFirstField: false,
showError: false,
},
image: {
type: 'image',
secondaryTypes: [],
synonyms: ['image', 'thumbnail', 'thumb', 'picture', 'avatar'],
fallbackToFirstField: false,
showError: false,
},
};
const selectors = {
[FOLDER]: {
entryExtension(collection) {
return collection.get('extension') || formatToExtension(collection.get('format') || 'markdown');
},
fields(collection) {
return collection.get('fields');
},
entryPath(collection, slug) {
return `${ collection.get('folder') }/${ slug }.${ this.entryExtension(collection) }`;
},
entrySlug(collection, path) {
return path.split('/').pop().replace(/\.[^\.]+$/, '');
},
listMethod() {
return 'entriesByFolder';
},
allowNewEntries(collection) {
return collection.get('create');
},
templateName(collection) {
return collection.get('name');
},
},
[FILES]: {
fileForEntry(collection, slug) {
const files = collection.get('files');
return files.filter(f => f.get('name') === slug).get(0);
},
fields(collection, slug) {
const file = this.fileForEntry(collection, slug);
return file && file.get('fields');
},
entryPath(collection, slug) {
const file = this.fileForEntry(collection, slug);
return file && file.get('file');
},
entrySlug(collection, path) {
const file = collection.get('files').filter(f => f.get('file') === path).get(0);
return file && file.get('name');
},
listMethod() {
return 'entriesByFiles';
},
allowNewEntries() {
return false;
},
templateName(collection, slug) {
return slug;
},
},
};
export const selectFields = (collection, slug) => selectors[collection.get('type')].fields(collection, slug);
export const selectEntryPath = (collection, slug) => selectors[collection.get('type')].entryPath(collection, slug);
export const selectEntrySlug = (collection, path) => selectors[collection.get('type')].entrySlug(collection, path);
export const selectListMethod = collection => selectors[collection.get('type')].listMethod();
export const selectAllowNewEntries = collection => selectors[collection.get('type')].allowNewEntries(collection);
export const selectTemplateName = (collection, slug) => selectors[collection.get('type')].templateName(collection, slug);
export const selectInferedField = (collection, fieldName) => {
const inferableField = inferables[fieldName];
const fields = collection.get('fields');
let field;
// If colllection has no fields or fieldName is not defined within inferables list, return null
if (!fields || !inferableField) return null;
// Try to return a field of the specified type with one of the synonyms
const mainTypeFields = fields.filter(f => f.get('widget') === inferableField.type).map(f => f.get('name'));
field = mainTypeFields.filter(f => inferableField.synonyms.indexOf(f) !== -1);
if (field && field.size > 0) return field.first();
// Try to return a field for each of the specified secondary types
const secondaryTypeFields = fields.filter(f => inferableField.secondaryTypes.indexOf(f.get('widget')) !== -1).map(f => f.get('name'));
field = secondaryTypeFields.filter(f => inferableField.synonyms.indexOf(f) !== -1);
if (field && field.size > 0) return field.first();
// Try to return the first field of the specified type
if (inferableField.fallbackToFirstField && mainTypeFields.size > 0) return mainTypeFields.first();
// Coundn't infer the field. Show error and return null.
if (inferableField.showError) {
consoleError(
`The Field ${ fieldName } is missing for the collection “${ collection.get('name') }`,
`Netlify CMS tries to infer the entry ${ fieldName } automatically, but one couldn\'t be found for entries of the collection “${ collection.get('name') }”. Please check your site configuration.`
);
}
return null;
};
export default collections;

View File

@ -1,17 +0,0 @@
/* Reducer for some global UI state that we want to share between components
* Now being used for isFetching state to display global loading indicator
* */
const globalReducer = (state = { isFetching: false }, action) => {
if ((action.type.indexOf('REQUEST') > -1)) {
return { isFetching: true };
} else if (
(action.type.indexOf('SUCCESS') > -1) ||
(action.type.indexOf('FAILURE') > -1)
) {
return { isFetching: false };
}
return state;
};
export default globalReducer;

27
src/reducers/globalUI.js Normal file
View File

@ -0,0 +1,27 @@
import { Map } from 'immutable';
import { TOGGLE_SIDEBAR, OPEN_SIDEBAR } from '../actions/globalUI';
/*
* Reducer for some global UI state that we want to share between components
* */
const globalUI = (state = Map({ isFetching: false, sidebarIsOpen: true }), action) => {
// Generic, global loading indicator
if ((action.type.indexOf('REQUEST') > -1)) {
return state.set('isFetching', true);
} else if (
(action.type.indexOf('SUCCESS') > -1) ||
(action.type.indexOf('FAILURE') > -1)
) {
return state.set('isFetching', false);
}
switch (action.type) {
case TOGGLE_SIDEBAR:
return state.set('sidebarIsOpen', !state.get('sidebarIsOpen'));
case OPEN_SIDEBAR:
return state.set('sidebarIsOpen', action.payload.open);
default:
return state;
}
};
export default globalUI;

View File

@ -7,7 +7,7 @@ import editorialWorkflow, * as fromEditorialWorkflow from './editorialWorkflow';
import entryDraft from './entryDraft';
import collections from './collections';
import medias, * as fromMedias from './medias';
import global from './global';
import globalUI from './globalUI';
const reducers = {
auth,
@ -19,7 +19,7 @@ const reducers = {
editorialWorkflow,
entryDraft,
medias,
global,
globalUI,
};
export default reducers;

View File

@ -1,121 +0,0 @@
import { FOLDER, FILES } from '../constants/collectionTypes';
function formatToExtension(format) {
return {
markdown: 'md',
yaml: 'yml',
json: 'json',
html: 'html',
}[format];
}
class FolderCollection {
constructor(collection) {
this.collection = collection;
}
entryFields() {
return this.collection.get('fields');
}
entryPath(slug) {
return `${ this.collection.get('folder') }/${ slug }.${ this.entryExtension() }`;
}
entrySlug(path) {
return path.split('/').pop().replace(/\.[^\.]+$/, '');
}
listMethod() {
return 'entriesByFolder';
}
entryExtension() {
return this.collection.get('extension') || formatToExtension(this.collection.get('format') || 'markdown');
}
allowNewEntries() {
return this.collection.get('create');
}
templateName() {
return this.collection.get('name');
}
}
class FilesCollection {
constructor(collection) {
this.collection = collection;
}
entryFields(slug) {
const file = this.fileForEntry(slug);
return file && file.get('fields');
}
entryPath(slug) {
const file = this.fileForEntry(slug);
return file && file.get('file');
}
entrySlug(path) {
const file = this.collection.get('files').filter(f => f.get('file') === path).get(0);
return file && file.get('name');
}
fileForEntry(slug) {
const files = this.collection.get('files');
return files.filter(f => f.get('name') === slug).get(0);
}
listMethod() {
return 'entriesByFiles';
}
allowNewEntries() {
return false;
}
templateName(slug) {
return slug;
}
}
export default class Collection {
constructor(collection) {
switch (collection.get('type')) {
case FOLDER:
this.collection = new FolderCollection(collection);
break;
case FILES:
this.collection = new FilesCollection(collection);
break;
default:
throw ('Unknown collection type: %o', collection.get('type'));
}
}
entryFields(slug) {
return this.collection.entryFields(slug);
}
entryPath(slug) {
return this.collection.entryPath(slug);
}
entrySlug(path) {
return this.collection.entrySlug(path);
}
listMethod() {
return this.collection.listMethod();
}
allowNewEntries() {
return this.collection.allowNewEntries();
}
templateName(slug) {
return this.collection.templateName(slug);
}
}

View File

@ -6961,6 +6961,10 @@ react-router@^2.5.1:
loose-envify "^1.2.0"
warning "^3.0.0"
react-sidebar:
version "2.2.1"
resolved "https://registry.yarnpkg.com/react-sidebar/-/react-sidebar-2.2.1.tgz#a8faf6a3c62ddc562c70680d5d016fe9741b585f"
react-simple-di@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/react-simple-di/-/react-simple-di-1.2.0.tgz#dde0e5bf689f391ef2ab02c9043b213fe239c6d0"