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:
Tom Moor
2018-02-28 23:28:36 -08:00
committed by GitHub
parent 1722b3f3d9
commit 18b0338736
16 changed files with 399 additions and 101 deletions

View File

@@ -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",

View File

@@ -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 },

View File

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

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

View File

@@ -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',

View File

@@ -427,6 +427,34 @@ export default function Pricing() {
</Arguments>
</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">
<Description>
Star (favorite) a document for authenticated user.
@@ -442,7 +470,7 @@ export default function Pricing() {
<Method method="documents.unstar" label="Unstar a document">
<Description>
Unstar as starred (favorited) a document for authenticated user.
Unstar a starred (favorited) document for authenticated user.
</Description>
<Arguments>
<Argument
@@ -473,6 +501,14 @@ export default function Pricing() {
<Arguments pagination />
</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="documents.revisions"
label="Get revisions for a document"

View File

@@ -39,6 +39,7 @@ async function present(ctx: Object, document: Document, options: ?Options) {
team: document.teamId,
collaborators: [],
starred: !!(document.starred && document.starred.length),
pinned: !!document.pinnedById,
revision: document.revisionCount,
collectionId: document.atlasId,
collaboratorCount: undefined,