Merge branch 'main' into feature/ganttastic

This commit is contained in:
kolaente 2022-10-02 14:01:23 +02:00
commit 6db97d1ba1
No known key found for this signature in database
GPG key ID: F40E70337AB24C9B
2 changed files with 343 additions and 348 deletions

View file

@ -34,9 +34,8 @@
</div>
</template>
<script lang="ts">
import {defineComponent} from 'vue'
<script setup lang="ts">
import {computed, ref, toRef, watch} from 'vue'
import {createRandomID} from '@/helpers/randomId'
const DEFAULT_COLORS = [
@ -48,17 +47,12 @@ const DEFAULT_COLORS = [
'#00db60',
]
export default defineComponent({
name: 'colorPicker',
data() {
return {
color: '',
lastChangeTimeout: null,
defaultColors: DEFAULT_COLORS,
colorListID: createRandomID(),
}
},
props: {
const color = ref('')
const lastChangeTimeout = ref<ReturnType<typeof setTimeout> | null>(null)
const defaultColors = ref(DEFAULT_COLORS)
const colorListID = ref(createRandomID())
const props = defineProps({
modelValue: {
type: String,
required: true,
@ -67,47 +61,43 @@ export default defineComponent({
type: String,
default: 'top',
},
},
emits: ['update:modelValue'],
watch: {
modelValue: {
handler(modelValue) {
this.color = modelValue
},
immediate: true,
},
color() {
this.update()
},
},
computed: {
isEmpty() {
return this.color === '#000000' || this.color === ''
},
},
methods: {
update(force = false) {
})
if(this.isEmpty && !force) {
const emit = defineEmits(['update:modelValue'])
const modelValue = toRef(props, 'modelValue')
watch(
modelValue,
(newValue) => {
color.value = newValue
},
{immediate: true},
)
watch(color, () => update())
const isEmpty = computed(() => color.value === '#000000' || color.value === '')
function update(force = false) {
if(isEmpty.value && !force) {
return
}
if (this.lastChangeTimeout !== null) {
clearTimeout(this.lastChangeTimeout)
if (lastChangeTimeout.value !== null) {
clearTimeout(lastChangeTimeout.value)
}
this.lastChangeTimeout = setTimeout(() => {
this.$emit('update:modelValue', this.color)
lastChangeTimeout.value = setTimeout(() => {
emit('update:modelValue', color.value)
}, 500)
},
reset() {
}
function reset() {
// FIXME: I havn't found a way to make it clear to the user the color war reset.
// Not sure if verte is capable of this - it does not show the change when setting this.color = ''
this.color = ''
this.update(true)
},
},
})
color.value = ''
update(true)
}
</script>
<style lang="scss" scoped>

View file

@ -39,11 +39,11 @@
<div class="search-results" :class="{'search-results-inline': inline}" v-if="searchResultsVisible">
<BaseButton
class="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)"
v-for="(data, index) in filteredSearchResults"
:key="index"
:ref="(el) => setResult(el, index)"
@keydown.up.prevent="() => preSelect(index - 1)"
@keydown.down.prevent="() => preSelect(index + 1)"
@click.prevent.stop="() => select(data)"
>
<span>
@ -59,7 +59,7 @@
<BaseButton
v-if="creatableAvailable"
class="is-fullwidth"
:ref="`result-${filteredSearchResults.length}`"
:ref="(el) => setResult(el, filteredSearchResults.length)"
@keydown.up.prevent="() => preSelect(filteredSearchResults.length - 1)"
@keydown.down.prevent="() => preSelect(filteredSearchResults.length + 1)"
@keyup.enter.prevent="create"
@ -82,9 +82,10 @@
</div>
</template>
<script lang="ts">
import {defineComponent} from 'vue'
import {i18n} from '@/i18n'
<script setup lang="ts">
import {computed, onBeforeUnmount, onMounted, ref, toRefs, watch, type ComponentPublicInstance, type PropType} from 'vue'
import {useI18n} from 'vue-i18n'
import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside'
import BaseButton from '@/components/base/BaseButton.vue'
@ -98,55 +99,31 @@ function elementInResults(elem: string | any, label: string, query: string): boo
return elem === query
}
export default defineComponent({
name: 'multiselect',
components: {
BaseButton,
},
data() {
return {
query: '',
searchTimeout: null,
localLoading: false,
showSearchResults: false,
internalValue: null,
}
},
props: {
const props = defineProps({
// When true, shows a loading spinner
loading: {
type: Boolean,
default() {
return false
},
default: false,
},
// The placeholder of the search input
placeholder: {
type: String,
default() {
return ''
},
default: '',
},
// The search results where the @search listener needs to put the results into
searchResults: {
type: Array,
default() {
return []
},
type: Array as PropType<{[id: string]: any}>,
default: () => [],
},
// 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 ''
},
default: '',
},
// The object with the value, updated every time an entry is selected.
modelValue: {
default() {
return null
},
default: null,
},
// If true, will provide an "add this as a new value" entry which fires an @create event when clicking on it.
creatable: {
@ -157,14 +134,16 @@ export default defineComponent({
createPlaceholder: {
type: String,
default() {
return i18n.global.t('input.multiselect.createPlaceholder')
const {t} = useI18n({useScope: 'global'})
return t('input.multiselect.createPlaceholder')
},
},
// The text shown next to an option.
selectPlaceholder: {
type: String,
default() {
return i18n.global.t('input.multiselect.selectPlaceholder')
const {t} = useI18n({useScope: 'global'})
return t('input.multiselect.selectPlaceholder')
},
},
// If true, allows for selecting multiple items. v-model will be an array with all selected values in that case.
@ -185,136 +164,151 @@ export default defineComponent({
// The delay in ms after which the search event will be fired. Used to avoid hitting the network on every keystroke.
searchDelay: {
type: Number,
default() {
return 200
},
default: 200,
},
closeAfterSelect: {
type: Boolean,
default: true,
},
},
})
/**
* 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.
*/
emits: ['update:modelValue', 'search', 'select', 'create', 'remove'],
const emit = defineEmits<{
(e: 'update:modelValue', value: null): void
// @search: Triggered every time the search query input changes
(e: 'search', query: string): void
// @select: Triggered every time an option from the search results is selected. Also triggers a change in v-model.
(e: 'select', value: null): void
// @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.
(e: 'create', query: string): void
// @remove: If `multiple` is enabled, this will be fired every time an item is removed from the array of selected items.
(e: 'remove', value: null): void
}>()
mounted() {
document.addEventListener('click', this.hideSearchResultsHandler)
},
beforeUnmount() {
document.removeEventListener('click', this.hideSearchResultsHandler)
},
watch: {
modelValue: {
handler(value) {
this.setSelectedObject(value)
},
const query = ref('')
const searchTimeout = ref<ReturnType<typeof setTimeout> | null>(null)
const localLoading = ref(false)
const showSearchResults = ref(false)
const internalValue = ref<string | {[key: string]: any} | any[] | null>(null)
onMounted(() => document.addEventListener('click', hideSearchResultsHandler))
onBeforeUnmount(() => document.removeEventListener('click', hideSearchResultsHandler))
const {modelValue, searchResults} = toRefs(props)
watch(
modelValue,
(value) => setSelectedObject(value),
{
immediate: true,
deep: true,
},
},
computed: {
searchResultsVisible() {
if (this.query === '' && !this.showEmpty) {
)
const searchResultsVisible = computed(() => {
if (query.value === '' && !props.showEmpty) {
return false
}
return this.showSearchResults && (
(this.filteredSearchResults.length > 0) ||
(this.creatable && this.query !== '')
return showSearchResults.value && (
(filteredSearchResults.value.length > 0) ||
(props.creatable && query.value !== '')
)
},
creatableAvailable() {
const hasResult = this.filteredSearchResults.some(elem => elementInResults(elem, this.label, this.query))
const hasQueryAlreadyAdded = Array.isArray(this.internalValue) && this.internalValue.some(elem => elementInResults(elem, this.label, this.query))
})
return this.creatable
&& this.query !== ''
const creatableAvailable = computed(() => {
const hasResult = filteredSearchResults.value.some((elem: any) => elementInResults(elem, props.label, query.value))
const hasQueryAlreadyAdded = Array.isArray(internalValue.value) && internalValue.value.some(elem => elementInResults(elem, props.label, query.value))
return props.creatable
&& query.value !== ''
&& !(hasResult || hasQueryAlreadyAdded)
},
filteredSearchResults() {
if (this.multiple && this.internalValue !== null && Array.isArray(this.internalValue)) {
return this.searchResults.filter(item => !this.internalValue.some(e => e === item))
})
const filteredSearchResults = computed(() => {
const currentInternal = internalValue.value
if (props.multiple && currentInternal !== null && Array.isArray(currentInternal)) {
return searchResults.value.filter((item: any) => !currentInternal.some(e => e === item))
}
return this.searchResults
},
hasMultiple() {
return this.multiple && Array.isArray(this.internalValue) && this.internalValue.length > 0
},
},
methods: {
// Searching will be triggered with a 200ms delay to avoid searching on every keyup event.
search() {
return searchResults.value
})
const hasMultiple = computed(() => {
return props.multiple && Array.isArray(internalValue.value) && internalValue.value.length > 0
})
const searchInput = ref<HTMLInputElement | null>(null)
// Searching will be triggered with a 200ms delay to avoid searching on every keyup event.
function search() {
// Updating the query with a binding does not work on mobile for some reason,
// getting the value manual does.
this.query = this.$refs.searchInput.value
query.value = searchInput.value?.value || ''
if (this.searchTimeout !== null) {
clearTimeout(this.searchTimeout)
this.searchTimeout = null
if (searchTimeout.value !== null) {
clearTimeout(searchTimeout.value)
searchTimeout.value = null
}
this.localLoading = true
localLoading.value = true
this.searchTimeout = setTimeout(() => {
this.$emit('search', this.query)
searchTimeout.value = setTimeout(() => {
emit('search', query.value)
setTimeout(() => {
this.localLoading = false
localLoading.value = false
}, 100) // The duration of the loading timeout of the services
this.showSearchResults = true
}, this.searchDelay)
},
hideSearchResultsHandler(e) {
closeWhenClickedOutside(e, this.$refs.multiselectRoot, this.closeSearchResults)
},
closeSearchResults() {
this.showSearchResults = false
},
handleFocus() {
showSearchResults.value = true
}, props.searchDelay)
}
const multiselectRoot = ref<HTMLElement | null>(null)
function hideSearchResultsHandler(e: MouseEvent) {
closeWhenClickedOutside(e, multiselectRoot.value, closeSearchResults)
}
function closeSearchResults() {
showSearchResults.value = false
}
function handleFocus() {
// We need the timeout to avoid the hideSearchResultsHandler hiding the search results right after the input
// is focused. That would lead to flickering pre-loaded search results and hiding them right after showing.
setTimeout(() => {
this.showSearchResults = true
showSearchResults.value = true
}, 10)
},
select(object) {
if (this.multiple) {
if (this.internalValue === null) {
this.internalValue = []
}
function select(object: {[key: string]: any}) {
if (props.multiple) {
if (internalValue.value === null) {
internalValue.value = []
}
this.internalValue.push(object)
(internalValue.value as any[]).push(object)
} else {
this.internalValue = object
internalValue.value = object
}
this.$emit('update:modelValue', this.internalValue)
this.$emit('select', object)
this.setSelectedObject(object)
if (this.closeAfterSelect && this.filteredSearchResults.length > 0 && !this.creatableAvailable) {
this.closeSearchResults()
emit('update:modelValue', internalValue.value)
emit('select', object)
setSelectedObject(object)
if (props.closeAfterSelect && filteredSearchResults.value.length > 0 && !creatableAvailable.value) {
closeSearchResults()
}
},
setSelectedObject(object, resetOnly = false) {
this.internalValue = object
}
function setSelectedObject(object: string | {[id: string]: any} | null, resetOnly = false) {
internalValue.value = 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 = ''
if (props.multiple) {
query.value = ''
return
}
if (object === null) {
this.query = ''
query.value = ''
return
}
@ -322,15 +316,25 @@ export default defineComponent({
return
}
this.query = this.label !== '' ? object[this.label] : object
},
preSelect(index) {
query.value = props.label !== '' ? object[props.label] : object
}
const results = ref<(Element | ComponentPublicInstance)[]>([])
function setResult(el: Element | ComponentPublicInstance | null, index: number) {
if (el === null) {
delete results.value[index]
} else {
results.value[index] = el
}
}
function preSelect(index: number) {
if (index < 0) {
this.$refs.searchInput.focus()
searchInput.value?.focus()
return
}
const elems = this.$refs[`result-${index}`]
const elems = results.value[index]
if (typeof elems === 'undefined' || elems.length === 0) {
return
}
@ -341,51 +345,52 @@ export default defineComponent({
}
elems.focus()
},
create() {
if (this.query === '') {
}
function create() {
if (query.value === '') {
return
}
this.$emit('create', this.query)
this.setSelectedObject(this.query, true)
this.closeSearchResults()
},
createOrSelectOnEnter() {
emit('create', query.value)
setSelectedObject(query.value, true)
closeSearchResults()
}
if (!this.creatableAvailable && this.searchResults.length === 1) {
this.select(this.searchResults[0])
function createOrSelectOnEnter() {
if (!creatableAvailable.value && searchResults.value.length === 1) {
select(searchResults.value[0])
return
}
if (!this.creatableAvailable) {
if (!creatableAvailable.value) {
// Check if there's an exact match for our search term
const exactMatch = this.filteredSearchResults.find(elem => elementInResults(elem, this.label, this.query))
const exactMatch = filteredSearchResults.value.find((elem: any) => elementInResults(elem, props.label, query.value))
if(exactMatch) {
this.select(exactMatch)
select(exactMatch)
}
return
}
this.create()
},
remove(item) {
for (const ind in this.internalValue) {
if (this.internalValue[ind] === item) {
this.internalValue.splice(ind, 1)
create()
}
function remove(item: any) {
for (const ind in internalValue.value) {
if (internalValue.value[ind] === item) {
internalValue.value.splice(ind, 1)
break
}
}
this.$emit('update:modelValue', this.internalValue)
this.$emit('remove', item)
},
focus() {
this.$refs.searchInput.focus()
},
},
})
emit('update:modelValue', internalValue.value)
emit('remove', item)
}
function focus() {
searchInput.value?.focus()
}
</script>
<style lang="scss" scoped>