feat: add include subprojects task view support
This commit is contained in:
parent
ddfc565c61
commit
6e8d424ca3
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue