feat: add include subprojects task view support

This commit is contained in:
James Batten 2026-02-20 14:18:41 +01:00
parent ddfc565c61
commit 6e8d424ca3
11 changed files with 266 additions and 16 deletions

View File

@ -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)),

View File

@ -38,6 +38,14 @@
>
{{ $t('task.show.noDates') }}
</FancyCheckbox>
<FancyCheckbox
v-if="filters.projectId > 0"
v-model="filters.includeSubprojects"
v-tooltip="$t('project.views.includeSubprojectsHint')"
is-block
>
{{ $t('project.views.includeSubprojects') }}
</FancyCheckbox>
</div>
</Card>

View File

@ -14,6 +14,14 @@
:project-id="projectId"
@update:modelValue="prepareFiltersAndLoadTasks()"
/>
<FancyCheckbox
v-if="projectId > 0"
v-model="includeSubprojects"
v-tooltip="$t('project.views.includeSubprojectsHint')"
class="include-subprojects-toggle"
>
{{ $t('project.views.includeSubprojects') }}
</FancyCheckbox>
</div>
</template>
@ -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;
}
</style>

View File

@ -81,6 +81,14 @@
:project-id="projectId"
@update:modelValue="taskList.loadTasks()"
/>
<FancyCheckbox
v-if="projectId > 0"
v-model="includeSubprojects"
v-tooltip="$t('project.views.includeSubprojectsHint')"
class="include-subprojects-toggle"
>
{{ $t('project.views.includeSubprojects') }}
</FancyCheckbox>
</div>
</template>
@ -226,6 +234,12 @@
{{ t.title }}
</RouterLink>
</TaskGlanceTooltip>
<span
v-if="includeSubprojects && t.projectId !== projectId && projectStore.projects[t.projectId]"
class="tag is-light is-info task-project-tag"
>
{{ projectStore.projects[t.projectId].title }}
</span>
</td>
<td v-if="activeColumns.priority">
<PriorityLabel
@ -374,6 +388,7 @@ const {
totalPages,
currentPage,
sortByParam,
includeSubprojects,
} = taskList
const tasks: Ref<ITask[]> = 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;
}
</style>

View File

@ -153,7 +153,7 @@
class="task-project"
@click.stop
>
{{ project.title }}
{{ projectLabel }}
</RouterLink>
<BaseButton
@ -286,6 +286,44 @@ const currentProject = computed(() => {
} : 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<number>()
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},

View File

@ -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<boolean>({
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,
}
}

View File

@ -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": {

View File

@ -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,

View File

@ -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<RouteLocationNormalized>): 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,
}
}

View File

@ -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 {

View File

@ -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,