Merge efa9d63ae1 into 076cd214fe
This commit is contained in:
commit
a417bd80f7
|
|
@ -102,6 +102,7 @@ import type {ITask, ITaskPartialWithId} from '@/modelTypes/ITask'
|
||||||
import type {DateISO} from '@/types/DateISO'
|
import type {DateISO} from '@/types/DateISO'
|
||||||
import type {GanttFilters} from '@/views/project/helpers/useGanttFilters'
|
import type {GanttFilters} from '@/views/project/helpers/useGanttFilters'
|
||||||
import type {GanttBarModel, GanttBarDateType} from '@/composables/useGanttBar'
|
import type {GanttBarModel, GanttBarDateType} from '@/composables/useGanttBar'
|
||||||
|
import {useProjectStore} from '@/stores/projects'
|
||||||
|
|
||||||
import GanttChartBody from '@/components/gantt/GanttChartBody.vue'
|
import GanttChartBody from '@/components/gantt/GanttChartBody.vue'
|
||||||
import GanttRow from '@/components/gantt/GanttRow.vue'
|
import GanttRow from '@/components/gantt/GanttRow.vue'
|
||||||
|
|
@ -117,6 +118,7 @@ import {roundToNaturalDayBoundary} from '@/helpers/time/roundToNaturalDayBoundar
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
isLoading: boolean,
|
isLoading: boolean,
|
||||||
filters: GanttFilters,
|
filters: GanttFilters,
|
||||||
|
includeSubprojects: boolean,
|
||||||
tasks: Map<ITask['id'], ITask>,
|
tasks: Map<ITask['id'], ITask>,
|
||||||
defaultTaskStartDate: DateISO
|
defaultTaskStartDate: DateISO
|
||||||
defaultTaskEndDate: DateISO
|
defaultTaskEndDate: DateISO
|
||||||
|
|
@ -131,6 +133,7 @@ const dayWidthPixels = ref(0)
|
||||||
let resizeObserver: ResizeObserver
|
let resizeObserver: ResizeObserver
|
||||||
|
|
||||||
const {tasks, filters} = toRefs(props)
|
const {tasks, filters} = toRefs(props)
|
||||||
|
const projectStore = useProjectStore()
|
||||||
|
|
||||||
const dayjsLanguageLoading = useDayjsLanguageSync(dayjs)
|
const dayjsLanguageLoading = useDayjsLanguageSync(dayjs)
|
||||||
const ganttContainer = ref(null)
|
const ganttContainer = ref(null)
|
||||||
|
|
@ -245,6 +248,24 @@ function getRoundedDate(value: string | Date | undefined, fallback: Date | strin
|
||||||
return roundToNaturalDayBoundary(value ? new Date(value) : new Date(fallback), isStart)
|
return roundToNaturalDayBoundary(value ? new Date(value) : new Date(fallback), isStart)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getTaskLabel(task: ITask): string {
|
||||||
|
if (!props.includeSubprojects) {
|
||||||
|
return task.title
|
||||||
|
}
|
||||||
|
|
||||||
|
const isProjectContext = filters.value.projectId > 0
|
||||||
|
if (isProjectContext && 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 {
|
function transformTaskToGanttBar(node: GanttTaskTreeNode): GanttBarModel {
|
||||||
const t = node.task
|
const t = node.task
|
||||||
const DEFAULT_SPAN_DAYS = 7
|
const DEFAULT_SPAN_DAYS = 7
|
||||||
|
|
@ -286,7 +307,7 @@ function transformTaskToGanttBar(node: GanttTaskTreeNode): GanttBarModel {
|
||||||
start: startDate,
|
start: startDate,
|
||||||
end: endDate,
|
end: endDate,
|
||||||
meta: {
|
meta: {
|
||||||
label: t.title,
|
label: getTaskLabel(t),
|
||||||
task: t,
|
task: t,
|
||||||
color: taskColor,
|
color: taskColor,
|
||||||
hasActualDates: Boolean(t.startDate && (t.endDate || t.dueDate)),
|
hasActualDates: Boolean(t.startDate && (t.endDate || t.dueDate)),
|
||||||
|
|
|
||||||
|
|
@ -20,15 +20,18 @@
|
||||||
class="filter-popup"
|
class="filter-popup"
|
||||||
:change-immediately="false"
|
:change-immediately="false"
|
||||||
:filter-from-view="filterFromView"
|
:filter-from-view="filterFromView"
|
||||||
|
:show-include-subprojects-toggle="isProjectView"
|
||||||
|
:include-subprojects="includeSubprojects"
|
||||||
show-close
|
show-close
|
||||||
@close="modalOpen = false"
|
@close="modalOpen = false"
|
||||||
|
@update:includeSubprojects="updateIncludeSubprojects"
|
||||||
@showResults="showResults"
|
@showResults="showResults"
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {computed, ref, watch, nextTick} from 'vue'
|
import {computed, ref, watch, nextTick, shallowReactive} from 'vue'
|
||||||
|
|
||||||
import Filters from '@/components/project/partials/Filters.vue'
|
import Filters from '@/components/project/partials/Filters.vue'
|
||||||
|
|
||||||
|
|
@ -36,6 +39,9 @@ import {type TaskFilterParams} from '@/services/taskCollection'
|
||||||
import {type IProjectView} from '@/modelTypes/IProjectView'
|
import {type IProjectView} from '@/modelTypes/IProjectView'
|
||||||
import {type IProject} from '@/modelTypes/IProject'
|
import {type IProject} from '@/modelTypes/IProject'
|
||||||
import {useProjectStore} from '@/stores/projects'
|
import {useProjectStore} from '@/stores/projects'
|
||||||
|
import ProjectViewService from '@/services/projectViews'
|
||||||
|
import ProjectViewModel from '@/models/projectView'
|
||||||
|
import {error} from '@/message'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: TaskFilterParams,
|
modelValue: TaskFilterParams,
|
||||||
|
|
@ -48,6 +54,7 @@ const emit = defineEmits<{
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const projectStore = useProjectStore()
|
const projectStore = useProjectStore()
|
||||||
|
const projectViewService = shallowReactive(new ProjectViewService())
|
||||||
|
|
||||||
const value = ref<TaskFilterParams>({})
|
const value = ref<TaskFilterParams>({})
|
||||||
const filtersRef = ref()
|
const filtersRef = ref()
|
||||||
|
|
@ -88,6 +95,49 @@ function showResults() {
|
||||||
modalOpen.value = false
|
modalOpen.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentView = computed(() => {
|
||||||
|
if (!isProjectView.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return projectStore.projects[props.projectId]?.views.find(v => v.id === props.viewId)
|
||||||
|
})
|
||||||
|
|
||||||
|
const isProjectView = computed(() => Boolean(props.projectId && props.projectId > 0 && props.viewId))
|
||||||
|
|
||||||
|
const includeSubprojects = computed(() => currentView.value?.includeSubprojects ?? false)
|
||||||
|
|
||||||
|
async function updateIncludeSubprojects(newValue: boolean) {
|
||||||
|
if (!currentView.value || !props.projectId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldView = currentView.value
|
||||||
|
const oldValue = oldView.includeSubprojects ?? false
|
||||||
|
if (oldValue === newValue) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
projectStore.setProjectView({
|
||||||
|
...oldView,
|
||||||
|
includeSubprojects: newValue,
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updatedView = await projectViewService.update(new ProjectViewModel({
|
||||||
|
...oldView,
|
||||||
|
includeSubprojects: newValue,
|
||||||
|
}))
|
||||||
|
projectStore.setProjectView(updatedView)
|
||||||
|
} catch (e) {
|
||||||
|
projectStore.setProjectView({
|
||||||
|
...oldView,
|
||||||
|
includeSubprojects: oldValue,
|
||||||
|
})
|
||||||
|
error(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const filterFromView = computed(() => {
|
const filterFromView = computed(() => {
|
||||||
if (!props.projectId || !props.viewId) {
|
if (!props.projectId || !props.viewId) {
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,14 @@
|
||||||
>
|
>
|
||||||
{{ $t('filters.attributes.includeNulls') }}
|
{{ $t('filters.attributes.includeNulls') }}
|
||||||
</FancyCheckbox>
|
</FancyCheckbox>
|
||||||
|
<FancyCheckbox
|
||||||
|
v-if="showIncludeSubprojectsToggle"
|
||||||
|
v-tooltip="$t('project.views.includeSubprojectsHint')"
|
||||||
|
:model-value="includeSubprojects"
|
||||||
|
@update:modelValue="(value: boolean) => emit('update:includeSubprojects', value)"
|
||||||
|
>
|
||||||
|
{{ $t('project.views.includeSubprojects') }}
|
||||||
|
</FancyCheckbox>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FilterInputDocs />
|
<FilterInputDocs />
|
||||||
|
|
@ -76,16 +84,21 @@ const props = withDefaults(defineProps<{
|
||||||
changeImmediately?: boolean,
|
changeImmediately?: boolean,
|
||||||
filterFromView?: string,
|
filterFromView?: string,
|
||||||
showClose?: boolean,
|
showClose?: boolean,
|
||||||
|
showIncludeSubprojectsToggle?: boolean,
|
||||||
|
includeSubprojects?: boolean,
|
||||||
}>(), {
|
}>(), {
|
||||||
hasTitle: false,
|
hasTitle: false,
|
||||||
hasFooter: true,
|
hasFooter: true,
|
||||||
changeImmediately: false,
|
changeImmediately: false,
|
||||||
filterFromView: undefined,
|
filterFromView: undefined,
|
||||||
showClose: false,
|
showClose: false,
|
||||||
|
showIncludeSubprojectsToggle: false,
|
||||||
|
includeSubprojects: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:modelValue': [value: TaskFilterParams],
|
'update:modelValue': [value: TaskFilterParams],
|
||||||
|
'update:includeSubprojects': [value: boolean],
|
||||||
'showResults': [],
|
'showResults': [],
|
||||||
'close': [],
|
'close': [],
|
||||||
}>()
|
}>()
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,15 @@
|
||||||
>
|
>
|
||||||
{{ $t('task.show.noDates') }}
|
{{ $t('task.show.noDates') }}
|
||||||
</FancyCheckbox>
|
</FancyCheckbox>
|
||||||
|
<FancyCheckbox
|
||||||
|
v-if="filters.projectId > 0"
|
||||||
|
v-tooltip="$t('project.views.includeSubprojectsHint')"
|
||||||
|
:model-value="includeSubprojects"
|
||||||
|
is-block
|
||||||
|
@update:modelValue="updateIncludeSubprojects"
|
||||||
|
>
|
||||||
|
{{ $t('project.views.includeSubprojects') }}
|
||||||
|
</FancyCheckbox>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|
@ -49,6 +58,7 @@
|
||||||
>
|
>
|
||||||
<GanttChart
|
<GanttChart
|
||||||
:filters="filters"
|
:filters="filters"
|
||||||
|
:include-subprojects="includeSubprojects"
|
||||||
:tasks="tasks"
|
:tasks="tasks"
|
||||||
:is-loading="isLoading"
|
:is-loading="isLoading"
|
||||||
:default-task-start-date="defaultTaskStartDate"
|
:default-task-start-date="defaultTaskStartDate"
|
||||||
|
|
@ -72,6 +82,7 @@ import {useI18n} from 'vue-i18n'
|
||||||
import type {RouteLocationNormalized} from 'vue-router'
|
import type {RouteLocationNormalized} from 'vue-router'
|
||||||
|
|
||||||
import {useBaseStore} from '@/stores/base'
|
import {useBaseStore} from '@/stores/base'
|
||||||
|
import {useProjectStore} from '@/stores/projects'
|
||||||
import {useFlatpickrLanguage} from '@/helpers/useFlatpickrLanguage'
|
import {useFlatpickrLanguage} from '@/helpers/useFlatpickrLanguage'
|
||||||
|
|
||||||
import Foo from '@/components/misc/flatpickr/Flatpickr.vue'
|
import Foo from '@/components/misc/flatpickr/Flatpickr.vue'
|
||||||
|
|
@ -83,6 +94,9 @@ import FormField from '@/components/input/FormField.vue'
|
||||||
import GanttChart from '@/components/gantt/GanttChart.vue'
|
import GanttChart from '@/components/gantt/GanttChart.vue'
|
||||||
import {useGanttFilters} from '../../../views/project/helpers/useGanttFilters'
|
import {useGanttFilters} from '../../../views/project/helpers/useGanttFilters'
|
||||||
import {PERMISSIONS} from '@/constants/permissions'
|
import {PERMISSIONS} from '@/constants/permissions'
|
||||||
|
import ProjectViewService from '@/services/projectViews'
|
||||||
|
import ProjectViewModel from '@/models/projectView'
|
||||||
|
import {error} from '@/message'
|
||||||
|
|
||||||
import type {DateISO} from '@/types/DateISO'
|
import type {DateISO} from '@/types/DateISO'
|
||||||
import type {ITask} from '@/modelTypes/ITask'
|
import type {ITask} from '@/modelTypes/ITask'
|
||||||
|
|
@ -98,9 +112,13 @@ const props = defineProps<{
|
||||||
|
|
||||||
|
|
||||||
const baseStore = useBaseStore()
|
const baseStore = useBaseStore()
|
||||||
|
const projectStore = useProjectStore()
|
||||||
const canWrite = computed(() => baseStore.currentProject?.maxPermission > PERMISSIONS.READ)
|
const canWrite = computed(() => baseStore.currentProject?.maxPermission > PERMISSIONS.READ)
|
||||||
|
const projectViewService = new ProjectViewService()
|
||||||
|
|
||||||
const {route, viewId} = toRefs(props)
|
const {route, viewId} = toRefs(props)
|
||||||
|
const currentView = computed(() => baseStore.currentProject?.views.find(v => v.id === viewId.value))
|
||||||
|
const includeSubprojects = computed(() => currentView.value?.includeSubprojects ?? false)
|
||||||
const {
|
const {
|
||||||
filters,
|
filters,
|
||||||
hasDefaultFilters,
|
hasDefaultFilters,
|
||||||
|
|
@ -109,7 +127,38 @@ const {
|
||||||
isLoading,
|
isLoading,
|
||||||
addTask,
|
addTask,
|
||||||
updateTask,
|
updateTask,
|
||||||
} = useGanttFilters(route, viewId)
|
} = useGanttFilters(route, viewId, includeSubprojects)
|
||||||
|
|
||||||
|
async function updateIncludeSubprojects(newValue: boolean) {
|
||||||
|
if (!currentView.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldView = currentView.value
|
||||||
|
const oldValue = oldView.includeSubprojects ?? false
|
||||||
|
if (oldValue === newValue) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
projectStore.setProjectView({
|
||||||
|
...oldView,
|
||||||
|
includeSubprojects: newValue,
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updatedView = await projectViewService.update(new ProjectViewModel({
|
||||||
|
...oldView,
|
||||||
|
includeSubprojects: newValue,
|
||||||
|
}))
|
||||||
|
projectStore.setProjectView(updatedView)
|
||||||
|
} catch (e) {
|
||||||
|
projectStore.setProjectView({
|
||||||
|
...oldView,
|
||||||
|
includeSubprojects: oldValue,
|
||||||
|
})
|
||||||
|
error(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const DEFAULT_DATE_RANGE_DAYS = 7
|
const DEFAULT_DATE_RANGE_DAYS = 7
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -445,6 +445,10 @@ const bucketDraggableComponentData = computed(() => ({
|
||||||
}))
|
}))
|
||||||
const project = computed(() => projectId.value ? projectStore.projects[projectId.value] : null)
|
const project = computed(() => projectId.value ? projectStore.projects[projectId.value] : null)
|
||||||
const view = computed(() => project.value?.views.find(v => v.id === props.viewId) as IProjectView || null)
|
const view = computed(() => project.value?.views.find(v => v.id === props.viewId) as IProjectView || null)
|
||||||
|
const requestParams = computed(() => ({
|
||||||
|
...params.value,
|
||||||
|
...(view.value?.includeSubprojects ? {include_subprojects: true} : {}),
|
||||||
|
}))
|
||||||
const canWrite = computed(() => baseStore.currentProject?.maxPermission > Permissions.READ && view.value.bucketConfigurationMode === 'manual')
|
const canWrite = computed(() => baseStore.currentProject?.maxPermission > Permissions.READ && view.value.bucketConfigurationMode === 'manual')
|
||||||
const canCreateTasks = computed(() => canWrite.value && projectId.value > 0)
|
const canCreateTasks = computed(() => canWrite.value && projectId.value > 0)
|
||||||
|
|
||||||
|
|
@ -489,7 +493,7 @@ const taskLoading = computed(() => taskStore.isLoading || taskPositionService.va
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => ({
|
() => ({
|
||||||
params: params.value,
|
params: requestParams.value,
|
||||||
projectId: projectId.value,
|
projectId: projectId.value,
|
||||||
viewId: props.viewId,
|
viewId: props.viewId,
|
||||||
}),
|
}),
|
||||||
|
|
@ -524,7 +528,7 @@ function handleTaskContainerScroll(id: IBucket['id'], el: HTMLElement) {
|
||||||
kanbanStore.loadNextTasksForBucket(
|
kanbanStore.loadNextTasksForBucket(
|
||||||
projectId.value,
|
projectId.value,
|
||||||
props.viewId,
|
props.viewId,
|
||||||
params.value,
|
requestParams.value,
|
||||||
id,
|
id,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -723,7 +727,7 @@ async function deleteBucket() {
|
||||||
projectId: projectIdWithFallback.value,
|
projectId: projectIdWithFallback.value,
|
||||||
projectViewId: props.viewId,
|
projectViewId: props.viewId,
|
||||||
}),
|
}),
|
||||||
params: params.value,
|
params: requestParams.value,
|
||||||
})
|
})
|
||||||
success({message: t('project.kanban.deleteBucketSuccess')})
|
success({message: t('project.kanban.deleteBucketSuccess')})
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -773,7 +777,7 @@ function handleRecurringTaskCompletion() {
|
||||||
|
|
||||||
if (filterContainsDateFields) {
|
if (filterContainsDateFields) {
|
||||||
// Reload the kanban board to refresh tasks that now match/don't match the filter
|
// Reload the kanban board to refresh tasks that now match/don't match the filter
|
||||||
kanbanStore.loadBucketsForProject(projectId.value, props.viewId, params.value)
|
kanbanStore.loadBucketsForProject(projectId.value, props.viewId, requestParams.value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -139,6 +139,10 @@ const projectId = toRef(props, 'projectId')
|
||||||
|
|
||||||
defineOptions({name: 'List'})
|
defineOptions({name: 'List'})
|
||||||
|
|
||||||
|
const baseStore = useBaseStore()
|
||||||
|
const project = computed(() => baseStore.currentProject)
|
||||||
|
const currentView = computed(() => project.value?.views.find(v => v.id === props.viewId))
|
||||||
|
|
||||||
const ctaVisible = ref(false)
|
const ctaVisible = ref(false)
|
||||||
|
|
||||||
const drag = ref(false)
|
const drag = ref(false)
|
||||||
|
|
@ -158,6 +162,7 @@ const {
|
||||||
() => projectId.value === -1
|
() => projectId.value === -1
|
||||||
? ['comment_count', 'is_unread']
|
? ['comment_count', 'is_unread']
|
||||||
: ['subtasks', 'comment_count', 'is_unread'],
|
: ['subtasks', 'comment_count', 'is_unread'],
|
||||||
|
() => currentView.value?.includeSubprojects ?? false,
|
||||||
)
|
)
|
||||||
|
|
||||||
const taskPositionService = ref(new TaskPositionService())
|
const taskPositionService = ref(new TaskPositionService())
|
||||||
|
|
@ -184,10 +189,8 @@ const firstNewPosition = computed(() => {
|
||||||
return calculateItemPosition(null, tasks.value[0].position)
|
return calculateItemPosition(null, tasks.value[0].position)
|
||||||
})
|
})
|
||||||
|
|
||||||
const baseStore = useBaseStore()
|
|
||||||
const taskStore = useTaskStore()
|
const taskStore = useTaskStore()
|
||||||
const {handleTaskDropToProject} = useTaskDragToProject()
|
const {handleTaskDropToProject} = useTaskDragToProject()
|
||||||
const project = computed(() => baseStore.currentProject)
|
|
||||||
|
|
||||||
const canWrite = computed(() => {
|
const canWrite = computed(() => {
|
||||||
return project.value?.maxPermission > Permissions.READ && project.value?.id > 0
|
return project.value?.maxPermission > Permissions.READ && project.value?.id > 0
|
||||||
|
|
@ -201,7 +204,6 @@ onMounted(async () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
const canDragTasks = computed(() => canWrite.value || isSavedFilter(project.value))
|
const canDragTasks = computed(() => canWrite.value || isSavedFilter(project.value))
|
||||||
|
|
||||||
const isTouchDevice = ref(false)
|
const isTouchDevice = ref(false)
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
isTouchDevice.value = !window.matchMedia('(hover: hover) and (pointer: fine)').matches
|
isTouchDevice.value = !window.matchMedia('(hover: hover) and (pointer: fine)').matches
|
||||||
|
|
|
||||||
|
|
@ -334,6 +334,7 @@ const props = defineProps<{
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const projectStore = useProjectStore()
|
const projectStore = useProjectStore()
|
||||||
|
const currentView = computed(() => projectStore.projects[props.projectId]?.views.find(v => v.id === props.viewId))
|
||||||
|
|
||||||
const ACTIVE_COLUMNS_DEFAULT = {
|
const ACTIVE_COLUMNS_DEFAULT = {
|
||||||
index: true,
|
index: true,
|
||||||
|
|
@ -366,6 +367,7 @@ const taskList = useTaskList(
|
||||||
() => props.viewId,
|
() => props.viewId,
|
||||||
sortBy.value,
|
sortBy.value,
|
||||||
() => ['comment_count', 'is_unread'],
|
() => ['comment_count', 'is_unread'],
|
||||||
|
() => currentView.value?.includeSubprojects ?? false,
|
||||||
)
|
)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import FancyCheckbox from '@/components/input/FancyCheckbox.vue'
|
||||||
import FilterInputDocs from '@/components/input/filter/FilterInputDocs.vue'
|
import FilterInputDocs from '@/components/input/filter/FilterInputDocs.vue'
|
||||||
import FilterInput from '@/components/input/filter/FilterInput.vue'
|
import FilterInput from '@/components/input/filter/FilterInput.vue'
|
||||||
import FormField from '@/components/input/FormField.vue'
|
import FormField from '@/components/input/FormField.vue'
|
||||||
|
import FancyCheckbox from '@/components/input/FancyCheckbox.vue'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
modelValue: IProjectView,
|
modelValue: IProjectView,
|
||||||
|
|
@ -189,6 +190,15 @@ function handleBubbleSave() {
|
||||||
<FilterInputDocs />
|
<FilterInputDocs />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="field mbe-3">
|
||||||
|
<FancyCheckbox
|
||||||
|
v-model="view.includeSubprojects"
|
||||||
|
v-tooltip="$t('project.views.includeSubprojects')"
|
||||||
|
>
|
||||||
|
{{ $t('project.views.includeSubprojects') }}
|
||||||
|
</FancyCheckbox>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="field mbe-3">
|
<div class="field mbe-3">
|
||||||
<FancyCheckbox
|
<FancyCheckbox
|
||||||
v-model="view.filter.filter_include_nulls"
|
v-model="view.filter.filter_include_nulls"
|
||||||
|
|
|
||||||
|
|
@ -153,7 +153,7 @@
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<RouterLink
|
<RouterLink
|
||||||
v-if="showProjectSeparately"
|
v-if="showProjectSeparately && project"
|
||||||
v-tooltip="$t('task.detail.belongsToProject', {project: project.title})"
|
v-tooltip="$t('task.detail.belongsToProject', {project: project.title})"
|
||||||
:to="{ name: 'project.index', params: { projectId: task.projectId } }"
|
:to="{ name: 'project.index', params: { projectId: task.projectId } }"
|
||||||
class="task-project"
|
class="task-project"
|
||||||
|
|
@ -161,7 +161,6 @@
|
||||||
>
|
>
|
||||||
{{ project.title }}
|
{{ project.title }}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
|
||||||
<BaseButton
|
<BaseButton
|
||||||
:class="{'is-favorite': task.isFavorite}"
|
:class="{'is-favorite': task.isFavorite}"
|
||||||
class="favorite"
|
class="favorite"
|
||||||
|
|
@ -292,6 +291,8 @@ const currentProject = computed(() => {
|
||||||
} : baseStore.currentProject
|
} : baseStore.currentProject
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const taskDetailRoute = computed(() => ({
|
const taskDetailRoute = computed(() => ({
|
||||||
name: 'task.detail',
|
name: 'task.detail',
|
||||||
params: {id: task.value.id},
|
params: {id: task.value.id},
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,7 @@ export function useTaskList(
|
||||||
projectViewIdGetter: ComputedGetter<IProjectView['id']>,
|
projectViewIdGetter: ComputedGetter<IProjectView['id']>,
|
||||||
sortByDefault: SortBy = SORT_BY_DEFAULT,
|
sortByDefault: SortBy = SORT_BY_DEFAULT,
|
||||||
expandGetter: ComputedGetter<ExpandTaskFilterParam> = () => 'subtasks',
|
expandGetter: ComputedGetter<ExpandTaskFilterParam> = () => 'subtasks',
|
||||||
|
includeSubprojectsGetter: ComputedGetter<boolean> = () => false,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
const projectId = computed(() => projectIdGetter())
|
const projectId = computed(() => projectIdGetter())
|
||||||
|
|
@ -121,6 +122,7 @@ export function useTaskList(
|
||||||
const page = useRouteQuery('page', '1', { transform: Number })
|
const page = useRouteQuery('page', '1', { transform: Number })
|
||||||
const filter = useRouteQuery('filter')
|
const filter = useRouteQuery('filter')
|
||||||
const s = useRouteQuery('s')
|
const s = useRouteQuery('s')
|
||||||
|
const includeSubprojects = computed(() => includeSubprojectsGetter())
|
||||||
|
|
||||||
watch(filter, v => { params.value.filter = v ?? '' }, { immediate: true })
|
watch(filter, v => { params.value.filter = v ?? '' }, { immediate: true })
|
||||||
watch(s, v => { params.value.s = v ?? '' }, { immediate: true })
|
watch(s, v => { params.value.s = v ?? '' }, { immediate: true })
|
||||||
|
|
@ -197,7 +199,7 @@ export function useTaskList(
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
[params, sortBy, page],
|
[params, sortBy, page, includeSubprojects],
|
||||||
([, , newPage], [, , oldPage]) => {
|
([, , newPage], [, , oldPage]) => {
|
||||||
if (newPage === oldPage) {
|
if (newPage === oldPage) {
|
||||||
page.value = 1
|
page.value = 1
|
||||||
|
|
@ -216,6 +218,7 @@ export function useTaskList(
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
...allParams.value,
|
...allParams.value,
|
||||||
|
...(includeSubprojects.value ? {include_subprojects: true} : {}),
|
||||||
filter_timezone: authStore.settings.timezone,
|
filter_timezone: authStore.settings.timezone,
|
||||||
expand: expandGetter(),
|
expand: expandGetter(),
|
||||||
},
|
},
|
||||||
|
|
@ -257,5 +260,6 @@ export function useTaskList(
|
||||||
loadTasks,
|
loadTasks,
|
||||||
params,
|
params,
|
||||||
sortByParam: sortBy,
|
sortByParam: sortBy,
|
||||||
|
includeSubprojects,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -563,7 +563,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!",
|
"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.",
|
"deleteSuccess": "The view was deleted successfully.",
|
||||||
"onlyAdminsCanEdit": "Only project admins can edit views.",
|
"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": {
|
"filters": {
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ export interface IProjectView extends IAbstract {
|
||||||
|
|
||||||
filter: IFilters|undefined
|
filter: IFilters|undefined
|
||||||
position: number
|
position: number
|
||||||
|
includeSubprojects?: boolean
|
||||||
|
|
||||||
bucketConfigurationMode: ProjectViewBucketConfigurationMode
|
bucketConfigurationMode: ProjectViewBucketConfigurationMode
|
||||||
bucketConfiguration: IProjectViewBucketConfiguration[]
|
bucketConfiguration: IProjectViewBucketConfiguration[]
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ export default class ProjectViewModel extends AbstractModel<IProjectView> implem
|
||||||
s: '',
|
s: '',
|
||||||
}
|
}
|
||||||
position = 0
|
position = 0
|
||||||
|
includeSubprojects = false
|
||||||
|
|
||||||
bucketConfiguration = []
|
bucketConfiguration = []
|
||||||
bucketConfigurationMode: ProjectViewBucketConfigurationMode = 'manual'
|
bucketConfigurationMode: ProjectViewBucketConfigurationMode = 'manual'
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ export interface TaskFilterParams {
|
||||||
order_by: ('asc' | 'desc')[],
|
order_by: ('asc' | 'desc')[],
|
||||||
filter: string,
|
filter: string,
|
||||||
filter_include_nulls: boolean,
|
filter_include_nulls: boolean,
|
||||||
|
include_subprojects?: boolean,
|
||||||
filter_timezone?: string,
|
filter_timezone?: string,
|
||||||
s: string,
|
s: string,
|
||||||
per_page?: number,
|
per_page?: number,
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,17 @@ const DEFAULT_DATETO_DAY_OFFSET = +55
|
||||||
|
|
||||||
const now = new Date()
|
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() {
|
function getDefaultDateFrom() {
|
||||||
return new Date(now.getFullYear(), now.getMonth(), now.getDate() + DEFAULT_DATEFROM_DAY_OFFSET).toISOString()
|
return new Date(now.getFullYear(), now.getMonth(), now.getDate() + DEFAULT_DATEFROM_DAY_OFFSET).toISOString()
|
||||||
}
|
}
|
||||||
|
|
@ -46,9 +57,9 @@ function ganttRouteToFilters(route: Partial<RouteLocationNormalized>): GanttFilt
|
||||||
return {
|
return {
|
||||||
projectId: Number(ganttRoute.params?.projectId),
|
projectId: Number(ganttRoute.params?.projectId),
|
||||||
viewId: Number(ganttRoute.params?.viewId),
|
viewId: Number(ganttRoute.params?.viewId),
|
||||||
dateFrom: parseDateProp(ganttRoute.query?.dateFrom as DateKebab) || getDefaultDateFrom(),
|
dateFrom: parseDateProp(normalizeRouteQueryValue(ganttRoute.query?.dateFrom) as DateKebab) || getDefaultDateFrom(),
|
||||||
dateTo: parseDateProp(ganttRoute.query?.dateTo as DateKebab) || getDefaultDateTo(),
|
dateTo: parseDateProp(normalizeRouteQueryValue(ganttRoute.query?.dateTo) as DateKebab) || getDefaultDateTo(),
|
||||||
showTasksWithoutDates: parseBooleanProp(ganttRoute.query?.showTasksWithoutDates as string) || DEFAULT_SHOW_TASKS_WITHOUT_DATES,
|
showTasksWithoutDates: parseBooleanProp(normalizeRouteQueryValue(ganttRoute.query?.showTasksWithoutDates)) || DEFAULT_SHOW_TASKS_WITHOUT_DATES,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -61,15 +72,13 @@ function ganttGetDefaultFilters(route: Partial<RouteLocationNormalized>): GanttF
|
||||||
|
|
||||||
// FIXME: use zod for this
|
// FIXME: use zod for this
|
||||||
function ganttFiltersToRoute(filters: GanttFilters): RouteLocationRaw {
|
function ganttFiltersToRoute(filters: GanttFilters): RouteLocationRaw {
|
||||||
let query: Record<string, string> = {}
|
const query: LocationQueryRaw = {}
|
||||||
if (
|
if (
|
||||||
filters.dateFrom !== getDefaultDateFrom() ||
|
filters.dateFrom !== getDefaultDateFrom() ||
|
||||||
filters.dateTo !== getDefaultDateTo()
|
filters.dateTo !== getDefaultDateTo()
|
||||||
) {
|
) {
|
||||||
query = {
|
query.dateFrom = isoToKebabDate(filters.dateFrom)
|
||||||
dateFrom: isoToKebabDate(filters.dateFrom),
|
query.dateTo = isoToKebabDate(filters.dateTo)
|
||||||
dateTo: isoToKebabDate(filters.dateTo),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filters.showTasksWithoutDates) {
|
if (filters.showTasksWithoutDates) {
|
||||||
|
|
@ -86,7 +95,7 @@ function ganttFiltersToRoute(filters: GanttFilters): RouteLocationRaw {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function ganttFiltersToApiParams(filters: GanttFilters): TaskFilterParams {
|
function ganttFiltersToApiParams(filters: GanttFilters, includeSubprojects: boolean): TaskFilterParams {
|
||||||
const dateFrom = isoToKebabDate(filters.dateFrom)
|
const dateFrom = isoToKebabDate(filters.dateFrom)
|
||||||
const dateTo = isoToKebabDate(filters.dateTo)
|
const dateTo = isoToKebabDate(filters.dateTo)
|
||||||
|
|
||||||
|
|
@ -101,6 +110,7 @@ function ganttFiltersToApiParams(filters: GanttFilters): TaskFilterParams {
|
||||||
')',
|
')',
|
||||||
filter_include_nulls: filters.showTasksWithoutDates,
|
filter_include_nulls: filters.showTasksWithoutDates,
|
||||||
expand: 'subtasks',
|
expand: 'subtasks',
|
||||||
|
include_subprojects: includeSubprojects,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -108,7 +118,11 @@ export type UseGanttFiltersReturn =
|
||||||
UseRouteFiltersReturn<GanttFilters> &
|
UseRouteFiltersReturn<GanttFilters> &
|
||||||
UseGanttTaskListReturn
|
UseGanttTaskListReturn
|
||||||
|
|
||||||
export function useGanttFilters(route: Ref<RouteLocationNormalized>, viewId: Ref<IProjectView['id']>): UseGanttFiltersReturn {
|
export function useGanttFilters(
|
||||||
|
route: Ref<RouteLocationNormalized>,
|
||||||
|
viewId: Ref<IProjectView['id']>,
|
||||||
|
includeSubprojects: Ref<boolean>,
|
||||||
|
): UseGanttFiltersReturn {
|
||||||
const viewFiltersStore = useViewFiltersStore()
|
const viewFiltersStore = useViewFiltersStore()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|
@ -145,7 +159,15 @@ export function useGanttFilters(route: Ref<RouteLocationNormalized>, viewId: Ref
|
||||||
isLoading,
|
isLoading,
|
||||||
addTask,
|
addTask,
|
||||||
updateTask,
|
updateTask,
|
||||||
} = useGanttTaskList<GanttFilters>(filters, ganttFiltersToApiParams, viewId)
|
} = useGanttTaskList<GanttFilters>(
|
||||||
|
filters,
|
||||||
|
currentFilters => ganttFiltersToApiParams(currentFilters, includeSubprojects.value),
|
||||||
|
viewId,
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(includeSubprojects, () => {
|
||||||
|
loadTasks()
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
filters,
|
filters,
|
||||||
|
|
|
||||||
|
|
@ -161,8 +161,8 @@ type ProjectView struct {
|
||||||
|
|
||||||
// The filter query to match tasks by. Check out https://vikunja.io/docs/filters for a full explanation.
|
// The filter query to match tasks by. Check out https://vikunja.io/docs/filters for a full explanation.
|
||||||
Filter *TaskCollection `xorm:"json null default null" query:"filter" json:"filter" doc:"The filter query used to match tasks shown in this view. See https://vikunja.io/docs/filters."`
|
Filter *TaskCollection `xorm:"json null default null" query:"filter" json:"filter" doc:"The filter query used to match tasks shown in this view. See https://vikunja.io/docs/filters."`
|
||||||
// The position of this view in the list. The list of all views will be sorted by this parameter.
|
// The position of this view in the list. The list of all views will be sorted by this position.
|
||||||
Position float64 `xorm:"double null" json:"position" doc:"The position of this view in the project's list of views. Views are sorted ascending by this value."`
|
Position float64 `xorm:"double null" json:"position" doc:"The position of this view in the list. The list of all views will be sorted by this position."`
|
||||||
|
|
||||||
// The bucket configuration mode. Can be `none`, `manual` or `filter`. `manual` allows to move tasks between buckets as you normally would. `filter` creates buckets based on a filter for each bucket.
|
// The bucket configuration mode. Can be `none`, `manual` or `filter`. `manual` allows to move tasks between buckets as you normally would. `filter` creates buckets based on a filter for each bucket.
|
||||||
BucketConfigurationMode BucketConfigurationModeKind `xorm:"default 0" json:"bucket_configuration_mode" swaggertype:"string" enums:"none,manual,filter" doc:"The bucket configuration mode. One of none, manual or filter. manual lets you move tasks between buckets; filter creates a bucket per filter."`
|
BucketConfigurationMode BucketConfigurationModeKind `xorm:"default 0" json:"bucket_configuration_mode" swaggertype:"string" enums:"none,manual,filter" doc:"The bucket configuration mode. One of none, manual or filter. manual lets you move tasks between buckets; filter creates a bucket per filter."`
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,8 @@ import (
|
||||||
type TaskCollection struct {
|
type TaskCollection struct {
|
||||||
ProjectID int64 `param:"project" json:"-"`
|
ProjectID int64 `param:"project" json:"-"`
|
||||||
ProjectViewID int64 `param:"view" json:"-"`
|
ProjectViewID int64 `param:"view" json:"-"`
|
||||||
|
// If set to true, tasks from all descendant subprojects will also be returned.
|
||||||
|
IncludeSubprojects bool `json:"include_subprojects" query:"include_subprojects"`
|
||||||
|
|
||||||
Search string `query:"s" json:"s" doc:"A search term to match tasks by their title."`
|
Search string `query:"s" json:"s" doc:"A search term to match tasks by their title."`
|
||||||
|
|
||||||
|
|
@ -129,6 +131,10 @@ func getTaskFilterOptsFromCollection(tf *TaskCollection, projectView *ProjectVie
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if s == taskPropertyPosition && tf.IncludeSubprojects {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
if s == taskPropertyPosition && projectView != nil {
|
if s == taskPropertyPosition && projectView != nil {
|
||||||
param.projectViewID = projectView.ID
|
param.projectViewID = projectView.ID
|
||||||
}
|
}
|
||||||
|
|
@ -201,9 +207,45 @@ 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
|
||||||
|
}
|
||||||
|
|
||||||
|
allProjects, _, _, err := getRawProjectsForUser(s, &projectOptions{
|
||||||
|
user: &user.User{ID: a.GetID()},
|
||||||
|
page: -1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
relevantProjects := make([]*Project, 0)
|
||||||
|
childrenMap := make(map[int64][]int64)
|
||||||
|
projectMap := make(map[int64]*Project)
|
||||||
|
for _, p := range allProjects {
|
||||||
|
projectMap[p.ID] = p
|
||||||
|
childrenMap[p.ParentProjectID] = append(childrenMap[p.ParentProjectID], p.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
queue := []int64{tf.ProjectID}
|
||||||
|
for len(queue) > 0 {
|
||||||
|
currentID := queue[0]
|
||||||
|
queue = queue[1:]
|
||||||
|
|
||||||
|
if p, exists := projectMap[currentID]; exists {
|
||||||
|
relevantProjects = append(relevantProjects, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
if children, exists := childrenMap[currentID]; exists {
|
||||||
|
queue = append(queue, children...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return relevantProjects, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
func getFilterValueForBucketFilter(filter string, view *ProjectView) (newFilter string, err error) {
|
func getFilterValueForBucketFilter(filter string, view *ProjectView) (newFilter string, err error) {
|
||||||
if view.BucketConfigurationMode != BucketConfigurationModeFilter {
|
if view.BucketConfigurationMode != BucketConfigurationModeFilter {
|
||||||
return filter, nil
|
return filter, nil
|
||||||
|
|
@ -238,6 +280,7 @@ func getFilterValueForBucketFilter(filter string, view *ProjectView) (newFilter
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param id path int true "The project ID."
|
// @Param id path int true "The project ID."
|
||||||
// @Param view path int true "The project view 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 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 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."
|
// @Param s query string false "Search tasks by task text."
|
||||||
|
|
@ -333,6 +376,10 @@ func (tf *TaskCollection) ReadAll(s *xorm.Session, a web.Auth, search string, pa
|
||||||
if view.Filter.FilterIncludeNulls {
|
if view.Filter.FilterIncludeNulls {
|
||||||
tf.FilterIncludeNulls = true
|
tf.FilterIncludeNulls = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if view.Filter.IncludeSubprojects {
|
||||||
|
tf.IncludeSubprojects = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.Contains(tf.Filter, taskPropertyBucketID) {
|
if strings.Contains(tf.Filter, taskPropertyBucketID) {
|
||||||
|
|
@ -344,6 +391,16 @@ func (tf *TaskCollection) ReadAll(s *xorm.Session, a web.Auth, search string, pa
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
effectiveIncludeSubprojects := tf.IncludeSubprojects &&
|
||||||
|
tf.ProjectID > 0 &&
|
||||||
|
!tf.isSavedFilter
|
||||||
|
|
||||||
|
if _, is := a.(*LinkSharing); is {
|
||||||
|
effectiveIncludeSubprojects = false
|
||||||
|
}
|
||||||
|
|
||||||
|
tf.IncludeSubprojects = effectiveIncludeSubprojects
|
||||||
|
|
||||||
opts, err := getTaskFilterOptsFromCollection(tf, view)
|
opts, err := getTaskFilterOptsFromCollection(tf, view)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, 0, err
|
return nil, 0, 0, err
|
||||||
|
|
@ -362,7 +419,7 @@ func (tf *TaskCollection) ReadAll(s *xorm.Session, a web.Auth, search string, pa
|
||||||
opts.expand = tf.Expand
|
opts.expand = tf.Expand
|
||||||
opts.isSavedFilter = tf.isSavedFilter
|
opts.isSavedFilter = tf.isSavedFilter
|
||||||
|
|
||||||
if view != nil {
|
if view != nil && !tf.IncludeSubprojects {
|
||||||
var hasOrderByPosition bool
|
var hasOrderByPosition bool
|
||||||
for _, param := range opts.sortby {
|
for _, param := range opts.sortby {
|
||||||
if param.sortBy == taskPropertyPosition {
|
if param.sortBy == taskPropertyPosition {
|
||||||
|
|
|
||||||
|
|
@ -646,11 +646,12 @@ func TestTaskCollection_ReadAll(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
type fields struct {
|
type fields struct {
|
||||||
ProjectID int64
|
ProjectID int64
|
||||||
ProjectViewID int64
|
ProjectViewID int64
|
||||||
Projects []*Project
|
IncludeSubprojects bool
|
||||||
SortBy []string // Is a string, since this is the place where a query string comes from the user
|
Projects []*Project
|
||||||
OrderBy []string
|
SortBy []string // Is a string, since this is the place where a query string comes from the user
|
||||||
|
OrderBy []string
|
||||||
|
|
||||||
FilterIncludeNulls bool
|
FilterIncludeNulls bool
|
||||||
Filter string
|
Filter string
|
||||||
|
|
@ -1561,6 +1562,63 @@ func TestTaskCollection_ReadAll(t *testing.T) {
|
||||||
want: []*Task{},
|
want: []*Task{},
|
||||||
wantErr: false,
|
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?
|
// TODO filter parent project?
|
||||||
{
|
{
|
||||||
name: "filter by index",
|
name: "filter by index",
|
||||||
|
|
@ -1688,6 +1746,26 @@ func TestTaskCollection_ReadAll(t *testing.T) {
|
||||||
task28,
|
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",
|
name: "saved filter with sort order asc",
|
||||||
fields: fields{
|
fields: fields{
|
||||||
|
|
@ -1778,10 +1856,11 @@ func TestTaskCollection_ReadAll(t *testing.T) {
|
||||||
defer s.Close()
|
defer s.Close()
|
||||||
|
|
||||||
lt := &TaskCollection{
|
lt := &TaskCollection{
|
||||||
ProjectID: tt.fields.ProjectID,
|
ProjectID: tt.fields.ProjectID,
|
||||||
ProjectViewID: tt.fields.ProjectViewID,
|
ProjectViewID: tt.fields.ProjectViewID,
|
||||||
SortBy: tt.fields.SortBy,
|
IncludeSubprojects: tt.fields.IncludeSubprojects,
|
||||||
OrderBy: tt.fields.OrderBy,
|
SortBy: tt.fields.SortBy,
|
||||||
|
OrderBy: tt.fields.OrderBy,
|
||||||
|
|
||||||
FilterIncludeNulls: tt.fields.FilterIncludeNulls,
|
FilterIncludeNulls: tt.fields.FilterIncludeNulls,
|
||||||
|
|
||||||
|
|
@ -1832,6 +1911,8 @@ func TestTaskCollection_ReadAll(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
func TestTaskCollection_SubtaskRemainsAfterMove(t *testing.T) {
|
func TestTaskCollection_SubtaskRemainsAfterMove(t *testing.T) {
|
||||||
db.LoadAndAssertFixtures(t)
|
db.LoadAndAssertFixtures(t)
|
||||||
s := db.NewSession()
|
s := db.NewSession()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue