feat(sort): add sorting popup for list view

This commit is contained in:
kolaente 2025-06-25 16:09:32 +02:00 committed by kolaente
parent b20df2ef63
commit 408e5b347c
4 changed files with 173 additions and 24 deletions

View File

@ -55,10 +55,6 @@
</Card>
</template>
<script lang="ts">
export const ALPHABETICAL_SORT = 'title'
</script>
<script setup lang="ts">
import {computed, ref, watch} from 'vue'
import FancyCheckbox from '@/components/input/FancyCheckbox.vue'

View File

@ -0,0 +1,132 @@
<template>
<Popup>
<template #trigger="{toggle}">
<XButton
variant="secondary"
icon="sort"
@click.prevent.stop="toggle()"
>
{{ $t('project.list.sort') }}
</XButton>
</template>
<template #content="{close}">
<Card class="sort-popup">
<p class="sort-description has-text-grey is-size-7">
{{ $t('sorting.description') }}
</p>
<div class="field">
<div class="select is-fullwidth">
<select v-model="selected">
<option
v-for="o in options"
:key="o.value"
:value="o.value"
>
{{ o.label }}
</option>
</select>
</div>
</div>
<div class="actions">
<XButton
variant="tertiary"
@click="close()"
>
{{ $t('misc.cancel') }}
</XButton>
<XButton
variant="primary"
@click="applySort(close)"
>
{{ $t('sorting.apply') }}
</XButton>
</div>
</Card>
</template>
</Popup>
</template>
<script setup lang="ts">
import {ref, computed, watch} from 'vue'
import {useI18n} from 'vue-i18n'
import XButton from '@/components/input/Button.vue'
import Popup from '@/components/misc/Popup.vue'
import Card from '@/components/misc/Card.vue'
import type {SortBy} from '@/composables/useTaskList'
const props = defineProps<{ modelValue: SortBy }>()
const emit = defineEmits<{ 'update:modelValue': [value: SortBy] }>()
const {t} = useI18n({useScope: 'global'})
const MANUAL = 'position:asc'
const selected = ref<string>(MANUAL)
watch(() => props.modelValue, (val) => {
const key = Object.keys(val)[0]
if (!key || key === 'position') {
selected.value = MANUAL
return
}
const order = (val as Record<string, 'asc' | 'desc'>)[key] ?? 'asc'
selected.value = `${key}:${order}`
}, {immediate: true})
const options = computed(() => {
const manual = {value: MANUAL, label: t('sorting.manually')}
const rest = [
{value: 'title:asc', label: t('sorting.options.titleAsc')},
{value: 'title:desc', label: t('sorting.options.titleDesc')},
{value: 'priority:desc', label: t('sorting.options.priorityDesc')},
{value: 'priority:asc', label: t('sorting.options.priorityAsc')},
{value: 'due_date:asc', label: t('sorting.options.dueDateAsc')},
{value: 'due_date:desc', label: t('sorting.options.dueDateDesc')},
{value: 'start_date:asc', label: t('sorting.options.startDateAsc')},
{value: 'start_date:desc', label: t('sorting.options.startDateDesc')},
{value: 'end_date:asc', label: t('sorting.options.endDateAsc')},
{value: 'end_date:desc', label: t('sorting.options.endDateDesc')},
{value: 'percent_done:desc', label: t('sorting.options.percentDoneDesc')},
{value: 'percent_done:asc', label: t('sorting.options.percentDoneAsc')},
{value: 'created:desc', label: t('sorting.options.createdDesc')},
{value: 'created:asc', label: t('sorting.options.createdAsc')},
{value: 'updated:desc', label: t('sorting.options.updatedDesc')},
{value: 'updated:asc', label: t('sorting.options.updatedAsc')},
].sort((a, b) => a.label.localeCompare(b.label))
return [manual, ...rest]
})
function applySort(close: () => void) {
const [field, order] = selected.value.split(':') as [string, 'asc' | 'desc']
const sort: SortBy = {} as SortBy
;(sort as Record<string, 'asc' | 'desc'>)[field] = order
emit('update:modelValue', sort)
close()
}
</script>
<style scoped lang="scss">
.sort-popup {
margin: 0;
min-inline-size: 18rem;
:deep(.card-content .content) {
display: flex;
flex-direction: column;
}
.sort-description {
margin-block-end: 1rem;
}
.field {
margin-block-end: 1rem;
}
.actions {
display: flex;
justify-content: flex-end;
gap: .5rem;
}
}
</style>

View File

@ -7,12 +7,15 @@
>
<template #header>
<div class="filter-container">
<SortPopup
v-model="sortByParam"
/>
<FilterPopup
v-if="!isSavedFilter(project)"
v-model="params"
:view-id="viewId"
:project-id="projectId"
@update:modelValue="prepareFiltersAndLoadTasks()"
@update:modelValue="loadTasks()"
/>
</div>
</template>
@ -49,13 +52,13 @@
v-if="tasks && tasks.length > 0"
v-model="tasks"
:group="{name: 'tasks', put: false}"
:disabled="!canDragTasks"
:disabled="!canDragTasks || !isPositionSorting"
item-key="id"
tag="ul"
:component-data="{
class: {
tasks: true,
'dragging-disabled': !canDragTasks || isAlphabeticalSorting
'dragging-disabled': !canDragTasks || !isPositionSorting
},
type: 'transition-group'
}"
@ -71,14 +74,13 @@
<SingleTaskInProject
:ref="(el) => setTaskRef(el, index)"
:show-list-color="false"
:disabled="!canDragTasks"
:can-mark-as-done="canWrite || isPseudoProject"
:the-task="t"
:all-tasks="allTasks"
@taskUpdated="updateTasks"
>
<span
v-if="canDragTasks"
v-if="canDragTasks && isPositionSorting"
class="icon handle"
>
<Icon icon="grip-lines" />
@ -109,7 +111,7 @@ import SingleTaskInProject from '@/components/tasks/partials/SingleTaskInProject
import FilterPopup from '@/components/project/partials/FilterPopup.vue'
import Nothing from '@/components/misc/Nothing.vue'
import Pagination from '@/components/misc/Pagination.vue'
import {ALPHABETICAL_SORT} from '@/components/project/partials/Filters.vue'
import SortPopup from '@/components/project/partials/SortPopup.vue'
import {useTaskList} from '@/composables/useTaskList'
import {useTaskDragToProject} from '@/composables/useTaskDragToProject'
@ -172,9 +174,7 @@ watch(
},
)
const isAlphabeticalSorting = computed(() => {
return params.value.sort_by.find(sortBy => sortBy === ALPHABETICAL_SORT) !== undefined
})
const isPositionSorting = computed(() => 'position' in sortByParam.value)
const firstNewPosition = computed(() => {
if (tasks.value.length === 0) {
@ -215,7 +215,7 @@ function focusNewTaskInput() {
}
function updateTaskList(task: ITask) {
if (isAlphabeticalSorting.value) {
if (!isPositionSorting.value) {
// reload tasks with current filter and sorting
loadTasks()
} else {
@ -287,15 +287,6 @@ async function saveTaskPosition(e: { originalEvent?: MouseEvent, to: HTMLElement
}
}
function prepareFiltersAndLoadTasks() {
if (isAlphabeticalSorting.value) {
sortByParam.value = {}
sortByParam.value[ALPHABETICAL_SORT] = 'asc'
}
loadTasks()
}
const taskRefs = ref<(InstanceType<typeof SingleTaskInProject> | null)[]>([])
const focusedIndex = ref(-1)
@ -365,6 +356,12 @@ onBeforeUnmount(() => {
</script>
<style lang="scss" scoped>
.filter-container {
display: flex;
align-items: center;
gap: .5rem;
}
.tasks {
padding: .5rem;
}

View File

@ -434,7 +434,8 @@
"addPlaceholder": "Add a task…",
"empty": "This project is currently empty.",
"newTaskCta": "Create a task.",
"editTask": "Edit Task"
"editTask": "Edit Task",
"sort": "Sort"
},
"gantt": {
"title": "Gantt",
@ -626,6 +627,29 @@
}
}
},
"sorting": {
"manually": "Manually",
"apply": "Apply sort",
"description": "Choose how tasks in this list are sorted. When sorting manually, you can drag and drop tasks to reorder them.",
"options": {
"titleAsc": "Title (AZ)",
"titleDesc": "Title (ZA)",
"priorityDesc": "Priority (Highest first)",
"priorityAsc": "Priority (Lowest first)",
"dueDateAsc": "Due date (Earliest first)",
"dueDateDesc": "Due date (Latest first)",
"startDateAsc": "Start date (Earliest first)",
"startDateDesc": "Start date (Latest first)",
"endDateAsc": "End date (Earliest first)",
"endDateDesc": "End date (Latest first)",
"percentDoneDesc": "% done (Most done first)",
"percentDoneAsc": "% done (Least done first)",
"createdDesc": "Created (Newest first)",
"createdAsc": "Created (Oldest first)",
"updatedDesc": "Updated (Newest first)",
"updatedAsc": "Updated (Oldest first)"
}
},
"migrate": {
"title": "Import from other services",
"titleService": "Import your data from {name} into Vikunja",