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 {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'
|
||||
|
|
@ -117,6 +118,7 @@ import {roundToNaturalDayBoundary} from '@/helpers/time/roundToNaturalDayBoundar
|
|||
const props = defineProps<{
|
||||
isLoading: boolean,
|
||||
filters: GanttFilters,
|
||||
includeSubprojects: boolean,
|
||||
tasks: Map<ITask['id'], ITask>,
|
||||
defaultTaskStartDate: DateISO
|
||||
defaultTaskEndDate: DateISO
|
||||
|
|
@ -131,6 +133,7 @@ const dayWidthPixels = ref(0)
|
|||
let resizeObserver: ResizeObserver
|
||||
|
||||
const {tasks, filters} = toRefs(props)
|
||||
const projectStore = useProjectStore()
|
||||
|
||||
const dayjsLanguageLoading = useDayjsLanguageSync(dayjs)
|
||||
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)
|
||||
}
|
||||
|
||||
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 {
|
||||
const t = node.task
|
||||
const DEFAULT_SPAN_DAYS = 7
|
||||
|
|
@ -286,7 +307,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)),
|
||||
|
|
|
|||
|
|
@ -20,15 +20,18 @@
|
|||
class="filter-popup"
|
||||
:change-immediately="false"
|
||||
:filter-from-view="filterFromView"
|
||||
:show-include-subprojects-toggle="isProjectView"
|
||||
:include-subprojects="includeSubprojects"
|
||||
show-close
|
||||
@close="modalOpen = false"
|
||||
@update:includeSubprojects="updateIncludeSubprojects"
|
||||
@showResults="showResults"
|
||||
/>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<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'
|
||||
|
||||
|
|
@ -36,6 +39,9 @@ import {type TaskFilterParams} from '@/services/taskCollection'
|
|||
import {type IProjectView} from '@/modelTypes/IProjectView'
|
||||
import {type IProject} from '@/modelTypes/IProject'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
import ProjectViewService from '@/services/projectViews'
|
||||
import ProjectViewModel from '@/models/projectView'
|
||||
import {error} from '@/message'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: TaskFilterParams,
|
||||
|
|
@ -48,6 +54,7 @@ const emit = defineEmits<{
|
|||
}>()
|
||||
|
||||
const projectStore = useProjectStore()
|
||||
const projectViewService = shallowReactive(new ProjectViewService())
|
||||
|
||||
const value = ref<TaskFilterParams>({})
|
||||
const filtersRef = ref()
|
||||
|
|
@ -88,6 +95,49 @@ function showResults() {
|
|||
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(() => {
|
||||
if (!props.projectId || !props.viewId) {
|
||||
return
|
||||
|
|
|
|||
|
|
@ -29,6 +29,14 @@
|
|||
>
|
||||
{{ $t('filters.attributes.includeNulls') }}
|
||||
</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>
|
||||
|
||||
<FilterInputDocs />
|
||||
|
|
@ -76,16 +84,21 @@ const props = withDefaults(defineProps<{
|
|||
changeImmediately?: boolean,
|
||||
filterFromView?: string,
|
||||
showClose?: boolean,
|
||||
showIncludeSubprojectsToggle?: boolean,
|
||||
includeSubprojects?: boolean,
|
||||
}>(), {
|
||||
hasTitle: false,
|
||||
hasFooter: true,
|
||||
changeImmediately: false,
|
||||
filterFromView: undefined,
|
||||
showClose: false,
|
||||
showIncludeSubprojectsToggle: false,
|
||||
includeSubprojects: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: TaskFilterParams],
|
||||
'update:includeSubprojects': [value: boolean],
|
||||
'showResults': [],
|
||||
'close': [],
|
||||
}>()
|
||||
|
|
|
|||
|
|
@ -38,6 +38,15 @@
|
|||
>
|
||||
{{ $t('task.show.noDates') }}
|
||||
</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>
|
||||
</Card>
|
||||
|
||||
|
|
@ -49,6 +58,7 @@
|
|||
>
|
||||
<GanttChart
|
||||
:filters="filters"
|
||||
:include-subprojects="includeSubprojects"
|
||||
:tasks="tasks"
|
||||
:is-loading="isLoading"
|
||||
:default-task-start-date="defaultTaskStartDate"
|
||||
|
|
@ -72,6 +82,7 @@ import {useI18n} from 'vue-i18n'
|
|||
import type {RouteLocationNormalized} from 'vue-router'
|
||||
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
import {useFlatpickrLanguage} from '@/helpers/useFlatpickrLanguage'
|
||||
|
||||
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 {useGanttFilters} from '../../../views/project/helpers/useGanttFilters'
|
||||
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 {ITask} from '@/modelTypes/ITask'
|
||||
|
|
@ -98,9 +112,13 @@ const props = defineProps<{
|
|||
|
||||
|
||||
const baseStore = useBaseStore()
|
||||
const projectStore = useProjectStore()
|
||||
const canWrite = computed(() => baseStore.currentProject?.maxPermission > PERMISSIONS.READ)
|
||||
const projectViewService = new ProjectViewService()
|
||||
|
||||
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 {
|
||||
filters,
|
||||
hasDefaultFilters,
|
||||
|
|
@ -109,7 +127,38 @@ const {
|
|||
isLoading,
|
||||
addTask,
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -445,6 +445,10 @@ const bucketDraggableComponentData = computed(() => ({
|
|||
}))
|
||||
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 requestParams = computed(() => ({
|
||||
...params.value,
|
||||
...(view.value?.includeSubprojects ? {include_subprojects: true} : {}),
|
||||
}))
|
||||
const canWrite = computed(() => baseStore.currentProject?.maxPermission > Permissions.READ && view.value.bucketConfigurationMode === 'manual')
|
||||
const canCreateTasks = computed(() => canWrite.value && projectId.value > 0)
|
||||
|
||||
|
|
@ -489,7 +493,7 @@ const taskLoading = computed(() => taskStore.isLoading || taskPositionService.va
|
|||
|
||||
watch(
|
||||
() => ({
|
||||
params: params.value,
|
||||
params: requestParams.value,
|
||||
projectId: projectId.value,
|
||||
viewId: props.viewId,
|
||||
}),
|
||||
|
|
@ -524,7 +528,7 @@ function handleTaskContainerScroll(id: IBucket['id'], el: HTMLElement) {
|
|||
kanbanStore.loadNextTasksForBucket(
|
||||
projectId.value,
|
||||
props.viewId,
|
||||
params.value,
|
||||
requestParams.value,
|
||||
id,
|
||||
)
|
||||
}
|
||||
|
|
@ -723,7 +727,7 @@ async function deleteBucket() {
|
|||
projectId: projectIdWithFallback.value,
|
||||
projectViewId: props.viewId,
|
||||
}),
|
||||
params: params.value,
|
||||
params: requestParams.value,
|
||||
})
|
||||
success({message: t('project.kanban.deleteBucketSuccess')})
|
||||
} finally {
|
||||
|
|
@ -773,7 +777,7 @@ function handleRecurringTaskCompletion() {
|
|||
|
||||
if (filterContainsDateFields) {
|
||||
// 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'})
|
||||
|
||||
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 drag = ref(false)
|
||||
|
|
@ -158,6 +162,7 @@ const {
|
|||
() => projectId.value === -1
|
||||
? ['comment_count', 'is_unread']
|
||||
: ['subtasks', 'comment_count', 'is_unread'],
|
||||
() => currentView.value?.includeSubprojects ?? false,
|
||||
)
|
||||
|
||||
const taskPositionService = ref(new TaskPositionService())
|
||||
|
|
@ -184,10 +189,8 @@ const firstNewPosition = computed(() => {
|
|||
return calculateItemPosition(null, tasks.value[0].position)
|
||||
})
|
||||
|
||||
const baseStore = useBaseStore()
|
||||
const taskStore = useTaskStore()
|
||||
const {handleTaskDropToProject} = useTaskDragToProject()
|
||||
const project = computed(() => baseStore.currentProject)
|
||||
|
||||
const canWrite = computed(() => {
|
||||
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 isTouchDevice = ref(false)
|
||||
if (typeof window !== 'undefined') {
|
||||
isTouchDevice.value = !window.matchMedia('(hover: hover) and (pointer: fine)').matches
|
||||
|
|
|
|||
|
|
@ -334,6 +334,7 @@ const props = defineProps<{
|
|||
}>()
|
||||
|
||||
const projectStore = useProjectStore()
|
||||
const currentView = computed(() => projectStore.projects[props.projectId]?.views.find(v => v.id === props.viewId))
|
||||
|
||||
const ACTIVE_COLUMNS_DEFAULT = {
|
||||
index: true,
|
||||
|
|
@ -366,6 +367,7 @@ const taskList = useTaskList(
|
|||
() => props.viewId,
|
||||
sortBy.value,
|
||||
() => ['comment_count', 'is_unread'],
|
||||
() => currentView.value?.includeSubprojects ?? false,
|
||||
)
|
||||
|
||||
const {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import FancyCheckbox from '@/components/input/FancyCheckbox.vue'
|
|||
import FilterInputDocs from '@/components/input/filter/FilterInputDocs.vue'
|
||||
import FilterInput from '@/components/input/filter/FilterInput.vue'
|
||||
import FormField from '@/components/input/FormField.vue'
|
||||
import FancyCheckbox from '@/components/input/FancyCheckbox.vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: IProjectView,
|
||||
|
|
@ -189,6 +190,15 @@ function handleBubbleSave() {
|
|||
<FilterInputDocs />
|
||||
</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">
|
||||
<FancyCheckbox
|
||||
v-model="view.filter.filter_include_nulls"
|
||||
|
|
|
|||
|
|
@ -153,7 +153,7 @@
|
|||
/>
|
||||
|
||||
<RouterLink
|
||||
v-if="showProjectSeparately"
|
||||
v-if="showProjectSeparately && project"
|
||||
v-tooltip="$t('task.detail.belongsToProject', {project: project.title})"
|
||||
:to="{ name: 'project.index', params: { projectId: task.projectId } }"
|
||||
class="task-project"
|
||||
|
|
@ -161,7 +161,6 @@
|
|||
>
|
||||
{{ project.title }}
|
||||
</RouterLink>
|
||||
|
||||
<BaseButton
|
||||
:class="{'is-favorite': task.isFavorite}"
|
||||
class="favorite"
|
||||
|
|
@ -292,6 +291,8 @@ const currentProject = computed(() => {
|
|||
} : baseStore.currentProject
|
||||
})
|
||||
|
||||
|
||||
|
||||
const taskDetailRoute = computed(() => ({
|
||||
name: 'task.detail',
|
||||
params: {id: task.value.id},
|
||||
|
|
|
|||
|
|
@ -108,6 +108,7 @@ export function useTaskList(
|
|||
projectViewIdGetter: ComputedGetter<IProjectView['id']>,
|
||||
sortByDefault: SortBy = SORT_BY_DEFAULT,
|
||||
expandGetter: ComputedGetter<ExpandTaskFilterParam> = () => 'subtasks',
|
||||
includeSubprojectsGetter: ComputedGetter<boolean> = () => false,
|
||||
) {
|
||||
|
||||
const projectId = computed(() => projectIdGetter())
|
||||
|
|
@ -121,6 +122,7 @@ export function useTaskList(
|
|||
const page = useRouteQuery('page', '1', { transform: Number })
|
||||
const filter = useRouteQuery('filter')
|
||||
const s = useRouteQuery('s')
|
||||
const includeSubprojects = computed(() => includeSubprojectsGetter())
|
||||
|
||||
watch(filter, v => { params.value.filter = v ?? '' }, { immediate: true })
|
||||
watch(s, v => { params.value.s = v ?? '' }, { immediate: true })
|
||||
|
|
@ -197,7 +199,7 @@ export function useTaskList(
|
|||
})
|
||||
|
||||
watch(
|
||||
[params, sortBy, page],
|
||||
[params, sortBy, page, includeSubprojects],
|
||||
([, , newPage], [, , oldPage]) => {
|
||||
if (newPage === oldPage) {
|
||||
page.value = 1
|
||||
|
|
@ -216,6 +218,7 @@ export function useTaskList(
|
|||
},
|
||||
{
|
||||
...allParams.value,
|
||||
...(includeSubprojects.value ? {include_subprojects: true} : {}),
|
||||
filter_timezone: authStore.settings.timezone,
|
||||
expand: expandGetter(),
|
||||
},
|
||||
|
|
@ -257,5 +260,6 @@ export function useTaskList(
|
|||
loadTasks,
|
||||
params,
|
||||
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!",
|
||||
"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": {
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ export interface IProjectView extends IAbstract {
|
|||
|
||||
filter: IFilters|undefined
|
||||
position: number
|
||||
includeSubprojects?: boolean
|
||||
|
||||
bucketConfigurationMode: ProjectViewBucketConfigurationMode
|
||||
bucketConfiguration: IProjectViewBucketConfiguration[]
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export default class ProjectViewModel extends AbstractModel<IProjectView> implem
|
|||
s: '',
|
||||
}
|
||||
position = 0
|
||||
includeSubprojects = false
|
||||
|
||||
bucketConfiguration = []
|
||||
bucketConfigurationMode: ProjectViewBucketConfigurationMode = 'manual'
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -32,6 +32,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()
|
||||
}
|
||||
|
|
@ -46,9 +57,9 @@ 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,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -61,15 +72,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) {
|
||||
|
|
@ -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 dateTo = isoToKebabDate(filters.dateTo)
|
||||
|
||||
|
|
@ -101,6 +110,7 @@ function ganttFiltersToApiParams(filters: GanttFilters): TaskFilterParams {
|
|||
')',
|
||||
filter_include_nulls: filters.showTasksWithoutDates,
|
||||
expand: 'subtasks',
|
||||
include_subprojects: includeSubprojects,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -108,7 +118,11 @@ export type UseGanttFiltersReturn =
|
|||
UseRouteFiltersReturn<GanttFilters> &
|
||||
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 {
|
||||
|
|
@ -145,7 +159,15 @@ export function useGanttFilters(route: Ref<RouteLocationNormalized>, viewId: Ref
|
|||
isLoading,
|
||||
addTask,
|
||||
updateTask,
|
||||
} = useGanttTaskList<GanttFilters>(filters, ganttFiltersToApiParams, viewId)
|
||||
} = useGanttTaskList<GanttFilters>(
|
||||
filters,
|
||||
currentFilters => ganttFiltersToApiParams(currentFilters, includeSubprojects.value),
|
||||
viewId,
|
||||
)
|
||||
|
||||
watch(includeSubprojects, () => {
|
||||
loadTasks()
|
||||
})
|
||||
|
||||
return {
|
||||
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.
|
||||
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.
|
||||
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."`
|
||||
// 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 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.
|
||||
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 {
|
||||
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 `json:"include_subprojects" query:"include_subprojects"`
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
if s == taskPropertyPosition && tf.IncludeSubprojects {
|
||||
continue
|
||||
}
|
||||
|
||||
if s == taskPropertyPosition && projectView != nil {
|
||||
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) {
|
||||
if view.BucketConfigurationMode != BucketConfigurationModeFilter {
|
||||
return filter, nil
|
||||
|
|
@ -238,6 +280,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."
|
||||
|
|
@ -333,6 +376,10 @@ func (tf *TaskCollection) ReadAll(s *xorm.Session, a web.Auth, search string, pa
|
|||
if view.Filter.FilterIncludeNulls {
|
||||
tf.FilterIncludeNulls = true
|
||||
}
|
||||
|
||||
if view.Filter.IncludeSubprojects {
|
||||
tf.IncludeSubprojects = true
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
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.isSavedFilter = tf.isSavedFilter
|
||||
|
||||
if view != nil {
|
||||
if view != nil && !tf.IncludeSubprojects {
|
||||
var hasOrderByPosition bool
|
||||
for _, param := range opts.sortby {
|
||||
if param.sortBy == taskPropertyPosition {
|
||||
|
|
|
|||
|
|
@ -646,11 +646,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
|
||||
|
|
@ -1561,6 +1562,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",
|
||||
|
|
@ -1688,6 +1746,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{
|
||||
|
|
@ -1778,10 +1856,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,
|
||||
|
||||
|
|
@ -1832,6 +1911,8 @@ func TestTaskCollection_ReadAll(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
func TestTaskCollection_SubtaskRemainsAfterMove(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
|
|
|
|||
Loading…
Reference in New Issue