Compare commits

...

5 Commits

Author SHA1 Message Date
kolaente 6185e2a3a0 style(sort): add spacing between filter and sort buttons 2026-03-03 13:50:56 +01:00
kolaente 7ba8e26a1c refactor(sort): replace modal with inline popup and improve UX
- Switch from Modal to lightweight Popup component
- Hide sort order picker when Manually (position) is selected
- Sort dropdown options alphabetically, keeping Manually first
- Add description text explaining sort behavior
2026-03-03 13:50:52 +01:00
kolaente 3b2a2fefe4 feat(i18n): rename Position to Manually and add sort description text 2026-03-03 13:50:47 +01:00
kolaente a0305e47d8 fix: formatting and build 2026-03-03 13:27:39 +01:00
kolaente d9ae9a2d2e feat(frontend): add sorting modal for list view 2026-03-03 13:27:36 +01:00
3 changed files with 167 additions and 14 deletions

View File

@ -0,0 +1,139 @@
<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">
<label class="label">{{ $t('sorting.sortBy') }}</label>
<div class="select is-fullwidth">
<select v-model="sortField">
<option
v-for="o in options"
:key="o.value"
:value="o.value"
>
{{ o.label }}
</option>
</select>
</div>
</div>
<div
v-if="!isManualSort"
class="field"
>
<label class="label">{{ $t('sorting.order') }}</label>
<div class="select is-fullwidth">
<select v-model="sortOrder">
<option value="asc">
{{ $t('sorting.asc') }}
</option>
<option value="desc">
{{ $t('sorting.desc') }}
</option>
</select>
</div>
</div>
<div class="actions">
<XButton
variant="tertiary"
@click="close()"
>
{{ $t('misc.cancel') }}
</XButton>
<XButton
variant="primary"
@click="applySort(close)"
>
{{ $t('misc.doit') }}
</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 sortField = ref<string>('position')
const sortOrder = ref<'asc' | 'desc'>('asc')
const isManualSort = computed(() => sortField.value === 'position')
watch(() => props.modelValue, (val) => {
const key = Object.keys(val)[0] || 'position'
sortField.value = key
sortOrder.value = (val as SortBy)[key as keyof SortBy] ?? 'asc'
}, {immediate: true})
const options = computed(() => {
const manualOption = {value: 'position', label: t('sorting.manually')}
const otherOptions = [
{value: 'title', label: t('task.attributes.title')},
{value: 'priority', label: t('task.attributes.priority')},
{value: 'due_date', label: t('task.attributes.dueDate')},
{value: 'start_date', label: t('task.attributes.startDate')},
{value: 'end_date', label: t('task.attributes.endDate')},
{value: 'percent_done', label: t('task.attributes.percentDone')},
{value: 'created', label: t('task.attributes.created')},
{value: 'updated', label: t('task.attributes.updated')},
].sort((a, b) => a.label.localeCompare(b.label))
return [manualOption, ...otherOptions]
})
function applySort(close: () => void) {
const sort: SortBy = {} as SortBy
const order = isManualSort.value ? 'asc' : sortOrder.value
;(sort as Record<string, 'asc' | 'desc'>)[sortField.value] = 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

@ -14,6 +14,9 @@
:project-id="projectId"
@update:modelValue="prepareFiltersAndLoadTasks()"
/>
<SortPopup
v-model="sortByParam"
/>
</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,14 @@
<SingleTaskInProject
:ref="(el) => setTaskRef(el, index)"
:show-list-color="false"
:disabled="!canDragTasks"
:disabled="!canDragTasks || !isPositionSorting"
: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 +112,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'
@ -171,8 +174,8 @@ watch(
},
)
const isAlphabeticalSorting = computed(() => {
return params.value.sort_by.find(sortBy => sortBy === ALPHABETICAL_SORT) !== undefined
const isPositionSorting = computed(() => {
return Object.keys(sortByParam.value).length === 0 || (Object.keys(sortByParam.value).length === 1 && typeof sortByParam.value.position !== 'undefined')
})
const firstNewPosition = computed(() => {
@ -214,7 +217,7 @@ function focusNewTaskInput() {
}
function updateTaskList(task: ITask) {
if (isAlphabeticalSorting.value) {
if (!isPositionSorting.value) {
// reload tasks with current filter and sorting
loadTasks()
} else {
@ -287,11 +290,6 @@ async function saveTaskPosition(e: { originalEvent?: MouseEvent, to: HTMLElement
}
function prepareFiltersAndLoadTasks() {
if (isAlphabeticalSorting.value) {
sortByParam.value = {}
sortByParam.value[ALPHABETICAL_SORT] = 'asc'
}
loadTasks()
}
@ -364,6 +362,12 @@ onBeforeUnmount(() => {
</script>
<style lang="scss" scoped>
.filter-container {
display: flex;
align-items: center;
gap: .5rem;
}
.tasks {
padding: .5rem;
}

View File

@ -380,7 +380,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",
@ -572,6 +573,15 @@
}
}
},
"sorting": {
"title": "Sort Tasks",
"sortBy": "Sort by",
"order": "Order",
"manually": "Manually",
"asc": "Ascending",
"desc": "Descending",
"description": "Choose how tasks in this list are sorted. When sorting manually, you can drag and drop tasks to reorder them."
},
"migrate": {
"title": "Import from other services",
"titleService": "Import your data from {name} into Vikunja",