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()