This repository has been archived on 2024-02-08. You can view files and clone it, but cannot push or open issues or pull requests.
frontend/src/components/list/partials/filters.vue

669 lines
18 KiB
Vue

<template>
<card class="filters has-overflow" :title="hasTitle ? $t('filters.title') : ''">
<div class="field is-flex is-flex-direction-column">
<fancycheckbox
v-model="params.filterIncludeNulls"
@update:model-value="change()"
>
{{ $t('filters.attributes.includeNulls') }}
</fancycheckbox>
<fancycheckbox
v-model="filters.requireAllFilters"
@update:model-value="setFilterConcat()"
>
{{ $t('filters.attributes.requireAll') }}
</fancycheckbox>
<fancycheckbox v-model="filters.done" @update:model-value="setDoneFilter">
{{ $t('filters.attributes.showDoneTasks') }}
</fancycheckbox>
<fancycheckbox
v-if="!$route.name.includes('list.kanban') || !$route.name.includes('list.table')"
v-model="sortAlphabetically"
@update:model-value="change()"
>
{{ $t('filters.attributes.sortAlphabetically') }}
</fancycheckbox>
</div>
<div class="field">
<label class="label">{{ $t('misc.search') }}</label>
<div class="control">
<input
class="input"
:placeholder="$t('misc.search')"
v-model="params.s"
@blur="change()"
@keyup.enter="change()"
/>
</div>
</div>
<div class="field">
<label class="label">{{ $t('task.attributes.priority') }}</label>
<div class="control single-value-control">
<fancycheckbox
v-model="filters.usePriority"
@update:model-value="setPriority"
:title="$t('filters.attributes.enablePriority')"
/>
<priority-select
:disabled="!filters.usePriority || undefined"
v-model.number="filters.priority"
@update:model-value="setPriority"
/>
</div>
</div>
<div class="field">
<label class="label">{{ $t('task.attributes.percentDone') }}</label>
<div class="control single-value-control">
<fancycheckbox
v-model="filters.usePercentDone"
@update:model-value="setPercentDoneFilter"
:title="$t('filters.attributes.enablePercentDone')"
/>
<percent-done-select
v-model.number="filters.percentDone"
@update:model-value="setPercentDoneFilter"
:disabled="!filters.usePercentDone || undefined"
/>
</div>
</div>
<div class="field">
<label class="label">{{ $t('task.attributes.dueDate') }}</label>
<div class="control">
<DatepickerWithRange
v-model="filters.dueDate"
@update:model-value="values => setDateFilter('dueDate', values)"
>
<template #trigger="{toggle, buttonText}">
<x-button
class="mb-2"
@click.prevent.stop="toggle()"
variant="secondary"
:shadow="false"
>
{{ buttonText }}
</x-button>
</template>
</DatepickerWithRange>
</div>
</div>
<div class="field">
<label class="label">{{ $t('task.attributes.startDate') }}</label>
<div class="control">
<DatepickerWithRange
v-model="filters.startDate"
@update:model-value="values => setDateFilter('startDate', values)"
>
<template #trigger="{toggle, buttonText}">
<x-button @click.prevent.stop="toggle()" variant="secondary" :shadow="false" class="mb-2">
{{ buttonText }}
</x-button>
</template>
</DatepickerWithRange>
</div>
</div>
<div class="field">
<label class="label">{{ $t('task.attributes.endDate') }}</label>
<div class="control">
<DatepickerWithRange
v-model="filters.endDate"
@update:model-value="values => setDateFilter('endDate', values)"
>
<template #trigger="{toggle, buttonText}">
<x-button @click.prevent.stop="toggle()" variant="secondary" :shadow="false" class="mb-2">
{{ buttonText }}
</x-button>
</template>
</DatepickerWithRange>
</div>
</div>
<div class="field">
<label class="label">{{ $t('task.attributes.reminders') }}</label>
<div class="control">
<DatepickerWithRange
v-model="filters.reminders"
@update:model-value="values => setDateFilter('reminders', values)"
>
<template #trigger="{toggle, buttonText}">
<x-button @click.prevent.stop="toggle()" variant="secondary" :shadow="false" class="mb-2">
{{ buttonText }}
</x-button>
</template>
</DatepickerWithRange>
</div>
</div>
<div class="field">
<label class="label">{{ $t('task.attributes.assignees') }}</label>
<div class="control">
<multiselect
:loading="service.user.loading"
:placeholder="$t('team.edit.search')"
@search="query => find('user', query)"
:search-results="foundEntities.user"
@select="() => add('user', 'assignees')"
label="username"
:multiple="true"
@remove="() => remove('user', 'assignees')"
v-model="entities.user"
/>
</div>
</div>
<div class="field">
<label class="label">{{ $t('task.attributes.labels') }}</label>
<div class="control labels-list">
<edit-labels v-model="entities.label" @update:model-value="changeLabelFilter"/>
</div>
</div>
<template
v-if="$route.name === 'filters.create' || $route.name === 'list.edit' || $route.name === 'filter.settings.edit'">
<div class="field">
<label class="label">{{ $t('list.lists') }}</label>
<div class="control">
<multiselect
:loading="service.list.loading"
:placeholder="$t('list.search')"
@search="query => find('list', query)"
:search-results="foundEntities.list"
@select="() => add('list', 'listId')"
label="title"
@remove="() => remove('list', 'listId')"
:multiple="true"
v-model="entities.list"
/>
</div>
</div>
<div class="field">
<label class="label">{{ $t('namespace.namespaces') }}</label>
<div class="control">
<multiselect
:loading="service.namespace.loading"
:placeholder="$t('namespace.search')"
@search="query => find('namespace', query)"
:search-results="foundEntities.namespace"
@select="() => add('namespace', 'namespace')"
label="title"
@remove="() => remove('namespace', 'namespace')"
:multiple="true"
v-model="entities.namespace"
/>
</div>
</div>
</template>
</card>
</template>
<script lang="ts">
export const ALPHABETICAL_SORT = 'title'
</script>
<script setup lang="ts">
import {computed, nextTick, onMounted, reactive, ref, shallowReactive, toRefs, watch} from 'vue'
import DatepickerWithRange from '@/components/date/datepickerWithRange.vue'
import EditLabels from '@/components/tasks/partials/editLabels.vue'
import Fancycheckbox from '@/components/input/fancycheckbox.vue'
import Multiselect from '@/components/input/multiselect.vue'
import PercentDoneSelect from '@/components/tasks/partials/percentDoneSelect.vue'
import PrioritySelect from '@/components/tasks/partials/prioritySelect.vue'
import UserService from '@/services/user'
import ListService from '@/services/list'
import NamespaceService from '@/services/namespace'
import {parseDateOrString} from '@/helpers/time/parseDateOrString'
import {includesById} from '@/helpers/utils'
import {objectToCamelCase} from '@/helpers/case'
import {getDefaultParams} from '@/composables/taskList'
import {useLabelStore} from '@/stores/labels'
import type {IUser} from '@/modelTypes/IUser'
import type {ILabel} from '@/modelTypes/ILabel'
import type {IList} from '@/modelTypes/IList'
import type {INamespace} from '@/modelTypes/INamespace'
import type {IParams, FilterBy, FilterComparator, FilterValue} from '@/types/IParams'
import type {IFilter, IFilterKey} from '@/types/IFilter'
// FIXME: merge with DEFAULT_PARAMS in taskList.js
const DEFAULT_PARAMS: IParams = {
sortBy: [],
orderBy: [],
filterBy: [],
filterValue: [],
filterComparator: [],
filterIncludeNulls: true,
filterConcat: 'or',
s: '',
}
const DEFAULT_FILTERS: IFilter = {
done: false,
dueDate: '',
priority: 0,
startDate: '',
endDate: '',
percentDone: 0,
reminders: '',
assignees: '',
labels: '',
listId: '',
namespace: '',
requireAllFilters: false,
usePercentDone: false,
usePriority: false,
} as const
const props = defineProps({
modelValue: {
required: true,
},
hasTitle: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['update:modelValue'])
const labelStore = useLabelStore()
const params = ref<IParams>(DEFAULT_PARAMS)
const filters = ref<IFilter>(DEFAULT_FILTERS)
const labelQuery = ref('')
interface Entities {
user: IUser[]
label: ILabel[]
list: IList[]
namespace: INamespace[]
}
type EntityKind = keyof Entities
const entities: Entities = reactive({
user: [],
label: [],
list: [],
namespace: [],
})
interface FoundEntities extends Entities {}
const foundEntities: FoundEntities = reactive({
user: [],
// FIXME: this is readonly and thus cannot be cleared
label: computed(() => labelStore.filterLabelsByQuery(entities.label, labelQuery.value)),
list: [],
namespace: [],
})
const service = reactive({
user: shallowReactive(new UserService()),
list: shallowReactive(new ListService()),
namespace: shallowReactive(new NamespaceService()),
})
type ServiceKind = keyof typeof service
onMounted(() => {
filters.value.requireAllFilters = params.value.filterConcat === 'and'
})
const {modelValue} = toRefs(props)
watch(
modelValue,
(value) => {
// FIXME: this is just a precaution in case some values are not in camelcase yet
params.value = objectToCamelCase(value)
prepareFilters()
},
{immediate: true},
)
const sortAlphabetically = computed({
get() {
return params.value?.sortBy?.find(sortBy => sortBy === ALPHABETICAL_SORT) !== undefined
},
set(sortAlphabetically) {
params.value.sortBy = sortAlphabetically
? [ALPHABETICAL_SORT]
: getDefaultParams().sortBy
change()
},
})
function change() {
const newParams: IParams = {...params.value}
newParams.filterValue = newParams.filterValue.map(v => v instanceof Date ? v.toISOString() : v)
// FIXME: use underscore case for emitting
// take also care of values like filterComparator
emit('update:modelValue', newParams)
}
function prepareFilters() {
prepareDone()
prepareDate('dueDate', 'dueDate')
prepareDate('startDate', 'startDate')
prepareDate('endDate', 'endDate')
prepareSingleValue('priority', 'priority', 'usePriority', true)
prepareSingleValue('percentDone', 'percentDone', 'usePercentDone', true)
prepareDate('reminders', 'reminders')
prepareRelatedObjectFilter('user', 'assignees')
prepareRelatedObjectFilter('list', 'listId')
prepareRelatedObjectFilter('namespace', 'namespace')
prepareSingleValue('labels')
const labelString = typeof filters.value.labels === 'string'
? filters.value.labels
: ''
const labelIds = labelString.split(',').map(i => Number(i))
entities.label = labelStore.getLabelsByIds(labelIds)
}
function removeFilterProperty(propertyName: FilterBy) {
// Because of the way arrays work, we can only ever remove one element at once.
// To remove multiple filter elements of the same name this function has to be called multiple times.
params.value.filterBy.find((current, index) => {
if (current === propertyName) {
params.value.filterBy.splice(index, 1)
params.value.filterComparator.splice(index, 1)
params.value.filterValue.splice(index, 1)
return true
}
})
}
function addFilterProperty(
filterBy: FilterBy,
filterComparator: FilterComparator,
filterValue: FilterValue,
) {
params.value.filterBy.push(filterBy)
params.value.filterComparator.push(filterComparator)
params.value.filterValue.push(filterValue)
}
function setDateFilter(filterName: FilterBy, dateRange: {dateFrom: string, dateTo: string}) {
const dateFrom = parseDateOrString(dateRange.dateFrom, null)
const dateTo = parseDateOrString(dateRange.dateTo, null)
// Only filter if we have a date
if (dateFrom !== null && dateTo !== null) {
// Check if we already have values in params and only update them if we do
let foundStart = false
let foundEnd = false
params.value.filterBy.forEach((filterBy, index) => {
if (filterBy === filterName && params.value.filterComparator[index] === 'greaterEquals') {
foundStart = true
params.value.filterValue[index] = dateFrom
}
if (filterBy === filterName && params.value.filterComparator[index] === 'lessEquals') {
foundEnd = true
params.value.filterValue[index] = dateTo
}
})
if (!foundStart) {
addFilterProperty(filterName, 'greaterEquals', dateFrom)
}
if (!foundEnd) {
addFilterProperty(filterName, 'lessEquals', dateTo)
}
filters.value[filterName] = {dateFrom, dateTo}
change()
return
}
removeFilterProperty(filterName)
removeFilterProperty(filterName)
change()
}
function prepareDate(filterName: FilterBy, variableName: IFilterKey) {
if (typeof params.value.filterBy === 'undefined') {
return
}
let foundDateStart : false | number = false
let foundDateEnd : false | number = false
params.value.filterBy.find((currentFilterBy, index) => {
if (
currentFilterBy === filterName &&
params.value.filterComparator[index] === 'greaterEquals'
) {
foundDateStart = index
}
if (
currentFilterBy === filterName &&
params.value.filterComparator[index] === 'lessEquals'
) {
foundDateEnd = index
}
if (foundDateStart !== false && foundDateEnd !== false) {
return true
}
})
if (foundDateStart !== false && foundDateEnd !== false) {
const startDate = new Date(params.value.filterValue[foundDateStart])
const endDate = new Date(params.value.filterValue[foundDateEnd])
filters.value[filterName] = {
dateFrom: !isNaN(startDate)
? `${startDate.getFullYear()}-${startDate.getMonth() + 1}-${startDate.getDate()}`
: params.value.filterValue[foundDateStart],
dateTo: !isNaN(endDate)
? `${endDate.getFullYear()}-${endDate.getMonth() + 1}-${endDate.getDate()}`
: params.value.filterValue[foundDateEnd],
}
}
}
function setSingleValueFilter(
filterName: FilterBy,
variableName: IFilterKey,
useVariableName: IFilterKey | '' = '',
comparator: FilterComparator = 'equals',
) {
if (useVariableName !== '' && !filters.value[useVariableName]) {
removeFilterProperty(filterName)
return
}
let found = false
params.value.filterBy.forEach((f, index) => {
if (f === filterName) {
found = true
params.value.filterValue[index] = filters.value[variableName]
}
})
if (!found) {
addFilterProperty(filterName, comparator, filters.value[variableName])
}
change()
}
/**
*
* @param filterName The filter name in the api.
* @param variableName The name of the variable in filters.value.
* @param useVariableName The name of the variable of the "Use this filter" variable. Will only be set if the parameter is not null.
* @param isNumber Toggles if the value should be parsed as a number.
*/
function prepareSingleValue(
filterName: FilterBy,
variableName: string | null = null,
useVariableName: IFilterKey | null = null,
isNumber = false,
) {
if (variableName === null) {
variableName = filterName
}
let found: number | false= false
for (const i in params.value.filterBy) {
if (params.value.filterBy[i] === filterName) {
found = i
break
}
}
if (found === false && useVariableName !== null) {
filters.value[useVariableName] = false
return
}
if (isNumber) {
filters.value[variableName] = Number(params.value.filterValue[found])
} else {
filters.value[variableName] = params.value.filterValue[found]
}
if (useVariableName !== null) {
filters.value[useVariableName] = true
}
}
function prepareDone() {
// Set filters.done based on params
if (typeof params.value.filterBy === 'undefined') {
return
}
filters.value.done = params.value.filterBy.some((f) => f === 'done') === false
}
async function prepareRelatedObjectFilter(
kind: EntityKind,
filterName: FilterBy,
servicePrefix: ServiceKind | null = null,
) {
if (servicePrefix === null) {
servicePrefix = kind as ServiceKind
}
prepareSingleValue(filterName)
if (typeof filters.value[filterName] === 'undefined' || filters.value[filterName] === '') {
return
}
// Don't load things if we already have something loaded.
// This is not the most ideal solution because it prevents a re-population when filters are changed
// from the outside. It is still fine because we're not changing them from the outside, other than
// loading them initially.
if(entities[kind].length > 0) {
return
}
entities[kind] = await service[servicePrefix].getAll({}, {s: filters.value[filterName]})
}
function setDoneFilter() {
if (filters.value.done) {
removeFilterProperty('done')
} else {
addFilterProperty('done', 'equals', 'false')
}
change()
}
function setFilterConcat() {
if (filters.value.requireAllFilters) {
params.value.filterConcat = 'and'
} else {
params.value.filterConcat = 'or'
}
change()
}
function setPriority() {
setSingleValueFilter('priority', 'priority', 'usePriority')
}
function setPercentDoneFilter() {
setSingleValueFilter('percentDone', 'percentDone', 'usePercentDone')
}
function clear(kind: EntityKind) {
foundEntities[kind] = []
}
async function find(kind: ServiceKind, query: string) {
if (query === '') {
clear(kind)
return
}
const response = await service[kind].getAll({}, {s: query})
// Filter users from the results who are already assigned
foundEntities[kind] = response.filter(({id}) => !includesById(entities[kind], id))
}
async function add(kind: EntityKind, filterName: FilterBy) {
await nextTick()
changeMultiselectFilter(kind, filterName)
}
async function remove(kind: EntityKind, filterName: FilterBy) {
await nextTick()
changeMultiselectFilter(kind, filterName)
}
function changeMultiselectFilter(kind: EntityKind, filterName: FilterBy) {
if (entities[kind].length === 0) {
removeFilterProperty(filterName)
change()
return
}
let ids: string[] = []
entities[kind].forEach(u => ids.push(kind === 'user' ? (u as IUser).username : String(u.id)))
filters.value[filterName] = ids.join(',')
setSingleValueFilter(filterName, filterName, '', 'in')
}
function changeLabelFilter() {
if (entities.label.length === 0) {
removeFilterProperty('labels')
change()
return
}
const labelIDs = entities.label.map(({id}) => id)
filters.value.labels = labelIDs.join(',')
setSingleValueFilter('labels', 'labels', '', 'in')
}
</script>
<style lang="scss" scoped>
.single-value-control {
display: flex;
align-items: center;
.fancycheckbox {
margin-left: .5rem;
}
}
:deep(.datepicker-with-range-container .popup) {
right: 0;
}
</style>