fix: address include-subprojects review feedback
This commit is contained in:
parent
6e8d424ca3
commit
a424d42e1d
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(() => ({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ export interface IFrontendSettings {
|
|||
defaultTaskRelationType: IRelationKind
|
||||
backgroundBrightness: number | null
|
||||
alwaysShowBucketTaskCount: boolean
|
||||
showIncludeSubprojectsToggle?: boolean
|
||||
sidebarWidth: number | null
|
||||
commentSortOrder: 'asc' | 'desc'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in New Issue