From 37fcf46972ad69fe5a9546e793faafd948b7c28c Mon Sep 17 00:00:00 2001 From: Andrew Nicolaou Date: Fri, 30 Aug 2019 20:26:57 +0200 Subject: [PATCH 01/25] Public API: Create new project (fixes #1095) (#1106) * Converts import script to use public API endpoints The endpoints don't exist yet, but this is a good way to see how the implementation of the data structures differ. * Exposes public API endpoint to fetch user's sketches * Implements public API delete endpoint * Adds helper to create custom ApplicationError classes * Adds create project endpoint that understand API's data structure This transforms the nested tree of file data into a mongoose Project model * Returns '201 Created' to match API spec * Removes 'CustomError' variable assignment as it shows up in test output * transformFiles will return file validation errors * Tests API project controller * Tests toModel() * Creates default files if no root-level .html file is provided * Do not auto-generate a slug if it is provided Fixes a bug where the slug was auto-generated using the sketch name, even if a slug property had been provided. * Validates uniqueness of slugs for projects created by the public API * Adds tests for slug uniqueness * Configures node's Promise implementation for mongoose (fixes warnings) * Moves createProject tests to match controller location * Adds support for code to ApplicationErrors * deleteProject controller tests * getProjectsForUser controller tests - implements tests - update apiKey tests to use new User mocks * Ensure error objects have consistent property names `message` is used as a high-level description of the errors `detail` is optional and has an plain language explanation of the individual errors `errors` is an array of each individual problem from `detail` in a machine-readable format * Assert environment variables are provided at script start * Version public API * Expect "files" property to always be provided * Fixes linting error * Converts import script to use public API endpoints The endpoints don't exist yet, but this is a good way to see how the implementation of the data structures differ. * Exposes public API endpoint to fetch user's sketches * Implements public API delete endpoint * Adds helper to create custom ApplicationError classes * Adds create project endpoint that understand API's data structure This transforms the nested tree of file data into a mongoose Project model * Returns '201 Created' to match API spec * Removes 'CustomError' variable assignment as it shows up in test output * transformFiles will return file validation errors * Tests API project controller * Tests toModel() * Creates default files if no root-level .html file is provided * Do not auto-generate a slug if it is provided Fixes a bug where the slug was auto-generated using the sketch name, even if a slug property had been provided. * Validates uniqueness of slugs for projects created by the public API * Adds tests for slug uniqueness * Configures node's Promise implementation for mongoose (fixes warnings) * Moves createProject tests to match controller location * deleteProject controller tests * Adds support for code to ApplicationErrors * getProjectsForUser controller tests - implements tests - update apiKey tests to use new User mocks * Ensure error objects have consistent property names `message` is used as a high-level description of the errors `detail` is optional and has an plain language explanation of the individual errors `errors` is an array of each individual problem from `detail` in a machine-readable format * Assert environment variables are provided at script start * Version public API * Expect "files" property to always be provided * Fixes linting error * Checks that authenticated user has permission to create under this namespace Previously, the project was always created under the authenticated user's namespace, but this not obvious behaviour. --- jest.setup.js | 3 + .../controllers/__mocks__/aws.controller.js | 5 + .../__test__/project.controller.test.js | 155 ------- server/controllers/project.controller.js | 59 +-- .../__test__/createProject.test.js | 391 ++++++++++++++++++ .../__test__/deleteProject.test.js | 119 ++++++ .../__test__/getProjectsForUser.test.js | 151 +++++++ .../project.controller/createProject.js | 65 +++ .../project.controller/deleteProject.js | 57 +++ .../project.controller/getProjectsForUser.js | 72 ++++ .../user.controller/__tests__/apiKey.test.js | 182 ++++---- server/controllers/user.controller/apiKey.js | 106 +++-- server/domain-objects/Project.js | 133 ++++++ .../domain-objects/__test__/Project.test.js | 385 +++++++++++++++++ server/domain-objects/createDefaultFiles.js | 48 +++ server/models/__mocks__/project.js | 18 + server/models/__mocks__/user.js | 41 +- server/models/project.js | 37 +- server/routes/api.routes.js | 27 ++ server/scripts/examples-ml5.js | 342 +++++++-------- server/server.js | 4 +- server/utils/__mocks__/createId.js | 16 + server/utils/createApplicationErrorClass.js | 33 ++ server/utils/createId.js | 8 + 24 files changed, 1905 insertions(+), 552 deletions(-) create mode 100644 server/controllers/__mocks__/aws.controller.js delete mode 100644 server/controllers/__test__/project.controller.test.js create mode 100644 server/controllers/project.controller/__test__/createProject.test.js create mode 100644 server/controllers/project.controller/__test__/deleteProject.test.js create mode 100644 server/controllers/project.controller/__test__/getProjectsForUser.test.js create mode 100644 server/controllers/project.controller/deleteProject.js create mode 100644 server/controllers/project.controller/getProjectsForUser.js create mode 100644 server/domain-objects/Project.js create mode 100644 server/domain-objects/__test__/Project.test.js create mode 100644 server/domain-objects/createDefaultFiles.js create mode 100644 server/routes/api.routes.js create mode 100644 server/utils/__mocks__/createId.js create mode 100644 server/utils/createApplicationErrorClass.js create mode 100644 server/utils/createId.js diff --git a/jest.setup.js b/jest.setup.js index 7c9d2b77..da036785 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -1,5 +1,8 @@ import { configure } from 'enzyme' import Adapter from 'enzyme-adapter-react-16' import '@babel/polyfill' +import mongoose from 'mongoose' + +mongoose.Promise = global.Promise; configure({ adapter: new Adapter() }) diff --git a/server/controllers/__mocks__/aws.controller.js b/server/controllers/__mocks__/aws.controller.js new file mode 100644 index 00000000..9e5709c4 --- /dev/null +++ b/server/controllers/__mocks__/aws.controller.js @@ -0,0 +1,5 @@ +export const getObjectKey = jest.mock(); +export const deleteObjectsFromS3 = jest.fn(); +export const signS3 = jest.fn(); +export const copyObjectInS3 = jest.fn(); +export const listObjectsInS3ForUser = jest.fn(); diff --git a/server/controllers/__test__/project.controller.test.js b/server/controllers/__test__/project.controller.test.js deleted file mode 100644 index e0852e61..00000000 --- a/server/controllers/__test__/project.controller.test.js +++ /dev/null @@ -1,155 +0,0 @@ -/** - * @jest-environment node - */ -import { Response } from 'jest-express'; - -import { createMock } from '../../models/project'; -import createProject from '../project.controller/createProject'; - -jest.mock('../../models/project'); - -describe('project.controller', () => { - describe('createProject()', () => { - let ProjectMock; - - beforeEach(() => { - ProjectMock = createMock(); - }); - - afterEach(() => { - ProjectMock.restore(); - }); - - it('fails if create fails', (done) => { - const error = new Error('An error'); - - ProjectMock - .expects('create') - .rejects(error); - - const request = { user: {} }; - const response = new Response(); - - const promise = createProject(request, response); - - function expectations() { - expect(response.json).toHaveBeenCalledWith({ success: false }); - - done(); - } - - promise.then(expectations, expectations).catch(expectations); - }); - - it('extracts parameters from request body', (done) => { - const request = { - user: { _id: 'abc123' }, - body: { - name: 'Wriggly worm', - files: [{ name: 'file.js', content: 'var hello = true;' }] - } - }; - const response = new Response(); - - - ProjectMock - .expects('create') - .withArgs({ - user: 'abc123', - name: 'Wriggly worm', - files: [{ name: 'file.js', content: 'var hello = true;' }] - }) - .resolves(); - - const promise = createProject(request, response); - - function expectations() { - expect(response.json).toHaveBeenCalled(); - - done(); - } - - promise.then(expectations, expectations).catch(expectations); - }); - - // TODO: This should be extracted to a new model object - // so the controllers just have to call a single - // method for this operation - it('populates referenced user on project creation', (done) => { - const request = { user: { _id: 'abc123' } }; - const response = new Response(); - - const result = { - _id: 'abc123', - id: 'abc123', - name: 'Project name', - serveSecure: false, - files: [] - }; - - const resultWithUser = { - ...result, - user: {} - }; - - ProjectMock - .expects('create') - .withArgs({ user: 'abc123' }) - .resolves(result); - - ProjectMock - .expects('populate') - .withArgs(result) - .yields(null, resultWithUser) - .resolves(resultWithUser); - - const promise = createProject(request, response); - - function expectations() { - const doc = response.json.mock.calls[0][0]; - - expect(response.json).toHaveBeenCalled(); - - expect(JSON.parse(JSON.stringify(doc))).toMatchObject(resultWithUser); - - done(); - } - - promise.then(expectations, expectations).catch(expectations); - }); - - it('fails if referenced user population fails', (done) => { - const request = { user: { _id: 'abc123' } }; - const response = new Response(); - - const result = { - _id: 'abc123', - id: 'abc123', - name: 'Project name', - serveSecure: false, - files: [] - }; - - const error = new Error('An error'); - - ProjectMock - .expects('create') - .resolves(result); - - ProjectMock - .expects('populate') - .yields(error) - .resolves(error); - - const promise = createProject(request, response); - - function expectations() { - expect(response.json).toHaveBeenCalledWith({ success: false }); - - done(); - } - - promise.then(expectations, expectations).catch(expectations); - }); - }); -}); diff --git a/server/controllers/project.controller.js b/server/controllers/project.controller.js index ae029918..bf59a865 100644 --- a/server/controllers/project.controller.js +++ b/server/controllers/project.controller.js @@ -2,7 +2,6 @@ import archiver from 'archiver'; import format from 'date-fns/format'; import isUrl from 'is-url'; import jsdom, { serializeDocument } from 'jsdom'; -import isBefore from 'date-fns/is_before'; import isAfter from 'date-fns/is_after'; import request from 'request'; import slugify from 'slugify'; @@ -10,9 +9,10 @@ import Project from '../models/project'; import User from '../models/user'; import { resolvePathToFile } from '../utils/filePath'; import generateFileSystemSafeName from '../utils/generateFileSystemSafeName'; -import { deleteObjectsFromS3, getObjectKey } from './aws.controller'; -export { default as createProject } from './project.controller/createProject'; +export { default as createProject, apiCreateProject } from './project.controller/createProject'; +export { default as deleteProject } from './project.controller/deleteProject'; +export { default as getProjectsForUser, apiGetProjectsForUser } from './project.controller/getProjectsForUser'; export function updateProject(req, res) { Project.findById(req.params.project_id, (findProjectErr, project) => { @@ -84,37 +84,6 @@ export function getProject(req, res) { }); } -function deleteFilesFromS3(files) { - deleteObjectsFromS3(files.filter((file) => { - if (file.url) { - if (!process.env.S3_DATE || ( - process.env.S3_DATE && - isBefore(new Date(process.env.S3_DATE), new Date(file.createdAt)))) { - return true; - } - } - return false; - }) - .map(file => getObjectKey(file.url))); -} - -export function deleteProject(req, res) { - Project.findById(req.params.project_id, (findProjectErr, project) => { - if (!project.user.equals(req.user._id)) { - res.status(403).json({ success: false, message: 'Session does not match owner of project.' }); - return; - } - deleteFilesFromS3(project.files); - Project.remove({ _id: req.params.project_id }, (removeProjectError) => { - if (removeProjectError) { - res.status(404).send({ message: 'Project with that id does not exist' }); - return; - } - res.json({ success: true }); - }); - }); -} - export function getProjectsForUserId(userId) { return new Promise((resolve, reject) => { Project.find({ user: userId }) @@ -157,10 +126,6 @@ export function getProjectAsset(req, res) { }); } -export function getProjectsForUserName(username) { - -} - export function getProjects(req, res) { if (req.user) { getProjectsForUserId(req.user._id) @@ -173,24 +138,6 @@ export function getProjects(req, res) { } } -export function getProjectsForUser(req, res) { - if (req.params.username) { - User.findOne({ username: req.params.username }, (err, user) => { - if (!user) { - res.status(404).json({ message: 'User with that username does not exist.' }); - return; - } - Project.find({ user: user._id }) - .sort('-createdAt') - .select('name files id createdAt updatedAt') - .exec((innerErr, projects) => res.json(projects)); - }); - } else { - // could just move this to client side - res.json([]); - } -} - export function projectExists(projectId, callback) { Project.findById(projectId, (err, project) => ( project ? callback(true) : callback(false) diff --git a/server/controllers/project.controller/__test__/createProject.test.js b/server/controllers/project.controller/__test__/createProject.test.js new file mode 100644 index 00000000..68025a0c --- /dev/null +++ b/server/controllers/project.controller/__test__/createProject.test.js @@ -0,0 +1,391 @@ +/** + * @jest-environment node + */ +import { Response } from 'jest-express'; + +import Project, { createMock, createInstanceMock } from '../../../models/project'; +import createProject, { apiCreateProject } from '../createProject'; + +jest.mock('../../../models/project'); + +describe('project.controller', () => { + describe('createProject()', () => { + let ProjectMock; + + beforeEach(() => { + ProjectMock = createMock(); + }); + + afterEach(() => { + ProjectMock.restore(); + }); + + it('fails if create fails', (done) => { + const error = new Error('An error'); + + ProjectMock + .expects('create') + .rejects(error); + + const request = { user: {} }; + const response = new Response(); + + const promise = createProject(request, response); + + function expectations() { + expect(response.json).toHaveBeenCalledWith({ success: false }); + + done(); + } + + promise.then(expectations, expectations).catch(expectations); + }); + + it('extracts parameters from request body', (done) => { + const request = { + user: { _id: 'abc123' }, + body: { + name: 'Wriggly worm', + files: [{ name: 'file.js', content: 'var hello = true;' }] + } + }; + const response = new Response(); + + + ProjectMock + .expects('create') + .withArgs({ + user: 'abc123', + name: 'Wriggly worm', + files: [{ name: 'file.js', content: 'var hello = true;' }] + }) + .resolves(); + + const promise = createProject(request, response); + + function expectations() { + expect(response.json).toHaveBeenCalled(); + + done(); + } + + promise.then(expectations, expectations).catch(expectations); + }); + + // TODO: This should be extracted to a new model object + // so the controllers just have to call a single + // method for this operation + it('populates referenced user on project creation', (done) => { + const request = { user: { _id: 'abc123' } }; + const response = new Response(); + + const result = { + _id: 'abc123', + id: 'abc123', + name: 'Project name', + serveSecure: false, + files: [] + }; + + const resultWithUser = { + ...result, + user: {} + }; + + ProjectMock + .expects('create') + .withArgs({ user: 'abc123' }) + .resolves(result); + + ProjectMock + .expects('populate') + .withArgs(result) + .yields(null, resultWithUser) + .resolves(resultWithUser); + + const promise = createProject(request, response); + + function expectations() { + const doc = response.json.mock.calls[0][0]; + + expect(response.json).toHaveBeenCalled(); + + expect(JSON.parse(JSON.stringify(doc))).toMatchObject(resultWithUser); + + done(); + } + + promise.then(expectations, expectations).catch(expectations); + }); + + it('fails if referenced user population fails', (done) => { + const request = { user: { _id: 'abc123' } }; + const response = new Response(); + + const result = { + _id: 'abc123', + id: 'abc123', + name: 'Project name', + serveSecure: false, + files: [] + }; + + const error = new Error('An error'); + + ProjectMock + .expects('create') + .resolves(result); + + ProjectMock + .expects('populate') + .yields(error) + .resolves(error); + + const promise = createProject(request, response); + + function expectations() { + expect(response.json).toHaveBeenCalledWith({ success: false }); + + done(); + } + + promise.then(expectations, expectations).catch(expectations); + }); + }); + + describe('apiCreateProject()', () => { + let ProjectMock; + let ProjectInstanceMock; + + beforeEach(() => { + ProjectMock = createMock(); + ProjectInstanceMock = createInstanceMock(); + }); + + afterEach(() => { + ProjectMock.restore(); + ProjectInstanceMock.restore(); + }); + + it('returns 201 with id of created sketch', (done) => { + const request = { + user: { _id: 'abc123', username: 'alice' }, + params: { username: 'alice' }, + body: { + name: 'My sketch', + files: {} + } + }; + const response = new Response(); + + const result = { + _id: 'abc123', + id: 'abc123', + name: 'Project name', + serveSecure: false, + files: [] + }; + + ProjectInstanceMock.expects('isSlugUnique') + .resolves({ isUnique: true, conflictingIds: [] }); + + ProjectInstanceMock.expects('save') + .resolves(new Project(result)); + + const promise = apiCreateProject(request, response); + + function expectations() { + const doc = response.json.mock.calls[0][0]; + + expect(response.status).toHaveBeenCalledWith(201); + expect(response.json).toHaveBeenCalled(); + + expect(JSON.parse(JSON.stringify(doc))).toMatchObject({ + id: 'abc123' + }); + + done(); + } + + promise.then(expectations, expectations).catch(expectations); + }); + + it('fails if slug is not unique', (done) => { + const request = { + user: { _id: 'abc123', username: 'alice' }, + params: { username: 'alice' }, + body: { + name: 'My sketch', + slug: 'a-slug', + files: {} + } + }; + const response = new Response(); + + const result = { + _id: 'abc123', + id: 'abc123', + name: 'Project name', + // slug: 'a-slug', + serveSecure: false, + files: [] + }; + + ProjectInstanceMock.expects('isSlugUnique') + .resolves({ isUnique: false, conflictingIds: ['cde123'] }); + + ProjectInstanceMock.expects('save') + .resolves(new Project(result)); + + const promise = apiCreateProject(request, response); + + function expectations() { + const doc = response.json.mock.calls[0][0]; + + expect(response.status).toHaveBeenCalledWith(409); + expect(response.json).toHaveBeenCalled(); + + expect(JSON.parse(JSON.stringify(doc))).toMatchObject({ + message: 'Sketch Validation Failed', + detail: 'Slug "a-slug" is not unique. Check cde123' + }); + + done(); + } + + promise.then(expectations, expectations).catch(expectations); + }); + + it('fails if user does not have permission', (done) => { + const request = { + user: { _id: 'abc123', username: 'alice' }, + params: { + username: 'dana', + }, + body: { + name: 'My sketch', + slug: 'a-slug', + files: {} + } + }; + const response = new Response(); + + const result = { + _id: 'abc123', + id: 'abc123', + name: 'Project name', + serveSecure: false, + files: [] + }; + + ProjectInstanceMock.expects('isSlugUnique') + .resolves({ isUnique: true, conflictingIds: [] }); + + ProjectInstanceMock.expects('save') + .resolves(new Project(result)); + + const promise = apiCreateProject(request, response); + + function expectations() { + expect(response.status).toHaveBeenCalledWith(401); + expect(response.json).toHaveBeenCalled(); + + done(); + } + + promise.then(expectations, expectations).catch(expectations); + }); + + it('returns validation errors on files input', (done) => { + const request = { + user: { username: 'alice' }, + params: { username: 'alice' }, + body: { + name: 'My sketch', + files: { + 'index.html': { + // missing content or url + } + } + } + }; + const response = new Response(); + + const promise = apiCreateProject(request, response); + + function expectations() { + const doc = response.json.mock.calls[0][0]; + + const responseBody = JSON.parse(JSON.stringify(doc)); + + expect(response.status).toHaveBeenCalledWith(422); + expect(responseBody.message).toBe('File Validation Failed'); + expect(responseBody.detail).not.toBeNull(); + expect(responseBody.errors.length).toBe(1); + expect(responseBody.errors).toEqual([ + { name: 'index.html', message: 'missing \'url\' or \'content\'' } + ]); + + done(); + } + + promise.then(expectations, expectations).catch(expectations); + }); + + it('rejects file parameters not in object format', (done) => { + const request = { + user: { _id: 'abc123', username: 'alice' }, + params: { username: 'alice' }, + body: { + name: 'Wriggly worm', + files: [{ name: 'file.js', content: 'var hello = true;' }] + } + }; + const response = new Response(); + + const promise = apiCreateProject(request, response); + + function expectations() { + const doc = response.json.mock.calls[0][0]; + + const responseBody = JSON.parse(JSON.stringify(doc)); + + expect(response.status).toHaveBeenCalledWith(422); + expect(responseBody.message).toBe('File Validation Failed'); + expect(responseBody.detail).toBe('\'files\' must be an object'); + + done(); + } + + promise.then(expectations, expectations).catch(expectations); + }); + + it('rejects if files object in not provided', (done) => { + const request = { + user: { _id: 'abc123', username: 'alice' }, + params: { username: 'alice' }, + body: { + name: 'Wriggly worm', + // files: {} is missing + } + }; + const response = new Response(); + + const promise = apiCreateProject(request, response); + + function expectations() { + const doc = response.json.mock.calls[0][0]; + + const responseBody = JSON.parse(JSON.stringify(doc)); + + expect(response.status).toHaveBeenCalledWith(422); + expect(responseBody.message).toBe('File Validation Failed'); + expect(responseBody.detail).toBe('\'files\' must be an object'); + + done(); + } + + promise.then(expectations, expectations).catch(expectations); + }); + }); +}); diff --git a/server/controllers/project.controller/__test__/deleteProject.test.js b/server/controllers/project.controller/__test__/deleteProject.test.js new file mode 100644 index 00000000..84a6824f --- /dev/null +++ b/server/controllers/project.controller/__test__/deleteProject.test.js @@ -0,0 +1,119 @@ +/** + * @jest-environment node + */ +import { Request, Response } from 'jest-express'; + +import Project, { createMock, createInstanceMock } from '../../../models/project'; +import User from '../../../models/user'; +import deleteProject from '../../project.controller/deleteProject'; +import { deleteObjectsFromS3 } from '../../aws.controller'; + + +jest.mock('../../../models/project'); +jest.mock('../../aws.controller'); + +describe('project.controller', () => { + describe('deleteProject()', () => { + let ProjectMock; + let ProjectInstanceMock; + + beforeEach(() => { + ProjectMock = createMock(); + ProjectInstanceMock = createInstanceMock(); + }); + + afterEach(() => { + ProjectMock.restore(); + ProjectInstanceMock.restore(); + }); + + it('returns 403 if project is not owned by authenticated user', (done) => { + const user = new User(); + const project = new Project(); + project.user = user; + + const request = new Request(); + request.setParams({ project_id: project._id }); + request.user = { _id: 'abc123' }; + + const response = new Response(); + + ProjectMock + .expects('findById') + .resolves(project); + + const promise = deleteProject(request, response); + + function expectations() { + expect(response.status).toHaveBeenCalledWith(403); + expect(response.json).toHaveBeenCalledWith({ + message: 'Authenticated user does not match owner of project' + }); + + done(); + } + + promise.then(expectations, expectations).catch(expectations); + }); + + it('returns 404 if project does not exist', (done) => { + const user = new User(); + const project = new Project(); + project.user = user; + + const request = new Request(); + request.setParams({ project_id: project._id }); + request.user = { _id: 'abc123' }; + + const response = new Response(); + + ProjectMock + .expects('findById') + .resolves(null); + + const promise = deleteProject(request, response); + + function expectations() { + expect(response.status).toHaveBeenCalledWith(404); + expect(response.json).toHaveBeenCalledWith({ + message: 'Project with that id does not exist' + }); + + done(); + } + + promise.then(expectations, expectations).catch(expectations); + }); + + it('deletes project and dependent files from S3 ', (done) => { + const user = new User(); + const project = new Project(); + project.user = user; + + const request = new Request(); + request.setParams({ project_id: project._id }); + request.user = { _id: user._id }; + + const response = new Response(); + + ProjectMock + .expects('findById') + .resolves(project); + + ProjectInstanceMock.expects('remove') + .yields(); + + const promise = deleteProject(request, response); + + function expectations() { + expect(response.status).toHaveBeenCalledWith(200); + expect(response.json).not.toHaveBeenCalled(); + expect(deleteObjectsFromS3).toHaveBeenCalled(); + + done(); + } + + promise.then(expectations, expectations).catch(expectations); + }); + }); +}); diff --git a/server/controllers/project.controller/__test__/getProjectsForUser.test.js b/server/controllers/project.controller/__test__/getProjectsForUser.test.js new file mode 100644 index 00000000..05a0cf71 --- /dev/null +++ b/server/controllers/project.controller/__test__/getProjectsForUser.test.js @@ -0,0 +1,151 @@ +/** + * @jest-environment node + */ +import { Request, Response } from 'jest-express'; + +import { createMock } from '../../../models/user'; +import getProjectsForUser, { apiGetProjectsForUser } from '../../project.controller/getProjectsForUser'; + +jest.mock('../../../models/user'); +jest.mock('../../aws.controller'); + +describe('project.controller', () => { + let UserMock; + + beforeEach(() => { + UserMock = createMock(); + }); + + afterEach(() => { + UserMock.restore(); + }); + + describe('getProjectsForUser()', () => { + it('returns empty array user not supplied as parameter', (done) => { + const request = new Request(); + request.setParams({}); + const response = new Response(); + + const promise = getProjectsForUser(request, response); + + function expectations() { + expect(response.status).toHaveBeenCalledWith(200); + expect(response.json).toHaveBeenCalledWith([]); + + done(); + } + + promise.then(expectations, expectations).catch(expectations); + }); + + it('returns 404 if user does not exist', (done) => { + const request = new Request(); + request.setParams({ username: 'abc123' }); + const response = new Response(); + + UserMock + .expects('findOne') + .withArgs({ username: 'abc123' }) + .yields(null, null); + + const promise = getProjectsForUser(request, response); + + function expectations() { + expect(response.status).toHaveBeenCalledWith(404); + expect(response.json).toHaveBeenCalledWith({ message: 'User with that username does not exist.' }); + + done(); + } + + promise.then(expectations, expectations).catch(expectations); + }); + + it('returns 500 on other errors', (done) => { + const request = new Request(); + request.setParams({ username: 'abc123' }); + const response = new Response(); + + UserMock + .expects('findOne') + .withArgs({ username: 'abc123' }) + .yields(new Error(), null); + + const promise = getProjectsForUser(request, response); + + function expectations() { + expect(response.status).toHaveBeenCalledWith(500); + expect(response.json).toHaveBeenCalledWith({ message: 'Error fetching projects' }); + + done(); + } + + promise.then(expectations, expectations).catch(expectations); + }); + }); + + describe('apiGetProjectsForUser()', () => { + it('returns validation error if user id not provided', (done) => { + const request = new Request(); + request.setParams({}); + const response = new Response(); + + const promise = apiGetProjectsForUser(request, response); + + function expectations() { + expect(response.status).toHaveBeenCalledWith(422); + expect(response.json).toHaveBeenCalledWith({ + message: 'Username not provided' + }); + + done(); + } + + promise.then(expectations, expectations).catch(expectations); + }); + + + it('returns 404 if user does not exist', (done) => { + const request = new Request(); + request.setParams({ username: 'abc123' }); + const response = new Response(); + + UserMock + .expects('findOne') + .withArgs({ username: 'abc123' }) + .yields(null, null); + + const promise = apiGetProjectsForUser(request, response); + + function expectations() { + expect(response.status).toHaveBeenCalledWith(404); + expect(response.json).toHaveBeenCalledWith({ message: 'User with that username does not exist.' }); + + done(); + } + + promise.then(expectations, expectations).catch(expectations); + }); + + it('returns 500 on other errors', (done) => { + const request = new Request(); + request.setParams({ username: 'abc123' }); + const response = new Response(); + + UserMock + .expects('findOne') + .withArgs({ username: 'abc123' }) + .yields(new Error(), null); + + const promise = apiGetProjectsForUser(request, response); + + function expectations() { + expect(response.status).toHaveBeenCalledWith(500); + expect(response.json).toHaveBeenCalledWith({ message: 'Error fetching projects' }); + + done(); + } + + promise.then(expectations, expectations).catch(expectations); + }); + }); +}); diff --git a/server/controllers/project.controller/createProject.js b/server/controllers/project.controller/createProject.js index 2cf34e32..e4040268 100644 --- a/server/controllers/project.controller/createProject.js +++ b/server/controllers/project.controller/createProject.js @@ -1,4 +1,5 @@ import Project from '../../models/project'; +import { toModel, FileValidationError, ProjectValidationError } from '../../domain-objects/Project'; export default function createProject(req, res) { let projectValues = { @@ -30,3 +31,67 @@ export default function createProject(req, res) { .then(populateUserData) .catch(sendFailure); } + +// TODO: What happens if you don't supply any files? +export function apiCreateProject(req, res) { + const params = Object.assign({ user: req.user._id }, req.body); + + function sendValidationErrors(err, type, code = 422) { + res.status(code).json({ + message: `${type} Validation Failed`, + detail: err.message, + errors: err.files, + }); + } + + // TODO: Error handling to match spec + function sendFailure(err) { + res.status(500).end(); + } + + function handleErrors(err) { + if (err instanceof FileValidationError) { + sendValidationErrors(err, 'File', err.code); + } else if (err instanceof ProjectValidationError) { + sendValidationErrors(err, 'Sketch', err.code); + } else { + sendFailure(); + } + } + + function checkUserHasPermission() { + if (req.user.username !== req.params.username) { + console.log('no permission'); + const error = new ProjectValidationError(`'${req.user.username}' does not have permission to create for '${req.params.username}'`); + error.code = 401; + + throw error; + } + } + + try { + checkUserHasPermission(); + + const model = toModel(params); + + return model.isSlugUnique() + .then(({ isUnique, conflictingIds }) => { + if (isUnique) { + return model.save() + .then((newProject) => { + res.status(201).json({ id: newProject.id }); + }); + } + + const error = new ProjectValidationError(`Slug "${model.slug}" is not unique. Check ${conflictingIds.join(', ')}`); + error.code = 409; + + throw error; + }) + .then(checkUserHasPermission) + .catch(handleErrors); + } catch (err) { + handleErrors(err); + return Promise.reject(err); + } +} diff --git a/server/controllers/project.controller/deleteProject.js b/server/controllers/project.controller/deleteProject.js new file mode 100644 index 00000000..dd980b23 --- /dev/null +++ b/server/controllers/project.controller/deleteProject.js @@ -0,0 +1,57 @@ +import isBefore from 'date-fns/is_before'; +import Project from '../../models/project'; +import { deleteObjectsFromS3, getObjectKey } from '../aws.controller'; +import createApplicationErrorClass from '../../utils/createApplicationErrorClass'; + +const ProjectDeletionError = createApplicationErrorClass('ProjectDeletionError'); + +function deleteFilesFromS3(files) { + deleteObjectsFromS3(files.filter((file) => { + if (file.url) { + if (!process.env.S3_DATE || ( + process.env.S3_DATE && + isBefore(new Date(process.env.S3_DATE), new Date(file.createdAt)))) { + return true; + } + } + return false; + }) + .map(file => getObjectKey(file.url))); +} + +export default function deleteProject(req, res) { + function sendFailure(error) { + res.status(error.code).json({ message: error.message }); + } + + function sendProjectNotFound() { + sendFailure(new ProjectDeletionError('Project with that id does not exist', { code: 404 })); + } + + function handleProjectDeletion(project) { + if (project == null) { + sendProjectNotFound(); + return; + } + + if (!project.user.equals(req.user._id)) { + sendFailure(new ProjectDeletionError('Authenticated user does not match owner of project', { code: 403 })); + return; + } + + deleteFilesFromS3(project.files); + + project.remove((removeProjectError) => { + if (removeProjectError) { + sendProjectNotFound(); + return; + } + + res.status(200).end(); + }); + } + + return Project.findById(req.params.project_id) + .then(handleProjectDeletion) + .catch(sendFailure); +} diff --git a/server/controllers/project.controller/getProjectsForUser.js b/server/controllers/project.controller/getProjectsForUser.js new file mode 100644 index 00000000..1d9a0e34 --- /dev/null +++ b/server/controllers/project.controller/getProjectsForUser.js @@ -0,0 +1,72 @@ +import Project from '../../models/project'; +import User from '../../models/user'; +import { toApi as toApiProjectObject } from '../../domain-objects/Project'; +import createApplicationErrorClass from '../../utils/createApplicationErrorClass'; + +const UserNotFoundError = createApplicationErrorClass('UserNotFoundError'); + +function getProjectsForUserName(username) { + return new Promise((resolve, reject) => { + User.findOne({ username }, (err, user) => { + if (err) { + reject(err); + return; + } + + if (!user) { + reject(new UserNotFoundError()); + return; + } + + Project.find({ user: user._id }) + .sort('-createdAt') + .select('name files id createdAt updatedAt') + .exec((innerErr, projects) => { + if (innerErr) { + reject(innerErr); + return; + } + + resolve(projects); + }); + }); + }); +} + +export default function getProjectsForUser(req, res) { + if (req.params.username) { + return getProjectsForUserName(req.params.username) + .then(projects => res.json(projects)) + .catch((err) => { + if (err instanceof UserNotFoundError) { + res.status(404).json({ message: 'User with that username does not exist.' }); + } else { + res.status(500).json({ message: 'Error fetching projects' }); + } + }); + } + + // could just move this to client side + res.status(200).json([]); + return Promise.resolve(); +} + +export function apiGetProjectsForUser(req, res) { + if (req.params.username) { + return getProjectsForUserName(req.params.username) + .then((projects) => { + const asApiObjects = projects.map(p => toApiProjectObject(p)); + res.json({ sketches: asApiObjects }); + }) + .catch((err) => { + if (err instanceof UserNotFoundError) { + res.status(404).json({ message: 'User with that username does not exist.' }); + } else { + res.status(500).json({ message: 'Error fetching projects' }); + } + }); + } + + res.status(422).json({ message: 'Username not provided' }); + return Promise.resolve(); +} diff --git a/server/controllers/user.controller/__tests__/apiKey.test.js b/server/controllers/user.controller/__tests__/apiKey.test.js index a496373b..7e396288 100644 --- a/server/controllers/user.controller/__tests__/apiKey.test.js +++ b/server/controllers/user.controller/__tests__/apiKey.test.js @@ -1,73 +1,40 @@ /* @jest-environment node */ import last from 'lodash/last'; +import { Request, Response } from 'jest-express'; + +import User, { createMock, createInstanceMock } from '../../../models/user'; import { createApiKey, removeApiKey } from '../../user.controller/apiKey'; jest.mock('../../../models/user'); -/* - Create a mock object representing an express Response -*/ -const createResponseMock = function createResponseMock(done) { - const json = jest.fn(() => { - if (done) { done(); } - }); - - const status = jest.fn(() => ({ json })); - - return { - status, - json - }; -}; - -/* - Create a mock of the mongoose User model -*/ -const createUserMock = function createUserMock() { - const apiKeys = []; - let nextId = 0; - - apiKeys.push = ({ label, hashedKey }) => { - const id = nextId; - nextId += 1; - const publicFields = { id, label }; - const allFields = { ...publicFields, hashedKey }; - - Object.defineProperty(allFields, 'toObject', { - value: () => publicFields, - enumerable: false - }); - - return Array.prototype.push.call(apiKeys, allFields); - }; - - apiKeys.pull = ({ _id }) => { - const index = apiKeys.findIndex(({ id }) => id === _id); - return apiKeys.splice(index, 1); - }; - - return { - apiKeys, - save: jest.fn(callback => callback()) - }; -}; - -const User = require('../../../models/user').default; - describe('user.controller', () => { + let UserMock; + let UserInstanceMock; + beforeEach(() => { - User.__setFindById(null, null); + UserMock = createMock(); + UserInstanceMock = createInstanceMock(); }); + afterEach(() => { + UserMock.restore(); + UserInstanceMock.restore(); + }); + + describe('createApiKey', () => { it('returns an error if user doesn\'t exist', () => { const request = { user: { id: '1234' } }; - const response = createResponseMock(); + const response = new Response(); + + UserMock + .expects('findById') + .withArgs('1234') + .yields(null, null); createApiKey(request, response); - expect(User.findById.mock.calls[0][0]).toBe('1234'); expect(response.status).toHaveBeenCalledWith(404); expect(response.json).toHaveBeenCalledWith({ error: 'User not found' @@ -75,10 +42,13 @@ describe('user.controller', () => { }); it('returns an error if label not provided', () => { - User.__setFindById(undefined, createUserMock()); - const request = { user: { id: '1234' }, body: {} }; - const response = createResponseMock(); + const response = new Response(); + + UserMock + .expects('findById') + .withArgs('1234') + .yields(null, new User()); createApiKey(request, response); @@ -89,46 +59,59 @@ describe('user.controller', () => { }); it('returns generated API key to the user', (done) => { - let response; + const request = new Request(); + request.setBody({ label: 'my key' }); + request.user = { id: '1234' }; - const request = { - user: { id: '1234' }, - body: { label: 'my key' } - }; + const response = new Response(); - const user = createUserMock(); + const user = new User(); - const checkExpecations = () => { + UserMock + .expects('findById') + .withArgs('1234') + .yields(null, user); + + UserInstanceMock.expects('save') + .yields(); + + function expectations() { const lastKey = last(user.apiKeys); expect(lastKey.label).toBe('my key'); expect(typeof lastKey.hashedKey).toBe('string'); - expect(response.json).toHaveBeenCalledWith({ - apiKeys: [ - { id: 0, label: 'my key', token: lastKey.hashedKey } - ] + const responseData = response.json.mock.calls[0][0]; + + expect(responseData.apiKeys.length).toBe(1); + expect(responseData.apiKeys[0]).toMatchObject({ + label: 'my key', + token: lastKey.hashedKey, + lastUsedAt: undefined, + createdAt: undefined }); done(); - }; + } - response = createResponseMock(checkExpecations); + const promise = createApiKey(request, response); - User.__setFindById(undefined, user); - - createApiKey(request, response); + promise.then(expectations, expectations).catch(expectations); }); }); describe('removeApiKey', () => { it('returns an error if user doesn\'t exist', () => { const request = { user: { id: '1234' } }; - const response = createResponseMock(); + const response = new Response(); + + UserMock + .expects('findById') + .withArgs('1234') + .yields(null, null); removeApiKey(request, response); - expect(User.findById.mock.calls[0][0]).toBe('1234'); expect(response.status).toHaveBeenCalledWith(404); expect(response.json).toHaveBeenCalledWith({ error: 'User not found' @@ -140,10 +123,13 @@ describe('user.controller', () => { user: { id: '1234' }, params: { keyId: 'not-a-real-key' } }; - const response = createResponseMock(); + const response = new Response(); + const user = new User(); - const user = createUserMock(); - User.__setFindById(undefined, user); + UserMock + .expects('findById') + .withArgs('1234') + .yields(null, user); removeApiKey(request, response); @@ -153,27 +139,41 @@ describe('user.controller', () => { }); }); - it.skip('removes key if it exists', () => { - const request = { - user: { id: '1234' }, - params: { keyId: 0 } - }; - const response = createResponseMock(); - - const user = createUserMock(); - + it('removes key if it exists', () => { + const user = new User(); user.apiKeys.push({ label: 'first key' }); // id 0 user.apiKeys.push({ label: 'second key' }); // id 1 - User.__setFindById(undefined, user); + const firstKeyId = user.apiKeys[0]._id.toString(); + const secondKeyId = user.apiKeys[1]._id.toString(); + + const request = { + user: { id: '1234' }, + params: { keyId: firstKeyId } + }; + const response = new Response(); + + UserMock + .expects('findById') + .withArgs('1234') + .yields(null, user); + + UserInstanceMock + .expects('save') + .yields(); removeApiKey(request, response); expect(response.status).toHaveBeenCalledWith(200); - expect(response.json).toHaveBeenCalledWith({ - apiKeys: [ - { id: 1, label: 'second key' } - ] + + const responseData = response.json.mock.calls[0][0]; + + expect(responseData.apiKeys.length).toBe(1); + expect(responseData.apiKeys[0]).toMatchObject({ + id: secondKeyId, + label: 'second key', + lastUsedAt: undefined, + createdAt: undefined }); }); }); diff --git a/server/controllers/user.controller/apiKey.js b/server/controllers/user.controller/apiKey.js index eed22e6f..5d45b123 100644 --- a/server/controllers/user.controller/apiKey.js +++ b/server/controllers/user.controller/apiKey.js @@ -19,67 +19,85 @@ function generateApiKey() { } export function createApiKey(req, res) { - User.findById(req.user.id, async (err, user) => { - if (!user) { - res.status(404).json({ error: 'User not found' }); - return; + return new Promise((resolve, reject) => { + function sendFailure(code, error) { + res.status(code).json({ error }); + resolve(); } - if (!req.body.label) { - res.status(400).json({ error: 'Expected field \'label\' was not present in request body' }); - return; - } - - const keyToBeHashed = await generateApiKey(); - - const addedApiKeyIndex = user.apiKeys.push({ label: req.body.label, hashedKey: keyToBeHashed }); - - user.save((saveErr) => { - if (saveErr) { - res.status(500).json({ error: saveErr }); + User.findById(req.user.id, async (err, user) => { + if (!user) { + sendFailure(404, 'User not found'); return; } - const apiKeys = user.apiKeys - .map((apiKey, index) => { - const fields = apiKey.toObject(); - const shouldIncludeToken = index === addedApiKeyIndex - 1; + if (!req.body.label) { + sendFailure(400, 'Expected field \'label\' was not present in request body'); + return; + } - return shouldIncludeToken ? - { ...fields, token: keyToBeHashed } : - fields; - }); + const keyToBeHashed = await generateApiKey(); - res.json({ apiKeys }); + const addedApiKeyIndex = user.apiKeys.push({ label: req.body.label, hashedKey: keyToBeHashed }); + + user.save((saveErr) => { + if (saveErr) { + sendFailure(500, saveErr); + return; + } + + const apiKeys = user.apiKeys + .map((apiKey, index) => { + const fields = apiKey.toObject(); + const shouldIncludeToken = index === addedApiKeyIndex - 1; + + return shouldIncludeToken ? + { ...fields, token: keyToBeHashed } : + fields; + }); + + res.json({ apiKeys }); + resolve(); + }); }); }); } export function removeApiKey(req, res) { - User.findById(req.user.id, (err, user) => { - if (err) { - res.status(500).json({ error: err }); - return; - } - if (!user) { - res.status(404).json({ error: 'User not found' }); - return; - } - const keyToDelete = user.apiKeys.find(key => key.id === req.params.keyId); - if (!keyToDelete) { - res.status(404).json({ error: 'Key does not exist for user' }); - return; + return new Promise((resolve, reject) => { + function sendFailure(code, error) { + res.status(code).json({ error }); + resolve(); } - user.apiKeys.pull({ _id: req.params.keyId }); - - user.save((saveErr) => { - if (saveErr) { - res.status(500).json({ error: saveErr }); + User.findById(req.user.id, (err, user) => { + if (err) { + sendFailure(500, err); return; } - res.status(200).json({ apiKeys: user.apiKeys }); + if (!user) { + sendFailure(404, 'User not found'); + return; + } + + const keyToDelete = user.apiKeys.find(key => key.id === req.params.keyId); + if (!keyToDelete) { + sendFailure(404, 'Key does not exist for user'); + return; + } + + user.apiKeys.pull({ _id: req.params.keyId }); + + user.save((saveErr) => { + if (saveErr) { + sendFailure(500, saveErr); + return; + } + + res.status(200).json({ apiKeys: user.apiKeys }); + resolve(); + }); }); }); } diff --git a/server/domain-objects/Project.js b/server/domain-objects/Project.js new file mode 100644 index 00000000..f0de023f --- /dev/null +++ b/server/domain-objects/Project.js @@ -0,0 +1,133 @@ +import isPlainObject from 'lodash/isPlainObject'; +import pick from 'lodash/pick'; +import Project from '../models/project'; +import createId from '../utils/createId'; +import createApplicationErrorClass from '../utils/createApplicationErrorClass'; +import createDefaultFiles from './createDefaultFiles'; + +export const FileValidationError = createApplicationErrorClass('FileValidationError'); +export const ProjectValidationError = createApplicationErrorClass('ProjectValidationError'); + +/** + * This converts between a mongoose Project model + * and the public API Project object properties + * + */ +export function toApi(model) { + return { + id: model.id, + name: model.name, + }; +} + +/** + * Transforms a tree of files matching the APIs DirectoryContents + * format into the data structure stored in mongodb + * + * - flattens the tree into an array of file/folders + * - each file/folder gets a generated BSON-ID + * - each folder has a `children` array containing the IDs of it's children + */ +function transformFilesInner(tree = {}, parentNode) { + const files = []; + const errors = []; + + Object.entries(tree).forEach(([name, params]) => { + const id = createId(); + const isFolder = params.files != null; + + if (isFolder) { + const folder = { + _id: id, + name, + fileType: 'folder', + children: [] // Initialise an empty folder + }; + + files.push(folder); + + // The recursion will return a list of child files/folders + // It will also push the child's id into `folder.children` + const subFolder = transformFilesInner(params.files, folder); + files.push(...subFolder.files); + errors.push(...subFolder.errors); + } else { + const file = { + _id: id, + name, + fileType: 'file' + }; + + if (typeof params.url === 'string') { + file.url = params.url; + } else if (typeof params.content === 'string') { + file.content = params.content; + } else { + errors.push({ name, message: 'missing \'url\' or \'content\'' }); + } + + files.push(file); + } + + // Push this child's ID onto it's parent's list + // of children + if (parentNode != null) { + parentNode.children.push(id); + } + }); + + return { files, errors }; +} + +export function transformFiles(tree = {}) { + const withRoot = { + root: { + files: tree + } + }; + + const { files, errors } = transformFilesInner(withRoot); + + if (errors.length > 0) { + const message = `${errors.length} files failed validation. See error.files for individual errors. + + Errors: + ${errors.map(e => `* ${e.name}: ${e.message}`).join('\n')} +`; + const error = new FileValidationError(message); + error.files = errors; + + throw error; + } + + return files; +} + +export function containsRootHtmlFile(tree) { + return Object.keys(tree).find(name => /\.html$/.test(name)) != null; +} + +/** + * This converts between the public API's Project object + * properties and a mongoose Project model + * + */ +export function toModel(object) { + let files = []; + let tree = object.files; + + if (isPlainObject(tree)) { + if (!containsRootHtmlFile(tree)) { + tree = Object.assign(createDefaultFiles(), tree); + } + + files = transformFiles(tree); + } else { + throw new FileValidationError('\'files\' must be an object'); + } + + const projectValues = pick(object, ['user', 'name', 'slug']); + projectValues.files = files; + + return new Project(projectValues); +} diff --git a/server/domain-objects/__test__/Project.test.js b/server/domain-objects/__test__/Project.test.js new file mode 100644 index 00000000..0f3c9dd9 --- /dev/null +++ b/server/domain-objects/__test__/Project.test.js @@ -0,0 +1,385 @@ +import find from 'lodash/find'; + +import { containsRootHtmlFile, toModel, transformFiles, FileValidationError } from '../Project'; + +jest.mock('../../utils/createId'); + +// TODO: File name validation +// TODO: File extension validation +// +describe('domain-objects/Project', () => { + describe('containsRootHtmlFile', () => { + it('returns true for at least one root .html', () => { + expect(containsRootHtmlFile({ 'index.html': {} })).toBe(true); + expect(containsRootHtmlFile({ 'another-one.html': {} })).toBe(true); + expect(containsRootHtmlFile({ 'one.html': {}, 'two.html': {} })).toBe(true); + expect(containsRootHtmlFile({ 'one.html': {}, 'sketch.js': {} })).toBe(true); + }); + + it('returns false anything else', () => { + expect(containsRootHtmlFile({ 'sketch.js': {} })).toBe(false); + }); + + it('ignores nested html', () => { + expect(containsRootHtmlFile({ + examples: { + files: { + 'index.html': {} + } + } + })).toBe(false); + }); + }); + + describe('toModel', () => { + it('filters extra properties', () => { + const params = { + name: 'My sketch', + extraThing: 'oopsie', + files: {} + }; + + const model = toModel(params); + + expect(model.name).toBe('My sketch'); + expect(model.extraThing).toBeUndefined(); + }); + + it('throws FileValidationError', () => { + const params = { + files: { + 'index.html': {} // missing content or url + } + }; + + expect(() => toModel(params)).toThrowError(FileValidationError); + }); + + it('throws if files is not an object', () => { + const params = { + files: [] + }; + + expect(() => toModel(params)).toThrowError(FileValidationError); + }); + + it('creates default index.html and dependent files if no root .html is provided', () => { + const params = { + files: {} + }; + + const { files } = toModel(params); + + expect(files.length).toBe(4); + expect(find(files, { name: 'index.html' })).not.toBeUndefined(); + expect(find(files, { name: 'sketch.js' })).not.toBeUndefined(); + expect(find(files, { name: 'style.css' })).not.toBeUndefined(); + }); + + it('does not create default files if any root .html is provided', () => { + const params = { + files: { + 'example.html': { + content: 'Hello!' + } + } + }; + + const { files } = toModel(params); + + expect(files.length).toBe(2); + expect(find(files, { name: 'example.html' })).not.toBeUndefined(); + expect(find(files, { name: 'index.html' })).toBeUndefined(); + expect(find(files, { name: 'sketch.js' })).toBeUndefined(); + expect(find(files, { name: 'style.css' })).toBeUndefined(); + }); + + it('does not overwrite default CSS and JS of the same name if provided', () => { + const params = { + files: { + 'sketch.js': { + content: 'const sketch = true;' + }, + 'style.css': { + content: 'body { outline: 10px solid red; }' + } + } + }; + + const { files } = toModel(params); + + expect(files.length).toBe(4); + expect(find(files, { name: 'index.html' })).not.toBeUndefined(); + + const sketchFile = find(files, { name: 'sketch.js' }); + expect(sketchFile.content).toBe('const sketch = true;'); + + const cssFile = find(files, { name: 'style.css' }); + expect(cssFile.content).toBe('body { outline: 10px solid red; }'); + }); + }); +}); + +describe('transformFiles', () => { + beforeEach(() => { + // eslint-disable-next-line global-require + const { resetMockCreateId } = require('../../utils/createId'); + + resetMockCreateId(); + }); + + it('creates an empty root with no data', () => { + const tree = {}; + + expect(transformFiles(tree)).toEqual([{ + _id: '0', + fileType: 'folder', + name: 'root', + children: [] + }]); + }); + + it('converts tree-shaped files into list', () => { + const tree = { + 'index.html': { + content: 'some contents', + } + }; + + expect(transformFiles(tree)).toEqual([ + { + _id: '0', + fileType: 'folder', + name: 'root', + children: ['1'] + }, + { + _id: '1', + content: 'some contents', + fileType: 'file', + name: 'index.html' + } + ]); + }); + + it('uses file url over content', () => { + const tree = { + 'script.js': { + url: 'http://example.net/something.js' + } + }; + + expect(transformFiles(tree)).toEqual([ + { + _id: '0', + fileType: 'folder', + name: 'root', + children: ['1'] + }, + { + _id: '1', + url: 'http://example.net/something.js', + fileType: 'file', + name: 'script.js' + } + ]); + }); + + it('creates folders', () => { + const tree = { + 'a-folder': { + files: {} + }, + }; + + expect(transformFiles(tree)).toEqual([ + { + _id: '0', + fileType: 'folder', + name: 'root', + children: ['1'] + }, + { + _id: '1', + children: [], + fileType: 'folder', + name: 'a-folder' + } + ]); + }); + + it('walks the tree processing files', () => { + const tree = { + 'index.html': { + content: 'some contents', + }, + 'a-folder': { + files: { + 'data.csv': { + content: 'this,is,data' + }, + 'another.txt': { + content: 'blah blah' + } + } + }, + }; + + expect(transformFiles(tree)).toEqual([ + { + _id: '0', + fileType: 'folder', + name: 'root', + children: ['1', '2'] + }, + { + _id: '1', + name: 'index.html', + fileType: 'file', + content: 'some contents' + }, + { + _id: '2', + name: 'a-folder', + fileType: 'folder', + children: ['3', '4'] + }, + { + _id: '3', + name: 'data.csv', + fileType: 'file', + content: 'this,is,data' + }, + { + _id: '4', + name: 'another.txt', + fileType: 'file', + content: 'blah blah' + } + ]); + }); + + it('handles deep nesting', () => { + const tree = { + first: { + files: { + second: { + files: { + third: { + files: { + 'hello.js': { + content: 'world!' + } + } + } + } + } + } + }, + }; + + expect(transformFiles(tree)).toEqual([ + { + _id: '0', + fileType: 'folder', + name: 'root', + children: ['1'] + }, + { + _id: '1', + name: 'first', + fileType: 'folder', + children: ['2'] + }, + { + _id: '2', + name: 'second', + fileType: 'folder', + children: ['3'] + }, + { + _id: '3', + name: 'third', + fileType: 'folder', + children: ['4'] + }, + { + _id: '4', + name: 'hello.js', + fileType: 'file', + content: 'world!' + } + ]); + }); + + + it('allows duplicate names in different folder', () => { + const tree = { + 'index.html': { + content: 'some contents', + }, + 'data': { + files: { + 'index.html': { + content: 'different file' + } + } + }, + }; + + expect(transformFiles(tree)).toEqual([ + { + _id: '0', + fileType: 'folder', + name: 'root', + children: ['1', '2'] + }, + { + _id: '1', + name: 'index.html', + fileType: 'file', + content: 'some contents' + }, + { + _id: '2', + name: 'data', + fileType: 'folder', + children: ['3'] + }, + { + _id: '3', + name: 'index.html', + fileType: 'file', + content: 'different file' + } + ]); + }); + + it('validates files', () => { + const tree = { + 'index.html': {} // missing `content` + }; + + expect(() => transformFiles(tree)).toThrowError(FileValidationError); + }); + + it('collects all file validation errors', () => { + const tree = { + 'index.html': {}, // missing `content` + 'something.js': {} // also missing `content` + }; + + try { + transformFiles(tree); + + // Should not get here + throw new Error('should have thrown before this point'); + } catch (err) { + expect(err).toBeInstanceOf(FileValidationError); + expect(err.files).toEqual([ + { name: 'index.html', message: 'missing \'url\' or \'content\'' }, + { name: 'something.js', message: 'missing \'url\' or \'content\'' } + ]); + } + }); +}); diff --git a/server/domain-objects/createDefaultFiles.js b/server/domain-objects/createDefaultFiles.js new file mode 100644 index 00000000..9164c12a --- /dev/null +++ b/server/domain-objects/createDefaultFiles.js @@ -0,0 +1,48 @@ +const defaultSketch = `function setup() { + createCanvas(400, 400); +} + +function draw() { + background(220); +}`; + +const defaultHTML = + ` + + + + + + + + + + + + + +`; + +const defaultCSS = + `html, body { + margin: 0; + padding: 0; +} +canvas { + display: block; +} +`; + +export default function createDefaultFiles() { + return { + 'index.html': { + content: defaultHTML + }, + 'style.css': { + content: defaultCSS + }, + 'sketch.js': { + content: defaultSketch + } + }; +} diff --git a/server/models/__mocks__/project.js b/server/models/__mocks__/project.js index 7260fcfd..2590046f 100644 --- a/server/models/__mocks__/project.js +++ b/server/models/__mocks__/project.js @@ -11,6 +11,24 @@ export function createMock() { return sinon.mock(Project); } +// Wraps the Project.prototype i.e. the +// instance methods in a mock so +// Project.save() can be mocked +export function createInstanceMock() { + // See: https://stackoverflow.com/questions/40962960/sinon-mock-of-mongoose-save-method-for-all-future-instances-of-a-model-with-pro + Object.defineProperty(Project.prototype, 'save', { + value: Project.prototype.save, + configurable: true, + }); + + Object.defineProperty(Project.prototype, 'remove', { + value: Project.prototype.remove, + configurable: true, + }); + + return sinon.mock(Project.prototype); +} + // Re-export the model, it will be // altered by mockingoose whenever // we call methods on the MockConfig diff --git a/server/models/__mocks__/user.js b/server/models/__mocks__/user.js index 585d8b67..fcd73f32 100644 --- a/server/models/__mocks__/user.js +++ b/server/models/__mocks__/user.js @@ -1,12 +1,31 @@ -let __err = null; -let __user = null; +import sinon from 'sinon'; +import 'sinon-mongoose'; -export default { - __setFindById(err, user) { - __err = err; - __user = user; - }, - findById: jest.fn(async (id, callback) => { - callback(__err, __user); - }) -}; +// Import the actual model to be mocked +const User = jest.requireActual('../user').default; + +// Wrap User in a sinon mock +// The returned object is used to configure +// the mocked model's behaviour +export function createMock() { + return sinon.mock(User); +} + +// Wraps the User.prototype i.e. the +// instance methods in a mock so +// User.save() can be mocked +export function createInstanceMock() { + // See: https://stackoverflow.com/questions/40962960/sinon-mock-of-mongoose-save-method-for-all-future-instances-of-a-model-with-pro + Object.defineProperty(User.prototype, 'save', { + value: User.prototype.save, + configurable: true, + }); + + return sinon.mock(User.prototype); +} + + +// Re-export the model, it will be +// altered by mockingoose whenever +// we call methods on the MockConfig +export default User; diff --git a/server/models/project.js b/server/models/project.js index 8642bc90..bf8c992e 100644 --- a/server/models/project.js +++ b/server/models/project.js @@ -49,8 +49,43 @@ projectSchema.set('toJSON', { projectSchema.pre('save', function generateSlug(next) { const project = this; - project.slug = slugify(project.name, '_'); + + if (!project.slug) { + project.slug = slugify(project.name, '_'); + } + return next(); }); +/** + * Check if slug is unique for this user's projects + */ +projectSchema.methods.isSlugUnique = async function isSlugUnique(cb) { + const project = this; + const hasCallback = typeof cb === 'function'; + + try { + const docsWithSlug = await project.model('Project') + .find({ user: project.user, slug: project.slug }, '_id') + .exec(); + + const result = { + isUnique: docsWithSlug.length === 0, + conflictingIds: docsWithSlug.map(d => d._id) || [] + }; + + if (hasCallback) { + cb(null, result); + } + + return result; + } catch (err) { + if (hasCallback) { + cb(err, null); + } + + throw err; + } +}; + export default mongoose.model('Project', projectSchema); diff --git a/server/routes/api.routes.js b/server/routes/api.routes.js new file mode 100644 index 00000000..60be3494 --- /dev/null +++ b/server/routes/api.routes.js @@ -0,0 +1,27 @@ +import { Router } from 'express'; +import passport from 'passport'; +import * as ProjectController from '../controllers/project.controller'; + +const router = new Router(); + +router.get( + '/:username/sketches', + passport.authenticate('basic', { session: false }), + ProjectController.apiGetProjectsForUser +); + +router.post( + '/:username/sketches', + passport.authenticate('basic', { session: false }), + ProjectController.apiCreateProject +); + +// NOTE: Currently :username will not be checked for ownership +// only the project's owner in the database. +router.delete( + '/:username/sketches/:project_id', + passport.authenticate('basic', { session: false }), + ProjectController.deleteProject +); + +export default router; diff --git a/server/scripts/examples-ml5.js b/server/scripts/examples-ml5.js index 0721fe54..51e1fe5d 100644 --- a/server/scripts/examples-ml5.js +++ b/server/scripts/examples-ml5.js @@ -1,11 +1,7 @@ import fs from 'fs'; import rp from 'request-promise'; import Q from 'q'; -import mongoose from 'mongoose'; -import objectID from 'bson-objectid'; -import shortid from 'shortid'; -import User from '../models/user'; -import Project from '../models/project'; +import { ok } from 'assert'; // TODO: Change branchName if necessary const branchName = 'release'; @@ -14,11 +10,20 @@ const baseUrl = 'https://api.github.com/repos/ml5js/ml5-examples/contents'; const clientId = process.env.GITHUB_ID; const clientSecret = process.env.GITHUB_SECRET; const editorUsername = process.env.ML5_EXAMPLES_USERNAME; +const personalAccessToken = process.env.EDITOR_API_ACCESS_TOKEN; +const editorApiUrl = process.env.EDITOR_API_URL; const headers = { 'User-Agent': 'p5js-web-editor/0.0.1' }; -const requestOptions = { +ok(clientId, 'GITHUB_ID is required'); +ok(clientSecret, 'GITHUB_SECRET is required'); +ok(editorUsername, 'ML5_EXAMPLES_USERNAME is required'); +ok(personalAccessToken, 'EDITOR_API_ACCESS_TOKEN is required'); +ok(editorApiUrl, 'EDITOR_API_URL is required'); + +// +const githubRequestOptions = { url: baseUrl, qs: { client_id: clientId, @@ -29,14 +34,15 @@ const requestOptions = { json: true }; -const mongoConnectionString = process.env.MONGO_URL; -mongoose.connect(mongoConnectionString, { - useMongoClient: true -}); -mongoose.connection.on('error', () => { - console.error('MongoDB Connection Error. Please make sure that MongoDB is running.'); - process.exit(1); -}); +const editorRequestOptions = { + url: `${editorApiUrl}/${editorUsername}`, + method: 'GET', + headers: { + ...headers, + Authorization: `Basic ${Buffer.from(`${editorUsername}:${personalAccessToken}`).toString('base64')}` + }, + json: true +}; /** * --------------------------------------------------------- @@ -51,12 +57,52 @@ function flatten(list) { return list.reduce((a, b) => a.concat(Array.isArray(b) ? flatten(b) : b), []); } +/** + * Fetch data for a single HTML/JS file, or return + * an url to the file's CDN location + */ +async function fetchFileContent(item) { + const { name } = item; + const file = { url: item.url }; + + // if it is an html or js file + if ( + (file.url != null && name.endsWith('.html')) || + name.endsWith('.js') + ) { + const options = Object.assign({}, githubRequestOptions); + options.url = `${file.url}`; + + if ( + options.url !== undefined || + options.url !== null || + options.url !== '' + ) { + file.content = await rp(options); + // NOTE: remove the URL property if there's content + // Otherwise the p5 editor will try to pull from that url + if (file.content !== null) delete file.url; + } + + return file; + // if it is NOT an html or js file + } + + if (file.url) { + const cdnRef = `https://cdn.jsdelivr.net/gh/ml5js/ml5-examples@${branchName}${file.url.split(branchName)[1]}`; + file.url = cdnRef; + } + + return file; +} + + /** * STEP 1: Get the top level cateogories */ async function getCategories() { try { - const options = Object.assign({}, requestOptions); + const options = Object.assign({}, githubRequestOptions); options.url = `${options.url}/p5js${branchRef}`; const results = await rp(options); @@ -76,13 +122,18 @@ async function getCategoryExamples(sketchRootList) { const output = []; const sketchRootCategories = sketchRootList.map(async (categories) => { // let options = Object.assign({url: `${requestOptions.url}/${categories.path}${branchRef}`}, requestOptions) - const options = Object.assign({}, requestOptions); + const options = Object.assign({}, githubRequestOptions); options.url = `${options.url}${categories.path}${branchRef}`; // console.log(options) const sketchDirs = await rp(options); - const result = flatten(sketchDirs); - return result; + try { + const result = flatten(sketchDirs); + + return result; + } catch (err) { + return []; + } }); const sketchList = await Q.all(sketchRootCategories); @@ -107,7 +158,7 @@ async function traverseSketchTree(parentObject) { return output; } // let options = `https://api.github.com/repos/ml5js/ml5-examples/contents/${sketches.path}${branchRef}` - const options = Object.assign({}, requestOptions); + const options = Object.assign({}, githubRequestOptions); options.url = `${options.url}${parentObject.path}${branchRef}`; output.tree = await rp(options); @@ -124,7 +175,6 @@ async function traverseSketchTree(parentObject) { * @param {*} categoryExamples - all of the categories in an array */ async function traverseSketchTreeAll(categoryExamples) { - // const sketches = categoryExamples.map(async sketch => await traverseSketchTree(sketch)); const sketches = categoryExamples.map(async sketch => traverseSketchTree(sketch)); const result = await Q.all(sketches); @@ -139,37 +189,24 @@ function traverseAndFormat(parentObject) { const parent = Object.assign({}, parentObject); if (!parentObject.tree) { - const newid = objectID().toHexString(); // returns the files return { name: parent.name, - url: parent.download_url, - content: null, - id: newid, - _id: newid, - fileType: 'file' + url: parent.download_url }; } const subdir = parentObject.tree.map((item) => { - const newid = objectID().toHexString(); if (!item.tree) { // returns the files return { name: item.name, - url: item.download_url, - content: null, - id: newid, - _id: newid, - fileType: 'file' + url: item.download_url }; } const feat = { name: item.name, - id: newid, - _id: newid, - fileType: 'folder', children: traverseAndFormat(item) }; return feat; @@ -178,69 +215,27 @@ function traverseAndFormat(parentObject) { } /** - * Traverse the tree and flatten for project.files[] + * Traverse the tree and download all the content, + * transforming into an object keyed by file/directory name * @param {*} projectFileTree */ -function traverseAndFlatten(projectFileTree) { - const r = objectID().toHexString(); +async function traverseAndDownload(projectFileTree) { + return projectFileTree.reduce( + async (previousPromise, item, idx) => { + const result = await previousPromise; - const projectRoot = { - name: 'root', - id: r, - _id: r, - children: [], - fileType: 'folder' - }; - - let currentParent; - - const output = projectFileTree.reduce( - (result, item, idx) => { - if (idx < projectFileTree.length) { - projectRoot.children.push(item.id); - } - - if (item.fileType === 'file') { - if (item.name === 'sketch.js') { - item.isSelectedFile = true; - } - result.push(item); - } - - // here's where the magic happens *twinkles* - if (item.fileType === 'folder') { - // recursively go down the tree of children - currentParent = traverseAndFlatten(item.children); - // the above will return an array of the children files - // concatenate that with the results - result = result.concat(currentParent); // eslint-disable-line no-param-reassign - // since we want to get the children ids, - // we can map the child ids to the current item - // then push that to our result array to get - // our flat files array. - item.children = item.children.map(child => child.id); - result.push(item); + if (Array.isArray(item.children)) { + result[item.name] = { + files: await traverseAndDownload(item.children) + }; + } else { + result[item.name] = await fetchFileContent(item); } return result; }, - [projectRoot] + {} ); - - // Kind of hacky way to remove all roots other than the starting one - let counter = 0; - output.forEach((item, idx) => { - if (item.name === 'root') { - if (counter === 0) { - counter += 1; - } else { - output.splice(idx, 1); - counter += 1; - } - } - }); - - return output; } /** @@ -249,16 +244,14 @@ function traverseAndFlatten(projectFileTree) { * @param {*} sketch * @param {*} user */ -function formatSketchForStorage(sketch, user) { - const newProject = new Project({ - _id: shortid.generate(), +async function formatSketchForStorage(sketch, user) { + const newProject = { name: sketch.name, - user: user._id, - files: [] // <== add files to this array as file objects and add _id reference to children of root - }); + files: {} // <== add files to this object + }; let projectFiles = traverseAndFormat(sketch); - projectFiles = traverseAndFlatten(projectFiles); + projectFiles = await traverseAndDownload(projectFiles); newProject.files = projectFiles; return newProject; } @@ -271,68 +264,50 @@ function formatSketchForStorageAll(sketchWithItems, user) { sketchList = sketchList.map(sketch => formatSketchForStorage(sketch, user)); - return sketchList; + return Promise.all(sketchList); } /** - * Get all the content for the relevant files in project.files[] - * @param {*} projectObject + * Fetch a list of all projects from the API */ -async function fetchSketchContent(projectObject) { - const output = Object.assign({}, JSON.parse(JSON.stringify(projectObject))); +async function getProjectsList() { + const options = Object.assign({}, editorRequestOptions); + options.url = `${options.url}/sketches`; - const newFiles = output.files.map(async (item, i) => { - // if it is an html or js file - if ( - (item.fileType === 'file' && item.name.endsWith('.html')) || - item.name.endsWith('.js') - ) { - const options = Object.assign({}, requestOptions); - options.url = `${item.url}`; + const results = await rp(options); - if ( - options.url !== undefined || - options.url !== null || - options.url !== '' - ) { - item.content = await rp(options); - // NOTE: remove the URL property if there's content - // Otherwise the p5 editor will try to pull from that url - if (item.content !== null) delete item.url; - } - - return item; - // if it is NOT an html or js file - } - - if (item.url) { - const cdnRef = `https://cdn.jsdelivr.net/gh/ml5js/ml5-examples@${branchName}${ - item.url.split(branchName)[1] - }`; - item.content = cdnRef; - item.url = cdnRef; - } - - return item; - }); - - output.files = await Q.all(newFiles); - return output; + return results.sketches; } /** - * STEP 5 - * Get all the content for the relevant files in project.files[] for all sketches - * @param {*} formattedSketchList + * Delete a project */ -async function fetchSketchContentAll(formattedSketchList) { - let output = formattedSketchList.slice(0); +async function deleteProject(project) { + const options = Object.assign({}, editorRequestOptions); + options.method = 'DELETE'; + options.url = `${options.url}/sketches/${project.id}`; - output = output.map(async item => fetchSketchContent(item)); + const results = await rp(options); - output = await Q.all(output); + return results; +} - return output; +/** + * Create a new project + */ +async function createProject(project) { + try { + const options = Object.assign({}, editorRequestOptions); + options.method = 'POST'; + options.url = `${options.url}/sketches`; + options.body = project; + + const results = await rp(options); + + return results; + } catch (err) { + throw err; + } } /** @@ -342,43 +317,34 @@ async function fetchSketchContentAll(formattedSketchList) { * @param {*} user */ async function createProjectsInP5User(filledProjectList, user) { - const userProjects = await Project.find({ user: user._id }); - const removeProjects = userProjects.map(async project => Project.remove({ _id: project._id })); - await Q.all(removeProjects); - console.log('deleted old projects!'); + console.log('Finding existing projects...'); - const newProjects = filledProjectList.map(async (project) => { - const item = new Project(project); - console.log(`saving ${project.name}`); - await item.save(); - }); - await Q.all(newProjects); - console.log(`Projects saved to User: ${editorUsername}!`); -} + const existingProjects = await getProjectsList(); -/** - * STEP 0 - * CHECK if user exists, ifnot create one - * - */ -async function checkP5User() { - const user = await User.findOne({ username: editorUsername }); + console.log(`Will delete ${existingProjects.length} projects`); - if (!user) { - const ml5user = new User({ - username: editorUsername, - email: process.env.ML5_EXAMPLES_EMAIL, - password: process.env.ML5_EXAMPLES_PASS - }); - - await ml5user.save((saveErr) => { - if (saveErr) throw saveErr; - console.log(`Created a user p5${ml5user}`); + try { + await Q.all(existingProjects.map(deleteProject)); + console.log('deleted old projects!'); + } catch (error) { + console.log('Problem deleting projects'); + console.log(error); + process.exit(1); + } + + try { + const newProjects = filledProjectList.map(async (project) => { + console.log(`saving ${project.name}`); + await createProject(project); }); + await Q.all(newProjects); + console.log(`Projects saved to User: ${editorUsername}!`); + } catch (error) { + console.log('Error saving projects'); + console.log(error); } } - /** * --------------------------------------------------------- * --------------------- main ------------------------------ @@ -394,18 +360,14 @@ async function checkP5User() { * Delete existing and save */ async function make() { - await checkP5User(); - // Get the user - const user = await User.findOne({ - username: editorUsername - }); // Get the categories and their examples const categories = await getCategories(); const categoryExamples = await getCategoryExamples(categories); + const examplesWithResourceTree = await traverseSketchTreeAll(categoryExamples); - const formattedSketchList = formatSketchForStorageAll(examplesWithResourceTree, user); - const filledProjectList = await fetchSketchContentAll(formattedSketchList); - await createProjectsInP5User(filledProjectList, user); + const formattedSketchList = await formatSketchForStorageAll(examplesWithResourceTree); + + await createProjectsInP5User(formattedSketchList); console.log('done!'); process.exit(); } @@ -418,20 +380,14 @@ async function make() { * Format the sketch files to be save to the db * Delete existing and save */ +// eslint-disable-next-line no-unused-vars async function test() { - await checkP5User(); - // Get the user - const user = await User.findOne({ - username: editorUsername - }); - // read from file while testing const examplesWithResourceTree = JSON.parse(fs.readFileSync('./ml5-examplesWithResourceTree.json')); - const formattedSketchList = formatSketchForStorageAll(examplesWithResourceTree, user); + const formattedSketchList = await formatSketchForStorageAll(examplesWithResourceTree); - const filledProjectList = await fetchSketchContentAll(formattedSketchList); - await createProjectsInP5User(filledProjectList, user); + await createProjectsInP5User(formattedSketchList); console.log('done!'); process.exit(); } diff --git a/server/server.js b/server/server.js index 4483b722..af6ea25a 100644 --- a/server/server.js +++ b/server/server.js @@ -16,6 +16,7 @@ import webpackHotMiddleware from 'webpack-hot-middleware'; import config from '../webpack/config.dev'; // Import all required modules +import api from './routes/api.routes'; import users from './routes/user.routes'; import sessions from './routes/session.routes'; import projects from './routes/project.routes'; @@ -51,7 +52,7 @@ const corsOriginsWhitelist = [ // Run Webpack dev server in development mode if (process.env.NODE_ENV === 'development') { const compiler = webpack(config); - app.use(webpackDevMiddleware(compiler, { noInfo: true, publicPath: config[0].output.publicPath })); + app.use(webpackDevMiddleware(compiler, { lazy: false, noInfo: true, publicPath: config[0].output.publicPath })); app.use(webpackHotMiddleware(compiler)); corsOriginsWhitelist.push(/localhost/); @@ -95,6 +96,7 @@ app.use(session({ app.use(passport.initialize()); app.use(passport.session()); +app.use('/api/v1', requestsOfTypeJSON(), api); app.use('/api', requestsOfTypeJSON(), users); app.use('/api', requestsOfTypeJSON(), sessions); app.use('/api', requestsOfTypeJSON(), files); diff --git a/server/utils/__mocks__/createId.js b/server/utils/__mocks__/createId.js new file mode 100644 index 00000000..919ea252 --- /dev/null +++ b/server/utils/__mocks__/createId.js @@ -0,0 +1,16 @@ +/** + * Creates an increasing numeric ID + * for testing + */ +let nextId = 0; + +export default function mockCreateId() { + const id = nextId; + nextId += 1; + + return String(id); +} + +export function resetMockCreateId() { + nextId = 0; +} diff --git a/server/utils/createApplicationErrorClass.js b/server/utils/createApplicationErrorClass.js new file mode 100644 index 00000000..f949866c --- /dev/null +++ b/server/utils/createApplicationErrorClass.js @@ -0,0 +1,33 @@ +/** + * This is the base class for custom errors in + * the application. + */ +export class ApplicationError extends Error { + constructor(message, extra = {}) { + super(); + if (Error.captureStackTrace) { + Error.captureStackTrace(this, this.constructor); + } + this.name = 'ApplicationError'; + this.message = message; + this.code = extra.code; + } +} + +/** + * Create a new custom error class e.g. + * const UserNotFoundError = createApplicationErrorClass('UserNotFoundError'); + * + * // Later + * throw new UserNotFoundError(`user ${user.name} not found`); + * + */ +export default function createApplicationErrorClass(name) { + return class extends ApplicationError { + constructor(...params) { + super(...params); + + this.name = name; + } + }; +} diff --git a/server/utils/createId.js b/server/utils/createId.js new file mode 100644 index 00000000..65b8290f --- /dev/null +++ b/server/utils/createId.js @@ -0,0 +1,8 @@ +import objectID from 'bson-objectid'; + +/** + * Creates a mongo ID + */ +export default function createId() { + return objectID().toHexString(); +} From 210e8b60bfb1c9d728ba93b41deff23f47568b2a Mon Sep 17 00:00:00 2001 From: Andrew Nicolaou Date: Fri, 30 Aug 2019 20:39:45 +0200 Subject: [PATCH 02/25] Public API: Namespace private and public APIs (#1148) * Converts import script to use public API endpoints The endpoints don't exist yet, but this is a good way to see how the implementation of the data structures differ. * Exposes public API endpoint to fetch user's sketches * Implements public API delete endpoint * Adds helper to create custom ApplicationError classes * Adds create project endpoint that understand API's data structure This transforms the nested tree of file data into a mongoose Project model * Returns '201 Created' to match API spec * Removes 'CustomError' variable assignment as it shows up in test output * transformFiles will return file validation errors * Tests API project controller * Tests toModel() * Creates default files if no root-level .html file is provided * Do not auto-generate a slug if it is provided Fixes a bug where the slug was auto-generated using the sketch name, even if a slug property had been provided. * Validates uniqueness of slugs for projects created by the public API * Adds tests for slug uniqueness * Configures node's Promise implementation for mongoose (fixes warnings) * Moves createProject tests to match controller location * Adds support for code to ApplicationErrors * deleteProject controller tests * getProjectsForUser controller tests - implements tests - update apiKey tests to use new User mocks * Ensure error objects have consistent property names `message` is used as a high-level description of the errors `detail` is optional and has an plain language explanation of the individual errors `errors` is an array of each individual problem from `detail` in a machine-readable format * Assert environment variables are provided at script start * Version public API * Expect "files" property to always be provided * Fixes linting error * Converts import script to use public API endpoints The endpoints don't exist yet, but this is a good way to see how the implementation of the data structures differ. * Exposes public API endpoint to fetch user's sketches * Implements public API delete endpoint * Adds helper to create custom ApplicationError classes * Adds create project endpoint that understand API's data structure This transforms the nested tree of file data into a mongoose Project model * Returns '201 Created' to match API spec * Removes 'CustomError' variable assignment as it shows up in test output * transformFiles will return file validation errors * Tests API project controller * Tests toModel() * Creates default files if no root-level .html file is provided * Do not auto-generate a slug if it is provided Fixes a bug where the slug was auto-generated using the sketch name, even if a slug property had been provided. * Validates uniqueness of slugs for projects created by the public API * Adds tests for slug uniqueness * Configures node's Promise implementation for mongoose (fixes warnings) * Moves createProject tests to match controller location * deleteProject controller tests * Adds support for code to ApplicationErrors * getProjectsForUser controller tests - implements tests - update apiKey tests to use new User mocks * Ensure error objects have consistent property names `message` is used as a high-level description of the errors `detail` is optional and has an plain language explanation of the individual errors `errors` is an array of each individual problem from `detail` in a machine-readable format * Assert environment variables are provided at script start * Version public API * Expect "files" property to always be provided * Fixes linting error * Checks that authenticated user has permission to create under this namespace Previously, the project was always created under the authenticated user's namespace, but this not obvious behaviour. * Splits private and public APIs The private API is under /editor and the public API under /api --- .env.example | 2 +- app.json | 2 +- server/server.js | 15 +++++++++------ 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/.env.example b/.env.example index 7680fb90..0385cf26 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -API_URL=/api +API_URL=/editor AWS_ACCESS_KEY= AWS_REGION= AWS_SECRET_KEY= diff --git a/app.json b/app.json index a588258e..3eff82c9 100644 --- a/app.json +++ b/app.json @@ -16,7 +16,7 @@ ], "env": { "API_URL": { - "value": "/api" + "value": "/editor" }, "AWS_ACCESS_KEY": { "description": "AWS Access Key", diff --git a/server/server.js b/server/server.js index af6ea25a..a7acc387 100644 --- a/server/server.js +++ b/server/server.js @@ -97,16 +97,19 @@ app.use(session({ app.use(passport.initialize()); app.use(passport.session()); app.use('/api/v1', requestsOfTypeJSON(), api); -app.use('/api', requestsOfTypeJSON(), users); -app.use('/api', requestsOfTypeJSON(), sessions); -app.use('/api', requestsOfTypeJSON(), files); -app.use('/api', requestsOfTypeJSON(), projects); -app.use('/api', requestsOfTypeJSON(), aws); +app.use('/editor', requestsOfTypeJSON(), users); +app.use('/editor', requestsOfTypeJSON(), sessions); +app.use('/editor', requestsOfTypeJSON(), files); +app.use('/editor', requestsOfTypeJSON(), projects); +app.use('/editor', requestsOfTypeJSON(), aws); // This is a temporary way to test access via Personal Access Tokens // Sending a valid username: combination will // return the user's information. -app.get('/api/auth/access-check', passport.authenticate('basic', { session: false }), (req, res) => res.json(req.user)); +app.get( + '/api/v1/auth/access-check', + passport.authenticate('basic', { session: false }), (req, res) => res.json(req.user) +); app.use(assetRoutes); // this is supposed to be TEMPORARY -- until i figure out From 0ae7a9eebbd1c569e3108957d4d26c09d2e80df5 Mon Sep 17 00:00:00 2001 From: Andrew Nicolaou Date: Fri, 30 Aug 2019 22:15:13 +0200 Subject: [PATCH 03/25] Display Access Token tab depending on UI_ACCESS_TOKEN_ENABLED feature flag (#1149) --- .env.example | 1 + client/modules/User/pages/AccountView.jsx | 4 +++- server/views/index.js | 2 ++ 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 0385cf26..17b6fe5a 100644 --- a/.env.example +++ b/.env.example @@ -23,3 +23,4 @@ PORT=8000 S3_BUCKET= S3_BUCKET_URL_BASE= SESSION_SECRET=whatever_you_want_this_to_be_it_only_matters_for_production +UI_ACCESS_TOKEN_ENABLED=false diff --git a/client/modules/User/pages/AccountView.jsx b/client/modules/User/pages/AccountView.jsx index 5648cee2..0cda4898 100644 --- a/client/modules/User/pages/AccountView.jsx +++ b/client/modules/User/pages/AccountView.jsx @@ -33,6 +33,8 @@ class AccountView extends React.Component { } render() { + const accessTokensUIEnabled = window.process.env.UI_ACCESS_TOKEN_ENABLED; + return (
@@ -50,7 +52,7 @@ class AccountView extends React.Component {

Account

-

Access Tokens

+ {accessTokensUIEnabled &&

Access Tokens

}
diff --git a/server/views/index.js b/server/views/index.js index d4062061..0539b927 100644 --- a/server/views/index.js +++ b/server/views/index.js @@ -30,6 +30,8 @@ export function renderIndex() { window.process.env.CLIENT = true; window.process.env.LOGIN_ENABLED = ${process.env.LOGIN_ENABLED === 'false' ? false : true}; window.process.env.EXAMPLES_ENABLED = ${process.env.EXAMPLES_ENABLED === 'false' ? false : true}; + window.process.env.EXAMPLES_ENABLED = ${process.env.EXAMPLES_ENABLED === 'false' ? false : true}; + window.process.env.UI_ACCESS_TOKEN_ENABLED = ${process.env.UI_ACCESS_TOKEN_ENABLED === 'false' ? false : true}; From 83978acc1de55c3320bc7e77c0a82785998cc71a Mon Sep 17 00:00:00 2001 From: Andrew Nicolaou Date: Mon, 8 Jul 2019 12:06:13 +0200 Subject: [PATCH 04/25] Adds Collections model and Editor API to manage collections - List any user's collections - Create new collection - Modify collection metadata - Delete collection - Add/remove any project to/from a collection --- .../addProjectToCollection.js | 74 +++++++++++++++++++ .../collection.controller/createCollection.js | 47 ++++++++++++ .../collection.controller/index.js | 6 ++ .../collection.controller/listCollections.js | 48 ++++++++++++ .../collection.controller/removeCollection.js | 34 +++++++++ .../removeProjectFromCollection.js | 62 ++++++++++++++++ .../collection.controller/updateCollection.js | 54 ++++++++++++++ server/models/collection.js | 48 ++++++++++++ server/routes/collection.routes.js | 20 +++++ server/server.js | 2 + 10 files changed, 395 insertions(+) create mode 100644 server/controllers/collection.controller/addProjectToCollection.js create mode 100644 server/controllers/collection.controller/createCollection.js create mode 100644 server/controllers/collection.controller/index.js create mode 100644 server/controllers/collection.controller/listCollections.js create mode 100644 server/controllers/collection.controller/removeCollection.js create mode 100644 server/controllers/collection.controller/removeProjectFromCollection.js create mode 100644 server/controllers/collection.controller/updateCollection.js create mode 100644 server/models/collection.js create mode 100644 server/routes/collection.routes.js diff --git a/server/controllers/collection.controller/addProjectToCollection.js b/server/controllers/collection.controller/addProjectToCollection.js new file mode 100644 index 00000000..6abf08bf --- /dev/null +++ b/server/controllers/collection.controller/addProjectToCollection.js @@ -0,0 +1,74 @@ +import Collection from '../../models/collection'; +import Project from '../../models/project'; + +export default function addProjectToCollection(req, res) { + const owner = req.user._id; + const { id: collectionId, projectId } = req.params; + + const collectionPromise = Collection.findById(collectionId).populate('items.project', '_id'); + const projectPromise = Project.findById(projectId); + + function sendFailure(code, message) { + res.status(code).json({ success: false, message }); + } + + function sendSuccess(collection) { + res.status(200).json(collection); + } + + function updateCollection([collection, project]) { + if (collection == null) { + sendFailure(404, 'Collection not found'); + return null; + } + + if (project == null) { + sendFailure(404, 'Project not found'); + return null; + } + + if (!collection.owner.equals(owner)) { + sendFailure(403, 'User does not own this collection'); + return null; + } + + const projectInCollection = collection.items.find(p => p.project._id === project._id); + + if (projectInCollection) { + sendFailure(404, 'Project already in collection'); + return null; + } + + try { + collection.items.push({ project }); + + return collection.save(); + } catch (error) { + console.error(error); + sendFailure(500, error.message); + return null; + } + } + + function populateReferences(collection) { + return Collection.populate( + collection, + [ + { path: 'owner', select: ['id', 'username'] }, + { + path: 'items.project', + select: ['id', 'name', 'slug'], + populate: { + path: 'user', select: ['username'] + } + } + ] + ); + } + + return Promise.all([collectionPromise, projectPromise]) + .then(updateCollection) + .then(populateReferences) + .then(sendSuccess) + .catch(sendFailure); +} diff --git a/server/controllers/collection.controller/createCollection.js b/server/controllers/collection.controller/createCollection.js new file mode 100644 index 00000000..9fd97be3 --- /dev/null +++ b/server/controllers/collection.controller/createCollection.js @@ -0,0 +1,47 @@ +import Collection from '../../models/collection'; + +export default function createCollection(req, res) { + const owner = req.user._id; + const { name, description, slug } = req.body; + + const values = { + owner, + name, + description, + slug + }; + + function sendFailure({ code = 500, message = 'Something went wrong' }) { + res.status(code).json({ success: false, message }); + } + + function sendSuccess(newCollection) { + res.json(newCollection); + } + + function populateReferences(newCollection) { + return Collection.populate( + newCollection, + [ + { path: 'owner', select: ['id', 'username'] }, + { + path: 'items.project', + select: ['id', 'name', 'slug'], + populate: { + path: 'user', select: ['username'] + } + } + ] + ); + } + + if (owner == null) { + sendFailure({ code: 404, message: 'No user specified' }); + return null; + } + + return Collection.create(values) + .then(populateReferences) + .then(sendSuccess) + .catch(sendFailure); +} diff --git a/server/controllers/collection.controller/index.js b/server/controllers/collection.controller/index.js new file mode 100644 index 00000000..b09db3f0 --- /dev/null +++ b/server/controllers/collection.controller/index.js @@ -0,0 +1,6 @@ +export { default as addProjectToCollection } from './addProjectToCollection'; +export { default as createCollection } from './createCollection'; +export { default as listCollections } from './listCollections'; +export { default as removeCollection } from './removeCollection'; +export { default as removeProjectFromCollection } from './removeProjectFromCollection'; +export { default as updateCollection } from './updateCollection'; diff --git a/server/controllers/collection.controller/listCollections.js b/server/controllers/collection.controller/listCollections.js new file mode 100644 index 00000000..c71041b3 --- /dev/null +++ b/server/controllers/collection.controller/listCollections.js @@ -0,0 +1,48 @@ +import Collection from '../../models/collection'; +import User from '../../models/user'; + +async function getOwnerUserId(req) { + if (req.params.username) { + const user = await User.findOne({ username: req.params.username }); + if (user && user._id) { + return user._id; + } + } else if (req.user._id) { + return req.user._id; + } + + return null; +} + +export default function listCollections(req, res) { + function sendFailure({ code = 500, message = 'Something went wrong' }) { + res.status(code).json({ success: false, message }); + } + + function sendSuccess(collections) { + res.status(200).json(collections); + } + + function findCollections(owner) { + if (owner == null) { + sendFailure({ code: 404, message: 'User not found' }); + } + + return Collection.find({ owner }) + .populate([ + { path: 'owner', select: ['id', 'username'] }, + { + path: 'items.project', + select: ['id', 'name', 'slug'], + populate: { + path: 'user', select: ['username'] + } + } + ]); + } + + return getOwnerUserId(req) + .then(findCollections) + .then(sendSuccess) + .catch(sendFailure); +} diff --git a/server/controllers/collection.controller/removeCollection.js b/server/controllers/collection.controller/removeCollection.js new file mode 100644 index 00000000..dffff876 --- /dev/null +++ b/server/controllers/collection.controller/removeCollection.js @@ -0,0 +1,34 @@ +import Collection from '../../models/collection'; + + +export default function createCollection(req, res) { + const { id: collectionId } = req.params; + const owner = req.user._id; + + function sendFailure({ code = 500, message = 'Something went wrong' }) { + res.status(code).json({ success: false, message }); + } + + function sendSuccess() { + res.status(200).json({ success: true }); + } + + function removeCollection(collection) { + if (collection == null) { + sendFailure({ code: 404, message: 'Not found, or you user does not own this collection' }); + return null; + } + + return collection.remove(); + } + + function findCollection() { + // Only returned if owner matches current user + return Collection.findOne({ _id: collectionId, owner }); + } + + return findCollection() + .then(removeCollection) + .then(sendSuccess) + .catch(sendFailure); +} diff --git a/server/controllers/collection.controller/removeProjectFromCollection.js b/server/controllers/collection.controller/removeProjectFromCollection.js new file mode 100644 index 00000000..561a9ce7 --- /dev/null +++ b/server/controllers/collection.controller/removeProjectFromCollection.js @@ -0,0 +1,62 @@ +import Collection from '../../models/collection'; +import Project from '../../models/project'; + +export default function addProjectToCollection(req, res) { + const owner = req.user._id; + const { id: collectionId, projectId } = req.params; + + function sendFailure({ code = 500, message = 'Something went wrong' }) { + res.status(code).json({ success: false, message }); + } + + function sendSuccess(collection) { + res.status(200).json(collection); + } + + function updateCollection(collection) { + if (collection == null) { + sendFailure({ code: 404, message: 'Collection not found' }); + return null; + } + + if (!collection.owner.equals(owner)) { + sendFailure({ code: 403, message: 'User does not own this collection' }); + return null; + } + + const project = collection.items.find(p => p.project._id === projectId); + + if (project != null) { + project.remove(); + return collection.save(); + } + + const error = new Error('not found'); + error.code = 404; + + throw error; + } + + function populateReferences(collection) { + return Collection.populate( + collection, + [ + { path: 'owner', select: ['id', 'username'] }, + { + path: 'items.project', + select: ['id', 'name', 'slug'], + populate: { + path: 'user', select: ['username'] + } + } + ] + ); + } + + return Collection.findById(collectionId) + .populate('items.project', '_id') + .then(updateCollection) + .then(populateReferences) + .then(sendSuccess) + .catch(sendFailure); +} diff --git a/server/controllers/collection.controller/updateCollection.js b/server/controllers/collection.controller/updateCollection.js new file mode 100644 index 00000000..365b8d24 --- /dev/null +++ b/server/controllers/collection.controller/updateCollection.js @@ -0,0 +1,54 @@ +import omitBy from 'lodash/omitBy'; +import isUndefined from 'lodash/isUndefined'; +import Collection from '../../models/collection'; + +function removeUndefined(obj) { + return omitBy(obj, isUndefined); +} + +export default function createCollection(req, res) { + const { id: collectionId } = req.params; + const owner = req.user._id; + const { name, description, slug } = req.body; + + const values = removeUndefined({ + name, + description, + slug + }); + + function sendFailure({ code = 500, message = 'Something went wrong' }) { + res.status(code).json({ success: false, message }); + } + + function sendSuccess(collection) { + if (collection == null) { + sendFailure({ code: 404, message: 'Not found, or you user does not own this collection' }); + return; + } + + res.json(collection); + } + + async function findAndUpdateCollection() { + // Only update if owner matches current user + return Collection.findOneAndUpdate( + { _id: collectionId, owner }, + values, + { new: true, runValidators: true, setDefaultsOnInsert: true } + ).populate([ + { path: 'owner', select: ['id', 'username'] }, + { + path: 'items.project', + select: ['id', 'name', 'slug'], + populate: { + path: 'user', select: ['username'] + } + } + ]).exec(); + } + + return findAndUpdateCollection() + .then(sendSuccess) + .catch(sendFailure); +} diff --git a/server/models/collection.js b/server/models/collection.js new file mode 100644 index 00000000..2753496b --- /dev/null +++ b/server/models/collection.js @@ -0,0 +1,48 @@ +import mongoose from 'mongoose'; +import shortid from 'shortid'; +import slugify from 'slugify'; + +const { Schema } = mongoose; + +const collectedProjectSchema = new Schema( + { + project: { type: Schema.Types.ObjectId, ref: 'Project' }, + }, + { timestamps: true, _id: true, usePushEach: true } +); + +collectedProjectSchema.virtual('id').get(function getId() { + return this._id.toHexString(); +}); + +collectedProjectSchema.set('toJSON', { + virtuals: true +}); + +const collectionSchema = new Schema( + { + _id: { type: String, default: shortid.generate }, + name: { type: String, default: 'My collection' }, + description: { type: String }, + slug: { type: String }, + owner: { type: Schema.Types.ObjectId, ref: 'User' }, + items: { type: [collectedProjectSchema] } + }, + { timestamps: true, usePushEach: true } +); + +collectionSchema.virtual('id').get(function getId() { + return this._id; +}); + +collectionSchema.set('toJSON', { + virtuals: true +}); + +collectionSchema.pre('save', function generateSlug(next) { + const collection = this; + collection.slug = slugify(collection.name, '_'); + return next(); +}); + +export default mongoose.model('Collection', collectionSchema); diff --git a/server/routes/collection.routes.js b/server/routes/collection.routes.js new file mode 100644 index 00000000..d4f7ff9a --- /dev/null +++ b/server/routes/collection.routes.js @@ -0,0 +1,20 @@ +import { Router } from 'express'; +import * as CollectionController from '../controllers/collection.controller'; +import isAuthenticated from '../utils/isAuthenticated'; + +const router = new Router(); + +// List collections +router.get('/collections', isAuthenticated, CollectionController.listCollections); +router.get('/:username/collections', CollectionController.listCollections); + +// Create, modify, delete collection +router.post('/collections', isAuthenticated, CollectionController.createCollection); +router.patch('/collections/:id', isAuthenticated, CollectionController.updateCollection); +router.delete('/collections/:id', isAuthenticated, CollectionController.removeCollection); + +// Add and remove projects to collection +router.post('/collections/:id/:projectId', isAuthenticated, CollectionController.addProjectToCollection); +router.delete('/collections/:id/:projectId', isAuthenticated, CollectionController.removeProjectFromCollection); + +export default router; diff --git a/server/server.js b/server/server.js index a7acc387..de850a23 100644 --- a/server/server.js +++ b/server/server.js @@ -21,6 +21,7 @@ import users from './routes/user.routes'; import sessions from './routes/session.routes'; import projects from './routes/project.routes'; import files from './routes/file.routes'; +import collections from './routes/collection.routes'; import aws from './routes/aws.routes'; import serverRoutes from './routes/server.routes'; import embedRoutes from './routes/embed.routes'; @@ -102,6 +103,7 @@ app.use('/editor', requestsOfTypeJSON(), sessions); app.use('/editor', requestsOfTypeJSON(), files); app.use('/editor', requestsOfTypeJSON(), projects); app.use('/editor', requestsOfTypeJSON(), aws); +app.use('/editor', requestsOfTypeJSON(), collections); // This is a temporary way to test access via Personal Access Tokens // Sending a valid username: combination will From 9c36f2b2e23061acdcafd310bdea2d9a80596bf0 Mon Sep 17 00:00:00 2001 From: Andrew Nicolaou Date: Tue, 9 Jul 2019 10:33:20 +0200 Subject: [PATCH 05/25] Adds collections Nav item behind a feature flag --- client/components/Nav.jsx | 12 ++++++++++++ server/views/index.js | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/client/components/Nav.jsx b/client/components/Nav.jsx index 31b7e5b8..8dab6854 100644 --- a/client/components/Nav.jsx +++ b/client/components/Nav.jsx @@ -574,6 +574,18 @@ class Nav extends React.PureComponent { My sketches + {__process.env.UI_COLLECTIONS_ENABLED && +
  • + + My collections + +
  • + }
  • From d02a413bf30aef5d5236f633133cde341875734c Mon Sep 17 00:00:00 2001 From: Andrew Nicolaou Date: Tue, 9 Jul 2019 10:35:24 +0200 Subject: [PATCH 06/25] Displays existing collection - List all collections for a given user - View an individual collection - Link to a sketch from a collection --- client/constants.js | 2 + client/modules/IDE/actions/collections.js | 34 ++ client/modules/IDE/components/Collection.jsx | 375 +++++++++++++++++ .../modules/IDE/components/CollectionList.jsx | 381 ++++++++++++++++++ client/modules/IDE/pages/IDEView.jsx | 22 +- client/modules/IDE/reducers/collections.js | 12 + client/modules/IDE/selectors/collections.js | 37 ++ .../User/components/DashboardTabSwitcher.jsx | 6 +- client/modules/User/pages/DashboardView.jsx | 23 +- client/reducers.js | 4 +- client/routes.jsx | 5 +- .../collectionForUserExists.js | 29 ++ .../collection.controller/index.js | 1 + server/routes/server.routes.js | 13 + 14 files changed, 921 insertions(+), 23 deletions(-) create mode 100644 client/modules/IDE/actions/collections.js create mode 100644 client/modules/IDE/components/Collection.jsx create mode 100644 client/modules/IDE/components/CollectionList.jsx create mode 100644 client/modules/IDE/reducers/collections.js create mode 100644 client/modules/IDE/selectors/collections.js create mode 100644 server/controllers/collection.controller/collectionForUserExists.js diff --git a/client/constants.js b/client/constants.js index 4dd57213..c4111b95 100644 --- a/client/constants.js +++ b/client/constants.js @@ -35,6 +35,8 @@ export const HIDE_EDIT_PROJECT_NAME = 'HIDE_EDIT_PROJECT_NAME'; export const SET_PROJECT = 'SET_PROJECT'; export const SET_PROJECTS = 'SET_PROJECTS'; +export const SET_COLLECTIONS = 'SET_COLLECTIONS'; + export const DELETE_PROJECT = 'DELETE_PROJECT'; export const SET_SELECTED_FILE = 'SET_SELECTED_FILE'; diff --git a/client/modules/IDE/actions/collections.js b/client/modules/IDE/actions/collections.js new file mode 100644 index 00000000..d3fffe6f --- /dev/null +++ b/client/modules/IDE/actions/collections.js @@ -0,0 +1,34 @@ +import axios from 'axios'; +import * as ActionTypes from '../../../constants'; +import { startLoader, stopLoader } from './loader'; + +const __process = (typeof global !== 'undefined' ? global : window).process; +const ROOT_URL = __process.env.API_URL; + +// eslint-disable-next-line +export function getCollections(username) { + return (dispatch) => { + dispatch(startLoader()); + let url; + if (username) { + url = `${ROOT_URL}/${username}/collections`; + } else { + url = `${ROOT_URL}/collections`; + } + axios.get(url, { withCredentials: true }) + .then((response) => { + dispatch({ + type: ActionTypes.SET_COLLECTIONS, + collections: response.data + }); + dispatch(stopLoader()); + }) + .catch((response) => { + dispatch({ + type: ActionTypes.ERROR, + error: response.data + }); + dispatch(stopLoader()); + }); + }; +} diff --git a/client/modules/IDE/components/Collection.jsx b/client/modules/IDE/components/Collection.jsx new file mode 100644 index 00000000..22ed85b9 --- /dev/null +++ b/client/modules/IDE/components/Collection.jsx @@ -0,0 +1,375 @@ +import format from 'date-fns/format'; +import PropTypes from 'prop-types'; +import React from 'react'; +import { Helmet } from 'react-helmet'; +import InlineSVG from 'react-inlinesvg'; +import { connect } from 'react-redux'; +import { Link } from 'react-router'; +import { bindActionCreators } from 'redux'; +import classNames from 'classnames'; +import * as ProjectActions from '../actions/project'; +import * as ProjectsActions from '../actions/projects'; +import * as CollectionsActions from '../actions/collections'; +import * as ToastActions from '../actions/toast'; +import * as SortingActions from '../actions/sorting'; +import * as IdeActions from '../actions/ide'; +import { getCollection } from '../selectors/collections'; +import Loader from '../../App/components/loader'; +import Overlay from '../../App/components/Overlay'; + +const arrowUp = require('../../../images/sort-arrow-up.svg'); +const arrowDown = require('../../../images/sort-arrow-down.svg'); +const downFilledTriangle = require('../../../images/down-filled-triangle.svg'); + +class CollectionItemRowBase extends React.Component { + constructor(props) { + super(props); + this.state = { + optionsOpen: false, + renameOpen: false, + renameValue: props.item.project.name, + isFocused: false + }; + } + + onFocusComponent = () => { + this.setState({ isFocused: true }); + } + + onBlurComponent = () => { + this.setState({ isFocused: false }); + setTimeout(() => { + if (!this.state.isFocused) { + this.closeAll(); + } + }, 200); + } + + openOptions = () => { + this.setState({ + optionsOpen: true + }); + } + + closeOptions = () => { + this.setState({ + optionsOpen: false + }); + } + + toggleOptions = () => { + if (this.state.optionsOpen) { + this.closeOptions(); + } else { + this.openOptions(); + } + } + + openRename = () => { + this.setState({ + renameOpen: true + }); + } + + closeRename = () => { + this.setState({ + renameOpen: false + }); + } + + closeAll = () => { + this.setState({ + renameOpen: false, + optionsOpen: false + }); + } + + handleRenameChange = (e) => { + this.setState({ + renameValue: e.target.value + }); + } + + handleRenameEnter = (e) => { + if (e.key === 'Enter') { + // TODO pass this func + this.props.changeProjectName(this.props.collection.id, this.state.renameValue); + this.closeAll(); + } + } + + resetSketchName = () => { + this.setState({ + renameValue: this.props.collection.name + }); + } + + handleDropdownOpen = () => { + this.closeAll(); + this.openOptions(); + } + + handleRenameOpen = () => { + this.closeAll(); + this.openRename(); + } + + handleSketchDownload = () => { + this.props.exportProjectAsZip(this.props.collection.id); + } + + handleSketchDuplicate = () => { + this.closeAll(); + this.props.cloneProject(this.props.collection.id); + } + + handleSketchShare = () => { + this.closeAll(); + this.props.showShareModal(this.props.collection.id, this.props.collection.name, this.props.username); + } + + handleSketchDelete = () => { + this.closeAll(); + if (window.confirm(`Are you sure you want to delete "${this.props.collection.name}"?`)) { + this.props.deleteProject(this.props.collection.id); + } + } + + render() { + const { item, username } = this.props; + const { renameOpen, optionsOpen, renameValue } = this.state; + const sketchOwnerUsername = item.project.user.username; + const userIsOwner = this.props.user.username === sketchOwnerUsername; + const sketchUrl = `/${item.project.user.username}/sketches/${item.project.id}`; + + const dropdown = ( + + + {optionsOpen && +
      + {userIsOwner && +
    • + +
    • } +
    + } + + ); + + return ( + + + + {renameOpen ? '' : item.project.name} + + {renameOpen + && + e.stopPropagation()} + /> + } + + {format(new Date(item.createdAt), 'MMM D, YYYY h:mm A')} + {sketchOwnerUsername} + {/* + {format(new Date(item.createdAt), 'MMM D, YYYY h:mm A')} + {format(new Date(itm.updatedAt), 'MMM D, YYYY h:mm A')} + {(collection.items || []).length} + {dropdown} + */} + ); + } +} + +CollectionItemRowBase.propTypes = { + collection: PropTypes.shape({ + id: PropTypes.string.isRequired, + name: PropTypes.string.isRequired + }).isRequired, + username: PropTypes.string.isRequired, + user: PropTypes.shape({ + username: PropTypes.string, + authenticated: PropTypes.bool.isRequired + }).isRequired, + deleteProject: PropTypes.func.isRequired, + showShareModal: PropTypes.func.isRequired, + cloneProject: PropTypes.func.isRequired, + exportProjectAsZip: PropTypes.func.isRequired, + changeProjectName: PropTypes.func.isRequired +}; + +function mapDispatchToPropsSketchListRow(dispatch) { + return bindActionCreators(Object.assign({}, ProjectActions, IdeActions), dispatch); +} + +const CollectionItemRow = connect(null, mapDispatchToPropsSketchListRow)(CollectionItemRowBase); + +class Collection extends React.Component { + constructor(props) { + super(props); + this.props.getCollections(this.props.username); + this.props.resetSorting(); + this._renderFieldHeader = this._renderFieldHeader.bind(this); + } + + getTitle() { + if (this.props.username === this.props.user.username) { + return 'p5.js Web Editor | My collections'; + } + return `p5.js Web Editor | ${this.props.username}'s collections`; + } + + getCollectionName() { + return this.props.collection.name; + } + + hasCollection() { + return !this.props.loading && this.props.collection != null; + } + + hasCollectionItems() { + return this.hasCollection() && this.props.collection.items.length > 0; + } + + _renderLoader() { + if (this.props.loading) return ; + return null; + } + + _renderCollectionMetadata() { + return ( +
    +

    {this.props.collection.description}

    +
    + ); + } + + _renderEmptyTable() { + if (!this.hasCollectionItems()) { + return (

    No sketches in collection.

    ); + } + return null; + } + + _renderFieldHeader(fieldName, displayName) { + const { field, direction } = this.props.sorting; + const headerClass = classNames({ + 'sketches-table__header': true, + 'sketches-table__header--selected': field === fieldName + }); + return ( + + + + ); + } + + render() { + const username = this.props.username !== undefined ? this.props.username : this.props.user.username; + const title = this.hasCollection() ? this.getCollectionName() : null; + + return ( + +
    + + {this.getTitle()} + + {this._renderLoader()} + {this.hasCollection() && this._renderCollectionMetadata()} + {this._renderEmptyTable()} + {this.hasCollectionItems() && + + + + {this._renderFieldHeader('name', 'Name')} + {this._renderFieldHeader('createdAt', 'Date Added')} + {this._renderFieldHeader('user', 'Owner')} + + + + + {this.props.collection.items.map(item => + ())} + +
    } +
    +
    + ); + } +} + +Collection.propTypes = { + user: PropTypes.shape({ + username: PropTypes.string, + authenticated: PropTypes.bool.isRequired + }).isRequired, + getCollections: PropTypes.func.isRequired, + collection: PropTypes.shape({}).isRequired, // TODO + username: PropTypes.string, + loading: PropTypes.bool.isRequired, + toggleDirectionForField: PropTypes.func.isRequired, + resetSorting: PropTypes.func.isRequired, + sorting: PropTypes.shape({ + field: PropTypes.string.isRequired, + direction: PropTypes.string.isRequired + }).isRequired +}; + +Collection.defaultProps = { + username: undefined +}; + +function mapStateToProps(state, ownProps) { + return { + user: state.user, + collection: getCollection(state, ownProps.collectionId), + sorting: state.sorting, + loading: state.loading, + project: state.project + }; +} + +function mapDispatchToProps(dispatch) { + return bindActionCreators(Object.assign({}, CollectionsActions, ProjectsActions, ToastActions, SortingActions), dispatch); +} + +export default connect(mapStateToProps, mapDispatchToProps)(Collection); diff --git a/client/modules/IDE/components/CollectionList.jsx b/client/modules/IDE/components/CollectionList.jsx new file mode 100644 index 00000000..2b071847 --- /dev/null +++ b/client/modules/IDE/components/CollectionList.jsx @@ -0,0 +1,381 @@ +import format from 'date-fns/format'; +import PropTypes from 'prop-types'; +import React from 'react'; +import { Helmet } from 'react-helmet'; +import InlineSVG from 'react-inlinesvg'; +import { connect } from 'react-redux'; +import { Link } from 'react-router'; +import { bindActionCreators } from 'redux'; +import classNames from 'classnames'; +import * as ProjectActions from '../actions/project'; +import * as ProjectsActions from '../actions/projects'; +import * as CollectionsActions from '../actions/collections'; +import * as ToastActions from '../actions/toast'; +import * as SortingActions from '../actions/sorting'; +import * as IdeActions from '../actions/ide'; +import getSortedCollections from '../selectors/collections'; +import Loader from '../../App/components/loader'; + +const arrowUp = require('../../../images/sort-arrow-up.svg'); +const arrowDown = require('../../../images/sort-arrow-down.svg'); +const downFilledTriangle = require('../../../images/down-filled-triangle.svg'); + +class CollectionListRowBase extends React.Component { + constructor(props) { + super(props); + this.state = { + optionsOpen: false, + renameOpen: false, + renameValue: props.collection.name, + isFocused: false + }; + } + + onFocusComponent = () => { + this.setState({ isFocused: true }); + } + + onBlurComponent = () => { + this.setState({ isFocused: false }); + setTimeout(() => { + if (!this.state.isFocused) { + this.closeAll(); + } + }, 200); + } + + openOptions = () => { + this.setState({ + optionsOpen: true + }); + } + + closeOptions = () => { + this.setState({ + optionsOpen: false + }); + } + + toggleOptions = () => { + if (this.state.optionsOpen) { + this.closeOptions(); + } else { + this.openOptions(); + } + } + + openRename = () => { + this.setState({ + renameOpen: true + }); + } + + closeRename = () => { + this.setState({ + renameOpen: false + }); + } + + closeAll = () => { + this.setState({ + renameOpen: false, + optionsOpen: false + }); + } + + handleRenameChange = (e) => { + this.setState({ + renameValue: e.target.value + }); + } + + handleRenameEnter = (e) => { + if (e.key === 'Enter') { + // TODO pass this func + this.props.changeProjectName(this.props.collection.id, this.state.renameValue); + this.closeAll(); + } + } + + resetSketchName = () => { + this.setState({ + renameValue: this.props.collection.name + }); + } + + handleDropdownOpen = () => { + this.closeAll(); + this.openOptions(); + } + + handleRenameOpen = () => { + this.closeAll(); + this.openRename(); + } + + handleSketchDownload = () => { + this.props.exportProjectAsZip(this.props.collection.id); + } + + handleSketchDuplicate = () => { + this.closeAll(); + this.props.cloneProject(this.props.collection.id); + } + + handleSketchShare = () => { + this.closeAll(); + this.props.showShareModal(this.props.collection.id, this.props.collection.name, this.props.username); + } + + handleSketchDelete = () => { + this.closeAll(); + if (window.confirm(`Are you sure you want to delete "${this.props.collection.name}"?`)) { + this.props.deleteProject(this.props.collection.id); + } + } + + render() { + const { collection, username } = this.props; + const { renameOpen, optionsOpen, renameValue } = this.state; + const userIsOwner = this.props.user.username === this.props.username; + + const dropdown = ( + + + {optionsOpen && +
      + {userIsOwner && +
    • + +
    • } +
    + } + + ); + + return ( + + + + {renameOpen ? '' : collection.name} + + {renameOpen + && + e.stopPropagation()} + /> + } + + {format(new Date(collection.createdAt), 'MMM D, YYYY h:mm A')} + {format(new Date(collection.updatedAt), 'MMM D, YYYY h:mm A')} + {(collection.items || []).length} + {dropdown} + ); + } +} + +CollectionListRowBase.propTypes = { + collection: PropTypes.shape({ + id: PropTypes.string.isRequired, + name: PropTypes.string.isRequired + }).isRequired, + username: PropTypes.string.isRequired, + user: PropTypes.shape({ + username: PropTypes.string, + authenticated: PropTypes.bool.isRequired + }).isRequired, + deleteProject: PropTypes.func.isRequired, + showShareModal: PropTypes.func.isRequired, + cloneProject: PropTypes.func.isRequired, + exportProjectAsZip: PropTypes.func.isRequired, + changeProjectName: PropTypes.func.isRequired +}; + +function mapDispatchToPropsSketchListRow(dispatch) { + return bindActionCreators(Object.assign({}, ProjectActions, IdeActions), dispatch); +} + +const CollectionListRow = connect(null, mapDispatchToPropsSketchListRow)(CollectionListRowBase); + +class CollectionList extends React.Component { + constructor(props) { + super(props); + this.props.getCollections(this.props.username); + this.props.resetSorting(); + this._renderFieldHeader = this._renderFieldHeader.bind(this); + } + + getTitle() { + if (this.props.username === this.props.user.username) { + return 'p5.js Web Editor | My collections'; + } + return `p5.js Web Editor | ${this.props.username}'s collections`; + } + + hasCollections() { + return !this.props.loading && this.props.collections.length > 0; + } + + _renderLoader() { + if (this.props.loading) return ; + return null; + } + + _renderEmptyTable() { + if (!this.props.loading && this.props.collections.length === 0) { + return (

    No collections.

    ); + } + return null; + } + + _renderFieldHeader(fieldName, displayName) { + const { field, direction } = this.props.sorting; + const headerClass = classNames({ + 'sketches-table__header': true, + 'sketches-table__header--selected': field === fieldName + }); + return ( + + + + ); + } + + render() { + const username = this.props.username !== undefined ? this.props.username : this.props.user.username; + return ( +
    + + {this.getTitle()} + + {this._renderLoader()} + {this._renderEmptyTable()} + {this.hasCollections() && + + + + {this._renderFieldHeader('name', 'Name')} + {this._renderFieldHeader('createdAt', 'Date Created')} + {this._renderFieldHeader('updatedAt', 'Date Updated')} + {this._renderFieldHeader('numItems', '# sketches')} + + + + + {this.props.collections.map(collection => + ())} + +
    } +
    + ); + } +} + +const ProjectShape = PropTypes.shape({ + id: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + createdAt: PropTypes.string.isRequired, + updatedAt: PropTypes.string.isRequired, + user: PropTypes.shape({ + username: PropTypes.string.isRequired + }).isRequired, +}); + +const ItemsShape = PropTypes.shape({ + createdAt: PropTypes.string.isRequired, + updatedAt: PropTypes.string.isRequired, + project: ProjectShape +}); + +CollectionList.propTypes = { + user: PropTypes.shape({ + username: PropTypes.string, + authenticated: PropTypes.bool.isRequired + }).isRequired, + getCollections: PropTypes.func.isRequired, + collections: PropTypes.arrayOf(PropTypes.shape({ + id: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + description: PropTypes.string, + createdAt: PropTypes.string.isRequired, + updatedAt: PropTypes.string.isRequired, + items: PropTypes.arrayOf(ItemsShape), + })).isRequired, + username: PropTypes.string, + loading: PropTypes.bool.isRequired, + toggleDirectionForField: PropTypes.func.isRequired, + resetSorting: PropTypes.func.isRequired, + sorting: PropTypes.shape({ + field: PropTypes.string.isRequired, + direction: PropTypes.string.isRequired + }).isRequired, + project: PropTypes.shape({ + id: PropTypes.string, + owner: PropTypes.shape({ + id: PropTypes.string + }) + }) +}; + +CollectionList.defaultProps = { + project: { + id: undefined, + owner: undefined + }, + username: undefined +}; + +function mapStateToProps(state) { + return { + user: state.user, + collections: getSortedCollections(state), + sorting: state.sorting, + loading: state.loading, + project: state.project + }; +} + +function mapDispatchToProps(dispatch) { + return bindActionCreators( + Object.assign({}, CollectionsActions, ProjectsActions, ToastActions, SortingActions), + dispatch + ); +} + +export default connect(mapStateToProps, mapDispatchToProps)(CollectionList); diff --git a/client/modules/IDE/pages/IDEView.jsx b/client/modules/IDE/pages/IDEView.jsx index b0be087e..e97a8d88 100644 --- a/client/modules/IDE/pages/IDEView.jsx +++ b/client/modules/IDE/pages/IDEView.jsx @@ -314,12 +314,12 @@ class IDEView extends React.Component { {( ( (this.props.preferences.textOutput || - this.props.preferences.gridOutput || - this.props.preferences.soundOutput + this.props.preferences.gridOutput || + this.props.preferences.soundOutput ) && - this.props.ide.isPlaying + this.props.ide.isPlaying ) || - this.props.ide.isAccessibleOutputPlaying + this.props.ide.isAccessibleOutputPlaying ) }
  • @@ -350,14 +350,14 @@ class IDEView extends React.Component { - { this.props.ide.modalIsVisible && + {this.props.ide.modalIsVisible && } - { this.props.ide.newFolderModalVisible && + {this.props.ide.newFolderModalVisible && } - { this.props.location.pathname === '/feedback' && + {this.props.location.pathname === '/feedback' && } - { this.props.ide.shareModalVisible && + {this.props.ide.shareModalVisible && } - { this.props.ide.keyboardShortcutVisible && + {this.props.ide.keyboardShortcutVisible && } - { this.props.ide.errorType && + {this.props.ide.errorType && } - { this.props.ide.helpType && + {this.props.ide.helpType && { + switch (action.type) { + case ActionTypes.SET_COLLECTIONS: + return action.collections; + default: + return state; + } +}; + +export default sketches; diff --git a/client/modules/IDE/selectors/collections.js b/client/modules/IDE/selectors/collections.js new file mode 100644 index 00000000..3ba90241 --- /dev/null +++ b/client/modules/IDE/selectors/collections.js @@ -0,0 +1,37 @@ +import { createSelector } from 'reselect'; +import differenceInMilliseconds from 'date-fns/difference_in_milliseconds'; +import find from 'lodash/find'; +import orderBy from 'lodash/orderBy'; +import { DIRECTION } from '../actions/sorting'; + +const getCollections = state => state.collections; +const getField = state => state.sorting.field; +const getDirection = state => state.sorting.direction; + +const getSortedCollections = createSelector( + getCollections, + getField, + getDirection, + (collections, field, direction) => { + if (field === 'name') { + if (direction === DIRECTION.DESC) { + return orderBy(collections, 'name', 'desc'); + } + return orderBy(collections, 'name', 'asc'); + } + const sortedCollections = [...collections].sort((a, b) => { + const result = + direction === DIRECTION.ASC + ? differenceInMilliseconds(new Date(a[field]), new Date(b[field])) + : differenceInMilliseconds(new Date(b[field]), new Date(a[field])); + return result; + }); + return sortedCollections; + } +); + +export function getCollection(state, id) { + return find(getCollections(state), { id }); +} + +export default getSortedCollections; diff --git a/client/modules/User/components/DashboardTabSwitcher.jsx b/client/modules/User/components/DashboardTabSwitcher.jsx index 8143759f..4b657b01 100644 --- a/client/modules/User/components/DashboardTabSwitcher.jsx +++ b/client/modules/User/components/DashboardTabSwitcher.jsx @@ -4,6 +4,7 @@ import { Link } from 'react-router'; const TabKey = { assets: 'assets', + collections: 'collections', sketches: 'sketches', }; @@ -30,8 +31,9 @@ Tab.propTypes = { const DashboardTabSwitcher = ({ currentTab, isOwner, username }) => (
      - Sketches - {isOwner && Assets} + Sketches + Collections + {isOwner && Assets}
    ); diff --git a/client/modules/User/pages/DashboardView.jsx b/client/modules/User/pages/DashboardView.jsx index ba1b63f0..3b6344b0 100644 --- a/client/modules/User/pages/DashboardView.jsx +++ b/client/modules/User/pages/DashboardView.jsx @@ -8,6 +8,7 @@ import { updateSettings, initiateVerification, createApiKey, removeApiKey } from import NavBasic from '../../../components/NavBasic'; import AssetList from '../../IDE/components/AssetList'; +import CollectionList from '../../IDE/components/CollectionList'; import SketchList from '../../IDE/components/SketchList'; import DashboardTabSwitcher, { TabKey } from '../components/DashboardTabSwitcher'; @@ -35,11 +36,13 @@ class DashboardView extends React.Component { browserHistory.push('/'); } - selectedTabName() { + selectedTabKey() { const path = this.props.location.pathname; if (/assets/.test(path)) { return TabKey.assets; + } else if (/collections/.test(path)) { + return TabKey.collections; } return TabKey.sketches; @@ -57,12 +60,20 @@ class DashboardView extends React.Component { return this.props.user.username === this.props.params.username; } - navigationItem() { - + renderContent(tabKey, username) { + switch (tabKey) { + case TabKey.assets: + return ; + case TabKey.collections: + return ; + case TabKey.sketches: + default: + return ; + } } render() { - const currentTab = this.selectedTabName(); + const currentTab = this.selectedTabKey(); const isOwner = this.isOwner(); const { username } = this.props.params; @@ -82,9 +93,7 @@ class DashboardView extends React.Component {
    - { - currentTab === TabKey.sketches ? : - } + {this.renderContent(currentTab, username)}
    diff --git a/client/reducers.js b/client/reducers.js index 057dbd62..b14fd59f 100644 --- a/client/reducers.js +++ b/client/reducers.js @@ -12,6 +12,7 @@ import console from './modules/IDE/reducers/console'; import assets from './modules/IDE/reducers/assets'; import sorting from './modules/IDE/reducers/sorting'; import loading from './modules/IDE/reducers/loading'; +import collections from './modules/IDE/reducers/collections'; const rootReducer = combineReducers({ form, @@ -26,7 +27,8 @@ const rootReducer = combineReducers({ toast, console, assets, - loading + loading, + collections }); export default rootReducer; diff --git a/client/routes.jsx b/client/routes.jsx index 89db6977..6910d923 100644 --- a/client/routes.jsx +++ b/client/routes.jsx @@ -41,8 +41,9 @@ const routes = store => ( - - + + + diff --git a/server/controllers/collection.controller/collectionForUserExists.js b/server/controllers/collection.controller/collectionForUserExists.js new file mode 100644 index 00000000..e2881fd4 --- /dev/null +++ b/server/controllers/collection.controller/collectionForUserExists.js @@ -0,0 +1,29 @@ +import Collection from '../../models/collection'; +import User from '../../models/user'; + +export default function collectionForUserExists(username, collectionId, callback) { + function sendFailure() { + callback(false); + } + + function sendSuccess(collection) { + callback(collection != null); + } + + function findUser() { + return User.findOne({ username }); + } + + function findCollection(owner) { + if (owner == null) { + throw new Error('User not found'); + } + + return Collection.findOne({ _id: collectionId, owner }); + } + + return findUser() + .then(findCollection) + .then(sendSuccess) + .catch(sendFailure); +} diff --git a/server/controllers/collection.controller/index.js b/server/controllers/collection.controller/index.js index b09db3f0..8cb9e368 100644 --- a/server/controllers/collection.controller/index.js +++ b/server/controllers/collection.controller/index.js @@ -1,4 +1,5 @@ export { default as addProjectToCollection } from './addProjectToCollection'; +export { default as collectionForUserExists } from './collectionForUserExists'; export { default as createCollection } from './createCollection'; export { default as listCollections } from './listCollections'; export { default as removeCollection } from './removeCollection'; diff --git a/server/routes/server.routes.js b/server/routes/server.routes.js index 41d7dc60..29877809 100644 --- a/server/routes/server.routes.js +++ b/server/routes/server.routes.js @@ -3,6 +3,7 @@ import { renderIndex } from '../views/index'; import { get404Sketch } from '../views/404Page'; import { userExists } from '../controllers/user.controller'; import { projectExists, projectForUserExists } from '../controllers/project.controller'; +import { collectionForUserExists } from '../controllers/collection.controller'; const router = new Router(); @@ -111,4 +112,16 @@ router.get('/:username/sketches', (req, res) => { )); }); +router.get('/:username/collections', (req, res) => { + userExists(req.params.username, exists => ( + exists ? res.send(renderIndex()) : get404Sketch(html => res.send(html)) + )); +}); + +router.get('/:username/collections/:id', (req, res) => { + collectionForUserExists(req.params.username, req.params.id, exists => ( + exists ? res.send(renderIndex()) : get404Sketch(html => res.send(html)) + )); +}); + export default router; From dcf65c6f46d02484976244bc27bf760f022a5404 Mon Sep 17 00:00:00 2001 From: Andrew Nicolaou Date: Tue, 9 Jul 2019 18:24:09 +0200 Subject: [PATCH 07/25] Create Collection --- client/constants.js | 4 + client/modules/IDE/actions/collections.js | 77 +++++++++++ .../modules/IDE/components/CollectionList.jsx | 19 ++- client/modules/IDE/reducers/collections.js | 12 ++ client/modules/IDE/reducers/project.js | 10 +- .../{IDE => User}/components/Collection.jsx | 0 .../User/components/CollectionCreate.jsx | 124 ++++++++++++++++++ client/modules/User/pages/CollectionView.jsx | 114 ++++++++++++++++ client/routes.jsx | 2 + client/styles/base/_base.scss | 7 +- client/styles/components/_forms.scss | 13 ++ client/utils/generateRandomName.js | 12 ++ server/routes/server.routes.js | 12 ++ 13 files changed, 394 insertions(+), 12 deletions(-) rename client/modules/{IDE => User}/components/Collection.jsx (100%) create mode 100644 client/modules/User/components/CollectionCreate.jsx create mode 100644 client/modules/User/pages/CollectionView.jsx create mode 100644 client/utils/generateRandomName.js diff --git a/client/constants.js b/client/constants.js index c4111b95..cd90d4c9 100644 --- a/client/constants.js +++ b/client/constants.js @@ -36,6 +36,10 @@ export const SET_PROJECT = 'SET_PROJECT'; export const SET_PROJECTS = 'SET_PROJECTS'; export const SET_COLLECTIONS = 'SET_COLLECTIONS'; +export const CREATE_COLLECTION = 'CREATED_COLLECTION'; + +export const ADD_TO_COLLECTION = 'ADD_TO_COLLECTION'; +export const REMOVE_FROM_COLLECTION = 'REMOVE_FROM_COLLECTION'; export const DELETE_PROJECT = 'DELETE_PROJECT'; diff --git a/client/modules/IDE/actions/collections.js b/client/modules/IDE/actions/collections.js index d3fffe6f..17f0b872 100644 --- a/client/modules/IDE/actions/collections.js +++ b/client/modules/IDE/actions/collections.js @@ -32,3 +32,80 @@ export function getCollections(username) { }); }; } + +export function createCollection(collection) { + return (dispatch) => { + dispatch(startLoader()); + const url = `${ROOT_URL}/collections`; + return axios.post(url, collection, { withCredentials: true }) + .then((response) => { + dispatch({ + type: ActionTypes.CREATE_COLLECTION + }); + dispatch(stopLoader()); + + return response.data; + }) + .catch((response) => { + dispatch({ + type: ActionTypes.ERROR, + error: response.data + }); + dispatch(stopLoader()); + + return response.data; + }); + }; +} + +export function addToCollection(collectionId, projectId) { + return (dispatch) => { + dispatch(startLoader()); + const url = `${ROOT_URL}/collections/${collectionId}/${projectId}`; + return axios.post(url, { withCredentials: true }) + .then((response) => { + dispatch({ + type: ActionTypes.ADD_TO_COLLECTION, + payload: response.data + }); + dispatch(stopLoader()); + + return response.data; + }) + .catch((response) => { + dispatch({ + type: ActionTypes.ERROR, + error: response.data + }); + dispatch(stopLoader()); + + return response.data; + }); + }; +} + +export function removeFromCollection(collectionId, projectId) { + return (dispatch) => { + dispatch(startLoader()); + const url = `${ROOT_URL}/collections/${collectionId}/${projectId}`; + return axios.delete(url, { withCredentials: true }) + .then((response) => { + dispatch({ + type: ActionTypes.REMOVE_FROM_COLLECTION, + payload: response.data + }); + dispatch(stopLoader()); + + return response.data; + }) + .catch((response) => { + dispatch({ + type: ActionTypes.ERROR, + error: response.data + }); + dispatch(stopLoader()); + + return response.data; + }); + }; +} diff --git a/client/modules/IDE/components/CollectionList.jsx b/client/modules/IDE/components/CollectionList.jsx index 2b071847..9da68917 100644 --- a/client/modules/IDE/components/CollectionList.jsx +++ b/client/modules/IDE/components/CollectionList.jsx @@ -21,6 +21,10 @@ const arrowDown = require('../../../images/sort-arrow-down.svg'); const downFilledTriangle = require('../../../images/down-filled-triangle.svg'); class CollectionListRowBase extends React.Component { + static projectInCollection(project, collection) { + return collection.items.find(item => item.project.id === project.id) != null; + } + constructor(props) { super(props); this.state = { @@ -134,6 +138,14 @@ class CollectionListRowBase extends React.Component { } } + handleCollectionAdd = () => { + this.props.addToCollection(this.props.collection.id, this.props.project.id); + } + + handleCollectionRemove = () => { + this.props.removeFromCollection(this.props.collection.id, this.props.project.id); + } + render() { const { collection, username } = this.props; const { renameOpen, optionsOpen, renameValue } = this.state; @@ -198,6 +210,8 @@ class CollectionListRowBase extends React.Component { } CollectionListRowBase.propTypes = { + addToCollection: PropTypes.func.isRequired, + removeFromCollection: PropTypes.func.isRequired, collection: PropTypes.shape({ id: PropTypes.string.isRequired, name: PropTypes.string.isRequired @@ -215,7 +229,7 @@ CollectionListRowBase.propTypes = { }; function mapDispatchToPropsSketchListRow(dispatch) { - return bindActionCreators(Object.assign({}, ProjectActions, IdeActions), dispatch); + return bindActionCreators(Object.assign({}, CollectionsActions, ProjectActions, IdeActions), dispatch); } const CollectionListRow = connect(null, mapDispatchToPropsSketchListRow)(CollectionListRowBase); @@ -279,6 +293,9 @@ class CollectionList extends React.Component { {this.getTitle()} + + New collection + {this._renderLoader()} {this._renderEmptyTable()} {this.hasCollections() && diff --git a/client/modules/IDE/reducers/collections.js b/client/modules/IDE/reducers/collections.js index fbbaefcc..4c4027b1 100644 --- a/client/modules/IDE/reducers/collections.js +++ b/client/modules/IDE/reducers/collections.js @@ -4,6 +4,18 @@ const sketches = (state = [], action) => { switch (action.type) { case ActionTypes.SET_COLLECTIONS: return action.collections; + + // The API returns the complete new collection + // with the items added or removed + case ActionTypes.ADD_TO_COLLECTION: + case ActionTypes.REMOVE_FROM_COLLECTION: + return state.map((collection) => { + if (collection.id === action.payload.id) { + return action.payload; + } + + return collection; + }); default: return state; } diff --git a/client/modules/IDE/reducers/project.js b/client/modules/IDE/reducers/project.js index 0779c0f5..2eb19d4d 100644 --- a/client/modules/IDE/reducers/project.js +++ b/client/modules/IDE/reducers/project.js @@ -1,14 +1,8 @@ -import friendlyWords from 'friendly-words'; import * as ActionTypes from '../../../constants'; - -const generateRandomName = () => { - const adj = friendlyWords.predicates[Math.floor(Math.random() * friendlyWords.predicates.length)]; - const obj = friendlyWords.objects[Math.floor(Math.random() * friendlyWords.objects.length)]; - return `${adj} ${obj}`; -}; +import { generateProjectName } from '../../../utils/generateRandomName'; const initialState = () => { - const generatedString = generateRandomName(); + const generatedString = generateProjectName(); const generatedName = generatedString.charAt(0).toUpperCase() + generatedString.slice(1); return { name: generatedName, diff --git a/client/modules/IDE/components/Collection.jsx b/client/modules/User/components/Collection.jsx similarity index 100% rename from client/modules/IDE/components/Collection.jsx rename to client/modules/User/components/Collection.jsx diff --git a/client/modules/User/components/CollectionCreate.jsx b/client/modules/User/components/CollectionCreate.jsx new file mode 100644 index 00000000..3542e6a3 --- /dev/null +++ b/client/modules/User/components/CollectionCreate.jsx @@ -0,0 +1,124 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { Helmet } from 'react-helmet'; +import { connect } from 'react-redux'; +import { browserHistory } from 'react-router'; +import { bindActionCreators } from 'redux'; +import * as CollectionsActions from '../../IDE/actions/collections'; + +import { generateCollectionName } from '../../../utils/generateRandomName'; + +class CollectionCreate extends React.Component { + constructor() { + super(); + + const name = generateCollectionName(); + + this.state = { + generatedCollectionName: name, + collection: { + name, + description: '' + } + }; + } + + getTitle() { + return 'p5.js Web Editor | Create collection'; + } + + handleTextChange = field => (evt) => { + this.setState({ + collection: { + ...this.state.collection, + [field]: evt.target.value, + } + }); + } + + handleCreateCollection = (event) => { + event.preventDefault(); + + this.props.createCollection(this.state.collection) + .then(({ id, owner }) => { + browserHistory.replace(`/${owner.username}/collections/${id}`); + }) + .catch((error) => { + console.error('Error creating collection', error); + this.setState({ + creationError: error, + }); + }); + } + + render() { + const { generatedCollectionName, creationError } = this.state; + const { name, description } = this.state.collection; + + const invalid = name === '' || name == null; + + return ( +
    + + {this.getTitle()} + + +
    + {creationError && Couldn't create collection} +

    + + + {invalid && Collection name is required} +

    +

    + +