Pinned documents (#608)
* Migrations and API for pinned documents * Documentation * Add pin icon * Fin. * v0.2.0 * Remove pin from DocumentPreview, add general menu Add Pinned documents header * Tidy * Fixed: Drafts appearing on collection home
This commit is contained in:
@ -8,24 +8,27 @@ class DocumentList extends React.Component {
|
|||||||
props: {
|
props: {
|
||||||
documents: Document[],
|
documents: Document[],
|
||||||
showCollection?: boolean,
|
showCollection?: boolean,
|
||||||
|
limit?: number,
|
||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { documents, showCollection } = this.props;
|
const { limit, showCollection } = this.props;
|
||||||
|
const documents = limit
|
||||||
|
? this.props.documents.splice(0, limit)
|
||||||
|
: this.props.documents;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ArrowKeyNavigation
|
<ArrowKeyNavigation
|
||||||
mode={ArrowKeyNavigation.mode.VERTICAL}
|
mode={ArrowKeyNavigation.mode.VERTICAL}
|
||||||
defaultActiveChildIndex={0}
|
defaultActiveChildIndex={0}
|
||||||
>
|
>
|
||||||
{documents &&
|
{documents.map(document => (
|
||||||
documents.map(document => (
|
<DocumentPreview
|
||||||
<DocumentPreview
|
key={document.id}
|
||||||
key={document.id}
|
document={document}
|
||||||
document={document}
|
showCollection={showCollection}
|
||||||
showCollection={showCollection}
|
/>
|
||||||
/>
|
))}
|
||||||
))}
|
|
||||||
</ArrowKeyNavigation>
|
</ArrowKeyNavigation>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -5,9 +5,11 @@ import { Link } from 'react-router-dom';
|
|||||||
import Document from 'models/Document';
|
import Document from 'models/Document';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import { color } from 'shared/styles/constants';
|
import { color } from 'shared/styles/constants';
|
||||||
|
import Flex from 'shared/components/Flex';
|
||||||
import Highlight from 'components/Highlight';
|
import Highlight from 'components/Highlight';
|
||||||
import StarredIcon from 'components/Icon/StarredIcon';
|
import StarredIcon from 'components/Icon/StarredIcon';
|
||||||
import PublishingInfo from './components/PublishingInfo';
|
import PublishingInfo from './components/PublishingInfo';
|
||||||
|
import DocumentMenu from 'menus/DocumentMenu';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
document: Document,
|
document: Document,
|
||||||
@ -19,10 +21,8 @@ type Props = {
|
|||||||
const StyledStar = styled(({ solid, ...props }) => (
|
const StyledStar = styled(({ solid, ...props }) => (
|
||||||
<StarredIcon color={solid ? color.black : color.text} {...props} />
|
<StarredIcon color={solid ? color.black : color.text} {...props} />
|
||||||
))`
|
))`
|
||||||
position: absolute;
|
|
||||||
opacity: ${props => (props.solid ? '1 !important' : 0)};
|
opacity: ${props => (props.solid ? '1 !important' : 0)};
|
||||||
transition: all 100ms ease-in-out;
|
transition: all 100ms ease-in-out;
|
||||||
margin-left: 2px;
|
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
transform: scale(1.1);
|
transform: scale(1.1);
|
||||||
@ -32,6 +32,13 @@ const StyledStar = styled(({ solid, ...props }) => (
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const StyledDocumentMenu = styled(DocumentMenu)`
|
||||||
|
position: absolute;
|
||||||
|
right: 16px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
`;
|
||||||
|
|
||||||
const DocumentLink = styled(Link)`
|
const DocumentLink = styled(Link)`
|
||||||
display: block;
|
display: block;
|
||||||
margin: 0 -16px;
|
margin: 0 -16px;
|
||||||
@ -41,6 +48,11 @@ const DocumentLink = styled(Link)`
|
|||||||
max-height: 50vh;
|
max-height: 50vh;
|
||||||
min-width: 100%;
|
min-width: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
${StyledDocumentMenu} {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
&:hover,
|
&:hover,
|
||||||
&:active,
|
&:active,
|
||||||
@ -49,7 +61,7 @@ const DocumentLink = styled(Link)`
|
|||||||
border: 2px solid ${color.smoke};
|
border: 2px solid ${color.smoke};
|
||||||
outline: none;
|
outline: none;
|
||||||
|
|
||||||
${StyledStar} {
|
${StyledStar}, ${StyledDocumentMenu} {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
@ -61,11 +73,19 @@ const DocumentLink = styled(Link)`
|
|||||||
&:focus {
|
&:focus {
|
||||||
border: 2px solid ${color.slateDark};
|
border: 2px solid ${color.slateDark};
|
||||||
}
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
h3 {
|
const Heading = styled.h3`
|
||||||
margin-top: 0;
|
display: flex;
|
||||||
margin-bottom: 0.25em;
|
align-items: center;
|
||||||
}
|
height: 24px;
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0.25em;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Actions = styled(Flex)`
|
||||||
|
margin-left: 4px;
|
||||||
|
align-items: center;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@observer
|
@observer
|
||||||
@ -95,19 +115,19 @@ class DocumentPreview extends Component {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DocumentLink to={document.url} innerRef={innerRef} {...rest}>
|
<DocumentLink to={document.url} innerRef={innerRef} {...rest}>
|
||||||
<h3>
|
<Heading>
|
||||||
<Highlight text={document.title} highlight={highlight} />
|
<Highlight text={document.title} highlight={highlight} />
|
||||||
{document.publishedAt &&
|
{document.publishedAt && (
|
||||||
(document.starred ? (
|
<Actions>
|
||||||
<span onClick={this.unstar}>
|
{document.starred ? (
|
||||||
<StyledStar solid />
|
<StyledStar onClick={this.unstar} solid />
|
||||||
</span>
|
) : (
|
||||||
) : (
|
<StyledStar onClick={this.star} />
|
||||||
<span onClick={this.star}>
|
)}
|
||||||
<StyledStar />
|
</Actions>
|
||||||
</span>
|
)}
|
||||||
))}
|
<StyledDocumentMenu document={document} />
|
||||||
</h3>
|
</Heading>
|
||||||
<PublishingInfo
|
<PublishingInfo
|
||||||
document={document}
|
document={document}
|
||||||
collection={showCollection ? document.collection : undefined}
|
collection={showCollection ? document.collection : undefined}
|
||||||
|
@ -14,6 +14,7 @@ type Props = {
|
|||||||
onOpen?: () => void,
|
onOpen?: () => void,
|
||||||
onClose?: () => void,
|
onClose?: () => void,
|
||||||
children?: React.Element<*>,
|
children?: React.Element<*>,
|
||||||
|
className?: string,
|
||||||
style?: Object,
|
style?: Object,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -23,10 +24,9 @@ class DropdownMenu extends Component {
|
|||||||
@observable top: number;
|
@observable top: number;
|
||||||
@observable right: number;
|
@observable right: number;
|
||||||
|
|
||||||
handleOpen = (openPortal: SyntheticEvent => void) => {
|
handleOpen = (openPortal: SyntheticEvent => *) => {
|
||||||
return (ev: SyntheticMouseEvent) => {
|
return (ev: SyntheticMouseEvent) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
ev.stopPropagation();
|
|
||||||
const currentTarget = ev.currentTarget;
|
const currentTarget = ev.currentTarget;
|
||||||
invariant(document.body, 'why you not here');
|
invariant(document.body, 'why you not here');
|
||||||
|
|
||||||
@ -41,30 +41,34 @@ class DropdownMenu extends Component {
|
|||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
const { className, label, children } = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className={className}>
|
||||||
<PortalWithState
|
<PortalWithState
|
||||||
onOpen={this.props.onOpen}
|
onOpen={this.props.onOpen}
|
||||||
onClose={this.props.onClose}
|
onClose={this.props.onClose}
|
||||||
closeOnEsc
|
|
||||||
closeOnOutsideClick
|
closeOnOutsideClick
|
||||||
|
closeOnEsc
|
||||||
>
|
>
|
||||||
{({ closePortal, openPortal, portal }) => [
|
{({ closePortal, openPortal, portal }) => (
|
||||||
<Label onClick={this.handleOpen(openPortal)} key="label">
|
<React.Fragment>
|
||||||
{this.props.label}
|
<Label onClick={this.handleOpen(openPortal)}>{label}</Label>
|
||||||
</Label>,
|
{portal(
|
||||||
portal(
|
<Menu
|
||||||
<Menu
|
onClick={ev => {
|
||||||
key="menu"
|
ev.stopPropagation();
|
||||||
onClick={closePortal}
|
closePortal();
|
||||||
style={this.props.style}
|
}}
|
||||||
top={this.top}
|
style={this.props.style}
|
||||||
right={this.right}
|
top={this.top}
|
||||||
>
|
right={this.right}
|
||||||
{this.props.children}
|
>
|
||||||
</Menu>
|
{children}
|
||||||
),
|
</Menu>
|
||||||
]}
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
)}
|
||||||
</PortalWithState>
|
</PortalWithState>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
12
app/components/Icon/PinIcon.js
Normal file
12
app/components/Icon/PinIcon.js
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
// @flow
|
||||||
|
import React from 'react';
|
||||||
|
import Icon from './Icon';
|
||||||
|
import type { Props } from './Icon';
|
||||||
|
|
||||||
|
export default function PinIcon(props: Props) {
|
||||||
|
return (
|
||||||
|
<Icon {...props}>
|
||||||
|
<path d="M5.8355996,10.9120278 C5.68780303,10.7642312 5.59344653,10.5714155 5.56742853,10.3640252 C5.50276659,9.84860302 5.86817998,9.37835193 6.38360216,9.31369 C7.5908221,9.16222691 8.66384332,9.11386012 9.60266432,9.16857768 L13.3590257,6.76661181 C13.3232787,6.36756241 13.3081075,5.93429416 13.3135122,5.46680705 C13.3163525,5.22112701 13.4152107,4.98631045 13.5889444,4.81257683 C13.9562711,4.4452501 14.5518254,4.4452501 14.9191521,4.81257683 L19.1874471,9.08089577 C19.3611825,9.25463123 19.4600417,9.48945036 19.4628817,9.735133 C19.4688865,10.2545814 19.0526582,10.6805453 18.5332098,10.6865501 C18.0657301,10.6919287 17.6324685,10.6767575 17.2334248,10.641011 L14.831459,14.3973723 C14.8861764,15.3361909 14.83781,16.4092091 14.6863598,17.6164268 C14.6603417,17.823818 14.5659848,18.0166347 14.4181875,18.164432 C14.0508706,18.5317489 13.4553322,18.5317489 13.0880152,18.164432 L10.1268984,15.2033198 L6.41184151,18.9183767 C6.04452202,19.2856962 5.44897945,19.2856962 5.08165995,18.9183767 C4.71434046,18.5510572 4.71434046,17.9555146 5.08165995,17.5881951 L8.7967158,13.8731393 L5.8355996,10.9120278 Z" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
}
|
@ -9,7 +9,6 @@ import Collection from 'models/Collection';
|
|||||||
import UiStore from 'stores/UiStore';
|
import UiStore from 'stores/UiStore';
|
||||||
import DocumentsStore from 'stores/DocumentsStore';
|
import DocumentsStore from 'stores/DocumentsStore';
|
||||||
import MoreIcon from 'components/Icon/MoreIcon';
|
import MoreIcon from 'components/Icon/MoreIcon';
|
||||||
import Flex from 'shared/components/Flex';
|
|
||||||
import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu';
|
import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@ -80,15 +79,16 @@ class CollectionMenu extends Component {
|
|||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
>
|
>
|
||||||
{collection && (
|
{collection && (
|
||||||
<Flex column>
|
<React.Fragment>
|
||||||
<DropdownMenuItem onClick={this.onNewDocument}>
|
<DropdownMenuItem onClick={this.onNewDocument}>
|
||||||
New document
|
New document
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={this.onImportDocument}>
|
<DropdownMenuItem onClick={this.onImportDocument}>
|
||||||
Import document
|
Import document
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<hr />
|
||||||
<DropdownMenuItem onClick={this.onEdit}>Edit…</DropdownMenuItem>
|
<DropdownMenuItem onClick={this.onEdit}>Edit…</DropdownMenuItem>
|
||||||
</Flex>
|
</React.Fragment>
|
||||||
)}
|
)}
|
||||||
<DropdownMenuItem onClick={this.onDelete}>Delete…</DropdownMenuItem>
|
<DropdownMenuItem onClick={this.onDelete}>Delete…</DropdownMenuItem>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
@ -15,44 +15,60 @@ class DocumentMenu extends Component {
|
|||||||
label?: React$Element<any>,
|
label?: React$Element<any>,
|
||||||
history: Object,
|
history: Object,
|
||||||
document: Document,
|
document: Document,
|
||||||
|
className: string,
|
||||||
};
|
};
|
||||||
|
|
||||||
handleNewChild = () => {
|
handleNewChild = (ev: SyntheticEvent) => {
|
||||||
const { history, document } = this.props;
|
const { history, document } = this.props;
|
||||||
history.push(
|
history.push(
|
||||||
`${document.collection.url}/new?parentDocument=${document.id}`
|
`${document.collection.url}/new?parentDocument=${document.id}`
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
handleDelete = () => {
|
handleDelete = (ev: SyntheticEvent) => {
|
||||||
const { document } = this.props;
|
const { document } = this.props;
|
||||||
this.props.ui.setActiveModal('document-delete', { document });
|
this.props.ui.setActiveModal('document-delete', { document });
|
||||||
};
|
};
|
||||||
|
|
||||||
handleMove = () => {
|
handleMove = (ev: SyntheticEvent) => {
|
||||||
this.props.history.push(documentMoveUrl(this.props.document));
|
this.props.history.push(documentMoveUrl(this.props.document));
|
||||||
};
|
};
|
||||||
|
|
||||||
handleStar = () => {
|
handlePin = (ev: SyntheticEvent) => {
|
||||||
|
this.props.document.pin();
|
||||||
|
};
|
||||||
|
|
||||||
|
handleUnpin = (ev: SyntheticEvent) => {
|
||||||
|
this.props.document.unpin();
|
||||||
|
};
|
||||||
|
|
||||||
|
handleStar = (ev: SyntheticEvent) => {
|
||||||
this.props.document.star();
|
this.props.document.star();
|
||||||
};
|
};
|
||||||
|
|
||||||
handleUnstar = () => {
|
handleUnstar = (ev: SyntheticEvent) => {
|
||||||
this.props.document.unstar();
|
this.props.document.unstar();
|
||||||
};
|
};
|
||||||
|
|
||||||
handleExport = () => {
|
handleExport = (ev: SyntheticEvent) => {
|
||||||
this.props.document.download();
|
this.props.document.download();
|
||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { document, label } = this.props;
|
const { document, label, className } = this.props;
|
||||||
const isDraft = !document.publishedAt;
|
const isDraft = !document.publishedAt;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu label={label || <MoreIcon />}>
|
<DropdownMenu label={label || <MoreIcon />} className={className}>
|
||||||
{!isDraft && (
|
{!isDraft && (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
|
{document.pinned ? (
|
||||||
|
<DropdownMenuItem onClick={this.handleUnpin}>
|
||||||
|
Unpin
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : (
|
||||||
|
<DropdownMenuItem onClick={this.handlePin}>Pin</DropdownMenuItem>
|
||||||
|
)}
|
||||||
{document.starred ? (
|
{document.starred ? (
|
||||||
<DropdownMenuItem onClick={this.handleUnstar}>
|
<DropdownMenuItem onClick={this.handleUnstar}>
|
||||||
Unstar
|
Unstar
|
||||||
@ -62,6 +78,7 @@ class DocumentMenu extends Component {
|
|||||||
Star
|
Star
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
<hr />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={this.handleNewChild}
|
onClick={this.handleNewChild}
|
||||||
title="Create a new child document for the current document"
|
title="Create a new child document for the current document"
|
||||||
@ -71,11 +88,12 @@ class DocumentMenu extends Component {
|
|||||||
<DropdownMenuItem onClick={this.handleMove}>Move…</DropdownMenuItem>
|
<DropdownMenuItem onClick={this.handleMove}>Move…</DropdownMenuItem>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
)}
|
)}
|
||||||
|
<DropdownMenuItem onClick={this.handleDelete}>Delete…</DropdownMenuItem>
|
||||||
|
<hr />
|
||||||
<DropdownMenuItem onClick={this.handleExport}>
|
<DropdownMenuItem onClick={this.handleExport}>
|
||||||
Download
|
Download
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={window.print}>Print</DropdownMenuItem>
|
<DropdownMenuItem onClick={window.print}>Print</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={this.handleDelete}>Delete…</DropdownMenuItem>
|
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -16,7 +16,7 @@ class Document extends BaseModel {
|
|||||||
hasPendingChanges: boolean = false;
|
hasPendingChanges: boolean = false;
|
||||||
errors: ErrorsStore;
|
errors: ErrorsStore;
|
||||||
|
|
||||||
collaborators: Array<User>;
|
collaborators: User[];
|
||||||
collection: $Shape<Collection>;
|
collection: $Shape<Collection>;
|
||||||
collectionId: string;
|
collectionId: string;
|
||||||
firstViewedAt: ?string;
|
firstViewedAt: ?string;
|
||||||
@ -24,18 +24,19 @@ class Document extends BaseModel {
|
|||||||
modifiedSinceViewed: ?boolean;
|
modifiedSinceViewed: ?boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
createdBy: User;
|
createdBy: User;
|
||||||
|
updatedAt: string;
|
||||||
|
updatedBy: User;
|
||||||
html: string;
|
html: string;
|
||||||
id: string;
|
id: string;
|
||||||
team: string;
|
team: string;
|
||||||
emoji: string;
|
emoji: string;
|
||||||
private: boolean = false;
|
private: boolean = false;
|
||||||
starred: boolean = false;
|
starred: boolean = false;
|
||||||
|
pinned: boolean = false;
|
||||||
text: string = '';
|
text: string = '';
|
||||||
title: string = '';
|
title: string = '';
|
||||||
parentDocument: ?string;
|
parentDocument: ?string;
|
||||||
publishedAt: ?string;
|
publishedAt: ?string;
|
||||||
updatedAt: string;
|
|
||||||
updatedBy: User;
|
|
||||||
url: string;
|
url: string;
|
||||||
views: number;
|
views: number;
|
||||||
revision: number;
|
revision: number;
|
||||||
@ -98,6 +99,28 @@ class Document extends BaseModel {
|
|||||||
|
|
||||||
/* Actions */
|
/* Actions */
|
||||||
|
|
||||||
|
@action
|
||||||
|
pin = async () => {
|
||||||
|
this.pinned = true;
|
||||||
|
try {
|
||||||
|
await client.post('/documents.pin', { id: this.id });
|
||||||
|
} catch (e) {
|
||||||
|
this.pinned = false;
|
||||||
|
this.errors.add('Document failed to pin');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
@action
|
||||||
|
unpin = async () => {
|
||||||
|
this.pinned = false;
|
||||||
|
try {
|
||||||
|
await client.post('/documents.unpin', { id: this.id });
|
||||||
|
} catch (e) {
|
||||||
|
this.pinned = true;
|
||||||
|
this.errors.add('Document failed to unpin');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
@action
|
@action
|
||||||
star = async () => {
|
star = async () => {
|
||||||
this.starred = true;
|
this.starred = true;
|
||||||
|
@ -17,6 +17,7 @@ import Actions, { Action, Separator } from 'components/Actions';
|
|||||||
import CenteredContent from 'components/CenteredContent';
|
import CenteredContent from 'components/CenteredContent';
|
||||||
import CollectionIcon from 'components/Icon/CollectionIcon';
|
import CollectionIcon from 'components/Icon/CollectionIcon';
|
||||||
import NewDocumentIcon from 'components/Icon/NewDocumentIcon';
|
import NewDocumentIcon from 'components/Icon/NewDocumentIcon';
|
||||||
|
import PinIcon from 'components/Icon/PinIcon';
|
||||||
import { ListPlaceholder } from 'components/LoadingPlaceholder';
|
import { ListPlaceholder } from 'components/LoadingPlaceholder';
|
||||||
import Button from 'components/Button';
|
import Button from 'components/Button';
|
||||||
import HelpText from 'components/HelpText';
|
import HelpText from 'components/HelpText';
|
||||||
@ -55,16 +56,21 @@ class CollectionScene extends Component {
|
|||||||
|
|
||||||
loadContent = async (id: string) => {
|
loadContent = async (id: string) => {
|
||||||
const { collections } = this.props;
|
const { collections } = this.props;
|
||||||
|
|
||||||
const collection = collections.getById(id) || (await collections.fetch(id));
|
const collection = collections.getById(id) || (await collections.fetch(id));
|
||||||
|
|
||||||
if (collection) {
|
if (collection) {
|
||||||
this.props.ui.setActiveCollection(collection);
|
this.props.ui.setActiveCollection(collection);
|
||||||
this.collection = collection;
|
this.collection = collection;
|
||||||
await this.props.documents.fetchRecentlyModified({
|
|
||||||
limit: 10,
|
await Promise.all([
|
||||||
collection: collection.id,
|
this.props.documents.fetchRecentlyModified({
|
||||||
});
|
limit: 10,
|
||||||
|
collection: id,
|
||||||
|
}),
|
||||||
|
this.props.documents.fetchPinned({
|
||||||
|
collection: id,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.isFetching = false;
|
this.isFetching = false;
|
||||||
@ -132,10 +138,18 @@ class CollectionScene extends Component {
|
|||||||
return this.renderEmptyCollection();
|
return this.renderEmptyCollection();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pinnedDocuments = this.collection
|
||||||
|
? this.props.documents.pinnedInCollection(this.collection.id)
|
||||||
|
: [];
|
||||||
|
const recentDocuments = this.collection
|
||||||
|
? this.props.documents.recentlyEditedInCollection(this.collection.id)
|
||||||
|
: [];
|
||||||
|
const hasPinnedDocuments = !!pinnedDocuments.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CenteredContent>
|
<CenteredContent>
|
||||||
{this.collection ? (
|
{this.collection ? (
|
||||||
<span>
|
<React.Fragment>
|
||||||
<PageTitle title={this.collection.name} />
|
<PageTitle title={this.collection.name} />
|
||||||
<Heading>
|
<Heading>
|
||||||
<CollectionIcon
|
<CollectionIcon
|
||||||
@ -145,14 +159,20 @@ class CollectionScene extends Component {
|
|||||||
/>{' '}
|
/>{' '}
|
||||||
{this.collection.name}
|
{this.collection.name}
|
||||||
</Heading>
|
</Heading>
|
||||||
|
|
||||||
|
{hasPinnedDocuments && (
|
||||||
|
<React.Fragment>
|
||||||
|
<Subheading>
|
||||||
|
<TinyPinIcon size={18} /> Pinned
|
||||||
|
</Subheading>
|
||||||
|
<DocumentList documents={pinnedDocuments} />
|
||||||
|
</React.Fragment>
|
||||||
|
)}
|
||||||
|
|
||||||
<Subheading>Recently edited</Subheading>
|
<Subheading>Recently edited</Subheading>
|
||||||
<DocumentList
|
<DocumentList documents={recentDocuments} limit={10} />
|
||||||
documents={this.props.documents.recentlyEditedIn(
|
|
||||||
this.collection.documentIds
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{this.renderActions()}
|
{this.renderActions()}
|
||||||
</span>
|
</React.Fragment>
|
||||||
) : (
|
) : (
|
||||||
<ListPlaceholder count={5} />
|
<ListPlaceholder count={5} />
|
||||||
)}
|
)}
|
||||||
@ -161,6 +181,12 @@ class CollectionScene extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TinyPinIcon = styled(PinIcon)`
|
||||||
|
position: relative;
|
||||||
|
top: 4px;
|
||||||
|
opacity: 0.8;
|
||||||
|
`;
|
||||||
|
|
||||||
const Heading = styled.h1`
|
const Heading = styled.h1`
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
||||||
|
@ -60,10 +60,19 @@ class DocumentsStore extends BaseStore {
|
|||||||
return docs;
|
return docs;
|
||||||
}
|
}
|
||||||
|
|
||||||
recentlyEditedIn(documentIds: string[]): Document[] {
|
pinnedInCollection(collectionId: string): Document[] {
|
||||||
|
return _.filter(
|
||||||
|
this.recentlyEditedInCollection(collectionId),
|
||||||
|
document => document.pinned
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
recentlyEditedInCollection(collectionId: string): Document[] {
|
||||||
return _.orderBy(
|
return _.orderBy(
|
||||||
_.filter(this.data.values(), document =>
|
_.filter(
|
||||||
documentIds.includes(document.id)
|
this.data.values(),
|
||||||
|
document =>
|
||||||
|
document.collectionId === collectionId && !!document.publishedAt
|
||||||
),
|
),
|
||||||
'updatedAt',
|
'updatedAt',
|
||||||
'desc'
|
'desc'
|
||||||
@ -147,6 +156,11 @@ class DocumentsStore extends BaseStore {
|
|||||||
await this.fetchPage('drafts', options);
|
await this.fetchPage('drafts', options);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@action
|
||||||
|
fetchPinned = async (options: ?PaginationParams): Promise<*> => {
|
||||||
|
await this.fetchPage('pinned', options);
|
||||||
|
};
|
||||||
|
|
||||||
@action
|
@action
|
||||||
search = async (
|
search = async (
|
||||||
query: string,
|
query: string,
|
||||||
|
@ -17,6 +17,15 @@ Object {
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
exports[`#documents.pin should require authentication 1`] = `
|
||||||
|
Object {
|
||||||
|
"error": "authentication_required",
|
||||||
|
"message": "Authentication required",
|
||||||
|
"ok": false,
|
||||||
|
"status": 401,
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
exports[`#documents.search should require authentication 1`] = `
|
exports[`#documents.search should require authentication 1`] = `
|
||||||
Object {
|
Object {
|
||||||
"error": "authentication_required",
|
"error": "authentication_required",
|
||||||
@ -44,6 +53,15 @@ Object {
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
exports[`#documents.unpin should require authentication 1`] = `
|
||||||
|
Object {
|
||||||
|
"error": "authentication_required",
|
||||||
|
"message": "Authentication required",
|
||||||
|
"ok": false,
|
||||||
|
"status": 401,
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
exports[`#documents.unstar should require authentication 1`] = `
|
exports[`#documents.unstar should require authentication 1`] = `
|
||||||
Object {
|
Object {
|
||||||
"error": "authentication_required",
|
"error": "authentication_required",
|
||||||
|
@ -19,8 +19,7 @@ router.post('documents.list', auth(), pagination(), async ctx => {
|
|||||||
let where = { teamId: user.teamId };
|
let where = { teamId: user.teamId };
|
||||||
if (collection) where = { ...where, atlasId: collection };
|
if (collection) where = { ...where, atlasId: collection };
|
||||||
|
|
||||||
const userId = user.id;
|
const starredScope = { method: ['withStarred', user.id] };
|
||||||
const starredScope = { method: ['withStarred', userId] };
|
|
||||||
const documents = await Document.scope('defaultScope', starredScope).findAll({
|
const documents = await Document.scope('defaultScope', starredScope).findAll({
|
||||||
where,
|
where,
|
||||||
order: [[sort, direction]],
|
order: [[sort, direction]],
|
||||||
@ -38,6 +37,36 @@ router.post('documents.list', auth(), pagination(), async ctx => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.post('documents.pinned', auth(), pagination(), async ctx => {
|
||||||
|
let { sort = 'updatedAt', direction, collection } = ctx.body;
|
||||||
|
if (direction !== 'ASC') direction = 'DESC';
|
||||||
|
ctx.assertPresent(collection, 'collection is required');
|
||||||
|
|
||||||
|
const user = ctx.state.user;
|
||||||
|
const starredScope = { method: ['withStarred', user.id] };
|
||||||
|
const documents = await Document.scope('defaultScope', starredScope).findAll({
|
||||||
|
where: {
|
||||||
|
teamId: user.teamId,
|
||||||
|
atlasId: collection,
|
||||||
|
pinnedById: {
|
||||||
|
[Op.ne]: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
order: [[sort, direction]],
|
||||||
|
offset: ctx.state.pagination.offset,
|
||||||
|
limit: ctx.state.pagination.limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await Promise.all(
|
||||||
|
documents.map(document => presentDocument(ctx, document))
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.body = {
|
||||||
|
pagination: ctx.state.pagination,
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
router.post('documents.viewed', auth(), pagination(), async ctx => {
|
router.post('documents.viewed', auth(), pagination(), async ctx => {
|
||||||
let { sort = 'updatedAt', direction } = ctx.body;
|
let { sort = 'updatedAt', direction } = ctx.body;
|
||||||
if (direction !== 'ASC') direction = 'DESC';
|
if (direction !== 'ASC') direction = 'DESC';
|
||||||
@ -166,8 +195,7 @@ router.post('documents.search', auth(), pagination(), async ctx => {
|
|||||||
const { offset, limit } = ctx.state.pagination;
|
const { offset, limit } = ctx.state.pagination;
|
||||||
ctx.assertPresent(query, 'query is required');
|
ctx.assertPresent(query, 'query is required');
|
||||||
|
|
||||||
const user = await ctx.state.user;
|
const user = ctx.state.user;
|
||||||
|
|
||||||
const documents = await Document.searchForUser(user, query, {
|
const documents = await Document.searchForUser(user, query, {
|
||||||
offset,
|
offset,
|
||||||
limit,
|
limit,
|
||||||
@ -183,13 +211,45 @@ router.post('documents.search', auth(), pagination(), async ctx => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.post('documents.pin', auth(), async ctx => {
|
||||||
|
const { id } = ctx.body;
|
||||||
|
ctx.assertPresent(id, 'id is required');
|
||||||
|
const user = ctx.state.user;
|
||||||
|
const document = await Document.findById(id);
|
||||||
|
|
||||||
|
authorize(user, 'update', document);
|
||||||
|
|
||||||
|
document.pinnedById = user.id;
|
||||||
|
await document.save();
|
||||||
|
|
||||||
|
ctx.body = {
|
||||||
|
data: await presentDocument(ctx, document),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('documents.unpin', auth(), async ctx => {
|
||||||
|
const { id } = ctx.body;
|
||||||
|
ctx.assertPresent(id, 'id is required');
|
||||||
|
const user = ctx.state.user;
|
||||||
|
const document = await Document.findById(id);
|
||||||
|
|
||||||
|
authorize(user, 'update', document);
|
||||||
|
|
||||||
|
document.pinnedById = null;
|
||||||
|
await document.save();
|
||||||
|
|
||||||
|
ctx.body = {
|
||||||
|
data: await presentDocument(ctx, document),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
router.post('documents.star', auth(), async ctx => {
|
router.post('documents.star', auth(), async ctx => {
|
||||||
const { id } = ctx.body;
|
const { id } = ctx.body;
|
||||||
ctx.assertPresent(id, 'id is required');
|
ctx.assertPresent(id, 'id is required');
|
||||||
const user = await ctx.state.user;
|
const user = ctx.state.user;
|
||||||
const document = await Document.findById(id);
|
const document = await Document.findById(id);
|
||||||
|
|
||||||
authorize(ctx.state.user, 'read', document);
|
authorize(user, 'read', document);
|
||||||
|
|
||||||
await Star.findOrCreate({
|
await Star.findOrCreate({
|
||||||
where: { documentId: document.id, userId: user.id },
|
where: { documentId: document.id, userId: user.id },
|
||||||
@ -199,10 +259,10 @@ router.post('documents.star', auth(), async ctx => {
|
|||||||
router.post('documents.unstar', auth(), async ctx => {
|
router.post('documents.unstar', auth(), async ctx => {
|
||||||
const { id } = ctx.body;
|
const { id } = ctx.body;
|
||||||
ctx.assertPresent(id, 'id is required');
|
ctx.assertPresent(id, 'id is required');
|
||||||
const user = await ctx.state.user;
|
const user = ctx.state.user;
|
||||||
const document = await Document.findById(id);
|
const document = await Document.findById(id);
|
||||||
|
|
||||||
authorize(ctx.state.user, 'read', document);
|
authorize(user, 'read', document);
|
||||||
|
|
||||||
await Star.destroy({
|
await Star.destroy({
|
||||||
where: { documentId: document.id, userId: user.id },
|
where: { documentId: document.id, userId: user.id },
|
||||||
|
@ -248,6 +248,66 @@ describe('#documents.starred', async () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('#documents.pin', async () => {
|
||||||
|
it('should pin the document', async () => {
|
||||||
|
const { user, document } = await seed();
|
||||||
|
|
||||||
|
const res = await server.post('/api/documents.pin', {
|
||||||
|
body: { token: user.getJwtToken(), id: document.id },
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.data.pinned).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should require authentication', async () => {
|
||||||
|
const res = await server.post('/api/documents.pin');
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
expect(res.status).toEqual(401);
|
||||||
|
expect(body).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should require authorization', async () => {
|
||||||
|
const { document } = await seed();
|
||||||
|
const user = await buildUser();
|
||||||
|
const res = await server.post('/api/documents.pin', {
|
||||||
|
body: { token: user.getJwtToken(), id: document.id },
|
||||||
|
});
|
||||||
|
expect(res.status).toEqual(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('#documents.unpin', async () => {
|
||||||
|
it('should unpin the document', async () => {
|
||||||
|
const { user, document } = await seed();
|
||||||
|
document.pinnedBy = user;
|
||||||
|
await document.save();
|
||||||
|
|
||||||
|
const res = await server.post('/api/documents.unpin', {
|
||||||
|
body: { token: user.getJwtToken(), id: document.id },
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.data.pinned).toEqual(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should require authentication', async () => {
|
||||||
|
const res = await server.post('/api/documents.unpin');
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
expect(res.status).toEqual(401);
|
||||||
|
expect(body).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should require authorization', async () => {
|
||||||
|
const { document } = await seed();
|
||||||
|
const user = await buildUser();
|
||||||
|
const res = await server.post('/api/documents.unpin', {
|
||||||
|
body: { token: user.getJwtToken(), id: document.id },
|
||||||
|
});
|
||||||
|
expect(res.status).toEqual(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('#documents.star', async () => {
|
describe('#documents.star', async () => {
|
||||||
it('should star the document', async () => {
|
it('should star the document', async () => {
|
||||||
const { user, document } = await seed();
|
const { user, document } = await seed();
|
||||||
|
13
server/migrations/20180225203847-document-pinning.js
Normal file
13
server/migrations/20180225203847-document-pinning.js
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
up: async (queryInterface, Sequelize) => {
|
||||||
|
await queryInterface.addColumn('documents', 'pinnedById', {
|
||||||
|
type: Sequelize.UUID,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
down: async (queryInterface, Sequelize) => {
|
||||||
|
await queryInterface.removeColumn('documents', 'pinnedById');
|
||||||
|
}
|
||||||
|
};
|
@ -85,20 +85,6 @@ const Document = sequelize.define(
|
|||||||
revisionCount: { type: DataTypes.INTEGER, defaultValue: 0 },
|
revisionCount: { type: DataTypes.INTEGER, defaultValue: 0 },
|
||||||
publishedAt: DataTypes.DATE,
|
publishedAt: DataTypes.DATE,
|
||||||
parentDocumentId: DataTypes.UUID,
|
parentDocumentId: DataTypes.UUID,
|
||||||
createdById: {
|
|
||||||
type: DataTypes.UUID,
|
|
||||||
allowNull: false,
|
|
||||||
references: {
|
|
||||||
model: 'users',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
lastModifiedById: {
|
|
||||||
type: DataTypes.UUID,
|
|
||||||
allowNull: false,
|
|
||||||
references: {
|
|
||||||
model: 'users',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
collaboratorIds: DataTypes.ARRAY(DataTypes.UUID),
|
collaboratorIds: DataTypes.ARRAY(DataTypes.UUID),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -129,6 +115,10 @@ Document.associate = models => {
|
|||||||
as: 'updatedBy',
|
as: 'updatedBy',
|
||||||
foreignKey: 'lastModifiedById',
|
foreignKey: 'lastModifiedById',
|
||||||
});
|
});
|
||||||
|
Document.belongsTo(models.User, {
|
||||||
|
as: 'pinnedBy',
|
||||||
|
foreignKey: 'pinnedById',
|
||||||
|
});
|
||||||
Document.hasMany(models.Revision, {
|
Document.hasMany(models.Revision, {
|
||||||
as: 'revisions',
|
as: 'revisions',
|
||||||
onDelete: 'cascade',
|
onDelete: 'cascade',
|
||||||
|
@ -427,6 +427,34 @@ export default function Pricing() {
|
|||||||
</Arguments>
|
</Arguments>
|
||||||
</Method>
|
</Method>
|
||||||
|
|
||||||
|
<Method method="documents.pin" label="Pin a document">
|
||||||
|
<Description>
|
||||||
|
Pins a document to the collection home. The pinned document is
|
||||||
|
visible to all members of the team.
|
||||||
|
</Description>
|
||||||
|
<Arguments>
|
||||||
|
<Argument
|
||||||
|
id="id"
|
||||||
|
description="Document ID or URI identifier"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Arguments>
|
||||||
|
</Method>
|
||||||
|
|
||||||
|
<Method method="documents.unpin" label="Unpin a document">
|
||||||
|
<Description>
|
||||||
|
Unpins a document from the collection home. It will still remain
|
||||||
|
in the collection itself.
|
||||||
|
</Description>
|
||||||
|
<Arguments>
|
||||||
|
<Argument
|
||||||
|
id="id"
|
||||||
|
description="Document ID or URI identifier"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Arguments>
|
||||||
|
</Method>
|
||||||
|
|
||||||
<Method method="documents.star" label="Star a document">
|
<Method method="documents.star" label="Star a document">
|
||||||
<Description>
|
<Description>
|
||||||
Star (favorite) a document for authenticated user.
|
Star (favorite) a document for authenticated user.
|
||||||
@ -442,7 +470,7 @@ export default function Pricing() {
|
|||||||
|
|
||||||
<Method method="documents.unstar" label="Unstar a document">
|
<Method method="documents.unstar" label="Unstar a document">
|
||||||
<Description>
|
<Description>
|
||||||
Unstar as starred (favorited) a document for authenticated user.
|
Unstar a starred (favorited) document for authenticated user.
|
||||||
</Description>
|
</Description>
|
||||||
<Arguments>
|
<Arguments>
|
||||||
<Argument
|
<Argument
|
||||||
@ -473,6 +501,14 @@ export default function Pricing() {
|
|||||||
<Arguments pagination />
|
<Arguments pagination />
|
||||||
</Method>
|
</Method>
|
||||||
|
|
||||||
|
<Method
|
||||||
|
method="documents.pinned"
|
||||||
|
label="Get pinned documents for a collection"
|
||||||
|
>
|
||||||
|
<Description>Return pinned documents for a collection</Description>
|
||||||
|
<Arguments pagination />
|
||||||
|
</Method>
|
||||||
|
|
||||||
<Method
|
<Method
|
||||||
method="documents.revisions"
|
method="documents.revisions"
|
||||||
label="Get revisions for a document"
|
label="Get revisions for a document"
|
||||||
|
@ -39,6 +39,7 @@ async function present(ctx: Object, document: Document, options: ?Options) {
|
|||||||
team: document.teamId,
|
team: document.teamId,
|
||||||
collaborators: [],
|
collaborators: [],
|
||||||
starred: !!(document.starred && document.starred.length),
|
starred: !!(document.starred && document.starred.length),
|
||||||
|
pinned: !!document.pinnedById,
|
||||||
revision: document.revisionCount,
|
revision: document.revisionCount,
|
||||||
collectionId: document.atlasId,
|
collectionId: document.atlasId,
|
||||||
collaboratorCount: undefined,
|
collaboratorCount: undefined,
|
||||||
|
Reference in New Issue
Block a user