This commit is contained in:
Malcolm Smith 2026-06-30 09:11:33 +02:00 committed by GitHub
commit a417bd80f7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 359 additions and 38 deletions

View File

@ -102,6 +102,7 @@ import type {ITask, ITaskPartialWithId} from '@/modelTypes/ITask'
import type {DateISO} from '@/types/DateISO' import type {DateISO} from '@/types/DateISO'
import type {GanttFilters} from '@/views/project/helpers/useGanttFilters' import type {GanttFilters} from '@/views/project/helpers/useGanttFilters'
import type {GanttBarModel, GanttBarDateType} from '@/composables/useGanttBar' import type {GanttBarModel, GanttBarDateType} from '@/composables/useGanttBar'
import {useProjectStore} from '@/stores/projects'
import GanttChartBody from '@/components/gantt/GanttChartBody.vue' import GanttChartBody from '@/components/gantt/GanttChartBody.vue'
import GanttRow from '@/components/gantt/GanttRow.vue' import GanttRow from '@/components/gantt/GanttRow.vue'
@ -117,6 +118,7 @@ import {roundToNaturalDayBoundary} from '@/helpers/time/roundToNaturalDayBoundar
const props = defineProps<{ const props = defineProps<{
isLoading: boolean, isLoading: boolean,
filters: GanttFilters, filters: GanttFilters,
includeSubprojects: boolean,
tasks: Map<ITask['id'], ITask>, tasks: Map<ITask['id'], ITask>,
defaultTaskStartDate: DateISO defaultTaskStartDate: DateISO
defaultTaskEndDate: DateISO defaultTaskEndDate: DateISO
@ -131,6 +133,7 @@ const dayWidthPixels = ref(0)
let resizeObserver: ResizeObserver let resizeObserver: ResizeObserver
const {tasks, filters} = toRefs(props) const {tasks, filters} = toRefs(props)
const projectStore = useProjectStore()
const dayjsLanguageLoading = useDayjsLanguageSync(dayjs) const dayjsLanguageLoading = useDayjsLanguageSync(dayjs)
const ganttContainer = ref(null) const ganttContainer = ref(null)
@ -245,6 +248,24 @@ function getRoundedDate(value: string | Date | undefined, fallback: Date | strin
return roundToNaturalDayBoundary(value ? new Date(value) : new Date(fallback), isStart) return roundToNaturalDayBoundary(value ? new Date(value) : new Date(fallback), isStart)
} }
function getTaskLabel(task: ITask): string {
if (!props.includeSubprojects) {
return task.title
}
const isProjectContext = filters.value.projectId > 0
if (isProjectContext && task.projectId === filters.value.projectId) {
return task.title
}
const projectTitle = projectStore.projects[task.projectId]?.title
if (!projectTitle) {
return task.title
}
return `${task.title} · ${projectTitle}`
}
function transformTaskToGanttBar(node: GanttTaskTreeNode): GanttBarModel { function transformTaskToGanttBar(node: GanttTaskTreeNode): GanttBarModel {
const t = node.task const t = node.task
const DEFAULT_SPAN_DAYS = 7 const DEFAULT_SPAN_DAYS = 7
@ -286,7 +307,7 @@ function transformTaskToGanttBar(node: GanttTaskTreeNode): GanttBarModel {
start: startDate, start: startDate,
end: endDate, end: endDate,
meta: { meta: {
label: t.title, label: getTaskLabel(t),
task: t, task: t,
color: taskColor, color: taskColor,
hasActualDates: Boolean(t.startDate && (t.endDate || t.dueDate)), hasActualDates: Boolean(t.startDate && (t.endDate || t.dueDate)),

View File

@ -20,15 +20,18 @@
class="filter-popup" class="filter-popup"
:change-immediately="false" :change-immediately="false"
:filter-from-view="filterFromView" :filter-from-view="filterFromView"
:show-include-subprojects-toggle="isProjectView"
:include-subprojects="includeSubprojects"
show-close show-close
@close="modalOpen = false" @close="modalOpen = false"
@update:includeSubprojects="updateIncludeSubprojects"
@showResults="showResults" @showResults="showResults"
/> />
</Modal> </Modal>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import {computed, ref, watch, nextTick} from 'vue' import {computed, ref, watch, nextTick, shallowReactive} from 'vue'
import Filters from '@/components/project/partials/Filters.vue' import Filters from '@/components/project/partials/Filters.vue'
@ -36,6 +39,9 @@ import {type TaskFilterParams} from '@/services/taskCollection'
import {type IProjectView} from '@/modelTypes/IProjectView' import {type IProjectView} from '@/modelTypes/IProjectView'
import {type IProject} from '@/modelTypes/IProject' import {type IProject} from '@/modelTypes/IProject'
import {useProjectStore} from '@/stores/projects' import {useProjectStore} from '@/stores/projects'
import ProjectViewService from '@/services/projectViews'
import ProjectViewModel from '@/models/projectView'
import {error} from '@/message'
const props = defineProps<{ const props = defineProps<{
modelValue: TaskFilterParams, modelValue: TaskFilterParams,
@ -48,6 +54,7 @@ const emit = defineEmits<{
}>() }>()
const projectStore = useProjectStore() const projectStore = useProjectStore()
const projectViewService = shallowReactive(new ProjectViewService())
const value = ref<TaskFilterParams>({}) const value = ref<TaskFilterParams>({})
const filtersRef = ref() const filtersRef = ref()
@ -88,6 +95,49 @@ function showResults() {
modalOpen.value = false modalOpen.value = false
} }
const currentView = computed(() => {
if (!isProjectView.value) {
return
}
return projectStore.projects[props.projectId]?.views.find(v => v.id === props.viewId)
})
const isProjectView = computed(() => Boolean(props.projectId && props.projectId > 0 && props.viewId))
const includeSubprojects = computed(() => currentView.value?.includeSubprojects ?? false)
async function updateIncludeSubprojects(newValue: boolean) {
if (!currentView.value || !props.projectId) {
return
}
const oldView = currentView.value
const oldValue = oldView.includeSubprojects ?? false
if (oldValue === newValue) {
return
}
projectStore.setProjectView({
...oldView,
includeSubprojects: newValue,
})
try {
const updatedView = await projectViewService.update(new ProjectViewModel({
...oldView,
includeSubprojects: newValue,
}))
projectStore.setProjectView(updatedView)
} catch (e) {
projectStore.setProjectView({
...oldView,
includeSubprojects: oldValue,
})
error(e)
}
}
const filterFromView = computed(() => { const filterFromView = computed(() => {
if (!props.projectId || !props.viewId) { if (!props.projectId || !props.viewId) {
return return

View File

@ -29,6 +29,14 @@
> >
{{ $t('filters.attributes.includeNulls') }} {{ $t('filters.attributes.includeNulls') }}
</FancyCheckbox> </FancyCheckbox>
<FancyCheckbox
v-if="showIncludeSubprojectsToggle"
v-tooltip="$t('project.views.includeSubprojectsHint')"
:model-value="includeSubprojects"
@update:modelValue="(value: boolean) => emit('update:includeSubprojects', value)"
>
{{ $t('project.views.includeSubprojects') }}
</FancyCheckbox>
</div> </div>
<FilterInputDocs /> <FilterInputDocs />
@ -76,16 +84,21 @@ const props = withDefaults(defineProps<{
changeImmediately?: boolean, changeImmediately?: boolean,
filterFromView?: string, filterFromView?: string,
showClose?: boolean, showClose?: boolean,
showIncludeSubprojectsToggle?: boolean,
includeSubprojects?: boolean,
}>(), { }>(), {
hasTitle: false, hasTitle: false,
hasFooter: true, hasFooter: true,
changeImmediately: false, changeImmediately: false,
filterFromView: undefined, filterFromView: undefined,
showClose: false, showClose: false,
showIncludeSubprojectsToggle: false,
includeSubprojects: false,
}) })
const emit = defineEmits<{ const emit = defineEmits<{
'update:modelValue': [value: TaskFilterParams], 'update:modelValue': [value: TaskFilterParams],
'update:includeSubprojects': [value: boolean],
'showResults': [], 'showResults': [],
'close': [], 'close': [],
}>() }>()

View File

@ -38,6 +38,15 @@
> >
{{ $t('task.show.noDates') }} {{ $t('task.show.noDates') }}
</FancyCheckbox> </FancyCheckbox>
<FancyCheckbox
v-if="filters.projectId > 0"
v-tooltip="$t('project.views.includeSubprojectsHint')"
:model-value="includeSubprojects"
is-block
@update:modelValue="updateIncludeSubprojects"
>
{{ $t('project.views.includeSubprojects') }}
</FancyCheckbox>
</div> </div>
</Card> </Card>
@ -49,6 +58,7 @@
> >
<GanttChart <GanttChart
:filters="filters" :filters="filters"
:include-subprojects="includeSubprojects"
:tasks="tasks" :tasks="tasks"
:is-loading="isLoading" :is-loading="isLoading"
:default-task-start-date="defaultTaskStartDate" :default-task-start-date="defaultTaskStartDate"
@ -72,6 +82,7 @@ import {useI18n} from 'vue-i18n'
import type {RouteLocationNormalized} from 'vue-router' import type {RouteLocationNormalized} from 'vue-router'
import {useBaseStore} from '@/stores/base' import {useBaseStore} from '@/stores/base'
import {useProjectStore} from '@/stores/projects'
import {useFlatpickrLanguage} from '@/helpers/useFlatpickrLanguage' import {useFlatpickrLanguage} from '@/helpers/useFlatpickrLanguage'
import Foo from '@/components/misc/flatpickr/Flatpickr.vue' import Foo from '@/components/misc/flatpickr/Flatpickr.vue'
@ -83,6 +94,9 @@ import FormField from '@/components/input/FormField.vue'
import GanttChart from '@/components/gantt/GanttChart.vue' import GanttChart from '@/components/gantt/GanttChart.vue'
import {useGanttFilters} from '../../../views/project/helpers/useGanttFilters' import {useGanttFilters} from '../../../views/project/helpers/useGanttFilters'
import {PERMISSIONS} from '@/constants/permissions' import {PERMISSIONS} from '@/constants/permissions'
import ProjectViewService from '@/services/projectViews'
import ProjectViewModel from '@/models/projectView'
import {error} from '@/message'
import type {DateISO} from '@/types/DateISO' import type {DateISO} from '@/types/DateISO'
import type {ITask} from '@/modelTypes/ITask' import type {ITask} from '@/modelTypes/ITask'
@ -98,9 +112,13 @@ const props = defineProps<{
const baseStore = useBaseStore() const baseStore = useBaseStore()
const projectStore = useProjectStore()
const canWrite = computed(() => baseStore.currentProject?.maxPermission > PERMISSIONS.READ) const canWrite = computed(() => baseStore.currentProject?.maxPermission > PERMISSIONS.READ)
const projectViewService = new ProjectViewService()
const {route, viewId} = toRefs(props) const {route, viewId} = toRefs(props)
const currentView = computed(() => baseStore.currentProject?.views.find(v => v.id === viewId.value))
const includeSubprojects = computed(() => currentView.value?.includeSubprojects ?? false)
const { const {
filters, filters,
hasDefaultFilters, hasDefaultFilters,
@ -109,7 +127,38 @@ const {
isLoading, isLoading,
addTask, addTask,
updateTask, updateTask,
} = useGanttFilters(route, viewId) } = useGanttFilters(route, viewId, includeSubprojects)
async function updateIncludeSubprojects(newValue: boolean) {
if (!currentView.value) {
return
}
const oldView = currentView.value
const oldValue = oldView.includeSubprojects ?? false
if (oldValue === newValue) {
return
}
projectStore.setProjectView({
...oldView,
includeSubprojects: newValue,
})
try {
const updatedView = await projectViewService.update(new ProjectViewModel({
...oldView,
includeSubprojects: newValue,
}))
projectStore.setProjectView(updatedView)
} catch (e) {
projectStore.setProjectView({
...oldView,
includeSubprojects: oldValue,
})
error(e)
}
}
const DEFAULT_DATE_RANGE_DAYS = 7 const DEFAULT_DATE_RANGE_DAYS = 7

View File

@ -445,6 +445,10 @@ const bucketDraggableComponentData = computed(() => ({
})) }))
const project = computed(() => projectId.value ? projectStore.projects[projectId.value] : null) const project = computed(() => projectId.value ? projectStore.projects[projectId.value] : null)
const view = computed(() => project.value?.views.find(v => v.id === props.viewId) as IProjectView || null) const view = computed(() => project.value?.views.find(v => v.id === props.viewId) as IProjectView || null)
const requestParams = computed(() => ({
...params.value,
...(view.value?.includeSubprojects ? {include_subprojects: true} : {}),
}))
const canWrite = computed(() => baseStore.currentProject?.maxPermission > Permissions.READ && view.value.bucketConfigurationMode === 'manual') const canWrite = computed(() => baseStore.currentProject?.maxPermission > Permissions.READ && view.value.bucketConfigurationMode === 'manual')
const canCreateTasks = computed(() => canWrite.value && projectId.value > 0) const canCreateTasks = computed(() => canWrite.value && projectId.value > 0)
@ -489,7 +493,7 @@ const taskLoading = computed(() => taskStore.isLoading || taskPositionService.va
watch( watch(
() => ({ () => ({
params: params.value, params: requestParams.value,
projectId: projectId.value, projectId: projectId.value,
viewId: props.viewId, viewId: props.viewId,
}), }),
@ -524,7 +528,7 @@ function handleTaskContainerScroll(id: IBucket['id'], el: HTMLElement) {
kanbanStore.loadNextTasksForBucket( kanbanStore.loadNextTasksForBucket(
projectId.value, projectId.value,
props.viewId, props.viewId,
params.value, requestParams.value,
id, id,
) )
} }
@ -723,7 +727,7 @@ async function deleteBucket() {
projectId: projectIdWithFallback.value, projectId: projectIdWithFallback.value,
projectViewId: props.viewId, projectViewId: props.viewId,
}), }),
params: params.value, params: requestParams.value,
}) })
success({message: t('project.kanban.deleteBucketSuccess')}) success({message: t('project.kanban.deleteBucketSuccess')})
} finally { } finally {
@ -773,7 +777,7 @@ function handleRecurringTaskCompletion() {
if (filterContainsDateFields) { if (filterContainsDateFields) {
// Reload the kanban board to refresh tasks that now match/don't match the filter // Reload the kanban board to refresh tasks that now match/don't match the filter
kanbanStore.loadBucketsForProject(projectId.value, props.viewId, params.value) kanbanStore.loadBucketsForProject(projectId.value, props.viewId, requestParams.value)
} }
} }

View File

@ -139,6 +139,10 @@ const projectId = toRef(props, 'projectId')
defineOptions({name: 'List'}) defineOptions({name: 'List'})
const baseStore = useBaseStore()
const project = computed(() => baseStore.currentProject)
const currentView = computed(() => project.value?.views.find(v => v.id === props.viewId))
const ctaVisible = ref(false) const ctaVisible = ref(false)
const drag = ref(false) const drag = ref(false)
@ -158,6 +162,7 @@ const {
() => projectId.value === -1 () => projectId.value === -1
? ['comment_count', 'is_unread'] ? ['comment_count', 'is_unread']
: ['subtasks', 'comment_count', 'is_unread'], : ['subtasks', 'comment_count', 'is_unread'],
() => currentView.value?.includeSubprojects ?? false,
) )
const taskPositionService = ref(new TaskPositionService()) const taskPositionService = ref(new TaskPositionService())
@ -184,10 +189,8 @@ const firstNewPosition = computed(() => {
return calculateItemPosition(null, tasks.value[0].position) return calculateItemPosition(null, tasks.value[0].position)
}) })
const baseStore = useBaseStore()
const taskStore = useTaskStore() const taskStore = useTaskStore()
const {handleTaskDropToProject} = useTaskDragToProject() const {handleTaskDropToProject} = useTaskDragToProject()
const project = computed(() => baseStore.currentProject)
const canWrite = computed(() => { const canWrite = computed(() => {
return project.value?.maxPermission > Permissions.READ && project.value?.id > 0 return project.value?.maxPermission > Permissions.READ && project.value?.id > 0
@ -201,7 +204,6 @@ onMounted(async () => {
}) })
const canDragTasks = computed(() => canWrite.value || isSavedFilter(project.value)) const canDragTasks = computed(() => canWrite.value || isSavedFilter(project.value))
const isTouchDevice = ref(false) const isTouchDevice = ref(false)
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
isTouchDevice.value = !window.matchMedia('(hover: hover) and (pointer: fine)').matches isTouchDevice.value = !window.matchMedia('(hover: hover) and (pointer: fine)').matches

View File

@ -334,6 +334,7 @@ const props = defineProps<{
}>() }>()
const projectStore = useProjectStore() const projectStore = useProjectStore()
const currentView = computed(() => projectStore.projects[props.projectId]?.views.find(v => v.id === props.viewId))
const ACTIVE_COLUMNS_DEFAULT = { const ACTIVE_COLUMNS_DEFAULT = {
index: true, index: true,
@ -366,6 +367,7 @@ const taskList = useTaskList(
() => props.viewId, () => props.viewId,
sortBy.value, sortBy.value,
() => ['comment_count', 'is_unread'], () => ['comment_count', 'is_unread'],
() => currentView.value?.includeSubprojects ?? false,
) )
const { const {

View File

@ -13,6 +13,7 @@ import FancyCheckbox from '@/components/input/FancyCheckbox.vue'
import FilterInputDocs from '@/components/input/filter/FilterInputDocs.vue' import FilterInputDocs from '@/components/input/filter/FilterInputDocs.vue'
import FilterInput from '@/components/input/filter/FilterInput.vue' import FilterInput from '@/components/input/filter/FilterInput.vue'
import FormField from '@/components/input/FormField.vue' import FormField from '@/components/input/FormField.vue'
import FancyCheckbox from '@/components/input/FancyCheckbox.vue'
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
modelValue: IProjectView, modelValue: IProjectView,
@ -189,6 +190,15 @@ function handleBubbleSave() {
<FilterInputDocs /> <FilterInputDocs />
</div> </div>
<div class="field mbe-3">
<FancyCheckbox
v-model="view.includeSubprojects"
v-tooltip="$t('project.views.includeSubprojects')"
>
{{ $t('project.views.includeSubprojects') }}
</FancyCheckbox>
</div>
<div class="field mbe-3"> <div class="field mbe-3">
<FancyCheckbox <FancyCheckbox
v-model="view.filter.filter_include_nulls" v-model="view.filter.filter_include_nulls"

View File

@ -153,7 +153,7 @@
/> />
<RouterLink <RouterLink
v-if="showProjectSeparately" v-if="showProjectSeparately && project"
v-tooltip="$t('task.detail.belongsToProject', {project: project.title})" v-tooltip="$t('task.detail.belongsToProject', {project: project.title})"
:to="{ name: 'project.index', params: { projectId: task.projectId } }" :to="{ name: 'project.index', params: { projectId: task.projectId } }"
class="task-project" class="task-project"
@ -161,7 +161,6 @@
> >
{{ project.title }} {{ project.title }}
</RouterLink> </RouterLink>
<BaseButton <BaseButton
:class="{'is-favorite': task.isFavorite}" :class="{'is-favorite': task.isFavorite}"
class="favorite" class="favorite"
@ -292,6 +291,8 @@ const currentProject = computed(() => {
} : baseStore.currentProject } : baseStore.currentProject
}) })
const taskDetailRoute = computed(() => ({ const taskDetailRoute = computed(() => ({
name: 'task.detail', name: 'task.detail',
params: {id: task.value.id}, params: {id: task.value.id},

View File

@ -108,6 +108,7 @@ export function useTaskList(
projectViewIdGetter: ComputedGetter<IProjectView['id']>, projectViewIdGetter: ComputedGetter<IProjectView['id']>,
sortByDefault: SortBy = SORT_BY_DEFAULT, sortByDefault: SortBy = SORT_BY_DEFAULT,
expandGetter: ComputedGetter<ExpandTaskFilterParam> = () => 'subtasks', expandGetter: ComputedGetter<ExpandTaskFilterParam> = () => 'subtasks',
includeSubprojectsGetter: ComputedGetter<boolean> = () => false,
) { ) {
const projectId = computed(() => projectIdGetter()) const projectId = computed(() => projectIdGetter())
@ -121,6 +122,7 @@ export function useTaskList(
const page = useRouteQuery('page', '1', { transform: Number }) const page = useRouteQuery('page', '1', { transform: Number })
const filter = useRouteQuery('filter') const filter = useRouteQuery('filter')
const s = useRouteQuery('s') const s = useRouteQuery('s')
const includeSubprojects = computed(() => includeSubprojectsGetter())
watch(filter, v => { params.value.filter = v ?? '' }, { immediate: true }) watch(filter, v => { params.value.filter = v ?? '' }, { immediate: true })
watch(s, v => { params.value.s = v ?? '' }, { immediate: true }) watch(s, v => { params.value.s = v ?? '' }, { immediate: true })
@ -197,7 +199,7 @@ export function useTaskList(
}) })
watch( watch(
[params, sortBy, page], [params, sortBy, page, includeSubprojects],
([, , newPage], [, , oldPage]) => { ([, , newPage], [, , oldPage]) => {
if (newPage === oldPage) { if (newPage === oldPage) {
page.value = 1 page.value = 1
@ -216,6 +218,7 @@ export function useTaskList(
}, },
{ {
...allParams.value, ...allParams.value,
...(includeSubprojects.value ? {include_subprojects: true} : {}),
filter_timezone: authStore.settings.timezone, filter_timezone: authStore.settings.timezone,
expand: expandGetter(), expand: expandGetter(),
}, },
@ -257,5 +260,6 @@ export function useTaskList(
loadTasks, loadTasks,
params, params,
sortByParam: sortBy, sortByParam: sortBy,
includeSubprojects,
} }
} }

View File

@ -563,7 +563,9 @@
"deleteText": "Are you sure you want to remove this view? It will no longer be possible to use it to view tasks in this project. This action won't delete any tasks. This cannot be undone!", "deleteText": "Are you sure you want to remove this view? It will no longer be possible to use it to view tasks in this project. This action won't delete any tasks. This cannot be undone!",
"deleteSuccess": "The view was deleted successfully.", "deleteSuccess": "The view was deleted successfully.",
"onlyAdminsCanEdit": "Only project admins can edit views.", "onlyAdminsCanEdit": "Only project admins can edit views.",
"updateSuccess": "The view was updated successfully." "updateSuccess": "The view was updated successfully.",
"includeSubprojects": "Include subprojects",
"includeSubprojectsHint": "Show tasks from all subprojects of this project."
} }
}, },
"filters": { "filters": {

View File

@ -32,6 +32,7 @@ export interface IProjectView extends IAbstract {
filter: IFilters|undefined filter: IFilters|undefined
position: number position: number
includeSubprojects?: boolean
bucketConfigurationMode: ProjectViewBucketConfigurationMode bucketConfigurationMode: ProjectViewBucketConfigurationMode
bucketConfiguration: IProjectViewBucketConfiguration[] bucketConfiguration: IProjectViewBucketConfiguration[]

View File

@ -15,6 +15,7 @@ export default class ProjectViewModel extends AbstractModel<IProjectView> implem
s: '', s: '',
} }
position = 0 position = 0
includeSubprojects = false
bucketConfiguration = [] bucketConfiguration = []
bucketConfigurationMode: ProjectViewBucketConfigurationMode = 'manual' bucketConfigurationMode: ProjectViewBucketConfigurationMode = 'manual'

View File

@ -11,6 +11,7 @@ export interface TaskFilterParams {
order_by: ('asc' | 'desc')[], order_by: ('asc' | 'desc')[],
filter: string, filter: string,
filter_include_nulls: boolean, filter_include_nulls: boolean,
include_subprojects?: boolean,
filter_timezone?: string, filter_timezone?: string,
s: string, s: string,
per_page?: number, per_page?: number,

View File

@ -32,6 +32,17 @@ const DEFAULT_DATETO_DAY_OFFSET = +55
const now = new Date() const now = new Date()
type RouteQueryValue = RouteLocationNormalized['query'][string]
function normalizeRouteQueryValue(value: RouteQueryValue): string | undefined {
const normalizedValue = Array.isArray(value) ? value[0] : value
if (normalizedValue === null || typeof normalizedValue === 'undefined') {
return undefined
}
return String(normalizedValue)
}
function getDefaultDateFrom() { function getDefaultDateFrom() {
return new Date(now.getFullYear(), now.getMonth(), now.getDate() + DEFAULT_DATEFROM_DAY_OFFSET).toISOString() return new Date(now.getFullYear(), now.getMonth(), now.getDate() + DEFAULT_DATEFROM_DAY_OFFSET).toISOString()
} }
@ -46,9 +57,9 @@ function ganttRouteToFilters(route: Partial<RouteLocationNormalized>): GanttFilt
return { return {
projectId: Number(ganttRoute.params?.projectId), projectId: Number(ganttRoute.params?.projectId),
viewId: Number(ganttRoute.params?.viewId), viewId: Number(ganttRoute.params?.viewId),
dateFrom: parseDateProp(ganttRoute.query?.dateFrom as DateKebab) || getDefaultDateFrom(), dateFrom: parseDateProp(normalizeRouteQueryValue(ganttRoute.query?.dateFrom) as DateKebab) || getDefaultDateFrom(),
dateTo: parseDateProp(ganttRoute.query?.dateTo as DateKebab) || getDefaultDateTo(), dateTo: parseDateProp(normalizeRouteQueryValue(ganttRoute.query?.dateTo) as DateKebab) || getDefaultDateTo(),
showTasksWithoutDates: parseBooleanProp(ganttRoute.query?.showTasksWithoutDates as string) || DEFAULT_SHOW_TASKS_WITHOUT_DATES, showTasksWithoutDates: parseBooleanProp(normalizeRouteQueryValue(ganttRoute.query?.showTasksWithoutDates)) || DEFAULT_SHOW_TASKS_WITHOUT_DATES,
} }
} }
@ -61,15 +72,13 @@ function ganttGetDefaultFilters(route: Partial<RouteLocationNormalized>): GanttF
// FIXME: use zod for this // FIXME: use zod for this
function ganttFiltersToRoute(filters: GanttFilters): RouteLocationRaw { function ganttFiltersToRoute(filters: GanttFilters): RouteLocationRaw {
let query: Record<string, string> = {} const query: LocationQueryRaw = {}
if ( if (
filters.dateFrom !== getDefaultDateFrom() || filters.dateFrom !== getDefaultDateFrom() ||
filters.dateTo !== getDefaultDateTo() filters.dateTo !== getDefaultDateTo()
) { ) {
query = { query.dateFrom = isoToKebabDate(filters.dateFrom)
dateFrom: isoToKebabDate(filters.dateFrom), query.dateTo = isoToKebabDate(filters.dateTo)
dateTo: isoToKebabDate(filters.dateTo),
}
} }
if (filters.showTasksWithoutDates) { if (filters.showTasksWithoutDates) {
@ -86,7 +95,7 @@ function ganttFiltersToRoute(filters: GanttFilters): RouteLocationRaw {
} }
} }
function ganttFiltersToApiParams(filters: GanttFilters): TaskFilterParams { function ganttFiltersToApiParams(filters: GanttFilters, includeSubprojects: boolean): TaskFilterParams {
const dateFrom = isoToKebabDate(filters.dateFrom) const dateFrom = isoToKebabDate(filters.dateFrom)
const dateTo = isoToKebabDate(filters.dateTo) const dateTo = isoToKebabDate(filters.dateTo)
@ -101,6 +110,7 @@ function ganttFiltersToApiParams(filters: GanttFilters): TaskFilterParams {
')', ')',
filter_include_nulls: filters.showTasksWithoutDates, filter_include_nulls: filters.showTasksWithoutDates,
expand: 'subtasks', expand: 'subtasks',
include_subprojects: includeSubprojects,
} }
} }
@ -108,7 +118,11 @@ export type UseGanttFiltersReturn =
UseRouteFiltersReturn<GanttFilters> & UseRouteFiltersReturn<GanttFilters> &
UseGanttTaskListReturn UseGanttTaskListReturn
export function useGanttFilters(route: Ref<RouteLocationNormalized>, viewId: Ref<IProjectView['id']>): UseGanttFiltersReturn { export function useGanttFilters(
route: Ref<RouteLocationNormalized>,
viewId: Ref<IProjectView['id']>,
includeSubprojects: Ref<boolean>,
): UseGanttFiltersReturn {
const viewFiltersStore = useViewFiltersStore() const viewFiltersStore = useViewFiltersStore()
const { const {
@ -145,7 +159,15 @@ export function useGanttFilters(route: Ref<RouteLocationNormalized>, viewId: Ref
isLoading, isLoading,
addTask, addTask,
updateTask, updateTask,
} = useGanttTaskList<GanttFilters>(filters, ganttFiltersToApiParams, viewId) } = useGanttTaskList<GanttFilters>(
filters,
currentFilters => ganttFiltersToApiParams(currentFilters, includeSubprojects.value),
viewId,
)
watch(includeSubprojects, () => {
loadTasks()
})
return { return {
filters, filters,

View File

@ -161,8 +161,8 @@ type ProjectView struct {
// The filter query to match tasks by. Check out https://vikunja.io/docs/filters for a full explanation. // The filter query to match tasks by. Check out https://vikunja.io/docs/filters for a full explanation.
Filter *TaskCollection `xorm:"json null default null" query:"filter" json:"filter" doc:"The filter query used to match tasks shown in this view. See https://vikunja.io/docs/filters."` Filter *TaskCollection `xorm:"json null default null" query:"filter" json:"filter" doc:"The filter query used to match tasks shown in this view. See https://vikunja.io/docs/filters."`
// The position of this view in the list. The list of all views will be sorted by this parameter. // The position of this view in the list. The list of all views will be sorted by this position.
Position float64 `xorm:"double null" json:"position" doc:"The position of this view in the project's list of views. Views are sorted ascending by this value."` Position float64 `xorm:"double null" json:"position" doc:"The position of this view in the list. The list of all views will be sorted by this position."`
// The bucket configuration mode. Can be `none`, `manual` or `filter`. `manual` allows to move tasks between buckets as you normally would. `filter` creates buckets based on a filter for each bucket. // The bucket configuration mode. Can be `none`, `manual` or `filter`. `manual` allows to move tasks between buckets as you normally would. `filter` creates buckets based on a filter for each bucket.
BucketConfigurationMode BucketConfigurationModeKind `xorm:"default 0" json:"bucket_configuration_mode" swaggertype:"string" enums:"none,manual,filter" doc:"The bucket configuration mode. One of none, manual or filter. manual lets you move tasks between buckets; filter creates a bucket per filter."` BucketConfigurationMode BucketConfigurationModeKind `xorm:"default 0" json:"bucket_configuration_mode" swaggertype:"string" enums:"none,manual,filter" doc:"The bucket configuration mode. One of none, manual or filter. manual lets you move tasks between buckets; filter creates a bucket per filter."`

View File

@ -31,6 +31,8 @@ import (
type TaskCollection struct { type TaskCollection struct {
ProjectID int64 `param:"project" json:"-"` ProjectID int64 `param:"project" json:"-"`
ProjectViewID int64 `param:"view" json:"-"` ProjectViewID int64 `param:"view" json:"-"`
// If set to true, tasks from all descendant subprojects will also be returned.
IncludeSubprojects bool `json:"include_subprojects" query:"include_subprojects"`
Search string `query:"s" json:"s" doc:"A search term to match tasks by their title."` Search string `query:"s" json:"s" doc:"A search term to match tasks by their title."`
@ -129,6 +131,10 @@ func getTaskFilterOptsFromCollection(tf *TaskCollection, projectView *ProjectVie
continue continue
} }
if s == taskPropertyPosition && tf.IncludeSubprojects {
continue
}
if s == taskPropertyPosition && projectView != nil { if s == taskPropertyPosition && projectView != nil {
param.projectViewID = projectView.ID param.projectViewID = projectView.ID
} }
@ -201,9 +207,45 @@ func getRelevantProjectsFromCollection(s *xorm.Session, a web.Auth, tf *TaskColl
} }
} }
return []*Project{{ID: tf.ProjectID}}, nil if !tf.IncludeSubprojects || tf.ProjectID <= 0 {
return []*Project{{ID: tf.ProjectID}}, nil
}
allProjects, _, _, err := getRawProjectsForUser(s, &projectOptions{
user: &user.User{ID: a.GetID()},
page: -1,
})
if err != nil {
return nil, err
}
relevantProjects := make([]*Project, 0)
childrenMap := make(map[int64][]int64)
projectMap := make(map[int64]*Project)
for _, p := range allProjects {
projectMap[p.ID] = p
childrenMap[p.ParentProjectID] = append(childrenMap[p.ParentProjectID], p.ID)
}
queue := []int64{tf.ProjectID}
for len(queue) > 0 {
currentID := queue[0]
queue = queue[1:]
if p, exists := projectMap[currentID]; exists {
relevantProjects = append(relevantProjects, p)
}
if children, exists := childrenMap[currentID]; exists {
queue = append(queue, children...)
}
}
return relevantProjects, nil
} }
func getFilterValueForBucketFilter(filter string, view *ProjectView) (newFilter string, err error) { func getFilterValueForBucketFilter(filter string, view *ProjectView) (newFilter string, err error) {
if view.BucketConfigurationMode != BucketConfigurationModeFilter { if view.BucketConfigurationMode != BucketConfigurationModeFilter {
return filter, nil return filter, nil
@ -238,6 +280,7 @@ func getFilterValueForBucketFilter(filter string, view *ProjectView) (newFilter
// @Produce json // @Produce json
// @Param id path int true "The project ID." // @Param id path int true "The project ID."
// @Param view path int true "The project view ID." // @Param view path int true "The project view ID."
// @Param include_subprojects query bool false "If true, also returns tasks from all descendant subprojects the user can access."
// @Param page query int false "The page number. Used for pagination. If not provided, the first page of results is returned." // @Param page query int false "The page number. Used for pagination. If not provided, the first page of results is returned."
// @Param per_page query int false "The maximum number of items per page. Note this parameter is limited by the configured maximum of items per page." // @Param per_page query int false "The maximum number of items per page. Note this parameter is limited by the configured maximum of items per page."
// @Param s query string false "Search tasks by task text." // @Param s query string false "Search tasks by task text."
@ -333,6 +376,10 @@ func (tf *TaskCollection) ReadAll(s *xorm.Session, a web.Auth, search string, pa
if view.Filter.FilterIncludeNulls { if view.Filter.FilterIncludeNulls {
tf.FilterIncludeNulls = true tf.FilterIncludeNulls = true
} }
if view.Filter.IncludeSubprojects {
tf.IncludeSubprojects = true
}
} }
if strings.Contains(tf.Filter, taskPropertyBucketID) { if strings.Contains(tf.Filter, taskPropertyBucketID) {
@ -344,6 +391,16 @@ func (tf *TaskCollection) ReadAll(s *xorm.Session, a web.Auth, search string, pa
} }
} }
effectiveIncludeSubprojects := tf.IncludeSubprojects &&
tf.ProjectID > 0 &&
!tf.isSavedFilter
if _, is := a.(*LinkSharing); is {
effectiveIncludeSubprojects = false
}
tf.IncludeSubprojects = effectiveIncludeSubprojects
opts, err := getTaskFilterOptsFromCollection(tf, view) opts, err := getTaskFilterOptsFromCollection(tf, view)
if err != nil { if err != nil {
return nil, 0, 0, err return nil, 0, 0, err
@ -362,7 +419,7 @@ func (tf *TaskCollection) ReadAll(s *xorm.Session, a web.Auth, search string, pa
opts.expand = tf.Expand opts.expand = tf.Expand
opts.isSavedFilter = tf.isSavedFilter opts.isSavedFilter = tf.isSavedFilter
if view != nil { if view != nil && !tf.IncludeSubprojects {
var hasOrderByPosition bool var hasOrderByPosition bool
for _, param := range opts.sortby { for _, param := range opts.sortby {
if param.sortBy == taskPropertyPosition { if param.sortBy == taskPropertyPosition {

View File

@ -646,11 +646,12 @@ func TestTaskCollection_ReadAll(t *testing.T) {
} }
type fields struct { type fields struct {
ProjectID int64 ProjectID int64
ProjectViewID int64 ProjectViewID int64
Projects []*Project IncludeSubprojects bool
SortBy []string // Is a string, since this is the place where a query string comes from the user Projects []*Project
OrderBy []string SortBy []string // Is a string, since this is the place where a query string comes from the user
OrderBy []string
FilterIncludeNulls bool FilterIncludeNulls bool
Filter string Filter string
@ -1561,6 +1562,63 @@ func TestTaskCollection_ReadAll(t *testing.T) {
want: []*Task{}, want: []*Task{},
wantErr: false, wantErr: false,
}, },
{
name: "project tasks only by default",
fields: fields{
ProjectID: 32,
},
args: args{
a: &user.User{ID: 1},
},
want: []*Task{
task21,
},
wantErr: false,
},
{
name: "project tasks including subprojects",
fields: fields{
ProjectID: 32,
IncludeSubprojects: true,
SortBy: []string{"id"},
OrderBy: []string{"asc"},
},
args: args{
a: &user.User{ID: 1},
},
want: []*Task{
task21,
task24,
},
wantErr: false,
},
{
name: "project tasks including subprojects recursively",
fields: fields{
ProjectID: 12,
IncludeSubprojects: true,
SortBy: []string{"id"},
OrderBy: []string{"asc"},
},
args: args{
a: &user.User{ID: 1},
},
want: []*Task{
task39,
},
wantErr: false,
},
{
name: "project tasks including subprojects with no project access",
fields: fields{
ProjectID: 32,
IncludeSubprojects: true,
},
args: args{
a: &user.User{ID: 14},
},
wantErr: true,
},
// TODO filter parent project? // TODO filter parent project?
{ {
name: "filter by index", name: "filter by index",
@ -1688,6 +1746,26 @@ func TestTaskCollection_ReadAll(t *testing.T) {
task28, task28,
}, },
}, },
{
name: "saved filter with sort order and include subprojects",
fields: fields{
ProjectID: -2,
IncludeSubprojects: true,
SortBy: []string{"title", "id"},
OrderBy: []string{"desc", "asc"},
},
args: args{
a: &user.User{ID: 1},
},
want: []*Task{
task9,
task8,
task7,
task6,
task5,
task28,
},
},
{ {
name: "saved filter with sort order asc", name: "saved filter with sort order asc",
fields: fields{ fields: fields{
@ -1778,10 +1856,11 @@ func TestTaskCollection_ReadAll(t *testing.T) {
defer s.Close() defer s.Close()
lt := &TaskCollection{ lt := &TaskCollection{
ProjectID: tt.fields.ProjectID, ProjectID: tt.fields.ProjectID,
ProjectViewID: tt.fields.ProjectViewID, ProjectViewID: tt.fields.ProjectViewID,
SortBy: tt.fields.SortBy, IncludeSubprojects: tt.fields.IncludeSubprojects,
OrderBy: tt.fields.OrderBy, SortBy: tt.fields.SortBy,
OrderBy: tt.fields.OrderBy,
FilterIncludeNulls: tt.fields.FilterIncludeNulls, FilterIncludeNulls: tt.fields.FilterIncludeNulls,
@ -1832,6 +1911,8 @@ func TestTaskCollection_ReadAll(t *testing.T) {
} }
} }
func TestTaskCollection_SubtaskRemainsAfterMove(t *testing.T) { func TestTaskCollection_SubtaskRemainsAfterMove(t *testing.T) {
db.LoadAndAssertFixtures(t) db.LoadAndAssertFixtures(t)
s := db.NewSession() s := db.NewSession()