Replace vue-multiselect with a custom component (#366)
All checks were successful
continuous-integration/drone/push Build is passing

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: #366
Co-authored-by: konrad <konrad@kola-entertainments.de>
Co-committed-by: konrad <konrad@kola-entertainments.de>
This commit is contained in:
konrad 2021-01-06 22:36:31 +00:00
parent 6178fe034b
commit fe6d975134
26 changed files with 986 additions and 1022 deletions

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

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

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

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

@ -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')
})
})

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

@ -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"
]
}
}

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

@ -0,0 +1,325 @@
<template>
<div
class="multiselect"
:class="{'has-search-results': searchResultsVisible}"
ref="multiselectRoot"
>
<div class="input-wrapper input">
<template v-if="Array.isArray(internalValue)">
<template v-for="(item, key) in internalValue">
<slot name="tag" :item="item">
<span :key="`item${key}`" class="tag ml-2 mt-2">
{{ label !== '' ? item[label] : item }}
<a @click="() => remove(item)" class="delete is-small"></a>
</span>
</slot>
</template>
</template>
<div class="input-loader-wrapper">
<input
type="text"
class="input"
v-model="query"
@keyup="search"
@keyup.enter.exact.prevent="() => createOrSelectOnEnter()"
:placeholder="placeholder"
@keydown.down.exact.prevent="() => preSelect(0, true)"
ref="searchInput"
@focus="() => showSearchResults = true"
/>
<span class="loader is-loading" v-if="loading || localLoading"></span>
</div>
</div>
<transition name="fade">
<div class="search-results" v-if="searchResultsVisible">
<button
v-if="creatableAvailable"
class="button is-ghost is-fullwidth"
ref="result--1"
@keydown.up.prevent="() => preSelect(-2)"
@keydown.down.prevent="() => preSelect(0)"
@keyup.enter.prevent="create"
@click="create"
>
<span>
<slot name="searchResult" :option="query">
{{ query }}
</slot>
</span>
<span class="hint-text">
{{ createPlaceholder }}
</span>
</button>
<button
class="button is-ghost is-fullwidth"
v-for="(data, key) in filteredSearchResults"
:key="key"
:ref="`result-${key}`"
@keydown.up.prevent="() => preSelect(key - 1)"
@keydown.down.prevent="() => preSelect(key + 1)"
@keyup.enter.prevent="() => select(data)"
@click="() => select(data)"
>
<span>
<slot name="searchResult" :option="data">
{{ label !== '' ? data[label] : data }}
</slot>
</span>
<span class="hint-text">
{{ selectPlaceholder }}
</span>
</button>
</div>
</transition>
</div>
</template>
<script>
import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside'
/**
* Available events:
* @search: Triggered every time the search query input changes
* @select: Triggered every time an option from the search results is selected. Also triggers a change in v-model.
* @create: If nothing or no exact match was found and `creatable` is true, this event is triggered with the current value of the search query.
* @remove: If `multiple` is enabled, this will be fired every time an item is removed from the array of selected items.
*/
export default {
name: 'multiselect',
data() {
return {
query: '',
searchTimeout: null,
localLoading: false,
showSearchResults: false,
internalValue: null,
}
},
props: {
// When true, shows a loading spinner
loading: {
type: Boolean,
default() {
return false
}
},
// The placeholder of the search input
placeholder: {
type: String,
default() {
return ''
}
},
// The search results where the @search listener needs to put the results into
searchResults: {
type: Array,
default() {
return []
}
},
// The name of the property of the searched object to show the user.
// If empty the component will show all raw data of an entry.
label: {
type: String,
default() {
return ''
}
},
// The object with the value, updated every time an entry is selected.
value: {
default() {
return null
}
},
// If true, will provide an "add this as a new value" entry which fires an @create event when clicking on it.
creatable: {
type: Boolean,
default() {
return false
},
},
// The text shown next to the new value option.
createPlaceholder: {
type: String,
default() {
return 'Create new'
},
},
// The text shown next to an option.
selectPlaceholder: {
type: String,
default() {
return 'Click or press enter to select'
},
},
// If true, allows for selecting multiple items. v-model will be an array with all selected values in that case.
multiple: {
type: Boolean,
default() {
return false
},
},
},
mounted() {
document.addEventListener('click', this.hideSearchResultsHandler)
this.setSelectedObject(this.value)
},
beforeDestroy() {
document.removeEventListener('click', this.hideSearchResultsHandler)
},
watch: {
value(newVal) {
this.setSelectedObject(newVal)
},
},
computed: {
searchResultsVisible() {
return this.showSearchResults && (
(this.filteredSearchResults.length > 0) ||
(this.creatable && this.query !== '')
)
},
creatableAvailable() {
return this.creatable && this.query !== '' && !this.filteredSearchResults.some(elem => {
// Don't make create available if we have an exact match in our search results.
if (this.label !== '') {
return elem[this.label] === this.query
}
return elem === this.query
})
},
filteredSearchResults() {
if (this.multiple && this.internalValue !== null) {
return this.searchResults.filter(item => !this.internalValue.some(e => e === item))
}
return this.searchResults
},
},
methods: {
// Searching will be triggered with a 200ms delay to avoid searching on every keyup event.
search() {
if (this.searchTimeout !== null) {
clearTimeout(this.searchTimeout)
this.searchTimeout = null
}
this.localLoading = true
this.searchTimeout = setTimeout(() => {
this.$emit('search', this.query)
setTimeout(() => {
this.localLoading = false
}, 100) // The duration of the loading timeout of the services
this.showSearchResults = true
}, 200)
},
hideSearchResultsHandler(e) {
closeWhenClickedOutside(e, this.$refs.multiselectRoot, this.closeSearchResults)
},
closeSearchResults() {
this.showSearchResults = false
},
select(object) {
if (this.multiple) {
if (this.internalValue === null) {
this.internalValue = []
}
this.internalValue.push(object)
} else {
this.internalValue = object
}
this.$emit('input', this.internalValue)
this.$emit('select', object)
this.setSelectedObject(object)
this.closeSearchResults()
},
setSelectedObject(object, resetOnly = false) {
this.$set(this, 'internalValue', object)
// We assume we're getting an array when multiple is enabled and can therefore leave the query
// value etc as it is
if (this.multiple) {
this.query = ''
return
}
if (object === null) {
this.query = ''
return
}
if (resetOnly) {
return
}
this.query = this.label !== '' ? object[this.label] : object
},
preSelect(index, lookForCreatable = false) {
if (index === 0 && this.creatable && lookForCreatable) {
index = -1
}
if (index < -1) {
this.$refs.searchInput.focus()
return
}
const elems = this.$refs[`result-${index}`]
if (typeof elems === 'undefined' || elems.length === 0) {
return
}
if (Array.isArray(elems)) {
elems[0].focus()
return
}
elems.focus()
},
create() {
if (this.query === '') {
return
}
this.$emit('create', this.query)
this.setSelectedObject(this.query, true)
this.closeSearchResults()
},
createOrSelectOnEnter() {
console.log('enter', this.creatableAvailable, this.searchResults.length)
if (!this.creatableAvailable && this.searchResults.length === 1) {
this.select(this.searchResults[0])
return
}
if (!this.creatableAvailable) {
return
}
this.create()
},
remove(item) {
for (const ind in this.internalValue) {
if (this.internalValue[ind] === item) {
this.internalValue.splice(ind, 1)
break
}
}
this.$emit('input', this.internalValue)
this.$emit('remove', item)
},
},
}
</script>

@ -1,5 +1,5 @@
<template>
<div class="card filters">
<div class="card filters has-overflow">
<div class="card-content">
<fancycheckbox v-model="params.filter_include_nulls">
Include Tasks which don't have a value set
@ -103,32 +103,16 @@
<label class="label">Assignees</label>
<div class="control">
<multiselect
:clear-on-select="true"
:close-on-select="true"
:hide-selected="true"
:internal-search="true"
:loading="usersService.loading"
:multiple="true"
:options="foundusers"
:options-limit="300"
:searchable="true"
:showNoOptions="false"
:taggable="false"
@search-change="query => 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"
>
<template slot="clear" slot-scope="props">
<div
@mousedown.prevent.stop="clear('users', props.search)"
class="multiselect__clear"
v-if="users.length"></div>
</template>
</multiselect>
/>
</div>
</div>
@ -136,39 +120,23 @@
<label class="label">Labels</label>
<div class="control">
<multiselect
:clear-on-select="true"
:close-on-select="false"
:hide-selected="true"
:internal-search="true"
:loading="labelService.loading"
:multiple="true"
:options="foundLabels"
:options-limit="300"
:searchable="true"
:showNoOptions="false"
@search-change="findLabels"
placeholder="Type to search for a label..."
@search="findLabels"
:search-results="foundLabels"
@select="label => addLabel(label)"
label="title"
placeholder="Type to search for a label..."
track-by="id"
:multiple="true"
v-model="labels"
>
<template
slot="tag"
slot-scope="{ option }">
<template v-slot:tag="props">
<span
:style="{'background': option.hexColor, 'color': option.textColor}"
class="tag mr-2 mb-2">
<span>{{ option.title }}</span>
<a @click="removeLabel(option)" class="delete is-small"></a>
:style="{'background': props.item.hexColor, 'color': props.item.textColor}"
class="tag ml-2 mt-2">
<span>{{ props.item.title }}</span>
<a @click="removeLabel(props.item)" class="delete is-small"></a>
</span>
</template>
<template slot="clear" slot-scope="props">
<div
@mousedown.prevent.stop="clearLabels(props.search)"
class="multiselect__clear"
v-if="labels.length"></div>
</template>
</multiselect>
</div>
</div>
@ -178,64 +146,32 @@
<label class="label">Lists</label>
<div class="control">
<multiselect
:clear-on-select="true"
:close-on-select="true"
:hide-selected="true"
:internal-search="true"
:loading="listsService.loading"
:multiple="true"
:options="foundlists"
:options-limit="300"
:searchable="true"
:showNoOptions="false"
:taggable="false"
@search-change="query => 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"
>
<template slot="clear" slot-scope="props">
<div
@mousedown.prevent.stop="clear('lists', props.search)"
class="multiselect__clear"
v-if="lists.length"></div>
</template>
</multiselect>
/>
</div>
</div>
<div class="field">
<label class="label">Namespaces</label>
<div class="control">
<multiselect
:clear-on-select="true"
:close-on-select="true"
:hide-selected="true"
:internal-search="true"
:loading="namespaceService.loading"
:multiple="true"
:options="foundnamespace"
:options-limit="300"
:searchable="true"
:showNoOptions="false"
:taggable="false"
@search-change="query => 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"
>
<template slot="clear" slot-scope="props">
<div
@mousedown.prevent.stop="clear('namespace', props.search)"
class="multiselect__clear"
v-if="namespace.length"></div>
</template>
</multiselect>
/>
</div>
</div>
</template>
@ -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'

@ -1,31 +1,20 @@
<template>
<multiselect
:internal-search="true"
:loading="namespaceService.loading"
:multiple="false"
:options="namespaces"
:searchable="true"
:showNoOptions="false"
@search-change="findNamespaces"
placeholder="Search for a namespace..."
@search="findNamespaces"
:search-results="namespaces"
@select="select"
label="title"
placeholder="Search for a namespace..."
track-by="id"
v-model="namespace">
<template slot="clear" slot-scope="props">
<div
@mousedown.prevent.stop="clearAll(props.search)" class="multiselect__clear"
v-if="namespace.id !== 0"></div>
</template>
<span slot="noResult">No namespace found. Consider changing the search query.</span>
</multiselect>
v-model="namespace"
/>
</template>
<script>
import NamespaceService from '../../services/namespace'
import NamespaceModel from '../../models/namespace'
import LoadingComponent from '../misc/loading'
import ErrorComponent from '../misc/error'
import Multiselect from '@/components/input/multiselect'
export default {
name: 'namespace-search',
@ -37,12 +26,7 @@ export default {
}
},
components: {
multiselect: () => ({
component: import(/* webpackChunkName: "multiselect" */ 'vue-multiselect'),
loading: LoadingComponent,
error: ErrorComponent,
timeout: 60000,
}),
Multiselect,
},
created() {
this.namespaceService = new NamespaceService()

@ -1,106 +1,87 @@
<template>
<div class="card is-fullwidth">
<div class="card is-fullwidth has-overflow">
<header class="card-header">
<p class="card-header-title">
Shared with these {{ shareType }}s
</p>
</header>
<div class="card-content content sharables-list">
<form @submit.prevent="add()" class="add-form" v-if="userIsAdmin">
<div class="field is-grouped">
<p class="control is-expanded" v-bind:class="{ 'is-loading': searchService.loading}">
<multiselect
:internal-search="true"
:label="searchLabel"
:loading="searchService.loading"
:multiple="false"
:options="found"
:searchable="true"
:showNoOptions="false"
@search-change="find"
placeholder="Type to search..."
track-by="id"
v-model="sharable">
<template slot="clear" slot-scope="props">
<div
@mousedown.prevent.stop="clearAll(props.search)"
class="multiselect__clear"
v-if="sharable.id !== 0"></div>
</template>
<span slot="noResult">
Oops! No {{ shareType }} found. Consider changing the search query.
</span>
</multiselect>
</p>
<p class="control">
<button class="button is-success" type="submit">
<span class="icon is-small">
<icon icon="plus"/>
</span>
Add
</button>
</p>
</div>
</form>
<table class="table is-striped is-hoverable is-fullwidth">
<tbody>
<tr :key="s.id" v-for="s in sharables">
<template v-if="shareType === 'user'">
<td>{{ s.getDisplayName() }}</td>
<td>
<template v-if="s.id === userInfo.id">
<b class="is-success">You</b>
</template>
</td>
</template>
<template v-if="shareType === 'team'">
<td>
<router-link :to="{name: 'teams.edit', params: {id: s.id}}">
{{ s.name }}
</router-link>
</td>
</template>
<td class="type">
<template v-if="s.right === rights.ADMIN">
<div class="card-content" v-if="userIsAdmin">
<div class="field has-addons">
<p class="control is-expanded" v-bind:class="{ 'is-loading': searchService.loading}">
<multiselect
:loading="searchService.loading"
placeholder="Type to search..."
@search="find"
:search-results="found"
:label="searchLabel"
v-model="sharable"
/>
</p>
<p class="control">
<button class="button is-primary" @click="add()">
Share
</button>
</p>
</div>
</div>
<table class="table is-striped is-hoverable is-fullwidth">
<tbody>
<tr :key="s.id" v-for="s in sharables">
<template v-if="shareType === 'user'">
<td>{{ s.getDisplayName() }}</td>
<td>
<template v-if="s.id === userInfo.id">
<b class="is-success">You</b>
</template>
</td>
</template>
<template v-if="shareType === 'team'">
<td>
<router-link :to="{name: 'teams.edit', params: {id: s.id}}">
{{ s.name }}
</router-link>
</td>
</template>
<td class="type">
<template v-if="s.right === rights.ADMIN">
<span class="icon is-small">
<icon icon="lock"/>
</span>
Admin
</template>
<template v-else-if="s.right === rights.READ_WRITE">
Admin
</template>
<template v-else-if="s.right === rights.READ_WRITE">
<span class="icon is-small">
<icon icon="pen"/>
</span>
Write
</template>
<template v-else>
Write
</template>
<template v-else>
<span class="icon is-small">
<icon icon="users"/>
</span>
Read-only
</template>
</td>
<td class="actions" v-if="userIsAdmin">
<div class="select">
<select @change="toggleType(s)" class="button buttonright" v-model="selectedRight[s.id]">
<option :selected="s.right === rights.READ" :value="rights.READ">Read only</option>
<option :selected="s.right === rights.READ_WRITE" :value="rights.READ_WRITE">Read &
write
</option>
<option :selected="s.right === rights.ADMIN" :value="rights.ADMIN">Admin</option>
</select>
</div>
<button @click="() => {sharable = s; showDeleteModal = true}"
class="button is-danger icon-only">
Read-only
</template>
</td>
<td class="actions" v-if="userIsAdmin">
<div class="select">
<select @change="toggleType(s)" class="button buttonright" v-model="selectedRight[s.id]">
<option :selected="s.right === rights.READ" :value="rights.READ">Read only</option>
<option :selected="s.right === rights.READ_WRITE" :value="rights.READ_WRITE">Read &
write
</option>
<option :selected="s.right === rights.ADMIN" :value="rights.ADMIN">Admin</option>
</select>
</div>
<button @click="() => {sharable = s; showDeleteModal = true}"
class="button is-danger icon-only">
<span class="icon is-small">
<icon icon="trash-alt"/>
</span>
</button>
</td>
</tr>
</tbody>
</table>
</div>
</button>
</td>
</tr>
</tbody>
</table>
<modal
@close="showDeleteModal = false"
@ -131,8 +112,7 @@ import TeamService from '../../services/team'
import TeamModel from '../../models/team'
import rights from '../../models/rights'
import LoadingComponent from '../misc/loading'
import ErrorComponent from '../misc/error'
import Multiselect from '@/components/input/multiselect'
export default {
name: 'userTeamShare',
@ -172,12 +152,7 @@ export default {
}
},
components: {
multiselect: () => ({
component: import(/* webpackChunkName: "multiselect" */ 'vue-multiselect'),
loading: LoadingComponent,
error: ErrorComponent,
timeout: 60000,
}),
Multiselect,
},
computed: mapState({
userInfo: state => state.auth.info,

@ -1,37 +1,24 @@
<template>
<multiselect
:clear-on-select="true"
:close-on-select="false"
:disabled="disabled"
:hide-selected="true"
:internal-search="true"
:loading="listUserService.loading"
placeholder="Type to assign a user..."
:disabled="disabled"
:multiple="true"
:options="foundUsers"
:options-limit="300"
:searchable="true"
:showNoOptions="false"
@search-change="findUser"
@search="findUser"
:search-results="foundUsers"
@select="addAssignee"
label="username"
placeholder="Type to assign a user..."
select-label="Assign this user"
track-by="id"
select-placeholder="Assign this user"
v-model="assignees"
>
<template slot="tag" slot-scope="{ option }">
<user :avatar-size="30" :show-username="false" :user="option"/>
<a @click="removeAssignee(option)" class="remove-assignee" v-if="!disabled">
<icon icon="times"/>
</a>
<template v-slot:tag="props">
<span class="assignee">
<user :avatar-size="32" :show-username="false" :user="props.item"/>
<a @click="removeAssignee(props.item)" class="remove-assignee" v-if="!disabled">
<icon icon="times"/>
</a>
</span>
</template>
<template slot="clear" slot-scope="props">
<div
@mousedown.prevent.stop="clearAllFoundUsers(props.search)"
class="multiselect__clear"
v-if="newAssignee !== null && newAssignee.id !== 0"></div>
</template>
<span slot="noResult">No user found. Consider changing the search query.</span>
</multiselect>
</template>
@ -42,19 +29,13 @@ import UserModel from '../../../models/user'
import ListUserService from '../../../services/listUsers'
import TaskAssigneeService from '../../../services/taskAssignee'
import User from '../../misc/user'
import LoadingComponent from '../../misc/loading'
import ErrorComponent from '../../misc/error'
import Multiselect from '@/components/input/multiselect'
export default {
name: 'editAssignees',
components: {
User,
multiselect: () => ({
component: import(/* webpackChunkName: "multiselect" */ 'vue-multiselect'),
loading: LoadingComponent,
error: ErrorComponent,
timeout: 60000,
}),
Multiselect,
},
props: {
taskId: {

@ -1,42 +1,25 @@
<template>
<multiselect
:clear-on-select="true"
:close-on-select="false"
:disabled="disabled"
:hide-selected="true"
:internal-search="true"
:loading="labelService.loading || labelTaskService.loading"
:multiple="true"
:options="foundLabels"
:options-limit="300"
:searchable="true"
:showNoOptions="false"
:taggable="true"
@search-change="findLabel"
@select="label => addLabel(label)"
@tag="createAndAddLabel"
label="title"
placeholder="Type to add a new label..."
tag-placeholder="Add this as new label"
track-by="id"
:multiple="true"
@search="findLabel"
:search-results="foundLabels"
@select="addLabel"
label="title"
:creatable="true"
@create="createAndAddLabel"
create-placeholder="Add this as new label"
v-model="labels"
>
<template
slot="tag"
slot-scope="{ option }">
<template v-slot:tag="props">
<span
:style="{'background': option.hexColor, 'color': option.textColor}"
class="tag">
<span>{{ option.title }}</span>
<a @click="removeLabel(option)" class="delete is-small"></a>
:style="{'background': props.item.hexColor, 'color': props.item.textColor}"
class="tag ml-2 mt-2">
<span>{{ props.item.title }}</span>
<a @click="removeLabel(props.item)" class="delete is-small"></a>
</span>
</template>
<template slot="clear" slot-scope="props">
<div
@mousedown.prevent.stop="clearAllLabels(props.search)"
class="multiselect__clear"
v-if="labels.length"></div>
</template>
</multiselect>
</template>
@ -46,8 +29,8 @@ import differenceWith from 'lodash/differenceWith'
import LabelService from '../../../services/label'
import LabelModel from '../../../models/label'
import LabelTaskService from '../../../services/labelTask'
import LoadingComponent from '../../misc/loading'
import ErrorComponent from '../../misc/error'
import Multiselect from '@/components/input/multiselect'
export default {
name: 'edit-labels',
@ -75,12 +58,7 @@ export default {
}
},
components: {
multiselect: () => ({
component: import(/* webpackChunkName: "multiselect" */ 'vue-multiselect'),
loading: LoadingComponent,
error: ErrorComponent,
timeout: 60000,
}),
Multiselect,
},
watch: {
value(newLabels) {

@ -1,39 +1,27 @@
<template>
<multiselect
:internal-search="true"
:loading="listSerivce.loading"
:multiple="false"
:options="foundLists"
:searchable="true"
:showNoOptions="false"
@search-change="findLists"
@select="select"
class="control is-expanded"
label="title"
placeholder="Type to search for a list..."
track-by="id"
v-focus
:loading="listSerivce.loading"
placeholder="Type to search for a list..."
@search="findLists"
:search-results="foundLists"
@select="select"
label="title"
v-model="list"
select-placeholder="Click or press enter to select this list"
>
<template slot="clear" slot-scope="props">
<div
@mousedown.prevent.stop="clearAll(props.search)"
class="multiselect__clear"
v-if="list !== null && list.id !== 0"></div>
</template>
<template slot="option" slot-scope="props">
<template v-slot:searchResult="props">
<span class="list-namespace-title">{{ namespace(props.option.namespaceId) }} ></span>
{{ props.option.title }}
</template>
<span slot="noResult">No list found. Consider changing the search query.</span>
</multiselect>
</template>
<script>
import ListService from '../../../services/list'
import ListModel from '../../../models/list'
import LoadingComponent from '../../misc/loading'
import ErrorComponent from '../../misc/error'
import Multiselect from '@/components/input/multiselect'
export default {
name: 'listSearch',
@ -45,12 +33,7 @@ export default {
}
},
components: {
multiselect: () => ({
component: import(/* webpackChunkName: "multiselect" */ 'vue-multiselect'),
loading: LoadingComponent,
error: ErrorComponent,
timeout: 60000,
}),
Multiselect,
},
beforeMount() {
this.listSerivce = new ListService()