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 @@ + + + + + + + + {{ label !== '' ? item[label] : item }} + remove(item)" class="delete is-small"> + + + + + + createOrSelectOnEnter()" + :placeholder="placeholder" + @keydown.down.exact.prevent="() => preSelect(0, true)" + ref="searchInput" + @focus="() => showSearchResults = true" + /> + + + + + + + preSelect(-2)" + @keydown.down.prevent="() => preSelect(0)" + @keyup.enter.prevent="create" + @click="create" + > + + + {{ query }} + + + + {{ createPlaceholder }} + + + + preSelect(key - 1)" + @keydown.down.prevent="() => preSelect(key + 1)" + @keyup.enter.prevent="() => select(data)" + @click="() => select(data)" + > + + + {{ label !== '' ? data[label] : data }} + + + + {{ selectPlaceholder }} + + + + + + + + + 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 @@ - + Include Tasks which don't have a value set @@ -103,32 +103,16 @@ Assignees find('users', query)" - @select="() => add('users', 'assignees')" - @remove="() => remove('users', 'assignees')" - label="username" placeholder="Type to search for a user..." - track-by="id" + @search="query => find('users', query)" + :search-results="foundusers" + @select="() => add('users', 'assignees')" + label="username" + :multiple="true" + @remove="() => remove('users', 'assignees')" v-model="users" - > - - - - + /> @@ -136,39 +120,23 @@ Labels addLabel(label)" label="title" - placeholder="Type to search for a label..." - track-by="id" + :multiple="true" v-model="labels" > - + - {{ option.title }} - + :style="{'background': props.item.hexColor, 'color': props.item.textColor}" + class="tag ml-2 mt-2"> + {{ props.item.title }} + - - - @@ -178,64 +146,32 @@ Lists find('lists', query)" - @select="() => add('lists', 'list_id')" - @remove="() => remove('lists', 'list_id')" - label="title" placeholder="Type to search for a list..." - track-by="id" + @search="query => find('lists', query)" + :search-results="foundlists" + @select="() => add('lists', 'list_id')" + label="title" + @remove="() => remove('lists', 'list_id')" + :multiple="true" v-model="lists" - > - - - - + /> Namespaces find('namespace', query)" - @select="() => add('namespace', 'namespace')" - @remove="() => remove('namespace', 'namespace')" - label="title" placeholder="Type to search for a namespace..." - track-by="id" + @search="query => find('namespace', query)" + :search-results="foundnamespace" + @select="() => add('namespace', 'namespace')" + label="title" + @remove="() => remove('namespace', 'namespace')" + :multiple="true" v-model="namespace" - > - - - - + /> @@ -247,13 +183,13 @@ import Fancycheckbox from '../../input/fancycheckbox' import flatPickr from 'vue-flatpickr-component' import 'flatpickr/dist/flatpickr.css' -import Multiselect from 'vue-multiselect' import {formatISO} from 'date-fns' import differenceWith from 'lodash/differenceWith' import PrioritySelect from '@/components/tasks/partials/prioritySelect' import PercentDoneSelect from '@/components/tasks/partials/percentDoneSelect' +import Multiselect from '@/components/input/multiselect' import UserService from '@/services/user' import LabelService from '@/services/label' diff --git a/src/components/namespace/namespace-search.vue b/src/components/namespace/namespace-search.vue index 3740fef3..d2fbc556 100644 --- a/src/components/namespace/namespace-search.vue +++ b/src/components/namespace/namespace-search.vue @@ -1,31 +1,20 @@ - - - - No namespace found. Consider changing the search query. - + v-model="namespace" + /> - - \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index b398182d..36bf5c4a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15173,11 +15173,6 @@ vue-loader@^15.9.2: vue-hot-reload-api "^2.3.0" vue-style-loader "^4.1.0" -vue-multiselect@2.1.6: - version "2.1.6" - resolved "https://registry.yarnpkg.com/vue-multiselect/-/vue-multiselect-2.1.6.tgz#5be5d811a224804a15c43a4edbb7485028a89c7f" - integrity sha512-s7jmZPlm9FeueJg1RwJtnE9KNPtME/7C8uRWSfp9/yEN4M8XcS/d+bddoyVwVnvFyRh9msFo0HWeW0vTL8Qv+w== - vue-notification@1.3.20: version "1.3.20" resolved "https://registry.yarnpkg.com/vue-notification/-/vue-notification-1.3.20.tgz#d85618127763b46f3e25b8962b857947d5a97cbe"