fix: address include-subprojects review feedback

This commit is contained in:
James Batten 2026-02-21 00:30:29 +01:00
parent 6e8d424ca3
commit a424d42e1d
14 changed files with 137 additions and 98 deletions

View File

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

View File

@ -39,7 +39,7 @@
{{ $t('task.show.noDates') }}
</FancyCheckbox>
<FancyCheckbox
v-if="filters.projectId > 0"
v-if="filters.projectId > 0 && showIncludeSubprojectsToggle"
v-model="filters.includeSubprojects"
v-tooltip="$t('project.views.includeSubprojectsHint')"
is-block
@ -80,6 +80,7 @@ import {useI18n} from 'vue-i18n'
import type {RouteLocationNormalized} from 'vue-router'
import {useBaseStore} from '@/stores/base'
import {useAuthStore} from '@/stores/auth'
import {useFlatpickrLanguage} from '@/helpers/useFlatpickrLanguage'
import Foo from '@/components/misc/flatpickr/Flatpickr.vue'
@ -106,7 +107,9 @@ const props = defineProps<{
const baseStore = useBaseStore()
const authStore = useAuthStore()
const canWrite = computed(() => baseStore.currentProject?.maxPermission > PERMISSIONS.READ)
const showIncludeSubprojectsToggle = computed(() => authStore.settings.frontendSettings.showIncludeSubprojectsToggle ?? false)
const {route, viewId} = toRefs(props)
const {

View File

@ -15,7 +15,7 @@
@update:modelValue="prepareFiltersAndLoadTasks()"
/>
<FancyCheckbox
v-if="projectId > 0"
v-if="projectId > 0 && showIncludeSubprojectsToggle"
v-model="includeSubprojects"
v-tooltip="$t('project.views.includeSubprojectsHint')"
class="include-subprojects-toggle"
@ -130,6 +130,7 @@ import {isSavedFilter, useSavedFilter} from '@/services/savedFilter'
import {useBaseStore} from '@/stores/base'
import {useTaskStore} from '@/stores/tasks'
import {useAuthStore} from '@/stores/auth'
import type {IProject} from '@/modelTypes/IProject'
import type {IProjectView} from '@/modelTypes/IProjectView'
@ -195,8 +196,10 @@ const firstNewPosition = computed(() => {
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

View File

@ -82,7 +82,7 @@
@update:modelValue="taskList.loadTasks()"
/>
<FancyCheckbox
v-if="projectId > 0"
v-if="projectId > 0 && showIncludeSubprojectsToggle"
v-model="includeSubprojects"
v-tooltip="$t('project.views.includeSubprojectsHint')"
class="include-subprojects-toggle"
@ -234,12 +234,6 @@
{{ 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
@ -340,6 +334,7 @@ import type {IProjectView} from '@/modelTypes/IProjectView'
import { camelCase } from 'change-case'
import {isSavedFilter} from '@/services/savedFilter'
import {useProjectStore} from '@/stores/projects'
import {useAuthStore} from '@/stores/auth'
const props = defineProps<{
isLoadingProject: boolean,
@ -348,6 +343,8 @@ const props = defineProps<{
}>()
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;
}
</style>

View File

@ -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<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('/')
return project.value.title
})
const taskDetailRoute = computed(() => ({

View File

@ -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<boolean>({
get: () => includeSubprojectsQuery.value === '1' || includeSubprojectsQuery.value === 'true',
set: (value) => {
includeSubprojectsQuery.value = value ? '1' : undefined
const includeSubprojects = useRouteQuery<RouteQueryBoolean, boolean>('includeSubprojects', undefined, {
transform: {
get: parseRouteBoolean,
set: (value) => value ? 'true' : undefined,
},
})

View File

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

View File

@ -22,6 +22,7 @@ export interface IFrontendSettings {
defaultTaskRelationType: IRelationKind
backgroundBrightness: number | null
alwaysShowBucketTaskCount: boolean
showIncludeSubprojectsToggle?: boolean
sidebarWidth: number | null
commentSortOrder: 'asc' | 'desc'
}

View File

@ -33,6 +33,7 @@ export default class UserSettingsModel extends AbstractModel<IUserSettings> impl
defaultTaskRelationType: RELATION_KIND.RELATED,
backgroundBrightness: null,
alwaysShowBucketTaskCount: false,
showIncludeSubprojectsToggle: false,
sidebarWidth: null,
commentSortOrder: 'asc',
}

View File

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

View File

@ -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<RouteLocationNormalized>): 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<RouteLocationNormalized>): GanttF
// FIXME: use zod for this
function ganttFiltersToRoute(filters: GanttFilters): RouteLocationRaw {
let query: Record<string, string> = {}
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 {

View File

@ -123,6 +123,15 @@
{{ $t('user.settings.general.overdueReminders') }}
</label>
</div>
<div class="field">
<label class="checkbox">
<input
v-model="settings.frontendSettings.showIncludeSubprojectsToggle"
type="checkbox"
>
{{ $t('user.settings.general.showIncludeSubprojectsToggle') }}
</label>
</div>
<div
v-if="settings.overdueTasksRemindersEnabled"
class="field"
@ -462,6 +471,7 @@ const settings = ref<IUserSettings>({
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,
},
})

View File

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

View File

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