├── public ├── uploaded │ └── .gitkeep ├── images │ ├── logo.png │ ├── right.png │ ├── trello-logo.png │ ├── cross.svg │ ├── logo.svg │ ├── logo-title.svg │ └── logo-title-inverse.svg └── data │ └── data.json ├── assets ├── scss │ ├── fontSizes.scss │ ├── levers.scss │ ├── trello.scss │ ├── tools.scss │ ├── base.scss │ ├── fonts.scss │ ├── colors.scss │ ├── boardDetail.scss │ ├── collection.scss │ ├── reusable.scss │ ├── nav.scss │ ├── background.scss │ ├── task.scss │ ├── loginModule.scss │ ├── list.scss │ └── taskModule.scss ├── js │ ├── directives │ │ └── vue-focus.js │ ├── components │ │ ├── navbar.js │ │ ├── collection.js │ │ ├── login.js │ │ └── board.js │ └── trello │ │ └── main.js └── css │ └── normalize.css ├── .github ├── FUNDING.yml └── workflows │ └── main.yml ├── cypress ├── fixtures │ └── cypressLogo.png ├── support │ ├── commands │ │ ├── deleteListApi.ts │ │ ├── renameListApi.ts │ │ ├── reorderListApi.ts │ │ ├── addBoardApi.ts │ │ ├── starBoardApi.ts │ │ ├── addBoardUi.ts │ │ ├── signupApi.ts │ │ ├── deleteBoardApi.ts │ │ ├── addListApi.ts │ │ ├── updateBoardApi.ts │ │ └── addTaskApi.ts │ ├── index.ts │ └── @types │ │ ├── commands.d.ts │ │ └── env.d.ts ├── integration │ ├── flaky.ts │ ├── main.ts │ ├── collection.ts │ ├── login.ts │ └── board.ts └── plugins │ └── index.ts ├── .gitignore ├── babel.config.js ├── tsconfig.json ├── cypress.json ├── Gruntfile.js ├── .eslintrc.js ├── README.md ├── package.json ├── index.js ├── middleware.js └── index.html /public/uploaded/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/scss/fontSizes.scss: -------------------------------------------------------------------------------- 1 | $large: 20px; 2 | $medium: 15px; 3 | $small: 12px; -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | 2 | github: [filiphric] 3 | custom: ['https://filiphric.com/workshops'] 4 | -------------------------------------------------------------------------------- /public/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/filiphric/trelloapp/HEAD/public/images/logo.png -------------------------------------------------------------------------------- /public/images/right.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/filiphric/trelloapp/HEAD/public/images/right.png -------------------------------------------------------------------------------- /public/data/data.json: -------------------------------------------------------------------------------- 1 | { 2 | "boards": [], 3 | "tasks": [], 4 | "users": [], 5 | "lists": [] 6 | } -------------------------------------------------------------------------------- /public/images/trello-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/filiphric/trelloapp/HEAD/public/images/trello-logo.png -------------------------------------------------------------------------------- /cypress/fixtures/cypressLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/filiphric/trelloapp/HEAD/cypress/fixtures/cypressLogo.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | .nyc_output 3 | coverage 4 | cypress/videos 5 | cypress/screenshots 6 | /public/uploaded/*.png 7 | /public/uploaded/*.jpg 8 | public/css 9 | public/js -------------------------------------------------------------------------------- /cypress/support/commands/deleteListApi.ts: -------------------------------------------------------------------------------- 1 | Cypress.Commands.add('deleteListApi', (index) => { 2 | 3 | cy 4 | .request('DELETE', `/api/lists/${Cypress.env('lists')[index].id}`); 5 | 6 | }); -------------------------------------------------------------------------------- /assets/scss/levers.scss: -------------------------------------------------------------------------------- 1 | $list-width: 275px; 2 | $border-radius: 3px; 3 | $list-item-width: 88%; 4 | $text-space: 4%; 5 | $task-space: 7px; 6 | $card-width: 330px; 7 | $card-height: 375px; 8 | $nav-height: 55px; -------------------------------------------------------------------------------- /assets/scss/trello.scss: -------------------------------------------------------------------------------- 1 | @import 'base.scss'; 2 | @import 'task.scss'; 3 | @import 'list.scss'; 4 | @import 'taskModule.scss'; 5 | @import 'loginModule.scss'; 6 | @import 'collection.scss'; 7 | @import 'boardDetail.scss'; -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'presets': [ 3 | [ 4 | '@babel/preset-env' 5 | ] 6 | ], 7 | plugins: [ 8 | ['babel-plugin-istanbul', { 9 | extension: ['.js'] 10 | }] 11 | ] 12 | }; -------------------------------------------------------------------------------- /cypress/support/commands/renameListApi.ts: -------------------------------------------------------------------------------- 1 | Cypress.Commands.add('renameListApi', ({ title, index = 0 }) => { 2 | 3 | cy 4 | .request('PATCH', `/api/lists/${Cypress.env('lists')[index].id}`, { 5 | title 6 | }); 7 | 8 | }); -------------------------------------------------------------------------------- /cypress/support/commands/reorderListApi.ts: -------------------------------------------------------------------------------- 1 | Cypress.Commands.add('reorderListApi', ({ order, index = 0 }) => { 2 | 3 | cy 4 | .request('PATCH', `/api/lists/${Cypress.env('lists')[index].id}`, { 5 | order 6 | }); 7 | 8 | }); -------------------------------------------------------------------------------- /assets/scss/tools.scss: -------------------------------------------------------------------------------- 1 | #tools { 2 | position: fixed; 3 | bottom: 0; 4 | right: 0; 5 | padding: 20px; 6 | background: rgb(0 0 0 / 0.1); 7 | margin: 10px; 8 | border-radius: 4px; 9 | button { 10 | margin: 5px; 11 | } 12 | } -------------------------------------------------------------------------------- /cypress/support/commands/addBoardApi.ts: -------------------------------------------------------------------------------- 1 | Cypress.Commands.add('addBoardApi', (name) => { 2 | 3 | cy 4 | .request('POST', '/api/boards', { name }) 5 | .then(({ body }) => { 6 | 7 | Cypress.env('boards').push(body); 8 | 9 | }); 10 | 11 | }); -------------------------------------------------------------------------------- /cypress/support/commands/starBoardApi.ts: -------------------------------------------------------------------------------- 1 | Cypress.Commands.add('addBoardApi', (name) => { 2 | 3 | cy 4 | .request('PATCH', '/api/boards/${}', { name }) 5 | .then(({ body }) => { 6 | 7 | Cypress.env('boards').push(body); 8 | 9 | }); 10 | 11 | }); -------------------------------------------------------------------------------- /assets/js/directives/vue-focus.js: -------------------------------------------------------------------------------- 1 | const Vue = require('vue'); 2 | 3 | Vue.directive('focus', { 4 | inserted: function (el) { 5 | el.focus(); 6 | }, 7 | update: function (el) { 8 | Vue.nextTick(function() { 9 | el.focus(); 10 | }); 11 | } 12 | }); -------------------------------------------------------------------------------- /assets/scss/base.scss: -------------------------------------------------------------------------------- 1 | @import '../../assets/css/normalize.css'; 2 | @import 'colors.scss'; 3 | @import 'levers.scss'; 4 | @import 'fonts.scss'; 5 | @import 'fontSizes.scss'; 6 | @import 'reusable.scss'; 7 | @import 'nav.scss'; 8 | @import 'background.scss'; 9 | @import 'tools.scss'; -------------------------------------------------------------------------------- /cypress/support/commands/addBoardUi.ts: -------------------------------------------------------------------------------- 1 | Cypress.Commands.add('addBoardUi', (name) => { 2 | 3 | cy 4 | .contains('h1', 'Create a board...') 5 | .click(); 6 | 7 | cy 8 | .get('[placeholder="Create a board..."]') 9 | .type(`${name}{enter}`); 10 | 11 | }); -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["es5", "dom"], 5 | "types": ["cypress", "cypress-real-events", "node"], 6 | "allowJs": true, 7 | "baseUrl": "./" 8 | }, 9 | "include": [ 10 | "**/*.ts" 11 | ] 12 | } -------------------------------------------------------------------------------- /cypress/support/commands/signupApi.ts: -------------------------------------------------------------------------------- 1 | Cypress.Commands.add('signupApi', ({ email, password }) => { 2 | 3 | cy 4 | .request('POST', '/api/signup', { 5 | email, password 6 | }).then(({ body }) => { 7 | Cypress.env('users').push(body); 8 | }); 9 | 10 | }); -------------------------------------------------------------------------------- /cypress/support/commands/deleteBoardApi.ts: -------------------------------------------------------------------------------- 1 | Cypress.Commands.add('deleteBoardApi', (index = 0) => { 2 | 3 | cy 4 | .request('DELETE', `/api/boards/${Cypress.env('boards')[index].id}`) 5 | .then(() => { 6 | 7 | Cypress.env('boards').splice(index, 1); 8 | 9 | }); 10 | 11 | }); -------------------------------------------------------------------------------- /assets/scss/fonts.scss: -------------------------------------------------------------------------------- 1 | @mixin openSans($size, $color) { 2 | font-family: 'Open Sans', sans-serif; 3 | font-weight: 400; 4 | font-size: $size; 5 | color: $color; 6 | } 7 | @mixin openSansBold($size, $color) { 8 | font-family: 'Open Sans', sans-serif; 9 | font-weight: 700; 10 | font-size: $size; 11 | color: $color; 12 | } -------------------------------------------------------------------------------- /cypress/support/commands/addListApi.ts: -------------------------------------------------------------------------------- 1 | Cypress.Commands.add('addListApi', ({ title, boardIndex = 0 }) => { 2 | 3 | cy 4 | .request('POST', '/api/lists', { 5 | boardId: Cypress.env('boards')[boardIndex].id, 6 | title, 7 | }).then(({ body }) => { 8 | Cypress.env('lists').push(body); 9 | }); 10 | 11 | }); -------------------------------------------------------------------------------- /cypress/support/commands/updateBoardApi.ts: -------------------------------------------------------------------------------- 1 | Cypress.Commands.add('updateBoardApi', ({ name, index = 0 }) => { 2 | 3 | cy 4 | .request('PATCH', `/api/boards/${Cypress.env('boards')[index].id}`, { 5 | name 6 | }) 7 | .then(({ body }) => { 8 | 9 | Cypress.env('boards')[index] = body; 10 | 11 | }); 12 | 13 | }); -------------------------------------------------------------------------------- /cypress/support/index.ts: -------------------------------------------------------------------------------- 1 | import '@4tw/cypress-drag-drop'; 2 | import 'cypress-file-upload'; 3 | import 'cypress-real-events/support'; 4 | import '@cypress/code-coverage/support'; 5 | 6 | beforeEach(() => { 7 | 8 | Cypress.env('boards', []); 9 | Cypress.env('lists', []); 10 | Cypress.env('tasks', []); 11 | Cypress.env('users', []); 12 | 13 | }); -------------------------------------------------------------------------------- /cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "baseUrl": "http://localhost:3000", 3 | "env": { 4 | "coverage": false 5 | }, 6 | "pluginsFile": "cypress/plugins/index.ts", 7 | "projectId": "7vknyb", 8 | "retries": { 9 | "openMode": 0, 10 | "runMode": 10 11 | }, 12 | "supportFile": "cypress/support/index.ts", 13 | "video": false, 14 | "viewportHeight": 550, 15 | "viewportWidth": 700 16 | } -------------------------------------------------------------------------------- /cypress/support/commands/addTaskApi.ts: -------------------------------------------------------------------------------- 1 | Cypress.Commands.add('addTaskApi', ({ title, boardIndex = 0, listIndex = 0 }) => { 2 | 3 | cy 4 | .request('POST', '/api/tasks', { 5 | title, 6 | boardId: Cypress.env('boards')[boardIndex].id, 7 | listId: Cypress.env('lists')[listIndex].id 8 | }) 9 | .then(({ body }) => { 10 | 11 | Cypress.env('tasks').push(body); 12 | 13 | }); 14 | 15 | }); -------------------------------------------------------------------------------- /cypress/integration/flaky.ts: -------------------------------------------------------------------------------- 1 | it('board id must be even', () => { 2 | 3 | cy 4 | .intercept('POST', '/api/boards') 5 | .as('board'); 6 | 7 | cy 8 | .visit('/'); 9 | 10 | cy 11 | .get('[data-cy="create-board"]') 12 | .click(); 13 | 14 | cy 15 | .get('[data-cy=new-board-input]') 16 | .type('new board{enter}'); 17 | 18 | cy 19 | .wait('@board') 20 | .then(({ response }) => { 21 | 22 | expect(response.body.id % 5 == 0).to.be.true; 23 | 24 | }); 25 | 26 | }); -------------------------------------------------------------------------------- /cypress/plugins/index.ts: -------------------------------------------------------------------------------- 1 | const clipboardy = require('clipboardy'); 2 | 3 | module.exports = (on, config) => { 4 | on('task', { 5 | getClipboard: () => { 6 | const clipboard: string = clipboardy.readSync(); 7 | return clipboard; 8 | }, 9 | }) 10 | require('@cypress/code-coverage/task')(on, config); 11 | 12 | // add other tasks to be registered here 13 | 14 | 15 | // IMPORTANT to return the config object 16 | // with the any changed environment variables 17 | return config; 18 | }; -------------------------------------------------------------------------------- /assets/scss/colors.scss: -------------------------------------------------------------------------------- 1 | $white: #FFFFFF; 2 | 3 | $yellow: #EEE82C; 4 | 5 | $blue-1: #E4F0F6; 6 | $blue-2: #BCD9EA; 7 | $blue-3: #8BBDD9; 8 | $blue-4: #5BA4CF; 9 | $blue-5: #298FCA; 10 | $blue-6: #0079BF; 11 | $blue-7: #026AA7; 12 | $blue-8: #055A8C; 13 | $blue-9: #094C72; 14 | $blue-10: #0C3953; 15 | 16 | $gray-1: #F8F9F9; 17 | $gray-2: #EDEFF0; 18 | $gray-3: #E2E4E6; 19 | $gray-4: #D6DADC; 20 | $gray-5: #CDD2D4; 21 | $gray-6: #C4C9CC; 22 | $gray-7: #B6BBBF; 23 | $gray-8: #A5ACB0; 24 | $gray-9: #959DA1; 25 | $gray-10: #838C91; 26 | 27 | $black: #4d4d4d; 28 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: [push] 3 | jobs: 4 | cypress-run: 5 | runs-on: ubuntu-16.04 6 | strategy: 7 | fail-fast: false 8 | matrix: 9 | containers: [1, 2, 3] 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v1 13 | - name: Cypress run 14 | uses: cypress-io/github-action@v2 15 | with: 16 | start: npm start 17 | record: true 18 | parallel: true 19 | env: 20 | CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} 21 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 22 | -------------------------------------------------------------------------------- /assets/js/components/navbar.js: -------------------------------------------------------------------------------- 1 | const Vue = require('vue'); 2 | const Login = require('./login'); 3 | 4 | Vue.component('Navbar', { 5 | components: { 6 | 'Login': Login 7 | }, 8 | template: '#navbar', 9 | data: function() { 10 | return { 11 | loginDropdown: false 12 | }; 13 | }, 14 | methods: { 15 | openLogin: function() { 16 | this.$root.showLoginModule = true; 17 | }, 18 | logout: function () { 19 | this.$root.loggedIn.active = false; 20 | // axios.defaults.headers.common['Authorization'] = ''; 21 | document.cookie = 'trello_token=;expires=Thu, 01 Jan 1970 00:00:00 GMT'; 22 | document.location.href = '/'; 23 | }, 24 | } 25 | }); -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | module.exports = function(grunt) { 2 | grunt.initConfig({ 3 | pkg: grunt.file.readJSON('package.json'), 4 | sass: { 5 | dist: { 6 | files: { 7 | 'public/css/trello.css': 'assets/scss/trello.scss' 8 | } 9 | } 10 | }, 11 | browserify: { 12 | dist: { 13 | files: { 14 | 'public/js/trello.js': 'assets/js/trello/main.js', 15 | }, 16 | }, 17 | options: { 18 | transform: [['babelify']], 19 | watch: true 20 | } 21 | }, 22 | watch: { 23 | css: { 24 | files: '**/*.scss', 25 | tasks: ['sass'], 26 | }, 27 | scripts: { 28 | files: './assets/**/*.js', 29 | tasks: ['browserify'], 30 | } 31 | } 32 | }); 33 | grunt.loadNpmTasks('grunt-contrib-sass'); 34 | grunt.loadNpmTasks('grunt-browserify'); 35 | grunt.loadNpmTasks('grunt-contrib-watch'); 36 | grunt.registerTask('s', ['sass']); 37 | grunt.registerTask('b', ['browserify']); 38 | grunt.registerTask('dev', ['sass', 'browserify']); 39 | 40 | }; -------------------------------------------------------------------------------- /cypress/integration/main.ts: -------------------------------------------------------------------------------- 1 | import '../support/commands/addBoardApi'; 2 | 3 | it('tools work', () => { 4 | 5 | cy 6 | .addBoardApi('new board'); 7 | 8 | cy 9 | .intercept('DELETE', '/api/tasks').as('tasks') 10 | .intercept('DELETE', '/api/lists').as('lists') 11 | .intercept('DELETE', '/api/boards').as('boards') 12 | .intercept('DELETE', '/api/users').as('users') 13 | .intercept('POST', '/api/reset').as('all'); 14 | 15 | cy 16 | .visit('/'); 17 | 18 | cy 19 | .get('[data-cy="board-item"]') 20 | .should('be.visible'); 21 | 22 | cy 23 | .window() 24 | .then(({ app }) => { 25 | app.toggleTools(); 26 | }); 27 | 28 | cy 29 | .get('#tools') 30 | .should('be.visible'); 31 | 32 | cy.contains('button', 'Tasks').click(); 33 | cy.contains('button', 'Lists').click(); 34 | cy.contains('button', 'Boards').click(); 35 | cy.contains('button', 'Users').click(); 36 | cy.contains('button', 'All').click(); 37 | 38 | cy 39 | .wait('@tasks') 40 | .wait('@lists') 41 | .wait('@boards') 42 | .wait('@users') 43 | .wait('@all'); 44 | 45 | }); -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | commonjs: true, 5 | es2020: true, 6 | }, 7 | extends: 'eslint:recommended', 8 | parserOptions: { 9 | ecmaVersion: 11, 10 | sourceType: 'module' 11 | }, 12 | plugins: [ 13 | 'vue', 14 | ], 15 | rules: { 16 | 'comma-spacing': ['error', { before: false, after: true }], 17 | 'space-infix-ops': 'error', 18 | 'key-spacing': ['error', { afterColon: true }], 19 | 'arrow-spacing': ['error', { before: true, after: true }], 20 | indent: [ 21 | 'error', 22 | 2, 23 | ], 24 | 'linebreak-style': [ 25 | 'error', 26 | 'unix', 27 | ], 28 | quotes: [ 29 | 'error', 30 | 'single', 31 | ], 32 | semi: [ 33 | 'error', 34 | 'always', 35 | ], 36 | 'no-multiple-empty-lines': [ 37 | 'error', { 38 | max: 1, 39 | maxEOF: 1, 40 | }, 41 | ], 42 | 'padding-line-between-statements': [ 43 | 'error', { blankLine: 'always', prev: 'multiline-block-like', next: 'multiline-block-like' }, 44 | ], 45 | 'no-console': 'off', 46 | 'no-undef': 'off' 47 | }, 48 | }; 49 | -------------------------------------------------------------------------------- /public/images/cross.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | a simple trello clone application built on vue and json-server. The point of this app is to be super easy to install and run, so that you don’t need to set up complicated database and have several scripts running in order to open app. Just install, write npm start and you are good to go. 2 | 3 | The original version of this app was built by Zack Thoutt and [you can find it here](https://github.com/zackthoutt/vue-trello). I updated the app to vue 2 and am using json-server and axios instead of local storage to handle data. I also implemented a simple JWT authentication by utilizing json-server-auth. You can also upload images to tasks, files are saved to your drive. 4 | 5 | I’m pretty happy with the result, although I have to say, that I am no Vue expert. I learn by copying the work of others, code from stackoverflow, documentation(s) and then try to use that knowledge and code to make something on my own. I bet you could find some antipatterns in the code and you are more than welcome to create an issue with a suggestion. I made this app for a workshop I’m am doing on testing in Cypress.io. If you are intereseted how that looks, check out my [quick course on Udemy](https://www.udemy.com/course/cypress-test-automation-for-people-in-a-hurry/?couponCode=D7F5FD6D19C9A5FF823D) (link with a discount coupon, cause you’re nice), although I’m using another app there. Maybe next time. 6 | 7 | Oh and the installation 8 | `npm install` 9 | `npm start` 10 | 11 | That should do it. If you update the code, use `npx grunt dev` to build the app again. 12 | 13 | I’ll do a better readme file, I swear. I’m just a little busy now. -------------------------------------------------------------------------------- /assets/js/trello/main.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | 3 | const Vue = require('vue'); 4 | const VueRouter = require('vue-router'); 5 | Vue.use(VueRouter); 6 | Vue.use(require('vue-shortkey')); 7 | 8 | require('vuedraggable'); 9 | require('./../directives/vue-focus.js'); 10 | require('./../components/collection.js'); 11 | require('./../components/board.js'); 12 | 13 | var router = new VueRouter({ 14 | mode: 'history', 15 | base: window.location.pathName, 16 | routes: [ 17 | { path: '*', redirect: '/' }, 18 | { path: '/', name: 'board-collection', component: Vue.component('board-collection') }, 19 | { path: '/board/:id', name: 'board', component: Vue.component('board') }, 20 | ] 21 | }); 22 | 23 | const app = new Vue({ 24 | data: function() { 25 | return { 26 | errorMessage: { 27 | show: false, 28 | text: 'Oops, there was an error' 29 | }, 30 | loggedIn: { 31 | active: false, 32 | email: '', 33 | }, 34 | showLoginModule: false, 35 | tools: false 36 | }; 37 | }, 38 | methods: { 39 | resetAll: function() { 40 | axios 41 | .post('/api/reset'); 42 | }, 43 | resetBoards: function() { 44 | axios 45 | .delete('/api/boards'); 46 | }, 47 | resetLists: function() { 48 | axios 49 | .delete('/api/lists'); 50 | }, 51 | resetTasks: function() { 52 | axios 53 | .delete('/api/tasks'); 54 | }, 55 | resetUsers: function() { 56 | axios 57 | .delete('/api/users'); 58 | }, 59 | toggleTools: function() { 60 | this.tools = !this.tools; 61 | }, 62 | }, 63 | router 64 | }).$mount('#trello-app'); 65 | 66 | window.app = app; -------------------------------------------------------------------------------- /cypress/support/@types/commands.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace Cypress { 2 | interface Chainable { 3 | 4 | /** 5 | * create new board via API 6 | */ 7 | addBoardApi(value: string): Chainable 8 | 9 | /** 10 | * create new board via UI 11 | */ 12 | addBoardUi(value: string): Chainable 13 | 14 | /** 15 | * Adds new list via API and saves it to env 16 | */ 17 | addListApi(options: { 18 | title: string; 19 | boardIndex?: number; 20 | }): Chainable 21 | 22 | /** 23 | * create new task via API 24 | */ 25 | addTaskApi(options: { 26 | title: string; 27 | boardIndex?: number; 28 | listIndex?: number; 29 | }): Chainable 30 | 31 | /** 32 | * deletes a board via API 33 | */ 34 | deleteBoardApi(options?: { 35 | id: number; 36 | }): Chainable 37 | 38 | 39 | /** 40 | * Deletes new list via API 41 | */ 42 | deleteListApi(index: number): Chainable 43 | 44 | /** 45 | * renames new list via API 46 | */ 47 | renameListApi(options: { 48 | title: string; 49 | id?: number; 50 | }): Chainable 51 | 52 | /** 53 | * reorders list via API 54 | */ 55 | reorderListApi(options: { 56 | order: number; 57 | index?: number; 58 | }): Chainable 59 | 60 | /** 61 | * signup via API 62 | */ 63 | signupApi(options: { 64 | email: string; 65 | password: string; 66 | }): Chainable 67 | 68 | /** 69 | * updtates a board via API 70 | */ 71 | updateBoardApi(options: { 72 | name: string; 73 | id?: number; 74 | }): Chainable 75 | 76 | } 77 | } -------------------------------------------------------------------------------- /assets/scss/boardDetail.scss: -------------------------------------------------------------------------------- 1 | .boardDetail { 2 | .boardDetail_title { 3 | @include openSans($large, $white); 4 | align-self: flex-start; 5 | width: 224px; 6 | margin: 0; 7 | padding: 2px 7px; 8 | outline: 0; 9 | border: 0; 10 | background-color: $blue-6; 11 | border-radius: $border-radius; 12 | &:focus { 13 | background-color: $white !important; 14 | color: $black !important; 15 | } 16 | } 17 | .boardDetail_info { 18 | width: 97%; 19 | margin: 2% auto 0 auto; 20 | height: auto; 21 | display: flex; 22 | .options { 23 | fill: #fff; 24 | width: 21px; 25 | height: 21px; 26 | margin: 5px 8px 0 8px; 27 | cursor: pointer; 28 | } 29 | .dropdown { 30 | position: relative; 31 | display: block; 32 | float: right; 33 | } 34 | .invisible-overlay { 35 | display: flex; 36 | justify-content: center; 37 | align-items: flex-start; 38 | position: fixed; 39 | top: 0; 40 | left: 0; 41 | height: 100vh; 42 | width: 100vw; 43 | background-color: rgba(0, 0, 0, 0); 44 | z-index: 1000; 45 | } 46 | .dropdown-content { 47 | position: absolute; 48 | background-color: #f1f1f1; 49 | min-width: 110px; 50 | box-shadow: 0px 8px 16px 0px rgba(0, 0, 0, 0.2); 51 | z-index: 1001; 52 | margin-left: -88px; 53 | span { 54 | @include openSans($small, $black); 55 | padding: 10px 16px; 56 | text-decoration: none; 57 | display: block; 58 | cursor: pointer; 59 | } 60 | span:hover { 61 | background-color: rgba(0,0,0,0.15); 62 | } 63 | span.delete { 64 | color: #FF220C; 65 | } 66 | } 67 | .show { 68 | display: block; 69 | } 70 | } 71 | } -------------------------------------------------------------------------------- /public/images/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | trello-mark-blue 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /assets/scss/collection.scss: -------------------------------------------------------------------------------- 1 | .board { 2 | display: flex; 3 | align-items: center; 4 | justify-content: flex-start; 5 | flex-wrap: wrap; 6 | width: 100%; 7 | .board_item { 8 | display: block; 9 | position: relative; 10 | height: 85px; 11 | margin: 0 20px 20px 0; 12 | border-radius: $border-radius; 13 | cursor: pointer; 14 | padding: 10px 2% 10px 2%; 15 | width: 250px; 16 | background-color: $blue-7; 17 | &:hover { 18 | background-color: $blue-8; 19 | } 20 | } 21 | .board_title { 22 | @include openSans($medium, $white); 23 | margin: 0; 24 | padding: 0; 25 | } 26 | .board_addBoard { 27 | @include openSans($medium, $black); 28 | margin: 0; 29 | padding: 7px; 30 | border: 0; 31 | outline: 0; 32 | border-radius: $border-radius; 33 | background-color: $white; 34 | } 35 | .board_options { 36 | padding: 0; 37 | display: flex; 38 | flex-wrap: wrap; 39 | align-items: center; 40 | justify-content: flex-start; 41 | margin: 10px auto; 42 | } 43 | .board_newItem { 44 | height: 85px; 45 | margin: 0 20px 20px 0; 46 | border-radius: $border-radius; 47 | cursor: pointer; 48 | padding: 10px 2% 10px 2%; 49 | width: 250px; 50 | background-color: $gray-5; 51 | @include openSans($medium, $black); 52 | &:hover { 53 | background-color: $gray-7; 54 | } 55 | } 56 | .board_newItem-active { 57 | background-color: $gray-5 !important; 58 | &:hover { 59 | background-color: $gray-5 !important; 60 | } 61 | } 62 | } 63 | .Star { 64 | height: 15px; 65 | width: 15px; 66 | position: absolute; 67 | right: 13px; 68 | top: 10px; 69 | padding: 2px; 70 | @extend .transition; 71 | // visibility: hidden; 72 | path { 73 | stroke-width: 10; 74 | stroke: $white; 75 | fill: $white; 76 | } 77 | &:hover { 78 | path { 79 | @extend .transition; 80 | stroke-width: 30; 81 | } 82 | } 83 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "trelloapp", 3 | "version": "1.0.0", 4 | "author": "Filip Hric", 5 | "license": "ISC", 6 | "scripts": { 7 | "start": "grunt dev && node index.js", 8 | "cy:open": "npx cypres open" 9 | }, 10 | "browser": { 11 | "vue": "vue/dist/vue.common.js" 12 | }, 13 | "devDependencies": { 14 | "@4tw/cypress-drag-drop": "^1.4.0", 15 | "@babel/core": "^7.12.10", 16 | "@babel/preset-env": "^7.12.10", 17 | "@cypress/code-coverage": "^3.8.7", 18 | "@types/node": "^14.14.11", 19 | "axios": "^0.21.1", 20 | "babel-eslint": "^10.1.0", 21 | "babel-plugin-istanbul": "^6.0.0", 22 | "babelify": "^10.0.0", 23 | "browserify": "^17.0.0", 24 | "chai-moment-string": "^1.0.0", 25 | "clipboardy": "^2.3.0", 26 | "connect-busboy": "0.0.2", 27 | "connect-history-api-fallback": "^1.6.0", 28 | "cypress": "^6.2.1", 29 | "cypress-file-upload": "^4.1.1", 30 | "cypress-real-events": "^1.1.0", 31 | "eslint": "^7.4.0", 32 | "eslint-config-airbnb-base": "^14.2.0", 33 | "eslint-plugin-import": "^2.22.0", 34 | "eslint-plugin-vue": "^6.2.2", 35 | "fs-extra": "^9.0.1", 36 | "grunt": "^1.0.1", 37 | "grunt-browserify": "^5.3.0", 38 | "grunt-contrib-sass": "^1.0.0", 39 | "grunt-contrib-watch": "^1.1.0", 40 | "json-server": "^0.16.1", 41 | "json-server-auth": "^1.2.1", 42 | "lodash": "^4.17.20", 43 | "moment": "^2.27.0", 44 | "nocache": "^2.1.0", 45 | "require-uncached": "^2.0.0", 46 | "sass": "^1.26.8", 47 | "sendmail": "^1.6.1", 48 | "socket.io": "^2.3.0", 49 | "typescript": "^4.0.2", 50 | "vue": "^2.6.11", 51 | "vue-focus": "^0.1.1", 52 | "vue-router": "^3.3.2", 53 | "vue-shortkey": "^3.1.7", 54 | "vue-socket.io-extended": "^4.0.4", 55 | "vue2-dropzone": "^3.6.0", 56 | "vuedraggable": "^2.23.2" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /assets/scss/reusable.scss: -------------------------------------------------------------------------------- 1 | .transition { 2 | transition: all 0.15s cubic-bezier(0.2, 0.3, 0.25, 0.9); 3 | -webkit-transition: all 0.15s cubic-bezier(0.2, 0.3, 0.25, 0.9); 4 | -moz-transition: all 0.15s cubic-bezier(0.2, 0.3, 0.25, 0.9); 5 | -o-transition: all 0.15s cubic-bezier(0.2, 0.3, 0.25, 0.9); 6 | } 7 | .baseShadow { 8 | box-shadow: 0px 2px 4px 0px rgba(0,0,0,0.50); 9 | } 10 | .Cancel { 11 | cursor: pointer; 12 | width: 15px; 13 | height: 15px; 14 | margin-left: 13px; 15 | vertical-align: center; 16 | stroke-linecap: round; 17 | stroke: $gray-7; 18 | stroke-width: 3; 19 | &:hover { 20 | stroke: $gray-9; 21 | } 22 | } 23 | .Input { 24 | width: $list-item-width; 25 | padding: 5px; 26 | min-height: 18px; 27 | display: block; 28 | margin: 0 auto $task-space auto; 29 | background-color: $gray-3; 30 | border-radius: $border-radius; 31 | color: $black; 32 | outline: none; 33 | border: none; 34 | &:focus { 35 | background-color: $white; 36 | } 37 | } 38 | .Button { 39 | background: linear-gradient(top, #61bd4f, #5aac44); 40 | background: -webkit-linear-gradient(top, #61bd4f, #5aac44); 41 | padding: 5px 15px; 42 | border: none; 43 | border-radius: $border-radius; 44 | outline: none; 45 | @include openSans($medium, $white); 46 | box-shadow: 0 1px 0 #3f6f21; 47 | &:hover { 48 | background: linear-gradient(top, #5aac44, #519839); 49 | background: -webkit-linear-gradient(top, #5aac44, #519839); 50 | } 51 | } 52 | .TextArea { 53 | margin: 0 auto; 54 | display: flex; 55 | justify-content: flex-start; 56 | align-items: center; 57 | background-color: $white; 58 | color: $black; 59 | border-radius: $border-radius; 60 | border: none; 61 | outline: none; 62 | opacity: 1; 63 | width: $list-item-width - 2 * $text-space; 64 | padding: 10px $text-space; 65 | min-height: 50px; 66 | @include openSans($small, $black); 67 | } 68 | hr.pad20 { 69 | width: 100%; 70 | padding: 10px 0; 71 | margin: 0; 72 | outline: 0; 73 | border: 0; 74 | } -------------------------------------------------------------------------------- /assets/scss/nav.scss: -------------------------------------------------------------------------------- 1 | nav.Nav { 2 | position: fixed; 3 | top: 0; 4 | left: 0; 5 | width: 100vw; 6 | height: $nav-height; 7 | background-color: $blue-7; 8 | display: block; 9 | z-index: 999; 10 | .Nav_boards { 11 | position: absolute; 12 | left: 10px; 13 | top: ($nav-height - 37px) / 2; 14 | } 15 | .Nav_logo { 16 | position: absolute; 17 | top: ($nav-height - 27.5px) / 2; 18 | left: 50%; 19 | margin: 0 0 0 -42px; 20 | display: block; 21 | overflow: hidden; 22 | height: $nav-height / 2; 23 | } 24 | .Nav_user { 25 | position: absolute; 26 | right: 10px; 27 | top: ($nav-height - 37px) / 2; 28 | } 29 | .Nav_button { 30 | display: block; 31 | @include openSans($small, $white); 32 | background-color: $blue-5; 33 | padding: 10px 12px; 34 | border-radius: $border-radius; 35 | cursor: pointer; 36 | svg { 37 | width: 20px; 38 | height: 20px; 39 | fill: #FFF; 40 | vertical-align: bottom; 41 | margin-bottom: -2px; 42 | } 43 | .Nav_button { 44 | position: relative; 45 | display: block; 46 | float: right; 47 | } 48 | } 49 | .Nav_button:hover { 50 | background-color: $blue-4; 51 | } 52 | .dropdown-content { 53 | display: none; 54 | position: absolute; 55 | background-color: $blue-5; 56 | min-width: 110px; 57 | box-shadow: 0px 6px 15px 0px rgba(0, 0, 0, 0.2); 58 | z-index: 1001; 59 | margin-left: 17px; 60 | margin-top: 10px; 61 | span { 62 | @include openSans($small, $white); 63 | padding: 10px 16px; 64 | text-decoration: none; 65 | display: block; 66 | cursor: pointer; 67 | } 68 | span:hover { 69 | background-color: $blue-4; 70 | } 71 | } 72 | .show { 73 | display: block; 74 | } 75 | .invisible-overlay { 76 | display: flex; 77 | justify-content: center; 78 | align-items: flex-start; 79 | position: fixed; 80 | top: 0; 81 | left: 0; 82 | height: 100vh; 83 | width: 100vw; 84 | background-color: rgba(0, 0, 0, 0); 85 | z-index: 1000; 86 | } 87 | } -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const jsonServer = require('json-server'); 2 | const auth = require('json-server-auth'); 3 | const nocache = require('nocache') 4 | 5 | const server = jsonServer.create(); 6 | 7 | const defaults = jsonServer.defaults({ static: '.' }); 8 | const busboy = require('connect-busboy'); 9 | const history = require('connect-history-api-fallback'); 10 | const middleware = require('./middleware'); 11 | 12 | const router = jsonServer.router('./public/data/data.json'); 13 | 14 | server.db = router.db; 15 | server.use(history()); 16 | server.use(defaults); 17 | server.use(nocache()); 18 | server.use(busboy()); 19 | server.use(jsonServer.rewriter({ 20 | '/api/*': '/$1', 21 | '/users/*': '/600/users/$1', 22 | })); 23 | server.use(auth); 24 | server.use(jsonServer.bodyParser); 25 | server.use(middleware); 26 | 27 | server.use(router); 28 | const app = server.listen(3000, () => { 29 | console.log('🚀 Trello app is up and running at:\n'); 30 | console.log('\x1b[4m\x1b[33m%s\x1b[0m', 'http://localhost:3000\n'); // yellow 31 | console.log('Enjoy!'); 32 | }); 33 | 34 | const io = require('socket.io')(app); 35 | 36 | io.on('connection', (socket) => { 37 | socket.on('boardCreated', (message) => { 38 | io.emit('boardCreated', message); 39 | }); 40 | socket.on('boardsState', (message) => { 41 | io.emit('boardsState', message); 42 | }); 43 | socket.on('boardDeleted', (id) => { 44 | io.emit('boardDeleted', id); 45 | }); 46 | socket.on('boardUpdate', (id, message) => { 47 | io.emit('boardUpdate', id, message); 48 | }); 49 | socket.on('listCreated', (boardId, message) => { 50 | io.emit('listCreated', boardId, message); 51 | }); 52 | socket.on('listUpdated', (id, message) => { 53 | io.emit('listUpdated', id, message); 54 | }); 55 | socket.on('listDeleted', (id) => { 56 | io.emit('listDeleted', id); 57 | }); 58 | socket.on('taskCreated', (listId, message) => { 59 | io.emit('taskCreated', listId, message); 60 | }); 61 | socket.on('taskUpdated', (id, message) => { 62 | io.emit('taskUpdated', id, message); 63 | }); 64 | socket.on('taskDeleted', (id, message) => { 65 | io.emit('taskDeleted', id, message); 66 | }); 67 | 68 | }); -------------------------------------------------------------------------------- /cypress/support/@types/env.d.ts: -------------------------------------------------------------------------------- 1 | // Arbitrary export is necessary to create module context, otherwise the declaration merging doesn't work here 2 | export { }; 3 | 4 | declare global { 5 | namespace Cypress { 6 | 7 | export interface Cypress { 8 | 9 | /** 10 | * Returns all environment variables set with CYPRESS_ prefix or in "env" object in "cypress.json" 11 | * 12 | * @see https://on.cypress.io/env 13 | */ 14 | env(): Partial; 15 | /** 16 | * Returns specific environment variable or undefined 17 | * @see https://on.cypress.io/env 18 | * @example 19 | * // cypress.json 20 | * { "env": { "foo": "bar" } } 21 | * Cypress.env("foo") // => bar 22 | */ 23 | env(key: T): EnvKeys[T]; 24 | /** 25 | * Set value for a variable. 26 | * Any value you change will be permanently changed for the remainder of your tests. 27 | * @see https://on.cypress.io/env 28 | * @example 29 | * Cypress.env("host", "http://server.dev.local") 30 | */ 31 | env(key: T, value: EnvKeys[T]): void; 32 | 33 | /** 34 | * Set values for multiple variables at once. Values are merged with existing values. 35 | * @see https://on.cypress.io/env 36 | * @example 37 | * Cypress.env({ host: "http://server.dev.local", foo: "foo" }) 38 | */ 39 | env(object: Partial): void; 40 | 41 | } 42 | 43 | } 44 | } 45 | 46 | interface EnvKeys { 47 | 'boards': Array<{ 48 | created: string; 49 | id: number; 50 | name: string; 51 | starred: boolean; 52 | user: number; 53 | }>; 54 | 'lists': Array<{ 55 | boardId: number 56 | title: string 57 | id: number 58 | created: string 59 | }>; 60 | 'tasks': Array<{ 61 | boardId: number; 62 | description: string; 63 | completed: boolean; 64 | listId: number; 65 | title: string; 66 | id: number; 67 | created: string; 68 | deadline: string; 69 | image: string; 70 | }>; 71 | 'users': Array<{ 72 | email: string; 73 | password: string; 74 | id: number; 75 | }>; 76 | } 77 | -------------------------------------------------------------------------------- /public/images/logo-title.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Group 2 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | Trello 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /public/images/logo-title-inverse.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Group 2 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | Trello 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /assets/js/components/collection.js: -------------------------------------------------------------------------------- 1 | const Vue = require('vue'); 2 | const axios = require('axios'); 3 | const VueSocketIOExt = require('vue-socket.io-extended'); 4 | const io = require('socket.io-client'); 5 | const socket = io('http://localhost:3000'); 6 | const Navbar = require('./navbar'); 7 | Vue.use(VueSocketIOExt, socket); 8 | 9 | Vue.component('board-collection', { 10 | components: { 11 | 'Navbar': Navbar 12 | }, 13 | template: '#trello-board-collection', 14 | sockets: { 15 | boardCreated(message) { 16 | this.boards.push(message); 17 | }, 18 | boardsState(message) { 19 | this.boards = message; 20 | }, 21 | boardDeleted(id) { 22 | let deleted = this.boards.findIndex( board => board.id === id); 23 | this.boards.splice(deleted, 1); 24 | }, 25 | boardUpdate(id, message) { 26 | let updated = this.boards.findIndex( board => board.id === id); 27 | let board = this.boards[updated]; 28 | this.$set(this.boards, [updated], { ...board, ...message }); 29 | } 30 | }, 31 | data: function() { 32 | return { 33 | showByIndex: null, 34 | loading: true, 35 | boards: [], 36 | newBoardTitle: '', 37 | newBoardInputActive: false 38 | };}, 39 | created () { 40 | axios 41 | .get('/api/boards') 42 | .then(r => r.data) 43 | .then(boards => { 44 | this.loading = false; 45 | this.boards = boards; 46 | }); 47 | }, 48 | methods: { 49 | createNewBoard () { 50 | if (!this.newBoardTitle) { 51 | return; 52 | } 53 | // send api request to create a board 54 | axios.post('/api/boards', { name: this.newBoardTitle }).then((r) => { 55 | this.boards.push(r.data); 56 | this.$router.push(`/board/${r.data.id}`); 57 | }).catch( () => { // handle error, show error message 58 | this.$root.errorMessage.show = true; 59 | this.$root.errorMessage.text = 'There was an error creating board'; 60 | setTimeout(() => { // hide error message after 4 seconds 61 | this.$root.errorMessage.show = false; 62 | }, 4000); 63 | }); 64 | this.newBoardTitle = ''; 65 | }, 66 | toggleNewBoardInput: function(flag) { 67 | this.newBoardInputActive = flag; 68 | }, 69 | updateBoardStarred: function(board) { 70 | let flag = !board.starred; 71 | axios.patch(`/api/boards/${board.id}`, {starred: flag}); 72 | this.boards.find(b => b.id === board.id).starred = flag; 73 | }, 74 | starred: function(boards) { 75 | let starredBoards = boards.filter(b => b.starred === true); 76 | return starredBoards; 77 | } 78 | } 79 | }); -------------------------------------------------------------------------------- /assets/scss/background.scss: -------------------------------------------------------------------------------- 1 | .background { 2 | width: 100vw; 3 | height: 100vh; 4 | margin: 0; 5 | padding: 0; 6 | .background_spacer { 7 | width: 100vw; 8 | height: $nav-height; 9 | } 10 | .background_container { 11 | margin: 10px auto; 12 | width: 90%; 13 | max-width: 1300px; 14 | overflow: scroll; 15 | max-height: 100vh; 16 | } 17 | .background_title { 18 | @include openSans($large, $black); 19 | margin: 10px 0; 20 | } 21 | } 22 | .background-white { 23 | background-color: $white; 24 | } 25 | .background-blue { 26 | background-color: $blue-6; 27 | } 28 | 29 | .loading { 30 | position: absolute; 31 | top: 50%; 32 | left: 50%; 33 | margin-right: -50%; 34 | transform: translate(-50%, -50%); 35 | } 36 | 37 | .background-white .loading { 38 | color: #000; 39 | } 40 | .background-blue .loading { 41 | color: #fff; 42 | } 43 | 44 | #errorMessage { 45 | -moz-box-shadow: 0 0 5px rgba(0, 0, 0, 0.2); 46 | -webkit-box-shadow: 0 0 5px rgba(0, 0, 0, 0.2); 47 | background: #f85252; 48 | top: -56px; 49 | height: 30px; 50 | padding: 16px 0 9px 0; 51 | box-shadow: 0 0 5px rgba(0, 0, 0, 0.2); 52 | color: #fff; 53 | @include openSans($large, $white); 54 | left: 0; 55 | line-height: 1.4em; 56 | font-size: 18px; 57 | overflow: hidden; 58 | position: absolute; 59 | right: 0; 60 | text-align: center; 61 | z-index: 1001; 62 | animation-name: slideDown; 63 | animation-duration: 4s; 64 | } 65 | 66 | #loginMessage { 67 | -moz-box-shadow: 0 0 5px rgba(0, 0, 0, 0.2); 68 | -webkit-box-shadow: 0 0 5px rgba(0, 0, 0, 0.2); 69 | background: #3BB273; 70 | top: -56px; 71 | height: 30px; 72 | padding: 16px 0 9px 0; 73 | box-shadow: 0 0 5px rgba(0, 0, 0, 0.2); 74 | color: #fff; 75 | @include openSans($large, $white); 76 | left: 0; 77 | line-height: 1.4em; 78 | font-size: 18px; 79 | overflow: hidden; 80 | position: absolute; 81 | right: 0; 82 | text-align: center; 83 | z-index: 1001; 84 | animation-name: slideDown; 85 | animation-duration: 4s; 86 | } 87 | 88 | @-webkit-keyframes slideDown { 89 | 10%, 90% { -webkit-transform: translateY(56px); } 90 | 0%, 100% { -webkit-transform: translateY(0px); } 91 | } 92 | @-moz-keyframes slideDown { 93 | 10%, 90% { -moz-transform: translateY(56px); } 94 | 0%, 100% { -moz-transform: translateY(0px); } 95 | } 96 | 97 | @-webkit-keyframes slideUp { 98 | 0%, 100% { -webkit-transform: translateY(56px); } 99 | 10%, 90% { -webkit-transform: translateY(0px); } 100 | } 101 | @-moz-keyframes slideUp { 102 | 0%, 100% { -moz-transform: translateY(56px); } 103 | 10%, 90% { -moz-transform: translateY(0px); } 104 | } -------------------------------------------------------------------------------- /assets/scss/task.scss: -------------------------------------------------------------------------------- 1 | .Task { 2 | display: flex; 3 | justify-content: flex-start; 4 | align-items: flex-start; 5 | background-color: $white; 6 | color: $black; 7 | border-radius: $border-radius; 8 | border: none; 9 | outline: none; 10 | opacity: 1; 11 | width: $list-item-width - 2 * $text-space; 12 | padding: 3% $text-space; 13 | min-height: 17px; 14 | margin: 0 auto $task-space auto; 15 | cursor: pointer; 16 | @include openSans($small, $black); 17 | &:hover { 18 | background-color: $gray-1; 19 | } 20 | .Task_title { 21 | display: block; 22 | margin: 0; 23 | padding: 0; 24 | text-align: left; 25 | width: 100%; 26 | @include openSans($small, $black); 27 | white-space: normal; 28 | } 29 | .Task_title.completed { 30 | text-decoration: line-through; 31 | } 32 | .container { 33 | display: block; 34 | position: relative; 35 | padding-left: 24px; 36 | cursor: pointer; 37 | font-size: 22px; 38 | -webkit-user-select: none; 39 | -moz-user-select: none; 40 | -ms-user-select: none; 41 | user-select: none; 42 | } 43 | 44 | /* Hide the browser's default checkbox */ 45 | .container input { 46 | position: absolute; 47 | -webkit-appearance: none; 48 | appearance: none; 49 | cursor: pointer; 50 | height: 16px; 51 | width: 16px; 52 | top: 0; 53 | left: 0; 54 | background-color: #eee; 55 | } 56 | 57 | /* Create a custom checkbox */ 58 | .checkmark { 59 | position: absolute; 60 | top: 0; 61 | left: 0; 62 | height: 16px; 63 | width: 16px; 64 | background-color: #eee; 65 | } 66 | 67 | /* On mouse-over, add a grey background color */ 68 | .container:hover input.checkmark { 69 | background-color: #ccc; 70 | } 71 | 72 | /* When the checkbox is checked, add a blue background */ 73 | .container input:checked.checkmark { 74 | background-color: $blue-6; 75 | } 76 | 77 | /* Create the checkmark/indicator (hidden when not checked) */ 78 | .checkmark:after { 79 | content: ""; 80 | position: absolute; 81 | display: none; 82 | } 83 | 84 | /* Show the checkmark when checked */ 85 | .container input:checked.checkmark:after { 86 | display: block; 87 | } 88 | 89 | /* Style the checkmark/indicator */ 90 | .container .checkmark:after { 91 | left: 5px; 92 | top: 2px; 93 | width: 5px; 94 | height: 7px; 95 | border: solid white; 96 | border-width: 0 2px 2px 0; 97 | -webkit-transform: rotate(45deg); 98 | -ms-transform: rotate(45deg); 99 | transform: rotate(45deg); 100 | } 101 | :focus { 102 | outline: 0; 103 | } 104 | 105 | } 106 | .overDue { 107 | background-color: #E7748D; 108 | .Task_title { 109 | color: #fff 110 | } 111 | &:hover { 112 | background-color: #E15170; 113 | } 114 | } -------------------------------------------------------------------------------- /cypress/integration/collection.ts: -------------------------------------------------------------------------------- 1 | import '../support/commands/addBoardApi'; 2 | import '../support/commands/updateBoardApi'; 3 | import '../support/commands/deleteBoardApi'; 4 | 5 | beforeEach(() => { 6 | 7 | cy 8 | .request('POST', '/reset'); 9 | 10 | }); 11 | 12 | it('opens board', () => { 13 | 14 | cy 15 | .visit('/'); 16 | 17 | cy 18 | .log('click on create a board button') 19 | .get('[data-cy="create-board"]') 20 | .click(); 21 | 22 | cy 23 | .get('[data-cy="new-board-create"]') 24 | .click(); 25 | 26 | cy 27 | .log('click on create a board button') 28 | .get('[data-cy="create-board"]') 29 | .click(); 30 | 31 | cy 32 | .log('type in board name and hit enter') 33 | .get('[data-cy="new-board-input"]') 34 | .type('new board{enter}'); 35 | 36 | }); 37 | 38 | it('stars a board', () => { 39 | 40 | cy 41 | .visit('/'); 42 | 43 | cy 44 | .log('create a new board via api') 45 | .addBoardApi('new board'); 46 | 47 | cy 48 | .log('created board is visible') 49 | .get('[data-cy="board-item"]') 50 | .should('be.visible') 51 | .should('have.length', 1); 52 | 53 | cy 54 | .log('click on star button') 55 | .get('[data-cy="board-item"]') 56 | .realHover() 57 | .get('.Star') 58 | .click(); 59 | 60 | }); 61 | 62 | it('renames a board via api', () => { 63 | 64 | cy 65 | .log('create a new board via api') 66 | .addBoardApi('new board'); 67 | 68 | cy 69 | .visit('/'); 70 | 71 | cy 72 | .get('[data-cy="board-item"]') 73 | .should('contain.text', 'new board'); 74 | 75 | cy 76 | .updateBoardApi({ name: 'updated board name' }); 77 | 78 | cy 79 | .get('[data-cy="board-item"]') 80 | .should('contain.text', 'updated board name'); 81 | 82 | }); 83 | 84 | it('deletes board via api', () => { 85 | 86 | cy 87 | .log('create a new board via api') 88 | .addBoardApi('new board'); 89 | 90 | cy 91 | .visit('/'); 92 | 93 | cy 94 | .log('created board is visible') 95 | .get('[data-cy="board-item"]') 96 | .should('have.length', 1); 97 | 98 | cy 99 | .deleteBoardApi(); 100 | 101 | cy 102 | .log('created board disappears') 103 | .get('board-item') 104 | .should('have.length', 0); 105 | 106 | }); 107 | 108 | it('shows an error when network does not work on creating board', () => { 109 | 110 | cy 111 | .intercept('POST', '/api/boards', { 112 | forceNetworkError: true 113 | }).as('createList'); 114 | 115 | cy 116 | .visit('/'); 117 | 118 | cy 119 | .log('click on create a board button') 120 | .get('[data-cy="create-board"]') 121 | .click(); 122 | 123 | cy 124 | .log('type in board name and hit enter') 125 | .get('[data-cy="new-board-input"]') 126 | .type('new board{enter}'); 127 | 128 | cy 129 | .log('error message appears') 130 | .get('#errorMessage') 131 | .should('be.visible'); 132 | 133 | cy 134 | .log('error message disappears') 135 | .get('#errorMessage') 136 | .should('not.be.visible'); 137 | 138 | }); -------------------------------------------------------------------------------- /assets/js/components/login.js: -------------------------------------------------------------------------------- 1 | const Vue = require('vue'); 2 | const axios = require('axios'); 3 | 4 | Vue.component('Login', { 5 | template: '#login', 6 | data: function() { 7 | return { 8 | signupCardActive: false, 9 | loginEmail: '', 10 | loginPassword: '', 11 | signupEmail: '', 12 | signupPassword: '', 13 | sendEmails: false, 14 | loginDropdown: false, 15 | loginCardActive: true, 16 | }; 17 | }, 18 | created () { 19 | let parsedCookies = document.cookie.split('; ').reduce((prev, current) => { 20 | const [name, value] = current.split('='); 21 | prev[name] = value; 22 | return prev; 23 | }, {}); 24 | 25 | if (parsedCookies['trello_token']) { 26 | axios.defaults.headers.common['Authorization'] = `Bearer ${parsedCookies['trello_token']}`; 27 | 28 | axios 29 | .get('/api/users').then( r => { 30 | this.$root.loggedIn.active = true; 31 | this.$root.loggedIn.email = r.data.user.email; 32 | }).catch( () => { 33 | document.cookie = 'trello_token=;expires=Thu, 01 Jan 1970 00:00:00 GMT'; 34 | }); 35 | 36 | } 37 | }, 38 | methods: { 39 | closeLogin: function() { 40 | this.$root.showLoginModule = false; 41 | this.loginEmail = ''; 42 | this.loginPassword = ''; 43 | this.signupEmail = ''; 44 | this.signupPassword = ''; 45 | }, 46 | logSignSwitch: function() { 47 | this.signupCardActive = !this.signupCardActive; 48 | this.loginCardActive = !this.loginCardActive; 49 | }, 50 | login: function () { 51 | axios 52 | .post('/login', { 53 | email: this.loginEmail, 54 | password: this.loginPassword 55 | }) 56 | .then( r => { 57 | axios.defaults.headers.common['Authorization'] = `Bearer ${r.data.accessToken}`; 58 | document.cookie = `trello_token=${r.data.accessToken}`; 59 | this.$root.loggedIn.email = this.loginEmail; 60 | this.$root.showLoginModule = false; 61 | this.loginCardActive = false; 62 | this.signupCardActive = false; 63 | this.$router.go(); 64 | }) 65 | .catch( r => { 66 | console.log(r.data); 67 | }); 68 | }, 69 | signup: function () { 70 | axios({ 71 | method: 'POST', 72 | url: '/signup', 73 | data: { 74 | email: this.signupEmail, 75 | password: this.signupPassword 76 | } 77 | }) 78 | .then( r => { 79 | axios.defaults.headers.common['Authorization'] = `Bearer ${r.data.accessToken}`; 80 | document.cookie = `trello_token=${r.data.accessToken}`; 81 | if (this.sendEmails) { 82 | axios 83 | .post('/welcomeemail', { 84 | email: this.signupEmail 85 | }).then(() => { 86 | this.$router.go(); 87 | }); 88 | } else { 89 | 90 | this.$router.go(); 91 | 92 | } 93 | 94 | this.$root.loggedIn.email = this.signupEmail; 95 | this.$root.showLoginModule = false; 96 | this.loginCardActive = false; 97 | this.signupCardActive = false; 98 | 99 | }) 100 | .catch( r => { 101 | console.log(r.data); 102 | }); 103 | } 104 | } 105 | }); -------------------------------------------------------------------------------- /assets/scss/loginModule.scss: -------------------------------------------------------------------------------- 1 | .Overlay { 2 | display: flex; 3 | justify-content: center; 4 | align-items: flex-start; 5 | position: fixed; 6 | top: 0; 7 | left: 0; 8 | height: 100vh; 9 | width: 100vw; 10 | background-color: rgba(0, 0, 0, 0.5); 11 | z-index: 1000; 12 | } 13 | .LoginModule { 14 | background-color: $gray-3; 15 | border-radius: $border-radius; 16 | min-height: 310px; 17 | width: 350px; 18 | z-index: 2000; 19 | margin: 4% 0; 20 | padding: 0; 21 | overflow: hidden; 22 | .LoginModule_exit { 23 | width: 100%; 24 | display: block; 25 | position: relative; 26 | height: 35px; 27 | .Cancel { 28 | position: absolute; 29 | right: 14.7px; 30 | top: 15px; 31 | margin: 0; 32 | } 33 | } 34 | .LoginModule_title { 35 | @include openSans($large, $black); 36 | margin: 0 auto 1px auto; 37 | text-align: center; 38 | } 39 | .LoginModule_description { 40 | @include openSans($small, $gray-9); 41 | width: $list-item-width; 42 | margin: 0 auto; 43 | padding: 0 0 20px 0; 44 | text-align: center; 45 | } 46 | .LoginModule_logSignSwitch { 47 | @include openSans($medium, $gray-10); 48 | width: $list-item-width; 49 | margin-top: 20px; 50 | margin-left: auto; 51 | margin-right: auto; 52 | padding: 0 0 20px 0; 53 | text-align: center; 54 | a { 55 | @include openSans($medium, $gray-10); 56 | text-decoration: underline; 57 | cursor: pointer; 58 | } 59 | } 60 | .LoginModule_buttons{ 61 | button { 62 | display: block; 63 | margin-top: 20px; 64 | margin-left: auto; 65 | margin-right: auto; 66 | } 67 | } 68 | .credentials { 69 | input { 70 | margin-top: 4px; 71 | margin-left: auto; 72 | margin-right: auto; 73 | padding: 10px; 74 | width: 250px; 75 | display: block; 76 | } 77 | } 78 | .welcomeEmail { 79 | display: block; 80 | position: relative; 81 | padding-top: 10px; 82 | cursor: pointer; 83 | font-size: 22px; 84 | -webkit-user-select: none; 85 | -moz-user-select: none; 86 | -ms-user-select: none; 87 | user-select: none; 88 | input { 89 | position: absolute; 90 | -webkit-appearance: none; 91 | appearance: none; 92 | cursor: pointer; 93 | height: 16px; 94 | width: 16px; 95 | top: 0; 96 | left: 0; 97 | background-color: #eee; 98 | } 99 | /* Create a custom checkbox */ 100 | .checkmark { 101 | position: relative; 102 | top: 6px; 103 | left: 0; 104 | height: 16px; 105 | width: 16px; 106 | background-color: #eee; 107 | display: inline-block; 108 | margin-right: 4px; 109 | } 110 | /* Create the checkmark/indicator (hidden when not checked) */ 111 | .checkmark:after { 112 | content: ""; 113 | position: absolute; 114 | display: none; 115 | } 116 | input:checked.checkmark:after { 117 | display: block; 118 | } 119 | .checkmark:after { 120 | left: 6px; 121 | top: 3px; 122 | width: 6px; 123 | height: 8px; 124 | border: solid white; 125 | border-width: 0 2px 2px 0; 126 | -webkit-transform: rotate(45deg); 127 | -ms-transform: rotate(45deg); 128 | transform: rotate(45deg); 129 | } 130 | .emails_title { 131 | display: block; 132 | padding: 5px 0px 0px 4px; 133 | text-align: center; 134 | font-family: "Open Sans", sans-serif; 135 | font-weight: 400; 136 | font-size: 12px; 137 | color: #4d4d4d; 138 | white-space: normal; 139 | width: 100%; 140 | margin: auto; 141 | cursor: pointer; 142 | } 143 | } 144 | 145 | /* On mouse-over, add a grey background color */ 146 | .welcomeEmail:hover input.checkmark { 147 | background-color: #ccc; 148 | } 149 | 150 | /* When the checkbox is checked, add a blue background */ 151 | .welcomeEmail input:checked.checkmark { 152 | background-color: $blue-6; 153 | } 154 | 155 | :focus { 156 | outline: 0; 157 | } 158 | 159 | 160 | } -------------------------------------------------------------------------------- /cypress/integration/login.ts: -------------------------------------------------------------------------------- 1 | import '../support/commands/signupApi'; 2 | 3 | beforeEach(() => { 4 | 5 | cy 6 | .request('POST', '/reset'); 7 | 8 | }); 9 | 10 | it('signup new user with welcome email', () => { 11 | 12 | cy 13 | .intercept({ 14 | method: 'POST', 15 | path: '/welcomeemail' 16 | }, 'success') 17 | .as('welcomeEmail'); 18 | 19 | cy 20 | .visit('/'); 21 | 22 | cy 23 | .get('[data-cy="login-menu"]') 24 | .click(); 25 | 26 | cy 27 | .get('[data-cy="login-module"]') 28 | .should('be.visible'); 29 | 30 | cy 31 | .get('[data-cy="close-login"]') 32 | .click(); 33 | 34 | cy 35 | .get('[data-cy="login-menu"]') 36 | .click(); 37 | 38 | cy 39 | .contains('Sign up here') 40 | .click(); 41 | 42 | cy 43 | .get('[data-cy="signup-email"]') 44 | .type('filip@filiphric.sk'); 45 | 46 | cy 47 | .get('[data-cy="signup-password"]') 48 | .type('abcd1234'); 49 | 50 | cy 51 | .get('[data-cy="welcome-email-checkbox"]') 52 | .check(); 53 | 54 | cy 55 | .get('[data-cy="signup"]') 56 | .click(); 57 | 58 | cy 59 | .wait('@welcomeEmail'); 60 | 61 | cy 62 | .get('[data-cy="login-module"]') 63 | .should('not.be.visible'); 64 | 65 | cy 66 | .location('pathname') 67 | .should('eq', '/'); 68 | 69 | cy 70 | .getCookie('trello_token') 71 | .should('exist'); 72 | 73 | }); 74 | 75 | it('signup user without welcome email', () => { 76 | 77 | cy 78 | .visit('/'); 79 | 80 | cy 81 | .get('[data-cy="login-menu"]') 82 | .click(); 83 | 84 | cy 85 | .get('[data-cy="login-module"]') 86 | .should('be.visible'); 87 | 88 | cy 89 | .contains('Sign up here') 90 | .click(); 91 | 92 | cy 93 | .get('[data-cy="signup-email"]') 94 | .type('filip@filiphric.sk'); 95 | 96 | cy 97 | .get('[data-cy="signup-password"]') 98 | .type('abcd1234'); 99 | 100 | cy 101 | .get('[data-cy="signup"]') 102 | .click(); 103 | 104 | cy 105 | .get('[data-cy="login-module"]') 106 | .should('not.be.visible'); 107 | 108 | cy 109 | .location('pathname') 110 | .should('eq', '/'); 111 | 112 | cy 113 | .getCookie('trello_token') 114 | .should('exist'); 115 | 116 | }); 117 | 118 | it('signup existing user', () => { 119 | 120 | cy 121 | .signupApi({ 122 | email: 'filip@filiphric.sk', 123 | password: 'abcd1234' 124 | }); 125 | 126 | cy 127 | .visit('/'); 128 | 129 | cy 130 | .get('[data-cy="login-menu"]') 131 | .click(); 132 | 133 | cy 134 | .get('[data-cy="login-module"]') 135 | .should('be.visible'); 136 | 137 | cy 138 | .contains('Sign up here') 139 | .click(); 140 | 141 | cy 142 | .get('[data-cy="signup-email"]') 143 | .type('filip@filiphric.sk'); 144 | 145 | cy 146 | .get('[data-cy="signup-password"]') 147 | .type('abcd1234'); 148 | 149 | cy 150 | .get('[data-cy="signup"]') 151 | .click(); 152 | 153 | }); 154 | 155 | it('log in existing user', () => { 156 | 157 | cy 158 | .signupApi({ 159 | email: 'filip@filiphric.sk', 160 | password: 'abcd1234' 161 | }); 162 | 163 | cy 164 | .visit('/'); 165 | 166 | cy 167 | .get('[data-cy="login-menu"]') 168 | .click(); 169 | 170 | cy 171 | .get('[data-cy="login-module"]') 172 | .should('be.visible'); 173 | 174 | cy 175 | .get('[data-cy="login-email"]') 176 | .type('filip@filiphric.sk'); 177 | 178 | cy 179 | .get('[data-cy="login-password"]') 180 | .type('a'); 181 | 182 | cy 183 | .get('[data-cy="login"]') 184 | .click(); 185 | 186 | cy 187 | .get('[data-cy="login-password"]') 188 | .clear() 189 | .type('abcd1234'); 190 | 191 | cy 192 | .get('[data-cy="login"]') 193 | .click(); 194 | 195 | cy 196 | .get('[data-cy="login-module"]') 197 | .should('not.be.visible'); 198 | 199 | cy 200 | .location('pathname') 201 | .should('eq', '/'); 202 | 203 | cy 204 | .getCookie('trello_token') 205 | .should('exist'); 206 | 207 | cy 208 | .get('[data-cy="logged-user"]') 209 | .click(); 210 | 211 | cy 212 | .contains('Log out') 213 | .click(); 214 | 215 | cy 216 | .getCookie('trello_token') 217 | .should('not.exist'); 218 | 219 | }); 220 | 221 | it('should handle not existing user', () => { 222 | 223 | cy 224 | .intercept('GET', '/api/users').as('user'); 225 | 226 | cy 227 | .setCookie('trello_token', 'aaa'); 228 | 229 | cy 230 | .visit('/'); 231 | 232 | cy 233 | .wait('@user'); 234 | 235 | }); -------------------------------------------------------------------------------- /assets/scss/list.scss: -------------------------------------------------------------------------------- 1 | .ListContainer { 2 | display: block; 3 | width: 100vw; 4 | height: 80vh; 5 | overflow: scroll; 6 | white-space: nowrap; 7 | > .current-target { 8 | background-color: $blue-8 !important; 9 | } 10 | .current-target > * { 11 | visibility: hidden; 12 | } 13 | } 14 | .List { 15 | padding-top: 10px; 16 | width: $list-width; 17 | background-color: $gray-3; 18 | border-radius: $border-radius; 19 | @include openSansBold($medium, $white); 20 | display: inline-block; 21 | margin: 0.5% 1.5%; 22 | vertical-align: top; 23 | .taskTitle { 24 | width: 212px; 25 | min-height: 18px; 26 | display: inline-block; 27 | margin: 0 auto 7px 17px; 28 | background-color: #E2E4E6; 29 | border-radius: 3px; 30 | color: #4d4d4d; 31 | outline: none; 32 | border: none; 33 | } 34 | .taskTitle:focus { 35 | background-color: #FFFFFF; 36 | } 37 | .options { 38 | fill: #959DA1; 39 | width: 21px; 40 | height: 21px; 41 | margin: 5px 8px 0 0; 42 | cursor: pointer; 43 | } 44 | .dropdown { 45 | position: relative; 46 | display: block; 47 | float: right; 48 | } 49 | .invisible-overlay { 50 | display: flex; 51 | justify-content: center; 52 | align-items: flex-start; 53 | position: fixed; 54 | top: 0; 55 | left: 0; 56 | height: 100vh; 57 | width: 100vw; 58 | background-color: rgba(0, 0, 0, 0); 59 | z-index: 1000; 60 | } 61 | .dropdown-content { 62 | position: absolute; 63 | background-color: #f1f1f1; 64 | min-width: 100px; 65 | box-shadow: 0px 8px 16px 0px rgba(0, 0, 0, 0.2); 66 | z-index: 1001; 67 | margin-left: -75px; 68 | span { 69 | @include openSans($small, $black); 70 | padding: 10px 16px; 71 | text-decoration: none; 72 | display: block; 73 | cursor: pointer; 74 | } 75 | span:hover { 76 | background-color: rgba(0,0,0,0.15); 77 | } 78 | span.delete { 79 | color: #FF220C; 80 | } 81 | } 82 | .show { 83 | display: block; 84 | } 85 | .List_tasks { 86 | max-height: 70vh; 87 | overflow-y: scroll; 88 | margin: 0; 89 | padding: 0; 90 | min-height: 2px; 91 | } 92 | .List_addTask { 93 | width: $list-item-width; 94 | padding: 7px (100% - $list-item-width) / 2; 95 | margin: 0; 96 | height: auto; 97 | text-align: left; 98 | cursor: pointer; 99 | border-radius: $border-radius; 100 | @include openSans($small, $gray-9); 101 | &:hover { 102 | text-decoration: underline; 103 | color: $black; 104 | background-color: $gray-5; 105 | border-top-left-radius: 0; 106 | border-top-right-radius: 0; 107 | } 108 | } 109 | .List_newTaskOptions { 110 | width: $list-item-width; 111 | padding: 10px (100% - $list-item-width) / 2; 112 | display: flex; 113 | flex-wrap: wrap; 114 | align-items: center; 115 | justify-content: flex-start; 116 | } 117 | .current-target { 118 | background-color: $gray-6 !important; 119 | } 120 | .current-target > * { 121 | visibility: hidden; 122 | } 123 | 124 | } 125 | 126 | .sortable-chosen { 127 | opacity: 0.9; 128 | } 129 | 130 | .sortable-ghost { 131 | opacity: 0.4; 132 | } 133 | 134 | .CreateList { 135 | @extend .transition; 136 | padding: 7px 0; 137 | width: $list-width; 138 | background-color: $blue-7; 139 | border-radius: $border-radius; 140 | @include openSansBold($medium, $white); 141 | display: inline-block; 142 | margin: 0.5% 1.5%; 143 | vertical-align: top; 144 | cursor: pointer; 145 | &:hover { 146 | background-color: $blue-8; 147 | } 148 | .CreateList_title { 149 | width: $list-item-width; 150 | padding: 5px; 151 | min-height: 18px; 152 | display: block; 153 | margin: 0 auto; 154 | @include openSans($medium, $white); 155 | } 156 | .CreateList_options { 157 | width: $list-item-width; 158 | padding: 10px $text-space; 159 | display: flex; 160 | flex-wrap: wrap; 161 | align-items: center; 162 | justify-content: flex-start; 163 | } 164 | .CreateList_input { 165 | width: $list-item-width; 166 | padding: 5px; 167 | min-height: 18px; 168 | // display: block; 169 | margin: 0 2px 7px 9px; 170 | background-color: $gray-3; 171 | border-radius: $border-radius; 172 | color: $black; 173 | outline: none; 174 | border: 1px solid $gray-5; 175 | box-shadow: inset 0 1px 8px rgba(0,0,0,.15); 176 | &:focus { 177 | background-color: $gray-3; 178 | } 179 | &::-webkit-input-placeholder { 180 | color: $gray-5; 181 | } 182 | } 183 | } 184 | 185 | .CreateList-active { 186 | @extend .transition; 187 | background-color: $gray-3 !important; 188 | padding: 10px 0 0 0 !important; 189 | } -------------------------------------------------------------------------------- /assets/scss/taskModule.scss: -------------------------------------------------------------------------------- 1 | .Overlay { 2 | display: flex; 3 | justify-content: center; 4 | align-items: flex-start; 5 | position: fixed; 6 | top: 0; 7 | left: 0; 8 | height: 100vh; 9 | width: 100vw; 10 | background-color: rgba(0, 0, 0, 0.5); 11 | z-index: 1000; 12 | } 13 | .TaskModule { 14 | background-color: $gray-3; 15 | border-radius: $border-radius; 16 | min-height: $card-height; 17 | width: $card-width; 18 | z-index: 2000; 19 | margin: 4% 0; 20 | padding: 0; 21 | overflow: hidden; 22 | .TaskModule_exit { 23 | width: 100%; 24 | display: block; 25 | position: relative; 26 | height: 35px; 27 | .Cancel { 28 | position: absolute; 29 | right: 38.7px; 30 | top: 10px; 31 | margin: 0; 32 | } 33 | .options { 34 | fill: #959DA1; 35 | width: 21px; 36 | height: 21px; 37 | margin: 8px; 38 | cursor: pointer; 39 | } 40 | .dropdown { 41 | position: relative; 42 | display: block; 43 | float: right; 44 | } 45 | .invisible-overlay { 46 | display: flex; 47 | justify-content: center; 48 | align-items: flex-start; 49 | position: fixed; 50 | top: 0; 51 | left: 0; 52 | height: 100vh; 53 | width: 100vw; 54 | background-color: rgba(0, 0, 0, 0); 55 | z-index: 1000; 56 | } 57 | .dropdown-content { 58 | display: none; 59 | position: absolute; 60 | background-color: #f1f1f1; 61 | min-width: 100px; 62 | box-shadow: 0px 8px 16px 0px rgba(0, 0, 0, 0.2); 63 | z-index: 1002; 64 | margin-left: -75px; 65 | span { 66 | @include openSans($small, $black); 67 | padding: 10px 16px; 68 | text-decoration: none; 69 | display: block; 70 | cursor: pointer; 71 | } 72 | span:hover { 73 | background-color: rgba(0,0,0,0.15); 74 | } 75 | span.delete { 76 | color: #FF220C; 77 | } 78 | } 79 | .show { 80 | display: block; 81 | } 82 | } 83 | .TaskModule_title { 84 | color: $black; 85 | margin: 0 auto 1px auto; 86 | } 87 | .TaskModule_list { 88 | @include openSans($small, $gray-9); 89 | width: $list-item-width; 90 | margin: 0 auto; 91 | strong { 92 | text-decoration: underline; 93 | } 94 | } 95 | .TaskModule_description { 96 | @include openSans($small, $gray-9); 97 | width: $list-item-width; 98 | margin: 0 auto; 99 | cursor: pointer; 100 | } 101 | .TaskModule_descriptionTitle { 102 | @include openSans($small, $black); 103 | margin: 15px auto 5px auto; 104 | width: $list-item-width; 105 | } 106 | .TaskModule_options { 107 | width: $list-item-width; 108 | padding: 10px $text-space; 109 | display: flex; 110 | flex-wrap: wrap; 111 | align-items: center; 112 | justify-content: flex-start; 113 | margin: 0 auto; 114 | } 115 | .dz-message{ 116 | position: relative; 117 | width: 265px; 118 | height: 100px; 119 | border: 4px dashed #cdd2d4; 120 | margin: 0 auto; 121 | svg { 122 | display: block; 123 | margin: auto; 124 | padding-top: 8px; 125 | fill: #B6BBBF 126 | } 127 | } 128 | .dz-message p{ 129 | @include openSans($medium, $gray-9); 130 | text-align: center; 131 | } 132 | .vue-dropzone { 133 | padding-bottom: 30px; 134 | padding-top: 8px; 135 | cursor: pointer; 136 | } 137 | .dz-drag-hover { 138 | svg { 139 | @extend .transition; 140 | fill: $gray-10 141 | } 142 | .dz-message{ 143 | @extend .transition; 144 | border-color: $gray-10; 145 | } 146 | .dz-message p{ 147 | @extend .transition; 148 | color: $gray-10; 149 | } 150 | } 151 | .imagePreview { 152 | img { 153 | max-height: 400px; 154 | max-width: 300px; 155 | margin: 0 auto; 156 | display: block; 157 | padding-bottom: 14px; 158 | padding-top: 8px; 159 | } 160 | width: 100%; 161 | display: block; 162 | position: relative; 163 | .Cancel { 164 | position: absolute; 165 | right: 20.7px; 166 | top: 15px; 167 | margin: 0; 168 | } 169 | } 170 | .dz-preview { 171 | display: none; 172 | } 173 | /* Removes the clear button from date inputs */ 174 | input[type="date"]::-webkit-clear-button { 175 | display: none; 176 | } 177 | 178 | /* Removes the spin button */ 179 | input[type="date"]::-webkit-inner-spin-button { 180 | display: none; 181 | } 182 | 183 | /* Always display the drop down caret */ 184 | input[type="date"]::-webkit-calendar-picker-indicator { 185 | color: #2c3e50; 186 | } 187 | 188 | /* A few custom styles for date inputs */ 189 | input[type="date"] { 190 | appearance: none; 191 | -webkit-appearance: none; 192 | @include openSans($small, $gray-9); 193 | border: none; 194 | outline: none; 195 | background-color: rgba(0,0,0,0); 196 | margin: 0px 0 0px 19px; 197 | display: inline-block !important; 198 | visibility: visible !important; 199 | &.overDue { 200 | background-color: #E7748D; 201 | color: #fff 202 | } 203 | } 204 | 205 | input[type="date"], focus { 206 | color: #95a5a6; 207 | box-shadow: none; 208 | -webkit-box-shadow: none; 209 | -moz-box-shadow: none; 210 | } 211 | } -------------------------------------------------------------------------------- /middleware.js: -------------------------------------------------------------------------------- 1 | const moment = require('moment'); 2 | const sendmail = require('sendmail')(); 3 | const path = require('path'); // used for file path 4 | const fs = require('fs-extra'); 5 | const socket = require('socket.io-client')('http://localhost:3000'); 6 | 7 | function randomId() { 8 | return Math.floor(100000 * Math.random() * 900000); 9 | } 10 | 11 | module.exports = (req, res, next) => { 12 | 13 | const unauthorized = function () { 14 | return res.status(403).jsonp({ 15 | error: 'User not authorized to access resource', 16 | }); 17 | }; 18 | 19 | const userNotFound = function () { 20 | return res.status(404).jsonp({ 21 | error: 'User not found', 22 | }); 23 | }; 24 | 25 | const badRequest = function (param) { 26 | return res.status(400).jsonp({ 27 | error: `Bad request. ${param} is required.`, 28 | }); 29 | }; 30 | 31 | const parseJWT = function () { 32 | if (req.headers.hasOwnProperty('authorization')) { 33 | const base64Url = req.headers.authorization.split('.')[1]; 34 | const base64 = base64Url.replace('-', '+').replace('_', '/'); 35 | return JSON.parse(Buffer.from(base64, 'base64')); 36 | } 37 | 38 | return false; 39 | }; 40 | 41 | const { db } = req.app; 42 | const userData = parseJWT(); 43 | const userId = parseInt(userData.sub); 44 | 45 | db.assign(require('require-uncached')('./public/data/data.json')).write(); 46 | 47 | // create board 48 | if (req.method === 'POST' && req.path === '/boards') { 49 | req.body.user = userId || 0; 50 | req.body.id = randomId(); 51 | req.body.starred = false; 52 | req.body.created = moment().format('YYYY-MM-DD'); 53 | socket.emit('boardCreated', req.body); 54 | } 55 | 56 | if (req.method === 'GET' && req.path === '/boards') { 57 | 58 | const publicBoards = db.get('boards').filter({ user: 0 }).value(); 59 | const boards = db.get('boards').filter({ user: userId }).value(); 60 | 61 | const result = [ ...publicBoards, ...boards ]; 62 | 63 | const response = res.status(200).jsonp(result); 64 | 65 | return response; 66 | } 67 | 68 | if (req.method === 'GET' && req.path.match(/\/boards\/\d*/g)) { 69 | const id = parseInt(req.path.replace('/boards/', '')); 70 | const board = db.get('boards').find({ id }).value(); 71 | const lists = db.get('lists').filter({ boardId: id }).sortBy('order').value(); 72 | const tasks = db.get('tasks').filter({ boardId: id }).sortBy('order').value(); 73 | 74 | const result = { ...board, lists, tasks }; 75 | 76 | const response = res.status(200).jsonp(result); 77 | 78 | return response; 79 | } 80 | 81 | if (req.method === 'DELETE' && req.path.match(/\/boards\/\d*/g)) { 82 | const id = parseInt(req.path.replace('/boards/', '')); 83 | 84 | socket.emit('boardDeleted', id); 85 | } 86 | 87 | if (req.method === 'PATCH' && req.path.match(/\/boards\/\d*/g)) { 88 | 89 | const id = parseInt(req.path.replace('/boards/', '')); 90 | 91 | socket.emit('boardUpdate', id, req.body); 92 | } 93 | 94 | if (req.method === 'POST' && req.path === '/lists') { 95 | 96 | // validation 97 | if (req.body.boardId === undefined) return badRequest('boardId'); 98 | 99 | // data generation 100 | req.body.id = randomId(); 101 | req.body.created = moment().format('YYYY-MM-DD'); 102 | 103 | // stream message 104 | socket.emit('listCreated', req.body.boardId, req.body); 105 | } 106 | 107 | if (req.method === 'PATCH' && req.path.match(/\/lists\/\d*/g)) { 108 | 109 | const id = parseInt(req.path.replace('/lists/', '')); 110 | socket.emit('listUpdated', id, req.body); 111 | } 112 | 113 | if (req.method === 'DELETE' && req.path.match(/\/lists\/\d*/g)) { 114 | 115 | const id = parseInt(req.path.replace('/lists/', '')); 116 | socket.emit('listDeleted', id); 117 | } 118 | 119 | if (req.method === 'POST' && req.path === '/tasks') { 120 | 121 | // validation 122 | if (req.body.boardId === undefined) return badRequest('boardId'); 123 | if (req.body.listId === undefined) return badRequest('listId'); 124 | 125 | // data generation 126 | req.body.id = randomId(); 127 | req.body.created = moment().format('YYYY-MM-DD'); 128 | req.body.deadline = moment().add(3, 'days').format('YYYY-MM-DD'); 129 | 130 | // stream message 131 | socket.emit('taskCreated', req.body.listId, req.body); 132 | 133 | } 134 | 135 | if (req.method === 'PATCH' && req.path.match(/\/tasks\/\d*/g)) { 136 | 137 | // stream message 138 | const id = parseInt(req.path.replace('/tasks/', '')); 139 | const task = db.get('tasks').find({ id }).value(); 140 | socket.emit('taskUpdated', id, { ...task, ...req.body }); 141 | 142 | } 143 | 144 | if (req.method === 'DELETE' && req.path.match(/\/tasks\/\d*/g)) { 145 | 146 | // stream message 147 | const id = parseInt(req.path.replace('/tasks/', '')); 148 | const task = db.get('tasks').find({ id }).value(); 149 | socket.emit('taskDeleted', id, { ...task, ...req.body }); 150 | 151 | } 152 | 153 | if (req.method === 'POST' && req.path === '/upload') { 154 | const name = req.headers.taskid; 155 | 156 | let fstream; 157 | req.pipe(req.busboy); 158 | req.busboy.on('file', (fieldname, file, filename) => { 159 | fstream = fs.createWriteStream(`${__dirname}/public/uploaded/${name}_${filename}`); 160 | file.pipe(fstream); 161 | fstream.on('close', () => { 162 | res.status(201).jsonp({ path: `/public/uploaded/${name}_${filename}` }); 163 | }); 164 | }); 165 | 166 | return; 167 | } 168 | 169 | if (req.method === 'GET' && req.path === '/users') { 170 | 171 | if (!userData) return unauthorized(); 172 | 173 | const user = db.get('users').find({ id: userId }).value(); 174 | const result = { user }; 175 | 176 | if (!user) return userNotFound(); 177 | 178 | const response = res.status(200).jsonp(result); 179 | 180 | return response; 181 | } 182 | 183 | if (req.method === 'POST' && req.path === '/welcomeemail') { 184 | 185 | // send welcome email if header is true 186 | sendmail({ 187 | from: 'trelloapp@filiphric.sk', 188 | to: req.body.email, 189 | subject: 'Welcome to Trello app', 190 | html: 'Your account was successfully created!\nIn the meantime, subscribe to my YouTube channel for Cypress tips!', 191 | }, function(err, reply) { 192 | console.log(err && err.stack); 193 | console.dir(reply); 194 | }); 195 | 196 | let response = res.status(201).jsonp(req.body); 197 | return response; 198 | 199 | } 200 | 201 | // cleanup methods 202 | if (req.method === 'POST' && req.path === '/reset') { 203 | 204 | db 205 | .setState({ 206 | boards: [], 207 | tasks: [], 208 | users: [], 209 | lists: [], 210 | }) 211 | .write(); 212 | 213 | socket.emit('boardsState', []); 214 | 215 | return res.sendStatus(204); 216 | } 217 | 218 | if (req.method === 'DELETE' && req.path === '/boards') { 219 | 220 | db.set('boards', []).write(); 221 | db.set('lists', []).write(); 222 | db.set('tasks', []).write(); 223 | 224 | return res.sendStatus(204); 225 | 226 | } 227 | 228 | if (req.method === 'DELETE' && req.path === '/lists') { 229 | 230 | db.set('lists', []).write(); 231 | db.set('tasks', []).write(); 232 | 233 | return res.sendStatus(204); 234 | 235 | } 236 | 237 | if (req.method === 'DELETE' && req.path === '/tasks') { 238 | 239 | db.set('tasks', []).write(); 240 | 241 | return res.sendStatus(204); 242 | 243 | } 244 | 245 | if (req.method === 'DELETE' && req.path === '/users') { 246 | 247 | db.set('users', []).write(); 248 | 249 | return res.sendStatus(204); 250 | 251 | } 252 | 253 | next(); 254 | }; 255 | -------------------------------------------------------------------------------- /assets/css/normalize.css: -------------------------------------------------------------------------------- 1 | /*! normalize.css v4.0.0 | MIT License | github.com/necolas/normalize.css */ 2 | 3 | /** 4 | * 1. Change the default font family in all browsers (opinionated). 5 | * 2. Prevent adjustments of font size after orientation changes in IE and iOS. 6 | */ 7 | 8 | html { 9 | font-family: sans-serif; /* 1 */ 10 | -ms-text-size-adjust: 100%; /* 2 */ 11 | -webkit-text-size-adjust: 100%; /* 2 */ 12 | } 13 | 14 | /** 15 | * Remove the margin in all browsers (opinionated). 16 | */ 17 | 18 | body { 19 | margin: 0; 20 | } 21 | 22 | /* HTML5 display definitions 23 | ========================================================================== */ 24 | 25 | /** 26 | * Add the correct display in IE 9-. 27 | * 1. Add the correct display in Edge, IE, and Firefox. 28 | * 2. Add the correct display in IE. 29 | */ 30 | 31 | article, 32 | aside, 33 | details, /* 1 */ 34 | figcaption, 35 | figure, 36 | footer, 37 | header, 38 | main, /* 2 */ 39 | menu, 40 | nav, 41 | section, 42 | summary { /* 1 */ 43 | display: block; 44 | } 45 | 46 | /** 47 | * Add the correct display in IE 9-. 48 | */ 49 | 50 | audio, 51 | canvas, 52 | progress, 53 | video { 54 | display: inline-block; 55 | } 56 | 57 | /** 58 | * Add the correct display in iOS 4-7. 59 | */ 60 | 61 | audio:not([controls]) { 62 | display: none; 63 | height: 0; 64 | } 65 | 66 | /** 67 | * Add the correct vertical alignment in Chrome, Firefox, and Opera. 68 | */ 69 | 70 | progress { 71 | vertical-align: baseline; 72 | } 73 | 74 | /** 75 | * Add the correct display in IE 10-. 76 | * 1. Add the correct display in IE. 77 | */ 78 | 79 | template, /* 1 */ 80 | [hidden] { 81 | display: none; 82 | } 83 | 84 | /* Links 85 | ========================================================================== */ 86 | 87 | /** 88 | * Remove the gray background on active links in IE 10. 89 | */ 90 | 91 | a { 92 | background-color: transparent; 93 | } 94 | 95 | /** 96 | * Remove the outline on focused links when they are also active or hovered 97 | * in all browsers (opinionated). 98 | */ 99 | 100 | a:active, 101 | a:hover { 102 | outline-width: 0; 103 | } 104 | 105 | /* Text-level semantics 106 | ========================================================================== */ 107 | 108 | /** 109 | * 1. Remove the bottom border in Firefox 39-. 110 | * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari. 111 | */ 112 | 113 | abbr[title] { 114 | border-bottom: none; /* 1 */ 115 | text-decoration: underline; /* 2 */ 116 | text-decoration: underline dotted; /* 2 */ 117 | } 118 | 119 | /** 120 | * Prevent the duplicate application of `bolder` by the next rule in Safari 6. 121 | */ 122 | 123 | b, 124 | strong { 125 | font-weight: inherit; 126 | } 127 | 128 | /** 129 | * Add the correct font weight in Chrome, Edge, and Safari. 130 | */ 131 | 132 | b, 133 | strong { 134 | font-weight: bolder; 135 | } 136 | 137 | /** 138 | * Add the correct font style in Android 4.3-. 139 | */ 140 | 141 | dfn { 142 | font-style: italic; 143 | } 144 | 145 | /** 146 | * Correct the font size and margin on `h1` elements within `section` and 147 | * `article` contexts in Chrome, Firefox, and Safari. 148 | */ 149 | 150 | h1 { 151 | font-size: 2em; 152 | margin: 0.67em 0; 153 | } 154 | 155 | /** 156 | * Add the correct background and color in IE 9-. 157 | */ 158 | 159 | mark { 160 | background-color: #ff0; 161 | color: #000; 162 | } 163 | 164 | /** 165 | * Add the correct font size in all browsers. 166 | */ 167 | 168 | small { 169 | font-size: 80%; 170 | } 171 | 172 | /** 173 | * Prevent `sub` and `sup` elements from affecting the line height in 174 | * all browsers. 175 | */ 176 | 177 | sub, 178 | sup { 179 | font-size: 75%; 180 | line-height: 0; 181 | position: relative; 182 | vertical-align: baseline; 183 | } 184 | 185 | sub { 186 | bottom: -0.25em; 187 | } 188 | 189 | sup { 190 | top: -0.5em; 191 | } 192 | 193 | /* Embedded content 194 | ========================================================================== */ 195 | 196 | /** 197 | * Remove the border on images inside links in IE 10-. 198 | */ 199 | 200 | img { 201 | border-style: none; 202 | } 203 | 204 | /** 205 | * Hide the overflow in IE. 206 | */ 207 | 208 | svg:not(:root) { 209 | overflow: hidden; 210 | } 211 | 212 | /* Grouping content 213 | ========================================================================== */ 214 | 215 | /** 216 | * 1. Correct the inheritance and scaling of font size in all browsers. 217 | * 2. Correct the odd `em` font sizing in all browsers. 218 | */ 219 | 220 | code, 221 | kbd, 222 | pre, 223 | samp { 224 | font-family: monospace, monospace; /* 1 */ 225 | font-size: 1em; /* 2 */ 226 | } 227 | 228 | /** 229 | * Add the correct margin in IE 8. 230 | */ 231 | 232 | figure { 233 | margin: 1em 40px; 234 | } 235 | 236 | /** 237 | * 1. Add the correct box sizing in Firefox. 238 | * 2. Show the overflow in Edge and IE. 239 | */ 240 | 241 | hr { 242 | box-sizing: content-box; /* 1 */ 243 | height: 0; /* 1 */ 244 | overflow: visible; /* 2 */ 245 | } 246 | 247 | /* Forms 248 | ========================================================================== */ 249 | 250 | /** 251 | * Change font properties to `inherit` in all browsers (opinionated). 252 | */ 253 | 254 | button, 255 | input, 256 | select, 257 | textarea { 258 | font: inherit; 259 | } 260 | 261 | /** 262 | * Restore the font weight unset by the previous rule. 263 | */ 264 | 265 | optgroup { 266 | font-weight: bold; 267 | } 268 | 269 | /** 270 | * Show the overflow in IE. 271 | * 1. Show the overflow in Edge. 272 | * 2. Show the overflow in Edge, Firefox, and IE. 273 | */ 274 | 275 | button, 276 | input, /* 1 */ 277 | select { /* 2 */ 278 | overflow: visible; 279 | } 280 | 281 | /** 282 | * Remove the margin in Safari. 283 | * 1. Remove the margin in Firefox and Safari. 284 | */ 285 | 286 | button, 287 | input, 288 | select, 289 | textarea { /* 1 */ 290 | margin: 0; 291 | } 292 | 293 | /** 294 | * Remove the inheritance of text transform in Edge, Firefox, and IE. 295 | * 1. Remove the inheritance of text transform in Firefox. 296 | */ 297 | 298 | button, 299 | select { /* 1 */ 300 | text-transform: none; 301 | } 302 | 303 | /** 304 | * Change the cursor in all browsers (opinionated). 305 | */ 306 | 307 | button, 308 | [type="button"], 309 | [type="reset"], 310 | [type="submit"] { 311 | cursor: pointer; 312 | } 313 | 314 | /** 315 | * Restore the default cursor to disabled elements unset by the previous rule. 316 | */ 317 | 318 | [disabled] { 319 | cursor: default; 320 | } 321 | 322 | /** 323 | * 1. Prevent a WebKit bug where (2) destroys native `audio` and `video` 324 | * controls in Android 4. 325 | * 2. Correct the inability to style clickable types in iOS. 326 | */ 327 | 328 | button, 329 | html [type="button"], /* 1 */ 330 | [type="reset"], 331 | [type="submit"] { 332 | -webkit-appearance: button; /* 2 */ 333 | } 334 | 335 | /** 336 | * Remove the inner border and padding in Firefox. 337 | */ 338 | 339 | button::-moz-focus-inner, 340 | input::-moz-focus-inner { 341 | border: 0; 342 | padding: 0; 343 | } 344 | 345 | /** 346 | * Restore the focus styles unset by the previous rule. 347 | */ 348 | 349 | button:-moz-focusring, 350 | input:-moz-focusring { 351 | outline: 1px dotted ButtonText; 352 | } 353 | 354 | /** 355 | * Change the border, margin, and padding in all browsers (opinionated). 356 | */ 357 | 358 | fieldset { 359 | border: 1px solid #c0c0c0; 360 | margin: 0 2px; 361 | padding: 0.35em 0.625em 0.75em; 362 | } 363 | 364 | /** 365 | * 1. Correct the text wrapping in Edge and IE. 366 | * 2. Correct the color inheritance from `fieldset` elements in IE. 367 | * 3. Remove the padding so developers are not caught out when they zero out 368 | * `fieldset` elements in all browsers. 369 | */ 370 | 371 | legend { 372 | box-sizing: border-box; /* 1 */ 373 | color: inherit; /* 2 */ 374 | display: table; /* 1 */ 375 | max-width: 100%; /* 1 */ 376 | padding: 0; /* 3 */ 377 | white-space: normal; /* 1 */ 378 | } 379 | 380 | /** 381 | * Remove the default vertical scrollbar in IE. 382 | */ 383 | 384 | textarea { 385 | overflow: auto; 386 | } 387 | 388 | /** 389 | * 1. Add the correct box sizing in IE 10-. 390 | * 2. Remove the padding in IE 10-. 391 | */ 392 | 393 | [type="checkbox"], 394 | [type="radio"] { 395 | box-sizing: border-box; /* 1 */ 396 | padding: 0; /* 2 */ 397 | } 398 | 399 | /** 400 | * Correct the cursor style of increment and decrement buttons in Chrome. 401 | */ 402 | 403 | [type="number"]::-webkit-inner-spin-button, 404 | [type="number"]::-webkit-outer-spin-button { 405 | height: auto; 406 | } 407 | 408 | /** 409 | * Correct the odd appearance of search inputs in Chrome and Safari. 410 | */ 411 | 412 | [type="search"] { 413 | -webkit-appearance: textfield; 414 | } 415 | 416 | /** 417 | * Remove the inner padding and cancel buttons in Chrome on OS X and 418 | * Safari on OS X. 419 | */ 420 | 421 | [type="search"]::-webkit-search-cancel-button, 422 | [type="search"]::-webkit-search-decoration { 423 | -webkit-appearance: none; 424 | } -------------------------------------------------------------------------------- /cypress/integration/board.ts: -------------------------------------------------------------------------------- 1 | import '../support/commands/addBoardApi'; 2 | import '../support/commands/addListApi'; 3 | import '../support/commands/addTaskApi'; 4 | 5 | beforeEach(() => { 6 | 7 | cy 8 | .request('POST', '/reset'); 9 | 10 | cy 11 | .addBoardApi('new board'); 12 | 13 | }); 14 | 15 | it('adds a new list', () => { 16 | 17 | cy 18 | .visit(`/board/${Cypress.env('boards')[0].id}`); 19 | 20 | // start typing and cancel 21 | cy 22 | .log('add a list') 23 | .get('[data-cy=add-list]') 24 | .click(); 25 | 26 | cy 27 | .log('options appear') 28 | .get('[data-cy=add-list-options]') 29 | .should('be.visible'); 30 | 31 | cy 32 | .get('[data-cy=cancel]') 33 | .click(); 34 | 35 | cy 36 | .log('options disappear') 37 | .get('[data-cy=add-list-options]') 38 | .should('not.be.visible'); 39 | 40 | // type empty name of list 41 | cy 42 | .log('add a list') 43 | .get('[data-cy=add-list]') 44 | .click(); 45 | 46 | cy 47 | .get('[data-cy=save]') 48 | .click(); 49 | 50 | cy 51 | .log('options disappear') 52 | .get('[data-cy=add-list-options]') 53 | .should('not.be.visible'); 54 | 55 | // create a new list 56 | cy 57 | .log('add a list') 58 | .get('[data-cy=add-list]') 59 | .click(); 60 | 61 | cy 62 | .log('type the list name') 63 | .get('[data-cy=add-list-input]') 64 | .type('new list{enter}'); 65 | 66 | cy 67 | .log('list is visible') 68 | .get('[data-cy=list]') 69 | .should('be.visible'); 70 | 71 | // update list name 72 | cy 73 | .log('change list name') 74 | .get('[data-cy=list-name]') 75 | .clear() 76 | .type('renamed list{enter}'); 77 | 78 | cy 79 | .log('change list name') 80 | .get('[data-cy=list] .dropdown') 81 | .click(); 82 | 83 | cy 84 | .get('[data-cy="copy-list-properties"]') 85 | .realClick() 86 | .task('getClipboard') 87 | .should('contain', 'title') 88 | .should('contain', 'id') 89 | .should('contain', 'created') 90 | .should('contain', 'boardId'); 91 | 92 | cy 93 | .contains('span', 'Delete list') 94 | .click(); 95 | 96 | cy 97 | .log('list is disappears') 98 | .get('[data-cy=list]') 99 | .should('not.exist'); 100 | 101 | }); 102 | 103 | it('adds, updates, checks, and deletes a task', () => { 104 | 105 | cy 106 | .addListApi({ boardIndex: 0, title: 'new list' }); 107 | 108 | cy 109 | .visit(`/board/${Cypress.env('boards')[0].id}`); 110 | 111 | cy 112 | .log('click on add task button') 113 | .get('[data-cy="new-task"]') 114 | .click(); 115 | 116 | cy 117 | .log('task options appear') 118 | .get('[data-cy="task-options"]') 119 | .should('be.visible'); 120 | 121 | cy 122 | .get('[data-cy="add-task"]') 123 | .click(); 124 | 125 | cy 126 | .log('task options appear') 127 | .get('[data-cy="task-options"]') 128 | .should('not.be.visible'); 129 | 130 | cy 131 | .log('click on add task button') 132 | .get('[data-cy="new-task"]') 133 | .click(); 134 | 135 | cy 136 | .log('task options appear') 137 | .get('[data-cy="task-options"]') 138 | .should('be.visible'); 139 | 140 | cy 141 | .log('type the task name') 142 | .get('[data-cy="task-input"]') 143 | .type('new task{enter}'); 144 | 145 | cy 146 | .log('task is created') 147 | .get('[data-cy="task"]') 148 | .should('be.visible'); 149 | 150 | cy 151 | .get('[data-cy="task-done"]') 152 | .check(); 153 | 154 | cy 155 | .get('[data-cy="task"]') 156 | .click(); 157 | 158 | cy 159 | .log('task module appears') 160 | .get('[data-cy="task-module"]') 161 | .should('be.visible'); 162 | 163 | cy 164 | .log('change the task name') 165 | .get('[data-cy="task-module-name"]') 166 | .clear() 167 | .type('updated task name{enter}'); 168 | 169 | cy 170 | .log('open dropdown') 171 | .get('[data-cy="task-module-close"]') 172 | .click(); 173 | 174 | cy 175 | .log('dropdown appear') 176 | .get('[data-cy="task-dropdown"]'); 177 | 178 | cy 179 | .contains('Delete task') 180 | .click(); 181 | 182 | cy 183 | .log('task module diappears') 184 | .get('[data-cy="task-module"]') 185 | .should('not.be.visible'); 186 | 187 | cy 188 | .log('task disappears') 189 | .get('[data-cy="task"]') 190 | .should('not.exist'); 191 | 192 | }); 193 | 194 | it('opens task detail', () => { 195 | 196 | cy 197 | .visit(`/board/${Cypress.env('boards')[0].id}`); 198 | 199 | cy 200 | .addListApi({ boardIndex: 0, title: 'new list' }) 201 | .addTaskApi({ boardIndex: 0, listIndex: 0, title: 'new task' }); 202 | 203 | cy 204 | .get('[data-cy="task"]') 205 | .click(); 206 | 207 | cy 208 | .get('[data-cy="task-module"]') 209 | .should('be.visible'); 210 | 211 | cy 212 | .get('[data-cy="task-description"]') 213 | .click(); 214 | 215 | cy 216 | .get('[data-cy="task-description-input"]') 217 | .type('hello world'); 218 | 219 | cy 220 | .get('[data-cy="task-description-save"]') 221 | .click(); 222 | 223 | cy 224 | .get('[data-cy="task-deadline"]') 225 | .focus() 226 | .type(Cypress.moment().format('YYYY-MM-DD')) 227 | .blur(); 228 | 229 | cy 230 | .get('[type="file"]') 231 | .attachFile('cypressLogo.png'); 232 | 233 | cy 234 | .get('[data-cy="remove-image"]') 235 | .click(); 236 | 237 | cy 238 | .log('open dropdown') 239 | .get('[data-cy="task-module-close"]') 240 | .click(); 241 | 242 | cy 243 | .log('dropdown appear') 244 | .get('[data-cy="task-dropdown"]'); 245 | 246 | cy 247 | .contains('Close task') 248 | .click(); 249 | 250 | }); 251 | 252 | it('sorts tasks and lists', () => { 253 | 254 | cy 255 | .addListApi({ boardIndex: 0, title: 'list 1' }) 256 | .addListApi({ boardIndex: 0, title: 'list 2' }) 257 | .addTaskApi({ boardIndex: 0, listIndex: 0, title: 'task 1' }) 258 | .addTaskApi({ boardIndex: 0, listIndex: 0, title: 'task 2' }); 259 | 260 | cy 261 | .visit(`/board/${Cypress.env('boards')[0].id}`); 262 | 263 | cy 264 | .get('[data-cy="task"]') 265 | .eq(0) 266 | .as('task1'); 267 | 268 | cy 269 | .get('[data-cy="task"]') 270 | .eq(1) 271 | .as('task2'); 272 | 273 | cy 274 | .get('@task1') 275 | .drag('@task2'); 276 | 277 | cy 278 | .get('[data-cy="tasks-list"]') 279 | .eq(0) 280 | .as('taskList1'); 281 | 282 | cy 283 | .get('[data-cy="tasks-list"]') 284 | .eq(1) 285 | .as('taskList2'); 286 | 287 | cy 288 | .get('[data-cy="task"]') 289 | .drag('@taskList2'); 290 | 291 | cy 292 | .get('[data-cy="list"]') 293 | .eq(0) 294 | .as('list1'); 295 | 296 | cy 297 | .get('[data-cy="list"]') 298 | .eq(1) 299 | .as('list2'); 300 | 301 | cy 302 | .get('@list2') 303 | .drag('@list1'); 304 | 305 | }); 306 | 307 | it('shows an error message when there’s a network error on creating list', () => { 308 | 309 | cy 310 | .intercept('POST', '/api/lists', { 311 | forceNetworkError: true 312 | }).as('createList'); 313 | 314 | cy 315 | .visit(`/board/${Cypress.env('boards')[0].id}`); 316 | 317 | cy 318 | .log('add a list') 319 | .get('[data-cy=add-list]') 320 | .click(); 321 | 322 | cy 323 | .clock(); 324 | 325 | cy 326 | .log('type the list name') 327 | .get('[data-cy=add-list-input]') 328 | .type('new list{enter}'); 329 | 330 | cy 331 | .log('error message appears') 332 | .get('#errorMessage') 333 | .should('be.visible'); 334 | 335 | cy 336 | .tick(4000); 337 | 338 | cy 339 | .log('error message disappears') 340 | .get('#errorMessage') 341 | .should('not.be.visible'); 342 | 343 | }); 344 | 345 | it('shows an error message when there’s a network error on creating task', () => { 346 | 347 | cy 348 | .intercept('POST', '/api/tasks', { 349 | forceNetworkError: true 350 | }).as('createList'); 351 | 352 | cy 353 | .visit(`/board/${Cypress.env('boards')[0].id}`); 354 | 355 | cy 356 | .addListApi({ boardIndex: 0, title: 'new list' }); 357 | 358 | cy 359 | .log('add a list') 360 | .get('[data-cy=new-task]') 361 | .click(); 362 | 363 | cy 364 | .clock(); 365 | 366 | cy 367 | .log('type the list name') 368 | .get('[data-cy=task-input]') 369 | .type('new list{enter}'); 370 | 371 | cy 372 | .log('error message appears') 373 | .get('#errorMessage') 374 | .should('be.visible'); 375 | 376 | cy 377 | .tick(4000); 378 | 379 | cy 380 | .log('error message disappears') 381 | .get('#errorMessage') 382 | .should('not.be.visible'); 383 | 384 | }); 385 | 386 | it('update board name and delete board', () => { 387 | 388 | cy 389 | .visit(`/board/${Cypress.env('boards')[0].id}`); 390 | 391 | cy 392 | .get('[data-cy="board-title"]') 393 | .should('have.value', 'new board') 394 | .clear() 395 | .type('updated board name{enter}') 396 | .should('have.value', 'updated board name'); 397 | 398 | cy 399 | .get('[data-cy="board-options"]') 400 | .click(); 401 | 402 | cy 403 | .contains('Delete board') 404 | .click(); 405 | 406 | }); -------------------------------------------------------------------------------- /assets/js/components/board.js: -------------------------------------------------------------------------------- 1 | const Vue = require('vue'); 2 | const moment = require('moment'); 3 | const axios = require('axios'); 4 | const vueDropzone = require('vue2-dropzone'); 5 | const VueSocketIOExt = require('vue-socket.io-extended'); 6 | const io = require('socket.io-client'); 7 | const socket = io('http://localhost:3000'); 8 | const _ = require('lodash'); 9 | const draggable = require('vuedraggable'); 10 | Vue.use(VueSocketIOExt, socket); 11 | 12 | Vue.component('board', { 13 | template: '#trello-board', 14 | components: { 15 | vueDropzone, 16 | draggable 17 | }, 18 | sockets: { 19 | listCreated(boardId, message) { 20 | // check that created list is in current board 21 | if (this.currentBoard.id === boardId) { 22 | // add list to board overview data 23 | this.currentBoard.lists.push(message); 24 | // add list to current list data 25 | this.$set(this.currentLists, message.id, []); 26 | } 27 | }, 28 | listUpdated(id, message) { 29 | // find list with ID in board list and update its values 30 | const updatedData = this.currentBoard.lists.map(x => (x.id === id ? { ...x, ...message } : x)); 31 | this.currentBoard.lists = updatedData; 32 | }, 33 | listDeleted(id) { 34 | // update current board overview data 35 | const updatedItem = this.currentBoard.lists.filter( list => { 36 | return list.id !== id; 37 | }); 38 | this.currentBoard.lists = updatedItem; 39 | // update current lists data 40 | const updatedList = _.pick(this.currentLists, id); 41 | this.$set(this.currentLists, updatedList); 42 | }, 43 | taskCreated(listId, message) { 44 | // check that created task is in lists of current board 45 | if (listId in this.currentLists) { 46 | this.currentLists[listId].push(message); 47 | } 48 | }, 49 | taskUpdated(id, message) { 50 | // find list with ID in board list and update its values 51 | const updatedData = this.currentLists[message.listId].map(x => (x.id === id ? { ...x, ...message } : x)); 52 | this.currentLists[message.listId] = updatedData; 53 | this.currentTask = message; 54 | }, 55 | taskDeleted(id, message) { 56 | // update current list tasks 57 | // if (message.listId in this.currentLists) { 58 | const updatedList = this.currentLists[message.listId].filter( task => { 59 | return task.id !== id; 60 | }); 61 | this.currentLists[message.listId] = updatedList; 62 | // } 63 | }, 64 | boardUpdate(id, message) { 65 | this.currentBoard.name = message.name; 66 | } 67 | }, 68 | data: function() { 69 | return { 70 | editTaskDescription: false, 71 | newListTitle: '', 72 | newTaskTitle: '', 73 | currentList: {}, 74 | currentTask: {}, 75 | currentLists: {}, 76 | showTaskModule: false, 77 | taskDropdown: false, 78 | listDropdown: false, 79 | boardDropdown: false, 80 | currentBoard: {}, 81 | loading: true, 82 | newListInputActive: false, 83 | newTaskInputActive: null, 84 | dropzoneOptions: { 85 | url: 'http://localhost:3000/api/upload', 86 | thumbnailWidth: 150, 87 | maxFilesize: 12 88 | } 89 | }; 90 | }, 91 | created () { 92 | axios 93 | .get(`/api/boards/${this.$route.params.id}`) 94 | .then(r => r.data) 95 | .then(board => { 96 | this.currentBoard = board; 97 | this.currentBoardName = board.name; 98 | this.loading = false; 99 | board.lists.forEach(list => { 100 | this.$set(this.currentLists, list.id, board.tasks.filter(task => task.listId === list.id)); 101 | }); 102 | }); 103 | }, 104 | methods: { 105 | copyProperties(content) { 106 | const board = JSON.stringify(content, null, 2); 107 | const clipboard = window.navigator.clipboard; 108 | /* 109 | * fallback to older browsers (including Safari) 110 | * if clipboard API not supported 111 | */ 112 | if (!clipboard || typeof clipboard.writeText !== 'function') { 113 | const textarea = document.createElement('textarea'); 114 | textarea.value = board; 115 | textarea.setAttribute('readonly', true); 116 | textarea.setAttribute('contenteditable', true); 117 | textarea.style.position = 'absolute'; 118 | textarea.style.left = '-9999px'; 119 | document.body.appendChild(textarea); 120 | textarea.select(); 121 | const range = document.createRange(); 122 | const sel = window.getSelection(); 123 | sel.removeAllRanges(); 124 | sel.addRange(range); 125 | textarea.setSelectionRange(0, textarea.value.length); 126 | document.execCommand('copy'); 127 | document.body.removeChild(textarea); 128 | return Promise.resolve(true); 129 | } 130 | return clipboard.writeText(board); 131 | }, 132 | fileUploaded(res) { 133 | 134 | let path = JSON.parse(res.xhr.response).path; 135 | 136 | axios 137 | .patch(`/api/tasks/${this.currentTask.id}`, { image: path }); 138 | 139 | this.$set(this.currentTask, 'image', path); 140 | 141 | }, 142 | addImageId(file, xhr){ 143 | xhr.setRequestHeader('taskId', this.currentTask.id); 144 | }, 145 | removeImage() { 146 | 147 | axios 148 | .patch(`/api/tasks/${this.currentTask.id}`, { image: null }); 149 | 150 | this.currentTask.image = null; 151 | 152 | }, 153 | sortList() { 154 | 155 | this.currentBoard.lists.forEach((list, index) => { 156 | 157 | list.order = index; 158 | 159 | axios 160 | .patch(`/api/lists/${list.id}`, { order: index }); 161 | 162 | }); 163 | }, 164 | sortTask(evt) { 165 | 166 | let from = parseInt(evt.from.parentElement.getAttribute('data-id')); 167 | let to = parseInt(evt.to.parentElement.getAttribute('data-id')); 168 | 169 | // get old position + new position and use it with slice 170 | // changing 0 and 1 - just change slice (0, 1) 171 | // changing 2 and 4 - just change slice (2, 4) 172 | // ordering backwards - revert slice order 173 | // how to get indexes numbers? 174 | 175 | this.currentLists[from].forEach((task, index) => { 176 | 177 | // change index in data store 178 | task.order = index; 179 | 180 | axios 181 | .patch(`/api/tasks/${task.id}`, { order: index }); 182 | 183 | }); 184 | 185 | // get old list and do a full reorder 186 | // get new list and new position, order everything from slice start to slice down 187 | 188 | if (from !== to) { 189 | 190 | this.currentLists[to].forEach((task, index) => { 191 | 192 | // change index in data store - keep this for full reorder of old list, but use currentLists[from] 193 | task.order = index; 194 | 195 | // send request to api 196 | axios 197 | .patch(`/api/tasks/${task.id}`, { order: index, listId: to }); 198 | 199 | }); 200 | 201 | } 202 | 203 | }, 204 | updateListName(list) { 205 | axios 206 | .patch(`/api/lists/${list.id}`, { title: list.title }); 207 | }, 208 | updateTaskName(task) { 209 | axios 210 | .patch(`/api/tasks/${task.id}`, { title: task.title }); 211 | }, 212 | updateBoardName() { 213 | axios 214 | .patch(`/api/boards/${this.currentBoard.id}`, { name: this.currentBoard.name }); 215 | }, 216 | addTask: function(list) { 217 | if (!this.newTaskTitle) { 218 | this.newTaskInputActive = false; 219 | return; 220 | } 221 | let task = { 222 | boardId: this.currentBoard.id, 223 | description: '', 224 | completed: false, 225 | listId: list.id, 226 | title: this.newTaskTitle 227 | }; 228 | axios // send api request to create a task 229 | .post('/api/tasks', task) 230 | .then(() => { 231 | this.newTaskTitle = ''; 232 | this.newTaskInputActive = false; 233 | }).catch( () => { // handle error, show error message 234 | this.$root.errorMessage.show = true; 235 | this.$root.errorMessage.text = 'There was an error creating task'; 236 | setTimeout(() => { // hide error message after 4 seconds 237 | this.$root.errorMessage.show = false; 238 | }, 4000); 239 | }); 240 | }, 241 | addList() { 242 | if (!this.newListTitle) { 243 | this.newListInputActive = false; 244 | return; 245 | } 246 | let list = { 247 | boardId: this.currentBoard.id, 248 | title: this.newListTitle 249 | }; 250 | axios // send api request to create a list 251 | .post('/api/lists', list) 252 | .then(() => { 253 | this.newListTitle = ''; 254 | this.newListInputActive = false; 255 | }).catch( () => { // handle error, show error message 256 | this.$root.errorMessage.show = true; 257 | this.$root.errorMessage.text = 'There was an error creating list'; 258 | setTimeout(() => { // hide error message after 4 seconds 259 | this.$root.errorMessage.show = false; 260 | }, 4000); 261 | }); 262 | }, 263 | cancelNewList() { 264 | this.newListTitle = ''; 265 | this.newListInputActive = false; 266 | }, 267 | // tasksList: function(list) { 268 | // return this.currentBoard.tasks.filter(b => b.listId === list.id); 269 | // }, 270 | editTask: function(list, task) { 271 | this.showTaskModule = true; 272 | this.currentList = list; 273 | this.currentTask = task; 274 | }, 275 | completeTask: function(task) { 276 | axios 277 | .patch(`/api/tasks/${task.id}`, {completed: task.completed}); 278 | }, 279 | closeTask: function() { 280 | this.currentTask = {}; 281 | }, 282 | deleteTask: function(task) { 283 | this.showTaskModule = false; 284 | this.currentTask = {}; 285 | // this.currentLists[task.listId] = this.currentLists[task.listId].filter(t => { return t.id !== task.id; }); 286 | 287 | axios 288 | .delete(`/api/tasks/${task.id}`); 289 | }, 290 | deleteList: function(list) { 291 | axios 292 | .delete(`/api/lists/${list.id}`); 293 | }, 294 | deleteBoard: function(deleteBoard) { 295 | 296 | axios 297 | .delete(`/api/boards/${deleteBoard.id}`) 298 | .then( () => { 299 | this.$router.push('/'); 300 | }); 301 | }, 302 | saveNewTaskDescription: function(task) { 303 | this.editTaskDescription = false; 304 | axios 305 | .patch(`/api/tasks/${task.id}`, {description: task.description}); 306 | }, 307 | saveNewTaskDeadline: function(task) { 308 | axios 309 | .patch(`/api/tasks/${task.id}`, {deadline: task.deadline}); 310 | }, 311 | overdue: function(task) { 312 | if (task.deadline && moment(task.deadline).diff(moment().startOf('day'), 'days') < 1) { 313 | return 'overDue'; 314 | } 315 | } 316 | } 317 | }); -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | Trello 13 | 14 | 15 | 16 | 17 | 22 | 23 | 24 | 25 | 26 | 27 | 239 | 240 | 314 | 315 | 341 | 342 | 395 | 396 | 397 |
398 | 399 | 400 |
{{ errorMessage.text }}
401 |
User is logged in
402 |
Reset application: 403 | 404 | 405 | 406 | 407 | 408 |
409 | 410 |
411 | 412 | 413 | 414 | 415 | 416 | --------------------------------------------------------------------------------