Document Viewers (#79)

* Recording document views

* Add 'views' to document response

* Basic displaying of document views, probably want it more sublte than this? But hey, lets get it in there

* Bigly improves. RESTful > RPC

* Display of who's viewed doc

* Add Popover, add Scrollable, move views store

* Working server tests 💁

* Document Stars (#81)

* Added: Starred documents

* UI is dumb but functionality works

* Star now displayed inline in title

* Optimistic rendering

* Documents Endpoints (#85)

* More seeds, documents.list endpoint

* Upgrade deprecated middleware

* document.viewers, specs

* Add documents.starred
Add request specs for star / unstar endpoints

* Basic /starred page

* Remove comment

* Fixed double layout
This commit is contained in:
Tom Moor
2017-06-25 17:21:33 -07:00
committed by GitHub
parent 1fa473b271
commit 52765d9d1d
66 changed files with 1629 additions and 460 deletions

View File

@@ -0,0 +1,10 @@
// @flow
import styled from 'styled-components';
const Avatar = styled.img`
width: 24px;
height: 24px;
border-radius: 50%;
`;
export default Avatar;

View File

@@ -0,0 +1,3 @@
// @flow
import Avatar from './Avatar';
export default Avatar;

View File

@@ -20,8 +20,8 @@ const DocumentLink = styled(Link)`
border-radius: 8px;
border: 2px solid transparent;
max-height: 50vh;
min-width: 100%;
overflow: hidden;
width: 100%;
&:hover,
&:active,

View File

@@ -0,0 +1,41 @@
// @flow
import { observable, action } from 'mobx';
import invariant from 'invariant';
import { client } from 'utils/ApiClient';
import type { User } from 'types';
type View = {
user: User,
count: number,
};
class DocumentViewersStore {
documentId: string;
@observable viewers: Array<View>;
@observable isFetching: boolean;
@action fetchViewers = async () => {
this.isFetching = true;
try {
const res = await client.get(
'/views.list',
{
id: this.documentId,
},
{ cache: true }
);
invariant(res && res.data, 'Data should be available');
this.viewers = res.data.users;
} catch (e) {
console.error('Something went wrong');
}
this.isFetching = false;
};
constructor(documentId: string) {
this.documentId = documentId;
}
}
export default DocumentViewersStore;

View File

@@ -0,0 +1,76 @@
// @flow
import React, { Component } from 'react';
import { observer } from 'mobx-react';
import Popover from 'components/Popover';
import styled from 'styled-components';
import DocumentViewers from './components/DocumentViewers';
import DocumentViewersStore from './DocumentViewersStore';
import { Flex } from 'reflexbox';
const Container = styled(Flex)`
font-size: 13px;
user-select: none;
a {
color: #ccc;
&:hover {
color: #aaa;
}
}
`;
type Props = {
documentId: string,
count: number,
};
@observer class DocumentViews extends Component {
anchor: HTMLElement;
store: DocumentViewersStore;
props: Props;
state: {
opened: boolean,
};
state = {};
constructor(props: Props) {
super(props);
this.store = new DocumentViewersStore(props.documentId);
}
openPopover = () => {
this.setState({ opened: true });
};
closePopover = () => {
this.setState({ opened: false });
};
setRef = (ref: HTMLElement) => {
this.anchor = ref;
};
render() {
return (
<Container align="center">
<a ref={this.setRef} onClick={this.openPopover}>
Viewed
{' '}
{this.props.count}
{' '}
{this.props.count === 1 ? 'time' : 'times'}
</a>
{this.state.opened &&
<Popover anchor={this.anchor} onClose={this.closePopover}>
<DocumentViewers
onMount={this.store.fetchViewers}
viewers={this.store.viewers}
/>
</Popover>}
</Container>
);
}
}
export default DocumentViews;

View File

@@ -0,0 +1,55 @@
// @flow
import React, { Component } from 'react';
import { Flex } from 'reflexbox';
import styled from 'styled-components';
import map from 'lodash/map';
import Avatar from 'components/Avatar';
import Scrollable from 'components/Scrollable';
type Props = {
viewers: Array<Object>,
onMount: Function,
};
const List = styled.ul`
list-style: none;
font-size: 13px;
margin: -4px 0;
padding: 0;
li {
padding: 4px 0;
}
`;
const UserName = styled.span`
padding-left: 8px;
`;
class DocumentViewers extends Component {
props: Props;
componentDidMount() {
this.props.onMount();
}
render() {
return (
<Scrollable>
<List>
{map(this.props.viewers, view => (
<li key={view.user.id}>
<Flex align="center">
<Avatar src={view.user.avatarUrl} />
{' '}
<UserName>{view.user.name}</UserName>
</Flex>
</li>
))}
</List>
</Scrollable>
);
}
}
export default DocumentViewers;

View File

@@ -0,0 +1,3 @@
// @flow
import DocumentViewers from './DocumentViewers';
export default DocumentViewers;

View File

@@ -0,0 +1,3 @@
// @flow
import DocumentViews from './DocumentViews';
export default DocumentViews;

View File

@@ -1,13 +1,14 @@
// @flow
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { observer } from 'mobx-react';
import { Editor, Plain } from 'slate';
import classnames from 'classnames/bind';
import type { Document, State, Editor as EditorType } from './types';
import ClickablePadding from './components/ClickablePadding';
import Toolbar from './components/Toolbar';
import schema from './schema';
import Markdown from './serializer';
import createSchema from './schema';
import createPlugins from './plugins';
import styles from './Editor.scss';
@@ -18,8 +19,11 @@ type Props = {
onChange: Function,
onSave: Function,
onCancel: Function,
onStar: Function,
onUnstar: Function,
onImageUploadStart: Function,
onImageUploadStop: Function,
starred: boolean,
readOnly: boolean,
};
@@ -28,10 +32,10 @@ type KeyData = {
key: string,
};
@observer
export default class MarkdownEditor extends Component {
@observer class MarkdownEditor extends Component {
props: Props;
editor: EditorType;
schema: Object;
plugins: Array<Object>;
state: {
@@ -41,6 +45,10 @@ export default class MarkdownEditor extends Component {
constructor(props: Props) {
super(props);
this.schema = createSchema({
onStar: props.onStar,
onUnstar: props.onUnstar,
});
this.plugins = createPlugins({
onImageUploadStart: props.onImageUploadStart,
onImageUploadStop: props.onImageUploadStop,
@@ -53,6 +61,10 @@ export default class MarkdownEditor extends Component {
}
}
getChildContext() {
return { starred: this.props.starred };
}
onChange = (state: State) => {
this.setState({ state });
};
@@ -103,10 +115,11 @@ export default class MarkdownEditor extends Component {
<ClickablePadding onClick={this.focusAtStart} />}
<Toolbar state={this.state.state} onChange={this.onChange} />
<Editor
key={this.props.starred}
ref={ref => (this.editor = ref)}
placeholder="Start with a title…"
className={cx(styles.editor, { readOnly: this.props.readOnly })}
schema={schema}
schema={this.schema}
plugins={this.plugins}
state={this.state.state}
onChange={this.onChange}
@@ -121,3 +134,9 @@ export default class MarkdownEditor extends Component {
);
};
}
MarkdownEditor.childContextTypes = {
starred: PropTypes.bool,
};
export default MarkdownEditor;

View File

@@ -1,7 +1,10 @@
// @flow
import React from 'react';
import PropTypes from 'prop-types';
import styled from 'styled-components';
import _ from 'lodash';
import slug from 'slug';
import StarIcon from 'components/Icon/StarIcon';
import type { Node, Editor } from '../types';
import styles from '../Editor.scss';
@@ -10,23 +13,53 @@ type Props = {
placeholder?: boolean,
parent: Node,
node: Node,
onStar?: Function,
onUnstar?: Function,
editor: Editor,
readOnly: boolean,
component?: string,
};
export default function Heading({
parent,
placeholder,
node,
editor,
readOnly,
children,
component = 'h1',
}: Props) {
type Context = {
starred?: boolean,
};
const StyledStar = styled(StarIcon)`
top: 3px;
position: relative;
margin-left: 4px;
opacity: ${props => (props.solid ? 1 : 0.25)};
transition: opacity 100ms ease-in-out;
&:hover {
opacity: 1;
}
svg {
width: 28px;
height: 28px;
}
`;
function Heading(
{
parent,
placeholder,
node,
editor,
onStar,
onUnstar,
readOnly,
children,
component = 'h1',
}: Props,
{ starred }: Context
) {
const firstHeading = parent.nodes.first() === node;
const showPlaceholder = placeholder && firstHeading && !node.text;
const slugish = readOnly && _.escape(`${component}-${slug(node.text)}`);
const slugish = _.escape(`${component}-${slug(node.text)}`);
const showStar = readOnly && !!onStar;
const showHash = readOnly && !!slugish && !showStar;
const Component = component;
return (
@@ -36,8 +69,16 @@ export default function Heading({
<span className={styles.placeholder}>
{editor.props.placeholder}
</span>}
{slugish &&
{showHash &&
<a name={slugish} className={styles.anchor} href={`#${slugish}`}>#</a>}
{showStar && starred && <a onClick={onUnstar}><StyledStar solid /></a>}
{showStar && !starred && <a onClick={onStar}><StyledStar /></a>}
</Component>
);
}
Heading.contextTypes = {
starred: PropTypes.bool,
};
export default Heading;

View File

@@ -12,15 +12,12 @@ import MarkdownShortcuts from './plugins/MarkdownShortcuts';
const onlyInCode = node => node.type === 'code';
type CreatePluginsOptions = {
type Options = {
onImageUploadStart: Function,
onImageUploadStop: Function,
};
const createPlugins = ({
onImageUploadStart,
onImageUploadStop,
}: CreatePluginsOptions) => {
const createPlugins = ({ onImageUploadStart, onImageUploadStop }: Options) => {
return [
PasteLinkify({
type: 'link',

View File

@@ -8,87 +8,98 @@ import Heading from './components/Heading';
import type { Props, Node, Transform } from './types';
import styles from './Editor.scss';
const schema = {
marks: {
bold: (props: Props) => <strong>{props.children}</strong>,
code: (props: Props) => <code>{props.children}</code>,
italic: (props: Props) => <em>{props.children}</em>,
underlined: (props: Props) => <u>{props.children}</u>,
deleted: (props: Props) => <del>{props.children}</del>,
added: (props: Props) => <mark>{props.children}</mark>,
},
nodes: {
paragraph: (props: Props) => <p>{props.children}</p>,
'block-quote': (props: Props) => <blockquote>{props.children}</blockquote>,
'horizontal-rule': (props: Props) => <hr />,
'bulleted-list': (props: Props) => <ul>{props.children}</ul>,
'ordered-list': (props: Props) => <ol>{props.children}</ol>,
'todo-list': (props: Props) => (
<ul className={styles.todoList}>{props.children}</ul>
),
table: (props: Props) => <table>{props.children}</table>,
'table-row': (props: Props) => <tr>{props.children}</tr>,
'table-head': (props: Props) => <th>{props.children}</th>,
'table-cell': (props: Props) => <td>{props.children}</td>,
code: Code,
image: Image,
link: Link,
'list-item': ListItem,
heading1: (props: Props) => <Heading placeholder {...props} />,
heading2: (props: Props) => <Heading component="h2" {...props} />,
heading3: (props: Props) => <Heading component="h3" {...props} />,
heading4: (props: Props) => <Heading component="h4" {...props} />,
heading5: (props: Props) => <Heading component="h5" {...props} />,
heading6: (props: Props) => <Heading component="h6" {...props} />,
},
rules: [
// ensure first node is a heading
{
match: (node: Node) => {
return node.kind === 'document';
},
validate: (document: Node) => {
const firstNode = document.nodes.first();
return firstNode && firstNode.type === 'heading1' ? null : firstNode;
},
normalize: (transform: Transform, document: Node, firstNode: Node) => {
transform.setBlock({ type: 'heading1' });
},
},
// remove any marks in first heading
{
match: (node: Node) => {
return node.kind === 'heading1';
},
validate: (heading: Node) => {
const hasMarks = heading.getMarks().isEmpty();
const hasInlines = heading.getInlines().isEmpty();
return !(hasMarks && hasInlines);
},
normalize: (transform: Transform, heading: Node) => {
transform.unwrapInlineByKey(heading.key);
heading.getMarks().forEach(mark => {
heading.nodes.forEach(textNode => {
if (textNode.kind === 'text') {
transform.removeMarkByKey(
textNode.key,
0,
textNode.text.length,
mark
);
}
});
});
return transform;
},
},
],
type Options = {
onStar: Function,
onUnstar: Function,
};
export default schema;
const createSchema = ({ onStar, onUnstar }: Options) => {
return {
marks: {
bold: (props: Props) => <strong>{props.children}</strong>,
code: (props: Props) => <code>{props.children}</code>,
italic: (props: Props) => <em>{props.children}</em>,
underlined: (props: Props) => <u>{props.children}</u>,
deleted: (props: Props) => <del>{props.children}</del>,
added: (props: Props) => <mark>{props.children}</mark>,
},
nodes: {
paragraph: (props: Props) => <p>{props.children}</p>,
'block-quote': (props: Props) => (
<blockquote>{props.children}</blockquote>
),
'horizontal-rule': (props: Props) => <hr />,
'bulleted-list': (props: Props) => <ul>{props.children}</ul>,
'ordered-list': (props: Props) => <ol>{props.children}</ol>,
'todo-list': (props: Props) => (
<ul className={styles.todoList}>{props.children}</ul>
),
table: (props: Props) => <table>{props.children}</table>,
'table-row': (props: Props) => <tr>{props.children}</tr>,
'table-head': (props: Props) => <th>{props.children}</th>,
'table-cell': (props: Props) => <td>{props.children}</td>,
code: Code,
image: Image,
link: Link,
'list-item': ListItem,
heading1: (props: Props) => (
<Heading placeholder onStar={onStar} onUnstar={onUnstar} {...props} />
),
heading2: (props: Props) => <Heading component="h2" {...props} />,
heading3: (props: Props) => <Heading component="h3" {...props} />,
heading4: (props: Props) => <Heading component="h4" {...props} />,
heading5: (props: Props) => <Heading component="h5" {...props} />,
heading6: (props: Props) => <Heading component="h6" {...props} />,
},
rules: [
// ensure first node is a heading
{
match: (node: Node) => {
return node.kind === 'document';
},
validate: (document: Node) => {
const firstNode = document.nodes.first();
return firstNode && firstNode.type === 'heading1' ? null : firstNode;
},
normalize: (transform: Transform, document: Node, firstNode: Node) => {
transform.setBlock({ type: 'heading1' });
},
},
// remove any marks in first heading
{
match: (node: Node) => {
return node.kind === 'heading1';
},
validate: (heading: Node) => {
const hasMarks = heading.getMarks().isEmpty();
const hasInlines = heading.getInlines().isEmpty();
return !(hasMarks && hasInlines);
},
normalize: (transform: Transform, heading: Node) => {
transform.unwrapInlineByKey(heading.key);
heading.getMarks().forEach(mark => {
heading.nodes.forEach(textNode => {
if (textNode.kind === 'text') {
transform.removeMarkByKey(
textNode.key,
0,
textNode.text.length,
mark
);
}
});
});
return transform;
},
},
],
};
};
export default createSchema;

View File

@@ -0,0 +1,43 @@
// @flow
import React from 'react';
import Icon from './Icon';
import type { Props } from './Icon';
export default function StarIcon(props: Props & { solid?: boolean }) {
let icon;
if (props.solid) {
icon = (
<svg
fill="#000000"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M0 0h24v24H0z" fill="none" />
<path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z" />
<path d="M0 0h24v24H0z" fill="none" />
</svg>
);
} else {
icon = (
<svg
fill="#000000"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M22 9.24l-7.19-.62L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21 12 17.27 18.18 21l-1.63-7.03L22 9.24zM12 15.4l-3.76 2.27 1-4.28-3.32-2.88 4.38-.38L12 6.1l1.71 4.04 4.38.38-3.32 2.88 1 4.28L12 15.4z" />
<path d="M0 0h24v24H0z" fill="none" />
</svg>
);
}
return (
<Icon {...props}>
{icon}
</Icon>
);
}

View File

@@ -0,0 +1,65 @@
// @flow
import React from 'react';
import BoundlessPopover from 'boundless-popover';
import styled, { keyframes } from 'styled-components';
const fadeIn = keyframes`
from {
opacity: 0;
}
50% {
opacity: 1;
}
`;
const StyledPopover = styled(BoundlessPopover)`
animation: ${fadeIn} 150ms ease-in-out;
display: flex;
flex-direction: column;
line-height: 0;
position: absolute;
top: 0;
left: 0;
z-index: 9999;
svg {
height: 16px;
width: 16px;
position: absolute;
polygon:first-child {
fill: rgba(0,0,0,.075);
}
polygon {
fill: #FFF;
}
}
`;
const Dialog = styled.div`
outline: none;
background: #FFF;
box-shadow: 0 0 0 1px rgba(0,0,0,.05), 0 8px 16px rgba(0,0,0,.1), 0 2px 4px rgba(0,0,0,.1);
border-radius: 4px;
line-height: 1.5;
padding: 16px;
margin-top: 14px;
min-width: 200px;
min-height: 150px;
`;
export const Preset = BoundlessPopover.preset;
export default function Popover(props: Object) {
return (
<StyledPopover
dialogComponent={Dialog}
closeOnOutsideScroll
closeOnOutsideFocus
closeOnEscKey
{...props}
/>
);
}

View File

@@ -0,0 +1,4 @@
// @flow
import Popover from './Popover';
export { Preset } from './Popover';
export default Popover;

View File

@@ -6,7 +6,7 @@ import type { User } from 'types';
import { Flex } from 'reflexbox';
const Container = styled(Flex)`
margin-bottom: 30px;
justify-content: space-between;
color: #ccc;
font-size: 13px;
`;
@@ -31,6 +31,7 @@ class PublishingInfo extends Component {
createdBy: User,
updatedAt: string,
updatedBy: User,
views?: number,
};
render() {
@@ -52,7 +53,9 @@ class PublishingInfo extends Component {
&nbsp;and&nbsp;
{this.props.createdBy.id !== this.props.updatedBy.id &&
` ${this.props.updatedBy.name} `}
modified {moment(this.props.updatedAt).fromNow()}
modified
{' '}
{moment(this.props.updatedAt).fromNow()}
</span>
: null}
</span>

View File

@@ -0,0 +1,19 @@
// @flow
import React, { Component } from 'react';
import styled from 'styled-components';
const Scroll = styled.div`
height: 100%;
overflow-y: auto;
overflow-x: hidden;
transform: translateZ(0);
-webkit-overflow-scrolling: touch;
`;
class Scrollable extends Component {
render() {
return <Scroll {...this.props} />;
}
}
export default Scrollable;

View File

@@ -0,0 +1,3 @@
// @flow
import Scrollable from './Scrollable';
export default Scrollable;