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,