+
- {({ closePortal, openPortal, portal }) => [
- ,
- portal(
-
- ),
- ]}
+ {({ closePortal, openPortal, portal }) => (
+
+
+ {portal(
+
+ )}
+
+ )}
);
diff --git a/app/components/Icon/PinIcon.js b/app/components/Icon/PinIcon.js
new file mode 100644
index 00000000..0b759434
--- /dev/null
+++ b/app/components/Icon/PinIcon.js
@@ -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 (
+
+
+
+ );
+}
diff --git a/app/menus/CollectionMenu.js b/app/menus/CollectionMenu.js
index 1a7edf0d..8702af9f 100644
--- a/app/menus/CollectionMenu.js
+++ b/app/menus/CollectionMenu.js
@@ -9,7 +9,6 @@ import Collection from 'models/Collection';
import UiStore from 'stores/UiStore';
import DocumentsStore from 'stores/DocumentsStore';
import MoreIcon from 'components/Icon/MoreIcon';
-import Flex from 'shared/components/Flex';
import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu';
type Props = {
@@ -80,15 +79,16 @@ class CollectionMenu extends Component {
onClose={onClose}
>
{collection && (
-
+
New document
Import document
+
Edit…
-
+
)}
Delete…
diff --git a/app/menus/DocumentMenu.js b/app/menus/DocumentMenu.js
index 2e4f5607..d24200ae 100644
--- a/app/menus/DocumentMenu.js
+++ b/app/menus/DocumentMenu.js
@@ -15,44 +15,60 @@ class DocumentMenu extends Component {
label?: React$Element
,
history: Object,
document: Document,
+ className: string,
};
- handleNewChild = () => {
+ handleNewChild = (ev: SyntheticEvent) => {
const { history, document } = this.props;
history.push(
`${document.collection.url}/new?parentDocument=${document.id}`
);
};
- handleDelete = () => {
+ handleDelete = (ev: SyntheticEvent) => {
const { document } = this.props;
this.props.ui.setActiveModal('document-delete', { document });
};
- handleMove = () => {
+ handleMove = (ev: SyntheticEvent) => {
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();
};
- handleUnstar = () => {
+ handleUnstar = (ev: SyntheticEvent) => {
this.props.document.unstar();
};
- handleExport = () => {
+ handleExport = (ev: SyntheticEvent) => {
this.props.document.download();
};
render() {
- const { document, label } = this.props;
+ const { document, label, className } = this.props;
const isDraft = !document.publishedAt;
return (
- }>
+ } className={className}>
{!isDraft && (
+ {document.pinned ? (
+
+ Unpin
+
+ ) : (
+ Pin
+ )}
{document.starred ? (
Unstar
@@ -62,6 +78,7 @@ class DocumentMenu extends Component {
Star
)}
+
Move…
)}
+ Delete…
+
Download
Print
- Delete…
);
}
diff --git a/app/models/Document.js b/app/models/Document.js
index 80da8eab..0237b4f2 100644
--- a/app/models/Document.js
+++ b/app/models/Document.js
@@ -16,7 +16,7 @@ class Document extends BaseModel {
hasPendingChanges: boolean = false;
errors: ErrorsStore;
- collaborators: Array;
+ collaborators: User[];
collection: $Shape;
collectionId: string;
firstViewedAt: ?string;
@@ -24,18 +24,19 @@ class Document extends BaseModel {
modifiedSinceViewed: ?boolean;
createdAt: string;
createdBy: User;
+ updatedAt: string;
+ updatedBy: User;
html: string;
id: string;
team: string;
emoji: string;
private: boolean = false;
starred: boolean = false;
+ pinned: boolean = false;
text: string = '';
title: string = '';
parentDocument: ?string;
publishedAt: ?string;
- updatedAt: string;
- updatedBy: User;
url: string;
views: number;
revision: number;
@@ -98,6 +99,28 @@ class Document extends BaseModel {
/* 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
star = async () => {
this.starred = true;
diff --git a/app/scenes/Collection/Collection.js b/app/scenes/Collection/Collection.js
index 7e70c249..80bf0ead 100644
--- a/app/scenes/Collection/Collection.js
+++ b/app/scenes/Collection/Collection.js
@@ -17,6 +17,7 @@ import Actions, { Action, Separator } from 'components/Actions';
import CenteredContent from 'components/CenteredContent';
import CollectionIcon from 'components/Icon/CollectionIcon';
import NewDocumentIcon from 'components/Icon/NewDocumentIcon';
+import PinIcon from 'components/Icon/PinIcon';
import { ListPlaceholder } from 'components/LoadingPlaceholder';
import Button from 'components/Button';
import HelpText from 'components/HelpText';
@@ -55,16 +56,21 @@ class CollectionScene extends Component {
loadContent = async (id: string) => {
const { collections } = this.props;
-
const collection = collections.getById(id) || (await collections.fetch(id));
if (collection) {
this.props.ui.setActiveCollection(collection);
this.collection = collection;
- await this.props.documents.fetchRecentlyModified({
- limit: 10,
- collection: collection.id,
- });
+
+ await Promise.all([
+ this.props.documents.fetchRecentlyModified({
+ limit: 10,
+ collection: id,
+ }),
+ this.props.documents.fetchPinned({
+ collection: id,
+ }),
+ ]);
}
this.isFetching = false;
@@ -132,10 +138,18 @@ class CollectionScene extends Component {
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 (
{this.collection ? (
-
+
{' '}
{this.collection.name}
+
+ {hasPinnedDocuments && (
+
+
+ Pinned
+
+
+
+ )}
+
Recently edited
-
+
{this.renderActions()}
-
+
) : (
)}
@@ -161,6 +181,12 @@ class CollectionScene extends Component {
}
}
+const TinyPinIcon = styled(PinIcon)`
+ position: relative;
+ top: 4px;
+ opacity: 0.8;
+`;
+
const Heading = styled.h1`
display: flex;
diff --git a/app/stores/DocumentsStore.js b/app/stores/DocumentsStore.js
index 4367b2d3..d161deaf 100644
--- a/app/stores/DocumentsStore.js
+++ b/app/stores/DocumentsStore.js
@@ -60,10 +60,19 @@ class DocumentsStore extends BaseStore {
return docs;
}
- recentlyEditedIn(documentIds: string[]): Document[] {
+ pinnedInCollection(collectionId: string): Document[] {
+ return _.filter(
+ this.recentlyEditedInCollection(collectionId),
+ document => document.pinned
+ );
+ }
+
+ recentlyEditedInCollection(collectionId: string): Document[] {
return _.orderBy(
- _.filter(this.data.values(), document =>
- documentIds.includes(document.id)
+ _.filter(
+ this.data.values(),
+ document =>
+ document.collectionId === collectionId && !!document.publishedAt
),
'updatedAt',
'desc'
@@ -147,6 +156,11 @@ class DocumentsStore extends BaseStore {
await this.fetchPage('drafts', options);
};
+ @action
+ fetchPinned = async (options: ?PaginationParams): Promise<*> => {
+ await this.fetchPage('pinned', options);
+ };
+
@action
search = async (
query: string,
diff --git a/server/api/__snapshots__/documents.test.js.snap b/server/api/__snapshots__/documents.test.js.snap
index 747d364a..eaf3a72b 100644
--- a/server/api/__snapshots__/documents.test.js.snap
+++ b/server/api/__snapshots__/documents.test.js.snap
@@ -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`] = `
Object {
"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`] = `
Object {
"error": "authentication_required",
diff --git a/server/api/documents.js b/server/api/documents.js
index 8b4a684a..1a9c20df 100644
--- a/server/api/documents.js
+++ b/server/api/documents.js
@@ -19,8 +19,7 @@ router.post('documents.list', auth(), pagination(), async ctx => {
let where = { teamId: user.teamId };
if (collection) where = { ...where, atlasId: collection };
- const userId = user.id;
- const starredScope = { method: ['withStarred', userId] };
+ const starredScope = { method: ['withStarred', user.id] };
const documents = await Document.scope('defaultScope', starredScope).findAll({
where,
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 => {
let { sort = 'updatedAt', direction } = ctx.body;
if (direction !== 'ASC') direction = 'DESC';
@@ -166,8 +195,7 @@ router.post('documents.search', auth(), pagination(), async ctx => {
const { offset, limit } = ctx.state.pagination;
ctx.assertPresent(query, 'query is required');
- const user = await ctx.state.user;
-
+ const user = ctx.state.user;
const documents = await Document.searchForUser(user, query, {
offset,
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 => {
const { id } = ctx.body;
ctx.assertPresent(id, 'id is required');
- const user = await ctx.state.user;
+ const user = ctx.state.user;
const document = await Document.findById(id);
- authorize(ctx.state.user, 'read', document);
+ authorize(user, 'read', document);
await Star.findOrCreate({
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 => {
const { id } = ctx.body;
ctx.assertPresent(id, 'id is required');
- const user = await ctx.state.user;
+ const user = ctx.state.user;
const document = await Document.findById(id);
- authorize(ctx.state.user, 'read', document);
+ authorize(user, 'read', document);
await Star.destroy({
where: { documentId: document.id, userId: user.id },
diff --git a/server/api/documents.test.js b/server/api/documents.test.js
index dcd4a4f0..bcd5abb6 100644
--- a/server/api/documents.test.js
+++ b/server/api/documents.test.js
@@ -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 () => {
it('should star the document', async () => {
const { user, document } = await seed();
diff --git a/server/migrations/20180225203847-document-pinning.js b/server/migrations/20180225203847-document-pinning.js
new file mode 100644
index 00000000..b0f153c9
--- /dev/null
+++ b/server/migrations/20180225203847-document-pinning.js
@@ -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');
+ }
+};
diff --git a/server/models/Document.js b/server/models/Document.js
index e5802601..878ac1d1 100644
--- a/server/models/Document.js
+++ b/server/models/Document.js
@@ -85,20 +85,6 @@ const Document = sequelize.define(
revisionCount: { type: DataTypes.INTEGER, defaultValue: 0 },
publishedAt: DataTypes.DATE,
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),
},
{
@@ -129,6 +115,10 @@ Document.associate = models => {
as: 'updatedBy',
foreignKey: 'lastModifiedById',
});
+ Document.belongsTo(models.User, {
+ as: 'pinnedBy',
+ foreignKey: 'pinnedById',
+ });
Document.hasMany(models.Revision, {
as: 'revisions',
onDelete: 'cascade',
diff --git a/server/pages/Api.js b/server/pages/Api.js
index f640b4db..43c09878 100644
--- a/server/pages/Api.js
+++ b/server/pages/Api.js
@@ -427,6 +427,34 @@ export default function Pricing() {
+
+
+ Pins a document to the collection home. The pinned document is
+ visible to all members of the team.
+
+
+
+
+
+
+
+
+ Unpins a document from the collection home. It will still remain
+ in the collection itself.
+
+
+
+
+
+
Star (favorite) a document for authenticated user.
@@ -442,7 +470,7 @@ export default function Pricing() {
- Unstar as starred (favorited) a document for authenticated user.
+ Unstar a starred (favorited) document for authenticated user.
+
+ Return pinned documents for a collection
+
+
+