From fe6d9751341a42b5c7b4f639dbcd9ab8999653e8 Mon Sep 17 00:00:00 2001 From: konrad Date: Wed, 6 Jan 2021 22:36:31 +0000 Subject: [PATCH] Replace vue-multiselect with a custom component (#366) Co-authored-by: kolaente Reviewed-on: https://kolaente.dev/vikunja/frontend/pulls/366 Co-authored-by: konrad Co-committed-by: konrad --- cypress/factories/label_task.js | 17 + cypress/factories/labels.js | 22 + cypress/factories/task_assignee.js | 17 + cypress/factories/task_comment.js | 2 + cypress/integration/sharing/team.spec.js | 38 ++ cypress/integration/task/task.spec.js | 147 ++++- package.json | 8 +- src/components/input/datepicker.vue | 21 +- src/components/input/multiselect.vue | 325 ++++++++++ src/components/list/partials/filters.vue | 128 +--- src/components/namespace/namespace-search.vue | 32 +- src/components/sharing/userTeam.vue | 161 ++--- .../tasks/partials/editAssignees.vue | 47 +- src/components/tasks/partials/editLabels.vue | 54 +- src/components/tasks/partials/listSearch.vue | 37 +- .../tasks/partials/relatedTasks.vue | 51 +- src/components/tasks/partials/repeatAfter.vue | 9 - src/helpers/closeWhenClickedOutside.js | 27 + src/styles/components/base/multiselect.scss | 609 ++++-------------- src/styles/components/list.scss | 4 - src/styles/components/task.scss | 33 +- src/styles/theme/form.scss | 6 +- src/styles/theme/theme.scss | 4 + src/views/list/EditList.vue | 20 +- src/views/teams/EditTeam.vue | 184 +++--- yarn.lock | 5 - 26 files changed, 986 insertions(+), 1022 deletions(-) create mode 100644 cypress/factories/label_task.js create mode 100644 cypress/factories/labels.js create mode 100644 cypress/factories/task_assignee.js create mode 100644 src/components/input/multiselect.vue create mode 100644 src/helpers/closeWhenClickedOutside.js diff --git a/cypress/factories/label_task.js b/cypress/factories/label_task.js new file mode 100644 index 00000000..069358ef --- /dev/null +++ b/cypress/factories/label_task.js @@ -0,0 +1,17 @@ +import {Factory} from '../support/factory' +import {formatISO} from 'date-fns' + +export class LabelTaskFactory extends Factory { + static table = 'label_task' + + static factory() { + const now = new Date() + + return { + id: '{increment}', + task_id: 1, + label_id: 1, + created: formatISO(now), + } + } +} \ No newline at end of file diff --git a/cypress/factories/labels.js b/cypress/factories/labels.js new file mode 100644 index 00000000..b3f9ab30 --- /dev/null +++ b/cypress/factories/labels.js @@ -0,0 +1,22 @@ +import faker from 'faker' + +import {Factory} from '../support/factory' +import {formatISO} from 'date-fns' + +export class LabelFactory extends Factory { + static table = 'labels' + + static factory() { + const now = new Date() + + return { + id: '{increment}', + title: faker.lorem.words(2), + description: faker.lorem.text(10), + hex_color: (Math.random()*0xFFFFFF<<0).toString(16), // random 6-digit hex number + created_by_id: 1, + created: formatISO(now), + updated: formatISO(now), + } + } +} \ No newline at end of file diff --git a/cypress/factories/task_assignee.js b/cypress/factories/task_assignee.js new file mode 100644 index 00000000..08034e55 --- /dev/null +++ b/cypress/factories/task_assignee.js @@ -0,0 +1,17 @@ +import {Factory} from '../support/factory' +import {formatISO} from 'date-fns' + +export class TaskAssigneeFactory extends Factory { + static table = 'task_assignees' + + static factory() { + const now = new Date() + + return { + id: '{increment}', + task_id: 1, + user_id: 1, + created: formatISO(now), + } + } +} \ No newline at end of file diff --git a/cypress/factories/task_comment.js b/cypress/factories/task_comment.js index b0b200d0..74e043f9 100644 --- a/cypress/factories/task_comment.js +++ b/cypress/factories/task_comment.js @@ -7,6 +7,8 @@ export class TaskCommentFactory extends Factory { static table = 'task_comments' static factory() { + const now = new Date() + return { id: '{increment}', comment: faker.lorem.text(3), diff --git a/cypress/integration/sharing/team.spec.js b/cypress/integration/sharing/team.spec.js index 1ec4b11f..95900e29 100644 --- a/cypress/integration/sharing/team.spec.js +++ b/cypress/integration/sharing/team.spec.js @@ -1,5 +1,6 @@ import {TeamFactory} from '../../factories/team' import {TeamMemberFactory} from '../../factories/team_member' +import {UserFactory} from '../../factories/user' import '../../support/authenticateUser' describe('Team', () => { @@ -88,4 +89,41 @@ describe('Team', () => { .contains('Member') .should('exist') }) + + it('Allows an admin to add members to the team', () => { + TeamMemberFactory.create(1, { + team_id: 1, + admin: true, + }) + TeamFactory.create(1, { + id: 1, + }) + const users = UserFactory.create(5) + + cy.visit('/teams/1/edit') + cy.get('.card') + .contains('Team Members') + .get('.card-content .multiselect .input-wrapper input') + .type(users[1].username) + cy.get('.card') + .contains('Team Members') + .get('.card-content .multiselect .search-results') + .children() + .first() + .click() + cy.get('.card') + .contains('Team Members') + .get('.card-content button.button') + .contains('Add To Team') + .click() + + cy.get('table.table td') + .contains('Admin') + .should('exist') + cy.get('table.table tr') + .should('contain', users[1].username) + .should('contain', 'Member') + cy.get('.global-notification') + .should('contain', 'Success') + }) }) diff --git a/cypress/integration/task/task.spec.js b/cypress/integration/task/task.spec.js index efa39855..def035dc 100644 --- a/cypress/integration/task/task.spec.js +++ b/cypress/integration/task/task.spec.js @@ -8,6 +8,9 @@ import {NamespaceFactory} from '../../factories/namespace' import {UserListFactory} from '../../factories/users_list' import '../../support/authenticateUser' +import {TaskAssigneeFactory} from '../../factories/task_assignee' +import {LabelFactory} from '../../factories/labels' +import {LabelTaskFactory} from '../../factories/label_task' describe('Task', () => { let namespaces @@ -202,8 +205,14 @@ describe('Task', () => { cy.get('.task-view .action-buttons .button') .contains('Move task') .click() - cy.get('.task-view .content.details .field .multiselect.control .multiselect__tags .multiselect__input') + cy.get('.task-view .content.details .field .multiselect.control .input-wrapper .input-loader-wrapper input') .type(`${lists[1].title}{enter}`) + // The requests happen with a 200ms timeout. Because of that, the results are not yet there when cypress + // presses enter and we can't simulate pressing on enter to select the item. + cy.get('.task-view .content.details .field .multiselect.control .search-results') + .children() + .first() + .click() cy.get('.task-view h6.subtitle') .should('contain', namespaces[0].title) @@ -233,5 +242,141 @@ describe('Task', () => { cy.url() .should('contain', `/lists/${tasks[0].list_id}/`) }) + + it('Can add an assignee to a task', () => { + const users = UserFactory.create(5) + const tasks = TaskFactory.create(1, { + id: 1, + list_id: 1, + }) + UserListFactory.create(5, { + list_id: 1, + user_id: '{increment}', + }) + + cy.visit(`/tasks/${tasks[0].id}`) + + cy.get('.task-view .action-buttons .button') + .contains('Assign this task to a user') + .click() + cy.get('.task-view .column.assignees .multiselect input') + .type(users[1].username) + cy.get('.task-view .column.assignees .multiselect .search-results') + .children() + .first() + .click() + + cy.get('.global-notification') + .should('contain', 'Success') + cy.get('.task-view .column.assignees .multiselect .input-wrapper span.assignee') + .should('exist') + }) + + it('Can remove an assignee from a task', () => { + const users = UserFactory.create(2) + const tasks = TaskFactory.create(1, { + id: 1, + list_id: 1, + }) + UserListFactory.create(5, { + list_id: 1, + user_id: '{increment}', + }) + TaskAssigneeFactory.create(1, { + task_id: tasks[0].id, + user_id: users[1].id, + }) + + cy.visit(`/tasks/${tasks[0].id}`) + + cy.get('.task-view .column.assignees .multiselect .input-wrapper span.assignee') + .get('a.remove-assignee') + .click() + + cy.get('.global-notification') + .should('contain', 'Success') + cy.get('.task-view .column.assignees .multiselect .input-wrapper span.assignee') + .should('not.exist') + }) + + it('Can add a new label to a task', () => { + const tasks = TaskFactory.create(1, { + id: 1, + list_id: 1, + }) + LabelFactory.truncate() + const newLabelText = 'some new label' + + cy.visit(`/tasks/${tasks[0].id}`) + + cy.get('.task-view .action-buttons .button') + .contains('Add labels') + .click() + cy.get('.task-view .details.labels-list .multiselect input') + .type(newLabelText) + cy.get('.task-view .details.labels-list .multiselect .search-results') + .children() + .first() + .click() + + cy.get('.global-notification') + .should('contain', 'Success') + cy.get('.task-view .details.labels-list .multiselect .input-wrapper span.tag') + .should('exist') + .should('contain', newLabelText) + }) + + it('Can add an existing label to a task', () => { + const tasks = TaskFactory.create(1, { + id: 1, + list_id: 1, + }) + const labels = LabelFactory.create(1) + + cy.visit(`/tasks/${tasks[0].id}`) + + cy.get('.task-view .action-buttons .button') + .contains('Add labels') + .click() + cy.get('.task-view .details.labels-list .multiselect input') + .type(labels[0].title) + cy.get('.task-view .details.labels-list .multiselect .search-results') + .children() + .first() + .click() + + cy.get('.global-notification') + .should('contain', 'Success') + cy.get('.task-view .details.labels-list .multiselect .input-wrapper span.tag') + .should('exist') + .should('contain', labels[0].title) + }) + + it('Can remove a label from a task', () => { + const tasks = TaskFactory.create(1, { + id: 1, + list_id: 1, + }) + const labels = LabelFactory.create(1) + LabelTaskFactory.create(1, { + task_id: tasks[0].id, + label_id: labels[0].id, + }) + + cy.visit(`/tasks/${tasks[0].id}`) + + cy.get('.task-view .details.labels-list .multiselect .input-wrapper') + .should('contain', labels[0].title) + cy.get('.task-view .details.labels-list .multiselect .input-wrapper') + .children() + .first() + .get('a.delete') + .click() + + cy.get('.global-notification') + .should('contain', 'Success') + cy.get('.task-view .details.labels-list .multiselect .input-wrapper') + .should('not.contain', labels[0].title) + }) }) }) diff --git a/package.json b/package.json index 263049fe..496f0c4c 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "serve": "vue-cli-service serve", "serve:dist": "node scripts/serve-dist.js", "build": "vue-cli-service build --modern", + "build:report": "vue-cli-service build --report", "lint": "vue-cli-service lint --ignore-pattern '*.test.*'", "cypress:open": "cypress open", "test:unit": "jest", @@ -51,7 +52,6 @@ "node-sass": "5.0.0", "sass-loader": "10.1.0", "vue-flatpickr-component": "8.1.6", - "vue-multiselect": "2.1.6", "vue-notification": "1.3.20", "vue-router": "3.4.9", "vue-template-compiler": "2.6.12", @@ -83,10 +83,12 @@ "browserslist": [ "> 1%", "last 2 versions", - "not ie <= 8" + "not ie < 11" ], "license": "AGPL-3.0-or-later", "jest": { - "testPathIgnorePatterns": ["cypress"] + "testPathIgnorePatterns": [ + "cypress" + ] } } diff --git a/src/components/input/datepicker.vue b/src/components/input/datepicker.vue index 9fea9288..957b4383 100644 --- a/src/components/input/datepicker.vue +++ b/src/components/input/datepicker.vue @@ -115,6 +115,7 @@ import 'flatpickr/dist/flatpickr.css' import {calculateDayInterval} from '@/helpers/time/calculateDayInterval' import {format} from 'date-fns' import {calculateNearestHours} from '@/helpers/time/calculateNearestHours' +import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside' export default { name: 'datepicker', @@ -188,25 +189,7 @@ export default { }, hideDatePopup(e) { if (this.show) { - - // We walk up the tree to see if any parent of the clicked element is the datepicker element. - // If it is not, we hide the popup. We're doing all this hassle to prevent the popup from closing when - // clicking an element of flatpickr. - let parent = e.target.parentElement - while (parent !== this.$refs.datepickerPopup) { - if (parent.parentElement === null) { - parent = null - break - } - - parent = parent.parentElement - } - - if (parent === this.$refs.datepickerPopup) { - return - } - - this.close() + closeWhenClickedOutside(e, this.$refs.datepickerPopup, this.close) } }, close() { diff --git a/src/components/input/multiselect.vue b/src/components/input/multiselect.vue new file mode 100644 index 00000000..e6dc5b47 --- /dev/null +++ b/src/components/input/multiselect.vue @@ -0,0 +1,325 @@ + + + diff --git a/src/components/list/partials/filters.vue b/src/components/list/partials/filters.vue index d2d2dfea..6f6e3b38 100644 --- a/src/components/list/partials/filters.vue +++ b/src/components/list/partials/filters.vue @@ -1,5 +1,5 @@