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

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

View File

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

View File

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

View File

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

View File

@ -7,6 +7,8 @@ export class TaskCommentFactory extends Factory {
static table = 'task_comments' static table = 'task_comments'
static factory() { static factory() {
const now = new Date()
return { return {
id: '{increment}', id: '{increment}',
comment: faker.lorem.text(3), comment: faker.lorem.text(3),

View File

@ -1,5 +1,6 @@
import {TeamFactory} from '../../factories/team' import {TeamFactory} from '../../factories/team'
import {TeamMemberFactory} from '../../factories/team_member' import {TeamMemberFactory} from '../../factories/team_member'
import {UserFactory} from '../../factories/user'
import '../../support/authenticateUser' import '../../support/authenticateUser'
describe('Team', () => { describe('Team', () => {
@ -88,4 +89,41 @@ describe('Team', () => {
.contains('Member') .contains('Member')
.should('exist') .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')
})
}) })

View File

@ -8,6 +8,9 @@ import {NamespaceFactory} from '../../factories/namespace'
import {UserListFactory} from '../../factories/users_list' import {UserListFactory} from '../../factories/users_list'
import '../../support/authenticateUser' import '../../support/authenticateUser'
import {TaskAssigneeFactory} from '../../factories/task_assignee'
import {LabelFactory} from '../../factories/labels'
import {LabelTaskFactory} from '../../factories/label_task'
describe('Task', () => { describe('Task', () => {
let namespaces let namespaces
@ -202,8 +205,14 @@ describe('Task', () => {
cy.get('.task-view .action-buttons .button') cy.get('.task-view .action-buttons .button')
.contains('Move task') .contains('Move task')
.click() .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}`) .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') cy.get('.task-view h6.subtitle')
.should('contain', namespaces[0].title) .should('contain', namespaces[0].title)
@ -233,5 +242,141 @@ describe('Task', () => {
cy.url() cy.url()
.should('contain', `/lists/${tasks[0].list_id}/`) .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)
})
}) })
}) })

View File

@ -6,6 +6,7 @@
"serve": "vue-cli-service serve", "serve": "vue-cli-service serve",
"serve:dist": "node scripts/serve-dist.js", "serve:dist": "node scripts/serve-dist.js",
"build": "vue-cli-service build --modern", "build": "vue-cli-service build --modern",
"build:report": "vue-cli-service build --report",
"lint": "vue-cli-service lint --ignore-pattern '*.test.*'", "lint": "vue-cli-service lint --ignore-pattern '*.test.*'",
"cypress:open": "cypress open", "cypress:open": "cypress open",
"test:unit": "jest", "test:unit": "jest",
@ -51,7 +52,6 @@
"node-sass": "5.0.0", "node-sass": "5.0.0",
"sass-loader": "10.1.0", "sass-loader": "10.1.0",
"vue-flatpickr-component": "8.1.6", "vue-flatpickr-component": "8.1.6",
"vue-multiselect": "2.1.6",
"vue-notification": "1.3.20", "vue-notification": "1.3.20",
"vue-router": "3.4.9", "vue-router": "3.4.9",
"vue-template-compiler": "2.6.12", "vue-template-compiler": "2.6.12",
@ -83,10 +83,12 @@
"browserslist": [ "browserslist": [
"> 1%", "> 1%",
"last 2 versions", "last 2 versions",
"not ie <= 8" "not ie < 11"
], ],
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"jest": { "jest": {
"testPathIgnorePatterns": ["cypress"] "testPathIgnorePatterns": [
"cypress"
]
} }
} }

View File

@ -115,6 +115,7 @@ import 'flatpickr/dist/flatpickr.css'
import {calculateDayInterval} from '@/helpers/time/calculateDayInterval' import {calculateDayInterval} from '@/helpers/time/calculateDayInterval'
import {format} from 'date-fns' import {format} from 'date-fns'
import {calculateNearestHours} from '@/helpers/time/calculateNearestHours' import {calculateNearestHours} from '@/helpers/time/calculateNearestHours'
import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside'
export default { export default {
name: 'datepicker', name: 'datepicker',
@ -188,25 +189,7 @@ export default {
}, },
hideDatePopup(e) { hideDatePopup(e) {
if (this.show) { if (this.show) {
closeWhenClickedOutside(e, this.$refs.datepickerPopup, this.close)
// 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()
} }
}, },
close() { close() {

View File

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

View File

@ -1,5 +1,5 @@
<template> <template>
<div class="card filters"> <div class="card filters has-overflow">
<div class="card-content"> <div class="card-content">
<fancycheckbox v-model="params.filter_include_nulls"> <fancycheckbox v-model="params.filter_include_nulls">
Include Tasks which don't have a value set Include Tasks which don't have a value set
@ -103,32 +103,16 @@
<label class="label">Assignees</label> <label class="label">Assignees</label>
<div class="control"> <div class="control">
<multiselect <multiselect
:clear-on-select="true"
:close-on-select="true"
:hide-selected="true"
:internal-search="true"
:loading="usersService.loading" :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..." 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" 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>
</div> </div>
@ -136,39 +120,23 @@
<label class="label">Labels</label> <label class="label">Labels</label>
<div class="control"> <div class="control">
<multiselect <multiselect
:clear-on-select="true"
:close-on-select="false"
:hide-selected="true"
:internal-search="true"
:loading="labelService.loading" :loading="labelService.loading"
:multiple="true" placeholder="Type to search for a label..."
:options="foundLabels" @search="findLabels"
:options-limit="300" :search-results="foundLabels"
:searchable="true"
:showNoOptions="false"
@search-change="findLabels"
@select="label => addLabel(label)" @select="label => addLabel(label)"
label="title" label="title"
placeholder="Type to search for a label..." :multiple="true"
track-by="id"
v-model="labels" v-model="labels"
> >
<template <template v-slot:tag="props">
slot="tag"
slot-scope="{ option }">
<span <span
:style="{'background': option.hexColor, 'color': option.textColor}" :style="{'background': props.item.hexColor, 'color': props.item.textColor}"
class="tag mr-2 mb-2"> class="tag ml-2 mt-2">
<span>{{ option.title }}</span> <span>{{ props.item.title }}</span>
<a @click="removeLabel(option)" class="delete is-small"></a> <a @click="removeLabel(props.item)" class="delete is-small"></a>
</span> </span>
</template> </template>
<template slot="clear" slot-scope="props">
<div
@mousedown.prevent.stop="clearLabels(props.search)"
class="multiselect__clear"
v-if="labels.length"></div>
</template>
</multiselect> </multiselect>
</div> </div>
</div> </div>
@ -178,64 +146,32 @@
<label class="label">Lists</label> <label class="label">Lists</label>
<div class="control"> <div class="control">
<multiselect <multiselect
:clear-on-select="true"
:close-on-select="true"
:hide-selected="true"
:internal-search="true"
:loading="listsService.loading" :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..." 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" 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> </div>
<div class="field"> <div class="field">
<label class="label">Namespaces</label> <label class="label">Namespaces</label>
<div class="control"> <div class="control">
<multiselect <multiselect
:clear-on-select="true"
:close-on-select="true"
:hide-selected="true"
:internal-search="true"
:loading="namespaceService.loading" :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..." 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" 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>
</div> </div>
</template> </template>
@ -247,13 +183,13 @@
import Fancycheckbox from '../../input/fancycheckbox' import Fancycheckbox from '../../input/fancycheckbox'
import flatPickr from 'vue-flatpickr-component' import flatPickr from 'vue-flatpickr-component'
import 'flatpickr/dist/flatpickr.css' import 'flatpickr/dist/flatpickr.css'
import Multiselect from 'vue-multiselect'
import {formatISO} from 'date-fns' import {formatISO} from 'date-fns'
import differenceWith from 'lodash/differenceWith' import differenceWith from 'lodash/differenceWith'
import PrioritySelect from '@/components/tasks/partials/prioritySelect' import PrioritySelect from '@/components/tasks/partials/prioritySelect'
import PercentDoneSelect from '@/components/tasks/partials/percentDoneSelect' import PercentDoneSelect from '@/components/tasks/partials/percentDoneSelect'
import Multiselect from '@/components/input/multiselect'
import UserService from '@/services/user' import UserService from '@/services/user'
import LabelService from '@/services/label' import LabelService from '@/services/label'

View File

@ -1,31 +1,20 @@
<template> <template>
<multiselect <multiselect
:internal-search="true"
:loading="namespaceService.loading" :loading="namespaceService.loading"
:multiple="false" placeholder="Search for a namespace..."
:options="namespaces" @search="findNamespaces"
:searchable="true" :search-results="namespaces"
:showNoOptions="false"
@search-change="findNamespaces"
@select="select" @select="select"
label="title" label="title"
placeholder="Search for a namespace..." v-model="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>
</template> </template>
<script> <script>
import NamespaceService from '../../services/namespace' import NamespaceService from '../../services/namespace'
import NamespaceModel from '../../models/namespace' import NamespaceModel from '../../models/namespace'
import LoadingComponent from '../misc/loading'
import ErrorComponent from '../misc/error' import Multiselect from '@/components/input/multiselect'
export default { export default {
name: 'namespace-search', name: 'namespace-search',
@ -37,12 +26,7 @@ export default {
} }
}, },
components: { components: {
multiselect: () => ({ Multiselect,
component: import(/* webpackChunkName: "multiselect" */ 'vue-multiselect'),
loading: LoadingComponent,
error: ErrorComponent,
timeout: 60000,
}),
}, },
created() { created() {
this.namespaceService = new NamespaceService() this.namespaceService = new NamespaceService()

View File

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

View File

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

View File

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

View File

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

View File

@ -15,29 +15,16 @@
</label> </label>
<div class="field"> <div class="field">
<multiselect <multiselect
:internal-search="true"
:loading="taskService.loading"
:multiple="false"
:options="foundTasks"
:searchable="true"
:showNoOptions="false"
:taggable="true"
@search-change="findTasks"
@tag="createAndRelateTask"
label="title"
placeholder="Type search for a new task to add as related..." placeholder="Type search for a new task to add as related..."
tag-placeholder="Add this as new related task" @search="findTasks"
track-by="id" :loading="taskService.loading"
:search-results="foundTasks"
label="title"
v-model="newTaskRelationTask" v-model="newTaskRelationTask"
> :creatable="true"
<template slot="clear" slot-scope="props"> create-placeholder="Add this as new related task"
<div @create="createAndRelateTask"
@mousedown.prevent.stop="clearAllFoundTasks(props.search)" />
class="multiselect__clear"
v-if="newTaskRelationTask !== null && newTaskRelationTask.id !== 0"></div>
</template>
<span slot="noResult">No task found. Consider changing the search query.</span>
</multiselect>
</div> </div>
<div class="field has-addons"> <div class="field has-addons">
<div class="control is-expanded"> <div class="control is-expanded">
@ -60,7 +47,7 @@
<template v-if="rts.length > 0"> <template v-if="rts.length > 0">
<span class="title">{{ relationKindTitle(kind, rts.length) }}</span> <span class="title">{{ relationKindTitle(kind, rts.length) }}</span>
<div class="tasks noborder"> <div class="tasks noborder">
<div :key="t.id" class="task" v-for="t in rts"> <div :key="t.id" class="task" v-for="t in rts.filter(t => t)">
<router-link :to="{ name: $route.name, params: { id: t.id } }"> <router-link :to="{ name: $route.name, params: { id: t.id } }">
<span :class="{ 'done': t.done}" class="tasktext"> <span :class="{ 'done': t.done}" class="tasktext">
<span <span
@ -107,8 +94,7 @@ import TaskRelationService from '../../../services/taskRelation'
import relationKinds from '../../../models/relationKinds' import relationKinds from '../../../models/relationKinds'
import TaskRelationModel from '../../../models/taskRelation' import TaskRelationModel from '../../../models/taskRelation'
import LoadingComponent from '../../misc/loading' import Multiselect from '@/components/input/multiselect'
import ErrorComponent from '../../misc/error'
export default { export default {
name: 'relatedTasks', name: 'relatedTasks',
@ -127,12 +113,7 @@ export default {
} }
}, },
components: { components: {
multiselect: () => ({ Multiselect,
component: import(/* webpackChunkName: "multiselect" */ 'vue-multiselect'),
loading: LoadingComponent,
error: ErrorComponent,
timeout: 60000,
}),
}, },
props: { props: {
taskId: { taskId: {
@ -171,11 +152,6 @@ export default {
}, },
methods: { methods: {
findTasks(query) { findTasks(query) {
if (query === '') {
this.clearAllFoundTasks()
return
}
this.taskService.getAll({}, {s: query}) this.taskService.getAll({}, {s: query})
.then(response => { .then(response => {
this.$set(this, 'foundTasks', response) this.$set(this, 'foundTasks', response)
@ -184,9 +160,6 @@ export default {
this.error(e, this) this.error(e, this)
}) })
}, },
clearAllFoundTasks() {
this.$set(this, 'foundTasks', [])
},
addTaskRelation() { addTaskRelation() {
let rel = new TaskRelationModel({ let rel = new TaskRelationModel({
taskId: this.taskId, taskId: this.taskId,
@ -199,7 +172,7 @@ export default {
this.$set(this.relatedTasks, this.newTaskRelationKind, []) this.$set(this.relatedTasks, this.newTaskRelationKind, [])
} }
this.relatedTasks[this.newTaskRelationKind].push(this.newTaskRelationTask) this.relatedTasks[this.newTaskRelationKind].push(this.newTaskRelationTask)
this.newTaskRelationTask = new TaskModel() this.newTaskRelationTask = null
this.saved = true this.saved = true
setTimeout(() => { setTimeout(() => {
this.saved = false this.saved = false

View File

@ -92,15 +92,6 @@ p {
padding-top: 6px; padding-top: 6px;
} }
.field.has-addons {
margin-bottom: .5rem;
.control .select select {
height: 2.5em;
}
}
.columns { .columns {
align-items: center; align-items: center;
} }

View File

@ -0,0 +1,27 @@
/**
* Calls the close callback when a click happened outside of the rootElement.
*
* @param event The "click" event object.
* @param rootElement
* @param closeCallback A closure function to call when the click event happened outside of the rootElement.
*/
export const closeWhenClickedOutside = (event, rootElement, closeCallback) => {
// We walk up the tree to see if any parent of the clicked element is the root element.
// If it is not, we call the close callback. We're doing all this hassle to only call the
// closing callback when a click happens outside of the rootElement.
let parent = event.target.parentElement
while (parent !== rootElement) {
if (parent.parentElement === null) {
parent = null
break
}
parent = parent.parentElement
}
if (parent === rootElement) {
return
}
closeCallback()
}

View File

@ -1,482 +1,135 @@
fieldset[disabled] .multiselect {
pointer-events: none;
}
.multiselect__spinner {
position: absolute;
right: 1px;
top: 1px;
width: 48px;
height: 35px;
background: $white;
display: block;
&:before, &:after {
position: absolute;
content: "";
top: 50%;
left: 50%;
margin: -8px 0 0 -8px;
width: 16px;
height: 16px;
border-radius: 100%;
border-color: $multiselect_primary transparent transparent;
border-style: solid;
border-width: 2px;
box-shadow: 0 0 0 1px transparent;
}
&:before {
animation: spinning 2.4s cubic-bezier(0.41, 0.26, 0.2, 0.62);
animation-iteration-count: infinite;
}
&:after {
animation: spinning 2.4s cubic-bezier(0.51, 0.09, 0.21, 0.8);
animation-iteration-count: infinite;
}
}
.multiselect__loading-enter-active, .multiselect__loading-leave-active {
transition: opacity 0.4s ease-in-out;
opacity: 1;
}
.multiselect__loading-enter, .multiselect__loading-leave-active {
opacity: 0;
}
.multiselect, .multiselect__input, .multiselect__single {
font-family: inherit;
font-size: 16px;
touch-action: manipulation;
}
.multiselect { .multiselect {
box-sizing: content-box; width: 100%;
display: block; position: relative;
position: relative;
width: 100%;
min-height: 40px;
text-align: left;
color: $text;
* { &.has-search-results .input-wrapper {
box-sizing: border-box; border-radius: $radius $radius 0 0;
} border-color: $primary !important;
background: $white !important;
&:focus { &, &:focus-within {
outline: none; border-bottom-color: $grey-lighter !important;
} }
} }
.multiselect--disabled { .input-wrapper {
pointer-events: none; padding: 0;
opacity: 0.6; background: $white !important;
} border-color: $grey-lighter !important;
flex-wrap: wrap;
.multiselect--active { height: auto;
z-index: 50;
&:hover {
&:not(.multiselect--above) { border-color: $grey-light !important;
.multiselect__current, .multiselect__input, .multiselect__tags { }
border-bottom-left-radius: 0;
border-bottom-right-radius: 0; .input-loader-wrapper {
} display: flex;
} max-width: 100%;
width: 100%;
.multiselect__select { align-items: center;
transform: rotateZ(180deg);
} .input {
} border: none !important;
background: transparent;
.multiselect--above.multiselect--active { height: auto;
.multiselect__current, .multiselect__input, .multiselect__tags {
border-top-left-radius: 0; &::placeholder {
border-top-right-radius: 0; font-style: normal !important;
} }
} }
}
.multiselect__input, .multiselect__single {
position: relative; &:focus-within {
display: inline-block; border-color: $primary !important;
min-height: 20px; background: $white !important;
line-height: 20px; }
border: none;
border-radius: 5px; .loader {
background: $white; margin: 0 .5rem;
padding: 0 0 0 5px; }
width: calc(100%); }
transition: border 0.1s ease;
box-sizing: border-box; .search-results {
margin-bottom: 8px; background: $white;
vertical-align: top; border-radius: 0 0 $radius $radius;
} border: 1px solid $primary;
border-top: none;
.multiselect__input::placeholder {
color: $multiselect-dark; max-height: 50vh;
} overflow-x: auto;
position: absolute;
.multiselect__tag ~ { z-index: 100;
.multiselect__input, .multiselect__single { max-width: 100%;
width: auto; min-width: 100%;
}
} button {
background: transparent;
.multiselect__input:hover, .multiselect__single:hover { display: block;
border-color: darken($white, 10); text-align: left;
} box-shadow: none;
border-radius: 0;
.multiselect__input:focus { text-transform: none;
border-color: $primary; font-family: $family-sans-serif;
outline: none; font-weight: normal;
}
display: flex;
.multiselect__single { justify-content: space-between;
&:focus { align-items: center;
border-color: $primary; overflow: hidden;
outline: none;
} span:first-child {
white-space: nowrap;
padding-left: 5px; text-overflow: ellipsis;
margin-bottom: 8px; overflow: hidden;
} }
.multiselect__tags-wrap { .hint-text {
display: inline; font-size: .75rem;
color: transparent;
.user { transition: color $transition;
display: inline-block; padding-left: .5rem;
min-height: 30px; }
margin: 0 0 .5em;
} &:focus, &:hover {
} background: $grey-lightest;
box-shadow: none !important;
.multiselect__tags {
display: block; .hint-text {
padding: 8px 40px 0 8px; color: $dark;
border-radius: 5px; }
border: 1px solid $multiselect-border; }
background: $white;
font-size: 14px; &:active {
} background: $grey-lighter;
}
.multiselect__tag { }
position: relative; }
display: inline-block;
padding: 4px 26px 4px 10px; .assignee {
border-radius: 5px; position: relative;
margin-right: 10px;
color: $white; &:not(:first-child) {
line-height: 1; margin-left: -1.75rem;
background: $multiselect-highlight; }
margin-bottom: 5px;
white-space: nowrap; .user img {
overflow: hidden; border: 2px solid $white;
max-width: 100%; }
text-overflow: ellipsis;
} .remove-assignee {
position: absolute;
.multiselect__tag-icon { top: 4px;
cursor: pointer; left: 2px;
margin-left: 7px; color: $red;
position: absolute; background: $white;
right: 0; padding: 0 4px;
top: 0; display: block;
bottom: 0; border-radius: 100%;
font-weight: 700; font-size: .75rem;
font-style: initial; width: 18px;
width: 22px; height: 18px;
text-align: center; z-index: 100;
line-height: 22px; }
transition: all 0.2s ease; }
border-radius: 5px;
&:after {
content: "×";
color: darken($multiselect-highlight, 20);
font-size: 14px;
}
&:focus, &:hover {
background: lighten($multiselect-highlight, 10);
}
&:focus:after, &:hover:after {
color: $white;
}
}
.multiselect__current {
line-height: 16px;
min-height: 40px;
box-sizing: border-box;
display: block;
overflow: hidden;
padding: 8px 30px 0 12px;
white-space: nowrap;
margin: 0;
text-decoration: none;
border-radius: 5px;
border: 1px solid $multiselect-border;
cursor: pointer;
}
.multiselect__select {
line-height: 16px;
display: block;
position: absolute;
box-sizing: border-box;
width: 40px;
height: 38px;
right: 1px;
top: 1px;
padding: 4px 8px;
margin: 0;
text-decoration: none;
text-align: center;
cursor: pointer;
transition: transform 0.2s ease;
&:before {
position: relative;
right: 0;
top: 65%;
color: darken($multiselect-border, 30);
margin-top: 4px;
border-style: solid;
border-width: 5px 5px 0 5px;
border-color: darken($multiselect-border, 30) transparent transparent transparent;
content: "";
}
}
.multiselect__placeholder {
color: darken($white, 15);
display: inline-block;
margin-bottom: 10px;
padding-top: 2px;
}
.multiselect--active .multiselect__placeholder {
display: none;
}
.multiselect__content-wrapper {
position: absolute;
display: block;
background: $white;
width: 100%;
max-height: 240px;
overflow: auto;
border: 1px solid $multiselect-border;
border-top: none;
border-bottom-left-radius: 5px;
border-bottom-right-radius: 5px;
z-index: 50;
-webkit-overflow-scrolling: touch;
}
.multiselect__content, .content ul.multiselect__content {
list-style: none;
display: inline-block;
padding: 0;
margin: 0;
min-width: 100%;
vertical-align: top;
li + li {
margin: 0;
}
}
.multiselect--above .multiselect__content-wrapper {
bottom: 100%;
border-radius: 5px 5px 0 0;
border-bottom: none;
border-top: 1px solid $multiselect-border;
}
.multiselect__content::webkit-scrollbar {
display: none;
}
.multiselect__element {
display: block;
}
.multiselect__option {
display: block;
padding: 12px;
min-height: 40px;
line-height: 16px;
text-decoration: none;
text-transform: none;
vertical-align: middle;
position: relative;
cursor: pointer;
white-space: nowrap;
&:after {
top: 0;
right: 0;
position: absolute;
line-height: 40px;
padding-right: 12px;
padding-left: 20px;
font-size: 13px;
}
}
.multiselect__option--highlight {
background: $multiselect-highlight;
outline: none;
color: $white;
&:after {
content: attr(data-select);
background: $multiselect-highlight;
color: $white;
}
}
.multiselect__option--selected {
background: darken($white, 10);
color: $multiselect-dark;
font-weight: bold;
&:after {
content: attr(data-selected);
color: silver;
}
&.multiselect__option--highlight {
background: $multiselect-highlight-negative;
color: $white;
&:after {
background: $multiselect-highlight-negative;
content: attr(data-deselect);
color: $white;
}
}
}
.multiselect--disabled {
pointer-events: none;
.multiselect__current, .multiselect__select {
background: $multiselect-disabled;
color: darken($multiselect-disabled, 40);
}
}
.multiselect__option--disabled {
background: $multiselect-disabled !important;
color: darken($multiselect-disabled, 40) !important;
cursor: text;
pointer-events: none;
}
.multiselect__option--group {
background: $multiselect-disabled;
color: $multiselect-dark;
&.multiselect__option--highlight {
background: $multiselect-dark;
color: $white;
&:after {
background: $multiselect-dark;
}
}
}
.multiselect__option--disabled.multiselect__option--highlight {
background: $multiselect-disabled;
}
.multiselect__option--group-selected.multiselect__option--highlight {
background: $multiselect-highlight-negative;
color: $white;
&:after {
background: $multiselect-highlight-negative;
content: attr(data-deselect);
color: $white;
}
}
.multiselect-enter-active, .multiselect-leave-active {
transition: all 0.15s ease;
}
.multiselect-enter, .multiselect-leave-active {
opacity: 0;
}
.multiselect__strong {
margin-bottom: 8px;
line-height: 20px;
display: inline-block;
vertical-align: top;
}
*[dir="rtl"] {
.multiselect {
text-align: right;
}
.multiselect__select {
right: auto;
left: 1px;
}
.multiselect__tags {
padding: 8px 8px 0px 40px;
}
.multiselect__content {
text-align: right;
}
.multiselect__option:after {
right: auto;
left: 0;
}
.multiselect__clear {
right: auto;
left: 12px;
}
.multiselect__spinner {
right: auto;
left: 1px;
}
}
@keyframes spinning {
from {
transform: rotate(0);
}
to {
transform: rotate(2turn);
}
}
.multiselect__tags {
.remove-assignee {
vertical-align: bottom;
color: $red;
margin-left: -1.8em;
background: $white;
padding: 0 4px;
display: inline-block;
border-radius: 100%;
font-size: .8em;
width: 18px;
height: 18px;
}
} }

View File

@ -28,10 +28,6 @@
} }
} }
.sharables-list, .sharables-namespace {
padding: 0 !important;
}
.task-add .button { .task-add .button {
padding: 10px 1em; padding: 10px 1em;
height: 40px; height: 40px;

View File

@ -117,32 +117,13 @@
} }
&.labels-list, .assignees { &.labels-list, .assignees {
.multiselect__tags { .multiselect {
padding: 3px 0 0 3px; .input-wrapper {
border: none; &:not(:focus-within):not(:hover) {
background: transparent; background: transparent !important;
} border-color: transparent !important;
}
.multiselect__input, .multiselect__single { }
width: auto !important;
margin: 0;
padding: .35em !important;
position: relative !important;
background: transparent;
max-width: 100%;
}
.multiselect__placeholder {
display: none;
}
.multiselect__select {
// We may need to enable this since it may also be responsable for showing the loading spinner
display: none;
}
.multiselect__content-wrapper {
border: none;
} }
} }

View File

@ -128,10 +128,8 @@
} }
} }
.field.has-addons { .field.has-addons .control .select select {
.control .select select { height: 100%;
height: 2.25em;
}
} }
.bigbuttons { .bigbuttons {

View File

@ -58,6 +58,10 @@ h1, h2, h3, h4, h5, h6 {
border-radius: $radius; border-radius: $radius;
} }
.has-overflow {
overflow: visible;
}
.image.is-avatar { .image.is-avatar {
border-radius: 100%; border-radius: 100%;
} }

View File

@ -99,7 +99,7 @@
</div> </div>
</div> </div>
<div class="card"> <div class="card has-overflow">
<header class="card-header"> <header class="card-header">
<p class="card-header-title"> <p class="card-header-title">
Duplicate this list Duplicate this list
@ -108,22 +108,20 @@
<div class="card-content"> <div class="card-content">
<div class="content"> <div class="content">
<p>Select a namespace which should hold the duplicated list:</p> <p>Select a namespace which should hold the duplicated list:</p>
<div class="field is-grouped">
<p class="control is-expanded"> <div class="field has-addons">
<div class="control is-expanded">
<namespace-search @selected="selectNamespace"/> <namespace-search @selected="selectNamespace"/>
</p> </div>
<p class="control"> <div class="control">
<button <button
:class="{'is-loading': listDuplicateService.loading}" :class="{'is-loading': listDuplicateService.loading}"
@click="duplicateList" @click="duplicateList"
class="button is-success" class="button is-primary"
type="submit"> type="submit">
<span class="icon is-small"> Duplicate
<icon icon="plus"/>
</span>
Add
</button> </button>
</p> </div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,5 +1,5 @@
<template> <template>
<div class="loader-container is-max-width-desktop" v-bind:class="{ 'is-loading': teamService.loading}"> <div class="loader-container is-max-width-desktop" :class="{ 'is-loading': teamService.loading}">
<div class="card is-fullwidth" v-if="userIsAdmin"> <div class="card is-fullwidth" v-if="userIsAdmin">
<header class="card-header"> <header class="card-header">
<p class="card-header-title"> <p class="card-header-title">
@ -8,7 +8,7 @@
</header> </header>
<div class="card-content"> <div class="card-content">
<div class="content"> <div class="content">
<form @submit.prevent="submit()"> <form @submit.prevent="save()">
<div class="field"> <div class="field">
<label class="label" for="teamtext">Team Name</label> <label class="label" for="teamtext">Team Name</label>
<div class="control"> <div class="control">
@ -43,7 +43,7 @@
<div class="columns bigbuttons"> <div class="columns bigbuttons">
<div class="column"> <div class="column">
<button :class="{ 'is-loading': teamService.loading}" @click="submit()" <button :class="{ 'is-loading': teamService.loading}" @click="save()"
class="button is-primary is-fullwidth"> class="button is-primary is-fullwidth">
Save Save
</button> </button>
@ -60,96 +60,75 @@
</div> </div>
</div> </div>
</div> </div>
<div class="card is-fullwidth"> <div class="card is-fullwidth has-overflow">
<header class="card-header"> <header class="card-header">
<p class="card-header-title"> <p class="card-header-title">
Team Members Team Members
</p> </p>
</header> </header>
<div class="card-content content team-members"> <div class="card-content" v-if="userIsAdmin">
<form @submit.prevent="addUser()" class="add-member-form" v-if="userIsAdmin"> <div class="field has-addons">
<div class="field is-grouped"> <div class="control is-expanded">
<p <multiselect
:class="{ 'is-loading': teamMemberService.loading}" :loading="userService.loading"
class="control has-icons-left is-expanded"> placeholder="Type to search..."
<multiselect @search="findUser"
:internal-search="true" :search-results="foundUsers"
:loading="userService.loading" label="username"
:multiple="false" v-model="newMember"
:options="foundUsers" />
:searchable="true"
:showNoOptions="false"
@search-change="findUser"
label="username"
placeholder="Type to search..."
track-by="id"
v-model="newMember">
<template slot="clear" slot-scope="props">
<div
@mousedown.prevent.stop="clearAll(props.search)" class="multiselect__clear"
v-if="newMember !== null && newMember.id !== 0">
</div>
</template>
<span slot="noResult">Oops! No user 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> </div>
</form> <div class="control">
<table class="table is-striped is-hoverable is-fullwidth"> <button class="button is-primary" @click="addUser">
<tbody> <span class="icon is-small">
<tr :key="m.id" v-for="m in team.members"> <icon icon="plus"/>
<td>{{ m.getDisplayName() }}</td> </span>
<td> Add To Team
<template v-if="m.id === userInfo.id"> </button>
<b class="is-success">You</b> </div>
</template> </div>
</td>
<td class="type">
<template v-if="m.admin">
<span class="icon is-small">
<icon icon="lock"/>
</span>
Admin
</template>
<template v-else>
<span class="icon is-small">
<icon icon="user"/>
</span>
Member
</template>
</td>
<td class="actions" v-if="userIsAdmin">
<button :class="{'is-loading': teamMemberService.loading}" @click="toggleUserType(m)"
class="button buttonright is-primary"
v-if="m.id !== userInfo.id">
Make
<template v-if="!m.admin">
Admin
</template>
<template v-else>
Member
</template>
</button>
<button :class="{'is-loading': teamMemberService.loading}" @click="() => {member = m; showUserDeleteModal = true}"
class="button is-danger"
v-if="m.id !== userInfo.id">
<span class="icon is-small">
<icon icon="trash-alt"/>
</span>
</button>
</td>
</tr>
</tbody>
</table>
</div> </div>
<table class="table is-striped is-hoverable is-fullwidth">
<tbody>
<tr :key="m.id" v-for="m in team.members">
<td>{{ m.getDisplayName() }}</td>
<td>
<template v-if="m.id === userInfo.id">
<b class="is-success">You</b>
</template>
</td>
<td class="type">
<template v-if="m.admin">
<span class="icon is-small">
<icon icon="lock"/>
</span>
Admin
</template>
<template v-else>
<span class="icon is-small">
<icon icon="user"/>
</span>
Member
</template>
</td>
<td class="actions" v-if="userIsAdmin">
<button :class="{'is-loading': teamMemberService.loading}" @click="() => toggleUserType(m)"
class="button buttonright is-primary"
v-if="m.id !== userInfo.id">
Make {{ m.admin ? 'Member' : 'Admin' }}
</button>
<button :class="{'is-loading': teamMemberService.loading}"
@click="() => {member = m; showUserDeleteModal = true}"
class="button is-danger"
v-if="m.id !== userInfo.id">
<span class="icon is-small">
<icon icon="trash-alt"/>
</span>
</button>
</td>
</tr>
</tbody>
</table>
</div> </div>
<!-- Team delete modal --> <!-- Team delete modal -->
@ -185,9 +164,12 @@ import TeamMemberService from '../../services/teamMember'
import TeamMemberModel from '../../models/teamMember' import TeamMemberModel from '../../models/teamMember'
import UserModel from '../../models/user' import UserModel from '../../models/user'
import UserService from '../../services/user' import UserService from '../../services/user'
import Rights from '../../models/rights.json'
import LoadingComponent from '../../components/misc/loading' import LoadingComponent from '../../components/misc/loading'
import ErrorComponent from '../../components/misc/error' import ErrorComponent from '../../components/misc/error'
import Rights from '../../models/rights.json'
import Multiselect from '@/components/input/multiselect'
export default { export default {
name: 'EditTeam', name: 'EditTeam',
@ -210,12 +192,7 @@ export default {
} }
}, },
components: { components: {
multiselect: () => ({ Multiselect,
component: import(/* webpackChunkName: "multiselect" */ 'vue-multiselect'),
loading: LoadingComponent,
error: ErrorComponent,
timeout: 60000,
}),
editor: () => ({ editor: () => ({
component: import(/* webpackChunkName: "editor" */ '../../components/input/editor'), component: import(/* webpackChunkName: "editor" */ '../../components/input/editor'),
loading: LoadingComponent, loading: LoadingComponent,
@ -253,7 +230,7 @@ export default {
this.error(e, this) this.error(e, this)
}) })
}, },
submit() { save() {
if (this.team.name === '') { if (this.team.name === '') {
this.showError = true this.showError = true
return return
@ -308,6 +285,7 @@ export default {
}, },
toggleUserType(member) { toggleUserType(member) {
member.admin = !member.admin member.admin = !member.admin
member.teamId = this.teamId
this.teamMemberService.update(member) this.teamMemberService.update(member)
.then(r => { .then(r => {
for (const tm in this.team.members) { for (const tm in this.team.members) {
@ -342,21 +320,3 @@ export default {
}, },
} }
</script> </script>
<style lang="scss" scoped>
.card {
margin-bottom: 1rem;
.add-member-form {
margin: 1rem;
}
}
.team-members {
padding: 0;
.table {
border-top: 0;
}
}
</style>

View File

@ -15173,11 +15173,6 @@ vue-loader@^15.9.2:
vue-hot-reload-api "^2.3.0" vue-hot-reload-api "^2.3.0"
vue-style-loader "^4.1.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: vue-notification@1.3.20:
version "1.3.20" version "1.3.20"
resolved "https://registry.yarnpkg.com/vue-notification/-/vue-notification-1.3.20.tgz#d85618127763b46f3e25b8962b857947d5a97cbe" resolved "https://registry.yarnpkg.com/vue-notification/-/vue-notification-1.3.20.tgz#d85618127763b46f3e25b8962b857947d5a97cbe"