From 6e8d424ca3f071ff3f635c132e8379d4ac31c9c2 Mon Sep 17 00:00:00 2001 From: James Batten Date: Fri, 20 Feb 2026 14:18:41 +0100 Subject: [PATCH 1/9] feat: add include subprojects task view support --- frontend/src/components/gantt/GanttChart.vue | 17 +++- .../components/project/views/ProjectGantt.vue | 8 ++ .../components/project/views/ProjectList.vue | 16 +++- .../components/project/views/ProjectTable.vue | 23 ++++++ .../tasks/partials/SingleTaskInProject.vue | 40 +++++++++- frontend/src/composables/useTaskList.ts | 12 ++- frontend/src/i18n/lang/en.json | 4 +- frontend/src/services/taskCollection.ts | 1 + .../views/project/helpers/useGanttFilters.ts | 8 ++ pkg/models/task_collection.go | 76 +++++++++++++++++- pkg/models/task_collection_test.go | 77 ++++++++++++++++--- 11 files changed, 266 insertions(+), 16 deletions(-) diff --git a/frontend/src/components/gantt/GanttChart.vue b/frontend/src/components/gantt/GanttChart.vue index 9dd96f17c..a7b0b2921 100644 --- a/frontend/src/components/gantt/GanttChart.vue +++ b/frontend/src/components/gantt/GanttChart.vue @@ -102,6 +102,7 @@ import type {ITask, ITaskPartialWithId} from '@/modelTypes/ITask' import type {DateISO} from '@/types/DateISO' import type {GanttFilters} from '@/views/project/helpers/useGanttFilters' import type {GanttBarModel, GanttBarDateType} from '@/composables/useGanttBar' +import {useProjectStore} from '@/stores/projects' import GanttChartBody from '@/components/gantt/GanttChartBody.vue' import GanttRow from '@/components/gantt/GanttRow.vue' @@ -129,6 +130,7 @@ const emit = defineEmits<{ const DAY_WIDTH_PIXELS = 30 const {tasks, filters} = toRefs(props) +const projectStore = useProjectStore() const dayjsLanguageLoading = useDayjsLanguageSync(dayjs) const ganttContainer = ref(null) @@ -243,6 +245,19 @@ function getRoundedDate(value: string | Date | undefined, fallback: Date | strin return roundToNaturalDayBoundary(value ? new Date(value) : new Date(fallback), isStart) } +function getTaskLabel(task: ITask): string { + if (!filters.value.includeSubprojects || 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 { const t = node.task const DEFAULT_SPAN_DAYS = 7 @@ -284,7 +299,7 @@ function transformTaskToGanttBar(node: GanttTaskTreeNode): GanttBarModel { start: startDate, end: endDate, meta: { - label: t.title, + label: getTaskLabel(t), task: t, color: taskColor, hasActualDates: Boolean(t.startDate && (t.endDate || t.dueDate)), diff --git a/frontend/src/components/project/views/ProjectGantt.vue b/frontend/src/components/project/views/ProjectGantt.vue index dc613208a..07cceba0c 100644 --- a/frontend/src/components/project/views/ProjectGantt.vue +++ b/frontend/src/components/project/views/ProjectGantt.vue @@ -38,6 +38,14 @@ > {{ $t('task.show.noDates') }} + + {{ $t('project.views.includeSubprojects') }} + diff --git a/frontend/src/components/project/views/ProjectList.vue b/frontend/src/components/project/views/ProjectList.vue index 0ff8a64d1..1d024bc18 100644 --- a/frontend/src/components/project/views/ProjectList.vue +++ b/frontend/src/components/project/views/ProjectList.vue @@ -14,6 +14,14 @@ :project-id="projectId" @update:modelValue="prepareFiltersAndLoadTasks()" /> + + {{ $t('project.views.includeSubprojects') }} + @@ -109,6 +117,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 FancyCheckbox from '@/components/input/FancyCheckbox.vue' import {ALPHABETICAL_SORT} from '@/components/project/partials/Filters.vue' import {useTaskList} from '@/composables/useTaskList' @@ -149,6 +158,7 @@ const { loadTasks, params, sortByParam, + includeSubprojects, } = useTaskList( () => projectId.value, () => props.viewId, @@ -199,7 +209,7 @@ onMounted(async () => { ctaVisible.value = true }) -const canDragTasks = computed(() => canWrite.value || isSavedFilter(project.value)) +const canDragTasks = computed(() => (canWrite.value || isSavedFilter(project.value)) && !includeSubprojects.value) const isTouchDevice = ref(false) if (typeof window !== 'undefined') { @@ -417,4 +427,8 @@ onBeforeUnmount(() => { margin-block-end: 0; } } + +.include-subprojects-toggle { + margin-inline-start: .75rem; +} diff --git a/frontend/src/components/project/views/ProjectTable.vue b/frontend/src/components/project/views/ProjectTable.vue index 76c641a61..af2812243 100644 --- a/frontend/src/components/project/views/ProjectTable.vue +++ b/frontend/src/components/project/views/ProjectTable.vue @@ -81,6 +81,14 @@ :project-id="projectId" @update:modelValue="taskList.loadTasks()" /> + + {{ $t('project.views.includeSubprojects') }} + @@ -226,6 +234,12 @@ {{ t.title }} + + {{ projectStore.projects[t.projectId].title }} + = taskList.tasks @@ -467,4 +482,12 @@ const taskDetailRoutes = computed(() => Object.fromEntries( .filter-container :deep(.popup) { inset-block-start: 7rem; } + +.include-subprojects-toggle { + margin-inline-start: .75rem; +} + +.task-project-tag { + margin-inline-start: .5rem; +} diff --git a/frontend/src/components/tasks/partials/SingleTaskInProject.vue b/frontend/src/components/tasks/partials/SingleTaskInProject.vue index 68b2421a5..e53ed69eb 100644 --- a/frontend/src/components/tasks/partials/SingleTaskInProject.vue +++ b/frontend/src/components/tasks/partials/SingleTaskInProject.vue @@ -153,7 +153,7 @@ class="task-project" @click.stop > - {{ project.title }} + {{ projectLabel }} { } : baseStore.currentProject }) +const projectLabel = computed(() => { + if (!project.value) { + return '' + } + + const rootProjectID = currentProject.value?.id ?? 0 + if (rootProjectID <= 0 || rootProjectID === task.value.projectId) { + return project.value.title + } + + const segments: string[] = [] + const visited = new Set() + let projectID = task.value.projectId + let hasReachedRoot = false + + while (projectID > 0 && !visited.has(projectID)) { + visited.add(projectID) + if (projectID === rootProjectID) { + hasReachedRoot = true + break + } + + const current = projectStore.projects[projectID] + if (!current) { + break + } + + segments.push(current.title) + projectID = current.parentProjectId + } + + if (!hasReachedRoot || segments.length === 0) { + return project.value.title + } + + return segments.reverse().join('/') +}) + const taskDetailRoute = computed(() => ({ name: 'task.detail', params: {id: task.value.id}, diff --git a/frontend/src/composables/useTaskList.ts b/frontend/src/composables/useTaskList.ts index dff757d9e..8f33622a4 100644 --- a/frontend/src/composables/useTaskList.ts +++ b/frontend/src/composables/useTaskList.ts @@ -73,6 +73,14 @@ export function useTaskList( const page = useRouteQuery('page', '1', { transform: Number }) const filter = useRouteQuery('filter') const s = useRouteQuery('s') + const includeSubprojectsQuery = useRouteQuery('includeSubprojects') + + const includeSubprojects = computed({ + get: () => includeSubprojectsQuery.value === '1' || includeSubprojectsQuery.value === 'true', + set: (value) => { + includeSubprojectsQuery.value = value ? '1' : undefined + }, + }) watch(filter, v => { params.value.filter = v ?? '' }, { immediate: true }) watch(s, v => { params.value.s = v ?? '' }, { immediate: true }) @@ -89,7 +97,7 @@ export function useTaskList( }) watch( - [params, sortBy, page], + [params, sortBy, page, includeSubprojects], ([, , newPage], [, , oldPage]) => { if (newPage === oldPage) { page.value = 1 @@ -108,6 +116,7 @@ export function useTaskList( }, { ...allParams.value, + ...(includeSubprojects.value ? {include_subprojects: true} : {}), filter_timezone: authStore.settings.timezone, expand: expandGetter(), }, @@ -149,5 +158,6 @@ export function useTaskList( loadTasks, params, sortByParam: sortBy, + includeSubprojects, } } diff --git a/frontend/src/i18n/lang/en.json b/frontend/src/i18n/lang/en.json index b7f4890a9..2920347c2 100644 --- a/frontend/src/i18n/lang/en.json +++ b/frontend/src/i18n/lang/en.json @@ -485,7 +485,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!", "deleteSuccess": "The view was deleted successfully.", "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": { diff --git a/frontend/src/services/taskCollection.ts b/frontend/src/services/taskCollection.ts index 8edca87d1..0c6cc3fa1 100644 --- a/frontend/src/services/taskCollection.ts +++ b/frontend/src/services/taskCollection.ts @@ -11,6 +11,7 @@ export interface TaskFilterParams { order_by: ('asc' | 'desc')[], filter: string, filter_include_nulls: boolean, + include_subprojects?: boolean, filter_timezone?: string, s: string, per_page?: number, diff --git a/frontend/src/views/project/helpers/useGanttFilters.ts b/frontend/src/views/project/helpers/useGanttFilters.ts index 1877ab9fd..686c4a2f2 100644 --- a/frontend/src/views/project/helpers/useGanttFilters.ts +++ b/frontend/src/views/project/helpers/useGanttFilters.ts @@ -23,9 +23,11 @@ export interface GanttFilters { dateFrom: DateISO dateTo: DateISO showTasksWithoutDates: boolean + includeSubprojects: boolean } const DEFAULT_SHOW_TASKS_WITHOUT_DATES = false +const DEFAULT_INCLUDE_SUBPROJECTS = false const DEFAULT_DATEFROM_DAY_OFFSET = -15 const DEFAULT_DATETO_DAY_OFFSET = +55 @@ -49,6 +51,7 @@ function ganttRouteToFilters(route: Partial): GanttFilt dateFrom: parseDateProp(ganttRoute.query?.dateFrom as DateKebab) || getDefaultDateFrom(), dateTo: parseDateProp(ganttRoute.query?.dateTo as DateKebab) || getDefaultDateTo(), showTasksWithoutDates: parseBooleanProp(ganttRoute.query?.showTasksWithoutDates as string) || DEFAULT_SHOW_TASKS_WITHOUT_DATES, + includeSubprojects: parseBooleanProp(ganttRoute.query?.includeSubprojects as string) || DEFAULT_INCLUDE_SUBPROJECTS, } } @@ -76,6 +79,10 @@ function ganttFiltersToRoute(filters: GanttFilters): RouteLocationRaw { query.showTasksWithoutDates = String(filters.showTasksWithoutDates) } + if (filters.includeSubprojects) { + query.includeSubprojects = '1' + } + return { name: 'project.view', params: { @@ -101,6 +108,7 @@ function ganttFiltersToApiParams(filters: GanttFilters): TaskFilterParams { ')', filter_include_nulls: filters.showTasksWithoutDates, expand: 'subtasks', + include_subprojects: filters.includeSubprojects, } } diff --git a/pkg/models/task_collection.go b/pkg/models/task_collection.go index c4841804e..398d52639 100644 --- a/pkg/models/task_collection.go +++ b/pkg/models/task_collection.go @@ -31,6 +31,8 @@ import ( type TaskCollection struct { ProjectID int64 `param:"project" json:"-"` ProjectViewID int64 `param:"view" json:"-"` + // If set to true, tasks from all descendant subprojects will also be returned. + IncludeSubprojects bool `query:"include_subprojects" json:"-"` Search string `query:"s" json:"s"` @@ -130,6 +132,10 @@ func getTaskFilterOptsFromCollection(tf *TaskCollection, projectView *ProjectVie continue } + if s == taskPropertyPosition && tf.IncludeSubprojects { + continue + } + if s == taskPropertyPosition && projectView != nil { param.projectViewID = projectView.ID } @@ -196,7 +202,58 @@ 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 + } + + projects, _, _, err = getRawProjectsForUser( + s, + &projectOptions{ + user: &user.User{ID: a.GetID()}, + page: -1, + }, + ) + if err != nil { + return nil, err + } + + return getProjectSubtree(projects, tf.ProjectID), nil +} + +func getProjectSubtree(projects []*Project, rootProjectID int64) []*Project { + projectsByParent := make(map[int64][]int64, len(projects)) + for _, p := range projects { + if p == nil || p.ID <= 0 { + continue + } + projectsByParent[p.ParentProjectID] = append(projectsByParent[p.ParentProjectID], p.ID) + } + + visited := map[int64]bool{rootProjectID: true} + queue := []int64{rootProjectID} + relevantProjectIDs := []int64{rootProjectID} + + for len(queue) > 0 { + currentProjectID := queue[0] + queue = queue[1:] + + for _, childProjectID := range projectsByParent[currentProjectID] { + if visited[childProjectID] { + continue + } + + visited[childProjectID] = true + relevantProjectIDs = append(relevantProjectIDs, childProjectID) + queue = append(queue, childProjectID) + } + } + + relevantProjects := make([]*Project, 0, len(relevantProjectIDs)) + for _, projectID := range relevantProjectIDs { + relevantProjects = append(relevantProjects, &Project{ID: projectID}) + } + + return relevantProjects } func getFilterValueForBucketFilter(filter string, view *ProjectView) (newFilter string, err error) { @@ -233,6 +290,7 @@ func getFilterValueForBucketFilter(filter string, view *ProjectView) (newFilter // @Produce json // @Param id path int true "The project 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 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." @@ -340,6 +398,20 @@ func (tf *TaskCollection) ReadAll(s *xorm.Session, a web.Auth, search string, pa } } + effectiveIncludeSubprojects := tf.IncludeSubprojects && + tf.ProjectID > 0 && + !tf.isSavedFilter + + if view != nil && view.ViewKind == ProjectViewKindKanban { + effectiveIncludeSubprojects = false + } + + if _, is := a.(*LinkSharing); is { + effectiveIncludeSubprojects = false + } + + tf.IncludeSubprojects = effectiveIncludeSubprojects + opts, err := getTaskFilterOptsFromCollection(tf, view) if err != nil { return nil, 0, 0, err @@ -358,7 +430,7 @@ func (tf *TaskCollection) ReadAll(s *xorm.Session, a web.Auth, search string, pa opts.expand = tf.Expand opts.isSavedFilter = tf.isSavedFilter - if view != nil { + if view != nil && !tf.IncludeSubprojects { var hasOrderByPosition bool for _, param := range opts.sortby { if param.sortBy == taskPropertyPosition { diff --git a/pkg/models/task_collection_test.go b/pkg/models/task_collection_test.go index a455b2065..af7e77e2f 100644 --- a/pkg/models/task_collection_test.go +++ b/pkg/models/task_collection_test.go @@ -698,11 +698,12 @@ func TestTaskCollection_ReadAll(t *testing.T) { } type fields struct { - ProjectID int64 - ProjectViewID int64 - Projects []*Project - SortBy []string // Is a string, since this is the place where a query string comes from the user - OrderBy []string + ProjectID int64 + ProjectViewID int64 + IncludeSubprojects bool + Projects []*Project + SortBy []string // Is a string, since this is the place where a query string comes from the user + OrderBy []string FilterIncludeNulls bool Filter string @@ -1593,6 +1594,63 @@ func TestTaskCollection_ReadAll(t *testing.T) { want: []*Task{}, 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? { name: "filter by index", @@ -1811,10 +1869,11 @@ func TestTaskCollection_ReadAll(t *testing.T) { defer s.Close() lt := &TaskCollection{ - ProjectID: tt.fields.ProjectID, - ProjectViewID: tt.fields.ProjectViewID, - SortBy: tt.fields.SortBy, - OrderBy: tt.fields.OrderBy, + ProjectID: tt.fields.ProjectID, + ProjectViewID: tt.fields.ProjectViewID, + IncludeSubprojects: tt.fields.IncludeSubprojects, + SortBy: tt.fields.SortBy, + OrderBy: tt.fields.OrderBy, FilterIncludeNulls: tt.fields.FilterIncludeNulls, From a424d42e1dc4dfbd9ab2efc2129ba54b4203567f Mon Sep 17 00:00:00 2001 From: James Batten Date: Sat, 21 Feb 2026 00:30:29 +0100 Subject: [PATCH 2/9] fix: address include-subprojects review feedback --- frontend/src/components/gantt/GanttChart.vue | 7 +- .../components/project/views/ProjectGantt.vue | 5 +- .../components/project/views/ProjectList.vue | 5 +- .../components/project/views/ProjectTable.vue | 15 ++-- .../tasks/partials/SingleTaskInProject.vue | 32 +-------- frontend/src/composables/useTaskList.ts | 21 ++++-- frontend/src/i18n/lang/en.json | 1 + frontend/src/modelTypes/IUserSettings.ts | 1 + frontend/src/models/userSettings.ts | 1 + frontend/src/stores/auth.ts | 1 + .../views/project/helpers/useGanttFilters.ts | 29 +++++--- frontend/src/views/user/settings/General.vue | 10 +++ pkg/models/task_collection.go | 69 +++++++++---------- pkg/models/task_collection_test.go | 38 ++++++++++ 14 files changed, 137 insertions(+), 98 deletions(-) diff --git a/frontend/src/components/gantt/GanttChart.vue b/frontend/src/components/gantt/GanttChart.vue index a7b0b2921..f0867172a 100644 --- a/frontend/src/components/gantt/GanttChart.vue +++ b/frontend/src/components/gantt/GanttChart.vue @@ -246,7 +246,12 @@ function getRoundedDate(value: string | Date | undefined, fallback: Date | strin } function getTaskLabel(task: ITask): string { - if (!filters.value.includeSubprojects || task.projectId === filters.value.projectId) { + if (!filters.value.includeSubprojects) { + return task.title + } + + const isProjectContext = filters.value.projectId > 0 + if (isProjectContext && task.projectId === filters.value.projectId) { return task.title } diff --git a/frontend/src/components/project/views/ProjectGantt.vue b/frontend/src/components/project/views/ProjectGantt.vue index 07cceba0c..84863e154 100644 --- a/frontend/src/components/project/views/ProjectGantt.vue +++ b/frontend/src/components/project/views/ProjectGantt.vue @@ -39,7 +39,7 @@ {{ $t('task.show.noDates') }} baseStore.currentProject?.maxPermission > PERMISSIONS.READ) +const showIncludeSubprojectsToggle = computed(() => authStore.settings.frontendSettings.showIncludeSubprojectsToggle ?? false) const {route, viewId} = toRefs(props) const { diff --git a/frontend/src/components/project/views/ProjectList.vue b/frontend/src/components/project/views/ProjectList.vue index 1d024bc18..d24b5ccd8 100644 --- a/frontend/src/components/project/views/ProjectList.vue +++ b/frontend/src/components/project/views/ProjectList.vue @@ -15,7 +15,7 @@ @update:modelValue="prepareFiltersAndLoadTasks()" /> { const baseStore = useBaseStore() const taskStore = useTaskStore() +const authStore = useAuthStore() const {handleTaskDropToProject} = useTaskDragToProject() const project = computed(() => baseStore.currentProject) +const showIncludeSubprojectsToggle = computed(() => authStore.settings.frontendSettings.showIncludeSubprojectsToggle ?? false) const canWrite = computed(() => { return project.value?.maxPermission > Permissions.READ && project.value?.id > 0 diff --git a/frontend/src/components/project/views/ProjectTable.vue b/frontend/src/components/project/views/ProjectTable.vue index af2812243..8768c63af 100644 --- a/frontend/src/components/project/views/ProjectTable.vue +++ b/frontend/src/components/project/views/ProjectTable.vue @@ -82,7 +82,7 @@ @update:modelValue="taskList.loadTasks()" /> - - {{ projectStore.projects[t.projectId].title }} - () const projectStore = useProjectStore() +const authStore = useAuthStore() +const showIncludeSubprojectsToggle = computed(() => authStore.settings.frontendSettings.showIncludeSubprojectsToggle ?? false) const ACTIVE_COLUMNS_DEFAULT = { index: true, @@ -486,8 +483,4 @@ const taskDetailRoutes = computed(() => Object.fromEntries( .include-subprojects-toggle { margin-inline-start: .75rem; } - -.task-project-tag { - margin-inline-start: .5rem; -} diff --git a/frontend/src/components/tasks/partials/SingleTaskInProject.vue b/frontend/src/components/tasks/partials/SingleTaskInProject.vue index e53ed69eb..4146ccda7 100644 --- a/frontend/src/components/tasks/partials/SingleTaskInProject.vue +++ b/frontend/src/components/tasks/partials/SingleTaskInProject.vue @@ -291,37 +291,7 @@ const projectLabel = computed(() => { return '' } - const rootProjectID = currentProject.value?.id ?? 0 - if (rootProjectID <= 0 || rootProjectID === task.value.projectId) { - return project.value.title - } - - const segments: string[] = [] - const visited = new Set() - let projectID = task.value.projectId - let hasReachedRoot = false - - while (projectID > 0 && !visited.has(projectID)) { - visited.add(projectID) - if (projectID === rootProjectID) { - hasReachedRoot = true - break - } - - const current = projectStore.projects[projectID] - if (!current) { - break - } - - segments.push(current.title) - projectID = current.parentProjectId - } - - if (!hasReachedRoot || segments.length === 0) { - return project.value.title - } - - return segments.reverse().join('/') + return project.value.title }) const taskDetailRoute = computed(() => ({ diff --git a/frontend/src/composables/useTaskList.ts b/frontend/src/composables/useTaskList.ts index 8f33622a4..03bf05c86 100644 --- a/frontend/src/composables/useTaskList.ts +++ b/frontend/src/composables/useTaskList.ts @@ -33,6 +33,17 @@ const SORT_BY_DEFAULT: SortBy = { id: 'desc', } +type RouteQueryBoolean = string | string[] | null | undefined + +function parseRouteBoolean(value: RouteQueryBoolean): boolean { + const routeValue = Array.isArray(value) ? value[0] : value + if (typeof routeValue !== 'string') { + return false + } + + return routeValue === '1' || routeValue === 'true' +} + // This makes sure an id sort order is always sorted last. // When tasks would be sorted first by id and then by whatever else was specified, the id sort takes // precedence over everything else, making any other sort columns pretty useless. @@ -73,12 +84,10 @@ export function useTaskList( const page = useRouteQuery('page', '1', { transform: Number }) const filter = useRouteQuery('filter') const s = useRouteQuery('s') - const includeSubprojectsQuery = useRouteQuery('includeSubprojects') - - const includeSubprojects = computed({ - get: () => includeSubprojectsQuery.value === '1' || includeSubprojectsQuery.value === 'true', - set: (value) => { - includeSubprojectsQuery.value = value ? '1' : undefined + const includeSubprojects = useRouteQuery('includeSubprojects', undefined, { + transform: { + get: parseRouteBoolean, + set: (value) => value ? 'true' : undefined, }, }) diff --git a/frontend/src/i18n/lang/en.json b/frontend/src/i18n/lang/en.json index 2920347c2..a3571efe8 100644 --- a/frontend/src/i18n/lang/en.json +++ b/frontend/src/i18n/lang/en.json @@ -95,6 +95,7 @@ "playSoundWhenDone": "Play a sound when marking tasks as done", "allowIconChanges": "Show special logos during certain times", "alwaysShowBucketTaskCount": "Always show task count on Kanban buckets", + "showIncludeSubprojectsToggle": "Show \"Include subprojects\" toggle in project views", "defaultTaskRelationType": "Default task relation type", "weekStart": "Week starts on", "weekStartSunday": "Sunday", diff --git a/frontend/src/modelTypes/IUserSettings.ts b/frontend/src/modelTypes/IUserSettings.ts index 246321d74..ddec855dd 100644 --- a/frontend/src/modelTypes/IUserSettings.ts +++ b/frontend/src/modelTypes/IUserSettings.ts @@ -22,6 +22,7 @@ export interface IFrontendSettings { defaultTaskRelationType: IRelationKind backgroundBrightness: number | null alwaysShowBucketTaskCount: boolean + showIncludeSubprojectsToggle?: boolean sidebarWidth: number | null commentSortOrder: 'asc' | 'desc' } diff --git a/frontend/src/models/userSettings.ts b/frontend/src/models/userSettings.ts index a4fcc91fb..9f1b1e611 100644 --- a/frontend/src/models/userSettings.ts +++ b/frontend/src/models/userSettings.ts @@ -33,6 +33,7 @@ export default class UserSettingsModel extends AbstractModel impl defaultTaskRelationType: RELATION_KIND.RELATED, backgroundBrightness: null, alwaysShowBucketTaskCount: false, + showIncludeSubprojectsToggle: false, sidebarWidth: null, commentSortOrder: 'asc', } diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts index b471a729a..a07ca3067 100644 --- a/frontend/src/stores/auth.ts +++ b/frontend/src/stores/auth.ts @@ -139,6 +139,7 @@ export const useAuthStore = defineStore('auth', () => { timeFormat: TIME_FORMAT.HOURS_24, defaultTaskRelationType: RELATION_KIND.RELATED, backgroundBrightness: 100, + showIncludeSubprojectsToggle: false, sidebarWidth: null, commentSortOrder: 'asc', ...newSettings.frontendSettings, diff --git a/frontend/src/views/project/helpers/useGanttFilters.ts b/frontend/src/views/project/helpers/useGanttFilters.ts index 686c4a2f2..6367c382b 100644 --- a/frontend/src/views/project/helpers/useGanttFilters.ts +++ b/frontend/src/views/project/helpers/useGanttFilters.ts @@ -34,6 +34,17 @@ const DEFAULT_DATETO_DAY_OFFSET = +55 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() { return new Date(now.getFullYear(), now.getMonth(), now.getDate() + DEFAULT_DATEFROM_DAY_OFFSET).toISOString() } @@ -48,10 +59,10 @@ function ganttRouteToFilters(route: Partial): GanttFilt return { projectId: Number(ganttRoute.params?.projectId), viewId: Number(ganttRoute.params?.viewId), - dateFrom: parseDateProp(ganttRoute.query?.dateFrom as DateKebab) || getDefaultDateFrom(), - dateTo: parseDateProp(ganttRoute.query?.dateTo as DateKebab) || getDefaultDateTo(), - showTasksWithoutDates: parseBooleanProp(ganttRoute.query?.showTasksWithoutDates as string) || DEFAULT_SHOW_TASKS_WITHOUT_DATES, - includeSubprojects: parseBooleanProp(ganttRoute.query?.includeSubprojects as string) || DEFAULT_INCLUDE_SUBPROJECTS, + dateFrom: parseDateProp(normalizeRouteQueryValue(ganttRoute.query?.dateFrom) as DateKebab) || getDefaultDateFrom(), + dateTo: parseDateProp(normalizeRouteQueryValue(ganttRoute.query?.dateTo) as DateKebab) || getDefaultDateTo(), + showTasksWithoutDates: parseBooleanProp(normalizeRouteQueryValue(ganttRoute.query?.showTasksWithoutDates)) || DEFAULT_SHOW_TASKS_WITHOUT_DATES, + includeSubprojects: parseBooleanProp(normalizeRouteQueryValue(ganttRoute.query?.includeSubprojects)) || DEFAULT_INCLUDE_SUBPROJECTS, } } @@ -64,15 +75,13 @@ function ganttGetDefaultFilters(route: Partial): GanttF // FIXME: use zod for this function ganttFiltersToRoute(filters: GanttFilters): RouteLocationRaw { - let query: Record = {} + const query: LocationQueryRaw = {} if ( filters.dateFrom !== getDefaultDateFrom() || filters.dateTo !== getDefaultDateTo() ) { - query = { - dateFrom: isoToKebabDate(filters.dateFrom), - dateTo: isoToKebabDate(filters.dateTo), - } + query.dateFrom = isoToKebabDate(filters.dateFrom) + query.dateTo = isoToKebabDate(filters.dateTo) } if (filters.showTasksWithoutDates) { @@ -80,7 +89,7 @@ function ganttFiltersToRoute(filters: GanttFilters): RouteLocationRaw { } if (filters.includeSubprojects) { - query.includeSubprojects = '1' + query.includeSubprojects = true } return { diff --git a/frontend/src/views/user/settings/General.vue b/frontend/src/views/user/settings/General.vue index 9643f1de8..3c50a8bed 100644 --- a/frontend/src/views/user/settings/General.vue +++ b/frontend/src/views/user/settings/General.vue @@ -123,6 +123,15 @@ {{ $t('user.settings.general.overdueReminders') }} +
+ +
({ timeFormat: authStore.settings.frontendSettings.timeFormat ?? TIME_FORMAT.HOURS_12, // Add fallback for old settings that don't have the default task relation type set defaultTaskRelationType: authStore.settings.frontendSettings.defaultTaskRelationType ?? 'related', + showIncludeSubprojectsToggle: authStore.settings.frontendSettings.showIncludeSubprojectsToggle ?? false, }, }) diff --git a/pkg/models/task_collection.go b/pkg/models/task_collection.go index 398d52639..59f4d7692 100644 --- a/pkg/models/task_collection.go +++ b/pkg/models/task_collection.go @@ -206,54 +206,49 @@ func getRelevantProjectsFromCollection(s *xorm.Session, a web.Auth, tf *TaskColl return []*Project{{ID: tf.ProjectID}}, nil } - projects, _, _, err = getRawProjectsForUser( - s, - &projectOptions{ - user: &user.User{ID: a.GetID()}, - page: -1, - }, - ) + projectIDs, err := getProjectAndDescendantIDs(s, tf.ProjectID) if err != nil { return nil, err } - return getProjectSubtree(projects, tf.ProjectID), nil -} + u, err := user.GetUserByID(s, a.GetID()) + if err != nil { + return nil, err + } -func getProjectSubtree(projects []*Project, rootProjectID int64) []*Project { - projectsByParent := make(map[int64][]int64, len(projects)) - for _, p := range projects { - if p == nil || p.ID <= 0 { + projectPermissions, err := checkPermissionsForProjects(s, u, projectIDs) + if err != nil { + return nil, err + } + + relevantProjects := make([]*Project, 0, len(projectIDs)) + for _, projectID := range projectIDs { + permission, has := projectPermissions[projectID] + if !has || permission.MaxPermission < PermissionRead { continue } - projectsByParent[p.ParentProjectID] = append(projectsByParent[p.ParentProjectID], p.ID) - } - visited := map[int64]bool{rootProjectID: true} - queue := []int64{rootProjectID} - relevantProjectIDs := []int64{rootProjectID} - - for len(queue) > 0 { - currentProjectID := queue[0] - queue = queue[1:] - - for _, childProjectID := range projectsByParent[currentProjectID] { - if visited[childProjectID] { - continue - } - - visited[childProjectID] = true - relevantProjectIDs = append(relevantProjectIDs, childProjectID) - queue = append(queue, childProjectID) - } - } - - relevantProjects := make([]*Project, 0, len(relevantProjectIDs)) - for _, projectID := range relevantProjectIDs { relevantProjects = append(relevantProjects, &Project{ID: projectID}) } - return relevantProjects + return relevantProjects, nil +} + +func getProjectAndDescendantIDs(s *xorm.Session, rootProjectID int64) (projectIDs []int64, err error) { + err = s.SQL(` +WITH RECURSIVE descendant_projects (id) AS ( + SELECT id + FROM projects + WHERE id = ? + UNION ALL + SELECT p.id + FROM projects p + INNER JOIN descendant_projects dp ON p.parent_project_id = dp.id +) +SELECT DISTINCT id +FROM descendant_projects`, rootProjectID). + Find(&projectIDs) + return } func getFilterValueForBucketFilter(filter string, view *ProjectView) (newFilter string, err error) { diff --git a/pkg/models/task_collection_test.go b/pkg/models/task_collection_test.go index af7e77e2f..613b04842 100644 --- a/pkg/models/task_collection_test.go +++ b/pkg/models/task_collection_test.go @@ -1779,6 +1779,26 @@ func TestTaskCollection_ReadAll(t *testing.T) { 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", fields: fields{ @@ -1924,6 +1944,24 @@ func TestTaskCollection_ReadAll(t *testing.T) { } } +func TestGetProjectAndDescendantIDs(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + t.Run("single project", func(t *testing.T) { + projectIDs, err := getProjectAndDescendantIDs(s, 1) + require.NoError(t, err) + assert.ElementsMatch(t, []int64{1}, projectIDs) + }) + + t.Run("recursive descendants", func(t *testing.T) { + projectIDs, err := getProjectAndDescendantIDs(s, 12) + require.NoError(t, err) + assert.ElementsMatch(t, []int64{12, 25, 26}, projectIDs) + }) +} + func TestTaskCollection_SubtaskRemainsAfterMove(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() From c1a92f6abd5da2b579af8b007621850c9a2277ac Mon Sep 17 00:00:00 2001 From: James Batten Date: Mon, 2 Mar 2026 14:27:40 +0100 Subject: [PATCH 3/9] fix(project-views): persist include subprojects per view --- frontend/src/components/gantt/GanttChart.vue | 3 +- .../project/partials/FilterPopup.vue | 60 ++++++++++++++++++- .../components/project/partials/Filters.vue | 13 ++++ .../components/project/views/ProjectGantt.vue | 45 +++++++++++++- .../project/views/ProjectKanban.vue | 12 ++-- .../components/project/views/ProjectList.vue | 23 ++----- .../components/project/views/ProjectTable.vue | 18 +----- .../components/project/views/ViewEditForm.vue | 15 ++++- frontend/src/composables/useTaskList.ts | 20 +------ frontend/src/modelTypes/IProjectView.ts | 1 + frontend/src/models/projectView.ts | 1 + .../views/project/helpers/useGanttFilters.ts | 27 +++++---- pkg/migration/20260302140100.go | 43 +++++++++++++ pkg/models/project_view.go | 3 + 14 files changed, 212 insertions(+), 72 deletions(-) create mode 100644 pkg/migration/20260302140100.go diff --git a/frontend/src/components/gantt/GanttChart.vue b/frontend/src/components/gantt/GanttChart.vue index f0867172a..0f9fb5983 100644 --- a/frontend/src/components/gantt/GanttChart.vue +++ b/frontend/src/components/gantt/GanttChart.vue @@ -118,6 +118,7 @@ import {roundToNaturalDayBoundary} from '@/helpers/time/roundToNaturalDayBoundar const props = defineProps<{ isLoading: boolean, filters: GanttFilters, + includeSubprojects: boolean, tasks: Map, defaultTaskStartDate: DateISO defaultTaskEndDate: DateISO @@ -246,7 +247,7 @@ function getRoundedDate(value: string | Date | undefined, fallback: Date | strin } function getTaskLabel(task: ITask): string { - if (!filters.value.includeSubprojects) { + if (!props.includeSubprojects) { return task.title } diff --git a/frontend/src/components/project/partials/FilterPopup.vue b/frontend/src/components/project/partials/FilterPopup.vue index 22bd9173c..4ae720bf7 100644 --- a/frontend/src/components/project/partials/FilterPopup.vue +++ b/frontend/src/components/project/partials/FilterPopup.vue @@ -21,15 +21,18 @@ class="filter-popup" :change-immediately="false" :filter-from-view="filterFromView" + :show-include-subprojects-toggle="showIncludeSubprojectsToggle" + :include-subprojects="includeSubprojects" show-close @close="modalOpen = false" + @update:includeSubprojects="updateIncludeSubprojects" @showResults="showResults" />