Replace vue-multiselect with a custom component (#366)

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: https://kolaente.dev/vikunja/frontend/pulls/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 factory() {
const now = new Date()
return {
id: '{increment}',
comment: faker.lorem.text(3),

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -15,29 +15,16 @@
</label>
<div class="field">
<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..."
tag-placeholder="Add this as new related task"
track-by="id"
@search="findTasks"
:loading="taskService.loading"
:search-results="foundTasks"
label="title"
v-model="newTaskRelationTask"
>
<template slot="clear" slot-scope="props">
<div
@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>
:creatable="true"
create-placeholder="Add this as new related task"
@create="createAndRelateTask"
/>
</div>
<div class="field has-addons">
<div class="control is-expanded">
@ -60,7 +47,7 @@
<template v-if="rts.length > 0">
<span class="title">{{ relationKindTitle(kind, rts.length) }}</span>
<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 } }">
<span :class="{ 'done': t.done}" class="tasktext">
<span
@ -107,8 +94,7 @@ import TaskRelationService from '../../../services/taskRelation'
import relationKinds from '../../../models/relationKinds'
import TaskRelationModel from '../../../models/taskRelation'
import LoadingComponent from '../../misc/loading'
import ErrorComponent from '../../misc/error'
import Multiselect from '@/components/input/multiselect'
export default {
name: 'relatedTasks',
@ -127,12 +113,7 @@ export default {
}
},
components: {
multiselect: () => ({
component: import(/* webpackChunkName: "multiselect" */ 'vue-multiselect'),
loading: LoadingComponent,
error: ErrorComponent,
timeout: 60000,
}),
Multiselect,
},
props: {
taskId: {
@ -171,11 +152,6 @@ export default {
},
methods: {
findTasks(query) {
if (query === '') {
this.clearAllFoundTasks()
return
}
this.taskService.getAll({}, {s: query})
.then(response => {
this.$set(this, 'foundTasks', response)
@ -184,9 +160,6 @@ export default {
this.error(e, this)
})
},
clearAllFoundTasks() {
this.$set(this, 'foundTasks', [])
},
addTaskRelation() {
let rel = new TaskRelationModel({
taskId: this.taskId,
@ -199,7 +172,7 @@ export default {
this.$set(this.relatedTasks, this.newTaskRelationKind, [])
}
this.relatedTasks[this.newTaskRelationKind].push(this.newTaskRelationTask)
this.newTaskRelationTask = new TaskModel()
this.newTaskRelationTask = null
this.saved = true
setTimeout(() => {
this.saved = false

View file

@ -92,15 +92,6 @@ p {
padding-top: 6px;
}
.field.has-addons {
margin-bottom: .5rem;
.control .select select {
height: 2.5em;
}
}
.columns {
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 {
box-sizing: content-box;
display: block;
position: relative;
width: 100%;
min-height: 40px;
text-align: left;
color: $text;
width: 100%;
position: relative;
* {
box-sizing: border-box;
}
&.has-search-results .input-wrapper {
border-radius: $radius $radius 0 0;
border-color: $primary !important;
background: $white !important;
&:focus {
outline: none;
}
}
.multiselect--disabled {
pointer-events: none;
opacity: 0.6;
}
.multiselect--active {
z-index: 50;
&:not(.multiselect--above) {
.multiselect__current, .multiselect__input, .multiselect__tags {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
}
.multiselect__select {
transform: rotateZ(180deg);
}
}
.multiselect--above.multiselect--active {
.multiselect__current, .multiselect__input, .multiselect__tags {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
}
.multiselect__input, .multiselect__single {
position: relative;
display: inline-block;
min-height: 20px;
line-height: 20px;
border: none;
border-radius: 5px;
background: $white;
padding: 0 0 0 5px;
width: calc(100%);
transition: border 0.1s ease;
box-sizing: border-box;
margin-bottom: 8px;
vertical-align: top;
}
.multiselect__input::placeholder {
color: $multiselect-dark;
}
.multiselect__tag ~ {
.multiselect__input, .multiselect__single {
width: auto;
}
}
.multiselect__input:hover, .multiselect__single:hover {
border-color: darken($white, 10);
}
.multiselect__input:focus {
border-color: $primary;
outline: none;
}
.multiselect__single {
&:focus {
border-color: $primary;
outline: none;
}
padding-left: 5px;
margin-bottom: 8px;
}
.multiselect__tags-wrap {
display: inline;
.user {
display: inline-block;
min-height: 30px;
margin: 0 0 .5em;
}
}
.multiselect__tags {
display: block;
padding: 8px 40px 0 8px;
border-radius: 5px;
border: 1px solid $multiselect-border;
background: $white;
font-size: 14px;
}
.multiselect__tag {
position: relative;
display: inline-block;
padding: 4px 26px 4px 10px;
border-radius: 5px;
margin-right: 10px;
color: $white;
line-height: 1;
background: $multiselect-highlight;
margin-bottom: 5px;
white-space: nowrap;
overflow: hidden;
max-width: 100%;
text-overflow: ellipsis;
}
.multiselect__tag-icon {
cursor: pointer;
margin-left: 7px;
position: absolute;
right: 0;
top: 0;
bottom: 0;
font-weight: 700;
font-style: initial;
width: 22px;
text-align: center;
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;
}
&, &:focus-within {
border-bottom-color: $grey-lighter !important;
}
}
.input-wrapper {
padding: 0;
background: $white !important;
border-color: $grey-lighter !important;
flex-wrap: wrap;
height: auto;
&:hover {
border-color: $grey-light !important;
}
.input-loader-wrapper {
display: flex;
max-width: 100%;
width: 100%;
align-items: center;
.input {
border: none !important;
background: transparent;
height: auto;
&::placeholder {
font-style: normal !important;
}
}
}
&:focus-within {
border-color: $primary !important;
background: $white !important;
}
.loader {
margin: 0 .5rem;
}
}
.search-results {
background: $white;
border-radius: 0 0 $radius $radius;
border: 1px solid $primary;
border-top: none;
max-height: 50vh;
overflow-x: auto;
position: absolute;
z-index: 100;
max-width: 100%;
min-width: 100%;
button {
background: transparent;
display: block;
text-align: left;
box-shadow: none;
border-radius: 0;
text-transform: none;
font-family: $family-sans-serif;
font-weight: normal;
display: flex;
justify-content: space-between;
align-items: center;
overflow: hidden;
span:first-child {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.hint-text {
font-size: .75rem;
color: transparent;
transition: color $transition;
padding-left: .5rem;
}
&:focus, &:hover {
background: $grey-lightest;
box-shadow: none !important;
.hint-text {
color: $dark;
}
}
&:active {
background: $grey-lighter;
}
}
}
.assignee {
position: relative;
&:not(:first-child) {
margin-left: -1.75rem;
}
.user img {
border: 2px solid $white;
}
.remove-assignee {
position: absolute;
top: 4px;
left: 2px;
color: $red;
background: $white;
padding: 0 4px;
display: block;
border-radius: 100%;
font-size: .75rem;
width: 18px;
height: 18px;
z-index: 100;
}
}
}

View file

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

View file

@ -117,32 +117,13 @@
}
&.labels-list, .assignees {
.multiselect__tags {
padding: 3px 0 0 3px;
border: none;
background: transparent;
}
.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;
.multiselect {
.input-wrapper {
&:not(:focus-within):not(:hover) {
background: transparent !important;
border-color: transparent !important;
}
}
}
}

View file

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

View file

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

View file

@ -99,7 +99,7 @@
</div>
</div>
<div class="card">
<div class="card has-overflow">
<header class="card-header">
<p class="card-header-title">
Duplicate this list
@ -108,22 +108,20 @@
<div class="card-content">
<div class="content">
<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"/>
</p>
<p class="control">
</div>
<div class="control">
<button
:class="{'is-loading': listDuplicateService.loading}"
@click="duplicateList"
class="button is-success"
class="button is-primary"
type="submit">
<span class="icon is-small">
<icon icon="plus"/>
</span>
Add
Duplicate
</button>
</p>
</div>
</div>
</div>
</div>

View file

@ -1,5 +1,5 @@
<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">
<header class="card-header">
<p class="card-header-title">
@ -8,7 +8,7 @@
</header>
<div class="card-content">
<div class="content">
<form @submit.prevent="submit()">
<form @submit.prevent="save()">
<div class="field">
<label class="label" for="teamtext">Team Name</label>
<div class="control">
@ -43,7 +43,7 @@
<div class="columns bigbuttons">
<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">
Save
</button>
@ -60,96 +60,75 @@
</div>
</div>
</div>
<div class="card is-fullwidth">
<div class="card is-fullwidth has-overflow">
<header class="card-header">
<p class="card-header-title">
Team Members
</p>
</header>
<div class="card-content content team-members">
<form @submit.prevent="addUser()" class="add-member-form" v-if="userIsAdmin">
<div class="field is-grouped">
<p
:class="{ 'is-loading': teamMemberService.loading}"
class="control has-icons-left is-expanded">
<multiselect
:internal-search="true"
:loading="userService.loading"
:multiple="false"
: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 class="card-content" v-if="userIsAdmin">
<div class="field has-addons">
<div class="control is-expanded">
<multiselect
:loading="userService.loading"
placeholder="Type to search..."
@search="findUser"
:search-results="foundUsers"
label="username"
v-model="newMember"
/>
</div>
</form>
<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
<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 class="control">
<button class="button is-primary" @click="addUser">
<span class="icon is-small">
<icon icon="plus"/>
</span>
Add To Team
</button>
</div>
</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>
<!-- Team delete modal -->
@ -185,9 +164,12 @@ import TeamMemberService from '../../services/teamMember'
import TeamMemberModel from '../../models/teamMember'
import UserModel from '../../models/user'
import UserService from '../../services/user'
import Rights from '../../models/rights.json'
import LoadingComponent from '../../components/misc/loading'
import ErrorComponent from '../../components/misc/error'
import Rights from '../../models/rights.json'
import Multiselect from '@/components/input/multiselect'
export default {
name: 'EditTeam',
@ -210,12 +192,7 @@ export default {
}
},
components: {
multiselect: () => ({
component: import(/* webpackChunkName: "multiselect" */ 'vue-multiselect'),
loading: LoadingComponent,
error: ErrorComponent,
timeout: 60000,
}),
Multiselect,
editor: () => ({
component: import(/* webpackChunkName: "editor" */ '../../components/input/editor'),
loading: LoadingComponent,
@ -253,7 +230,7 @@ export default {
this.error(e, this)
})
},
submit() {
save() {
if (this.team.name === '') {
this.showError = true
return
@ -308,6 +285,7 @@ export default {
},
toggleUserType(member) {
member.admin = !member.admin
member.teamId = this.teamId
this.teamMemberService.update(member)
.then(r => {
for (const tm in this.team.members) {
@ -342,21 +320,3 @@ export default {
},
}
</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-style-loader "^4.1.0"
vue-multiselect@2.1.6:
version "2.1.6"
resolved "https://registry.yarnpkg.com/vue-multiselect/-/vue-multiselect-2.1.6.tgz#5be5d811a224804a15c43a4edbb7485028a89c7f"
integrity sha512-s7jmZPlm9FeueJg1RwJtnE9KNPtME/7C8uRWSfp9/yEN4M8XcS/d+bddoyVwVnvFyRh9msFo0HWeW0vTL8Qv+w==
vue-notification@1.3.20:
version "1.3.20"
resolved "https://registry.yarnpkg.com/vue-notification/-/vue-notification-1.3.20.tgz#d85618127763b46f3e25b8962b857947d5a97cbe"