wip daterange

This commit is contained in:
Dominik Pschenitschni 2022-10-10 21:44:59 +02:00
parent 6510ca4724
commit 8e4e4b13d4
Signed by: dpschen
GPG Key ID: B257AC0149F43A77
18 changed files with 664 additions and 160 deletions

View File

@ -38,6 +38,7 @@
"camel-case": "4.1.2",
"codemirror": "5.65.9",
"date-fns": "2.29.3",
"dayjs": "^1.11.5",
"dompurify": "2.4.0",
"easymde": "2.18.0",
"flatpickr": "4.6.13",
@ -101,7 +102,7 @@
"vite-plugin-pwa": "0.13.1",
"vite-svg-loader": "3.6.0",
"vitest": "0.23.4",
"vue-tsc": "0.40.13",
"vue-tsc": "1.0.2",
"wait-on": "6.0.1",
"workbox-cli": "6.5.4"
},

View File

@ -42,6 +42,7 @@ specifiers:
codemirror: 5.65.9
cypress: 10.9.0
date-fns: 2.29.3
dayjs: ^1.11.5
dompurify: 2.4.0
easymde: 2.18.0
esbuild: 0.15.10
@ -80,7 +81,7 @@ specifiers:
vue-flatpickr-component: 9.0.6
vue-i18n: 9.2.2
vue-router: 4.1.5
vue-tsc: 0.40.13
vue-tsc: 1.0.2
wait-on: 6.0.1
workbox-cli: 6.5.4
workbox-precaching: 6.5.4
@ -107,6 +108,7 @@ dependencies:
camel-case: 4.1.2
codemirror: 5.65.9
date-fns: 2.29.3
dayjs: 1.11.5
dompurify: 2.4.0
easymde: 2.18.0
flatpickr: 4.6.13
@ -170,7 +172,7 @@ devDependencies:
vite-plugin-pwa: 0.13.1_bhe5iaipiq3lmbaxwdxgnnn2gq
vite-svg-loader: 3.6.0
vitest: 0.23.4_k3tqzgv3vrnpmbq3o6ks4hg4vi
vue-tsc: 0.40.13_typescript@4.8.4
vue-tsc: 1.0.2_typescript@4.8.4
wait-on: 6.0.1
workbox-cli: 6.5.4
@ -3385,42 +3387,44 @@ packages:
vue: 3.2.40
dev: true
/@volar/code-gen/0.40.13:
resolution: {integrity: sha512-4gShBWuMce868OVvgyA1cU5WxHbjfEme18Tw6uVMfweZCF5fB2KECG0iPrA9D54vHk3FeHarODNwgIaaFfUBlA==}
/@volar/language-core/1.0.2:
resolution: {integrity: sha512-kNzrnZYxNWcIgU8b1Yzm4pDb4fEM/LNicWLyVOxaNRvemU5Sh4ORDzKihI5AkzGGkvy0F/67xVe7/5/o9U1oPg==}
dependencies:
'@volar/source-map': 0.40.13
'@volar/source-map': 1.0.2
'@vue/reactivity': 3.2.40
muggle-string: 0.1.0
dev: true
/@volar/source-map/0.40.13:
resolution: {integrity: sha512-dbdkAB2Nxb0wLjAY5O64o3ywVWlAGONnBIoKAkXSf6qkGZM+nJxcizsoiI66K+RHQG0XqlyvjDizfnTxr+6PWg==}
/@volar/source-map/1.0.2:
resolution: {integrity: sha512-MizyyDw+Eg704bSVkS/8MeP7G4t5U4ZmaXdA2SW3cYAncQKn10P2r6eSyI/8vno+y8wgpM3k9aRfineXB1fMkQ==}
dependencies:
'@vue/reactivity': 3.2.38
muggle-string: 0.1.0
dev: true
/@volar/typescript-faster/0.40.13:
resolution: {integrity: sha512-uy+TlcFkKoNlKEnxA4x5acxdxLyVDIXGSc8cYDNXpPKjBKXrQaetzCzlO3kVBqu1VLMxKNGJMTKn35mo+ILQmw==}
/@volar/typescript/1.0.2:
resolution: {integrity: sha512-yQ6ze+gmf+jYTT/Y+IIH9cBE2YF3I92N6vGfdrVgwnDOj1jnulvJ5viwdTYcAIeEQRB0qtwYfRpSHOrGG2FL2w==}
dependencies:
semver: 7.3.8
'@volar/language-core': 1.0.2
dev: true
/@volar/vue-language-core/0.40.13:
resolution: {integrity: sha512-QkCb8msi2KUitTdM6Y4kAb7/ZlEvuLcbBFOC2PLBlFuoZwyxvSP7c/dBGmKGtJlEvMX0LdCyrg5V2aBYxD38/Q==}
/@volar/vue-language-core/1.0.2:
resolution: {integrity: sha512-SiFHeXu0yWl9N+sIrvQ+iIh/P2oMCBkUT6XFhhh6RCY01KNKc7/iO/xB/OJlO9yl/6mAiFwIVdtPcp6GNOkb3g==}
dependencies:
'@volar/code-gen': 0.40.13
'@volar/source-map': 0.40.13
'@vue/compiler-core': 3.2.40
'@volar/language-core': 1.0.2
'@volar/source-map': 1.0.2
'@vue/compiler-dom': 3.2.40
'@vue/compiler-sfc': 3.2.40
'@vue/reactivity': 3.2.40
'@vue/shared': 3.2.40
minimatch: 5.1.0
vue-template-compiler: 2.7.10
dev: true
/@volar/vue-typescript/0.40.13:
resolution: {integrity: sha512-o7bNztwjs8JmbQjVkrnbZUOfm7q4B8ZYssETISN1tRaBdun6cfNqgpkvDYd+VUBh1O4CdksvN+5BUNnwAz4oCQ==}
/@volar/vue-typescript/1.0.2:
resolution: {integrity: sha512-wOUebVHS8ENF0uMGvFdnkjVdVnf80SHUKbCoCkgngxeVFQBvAlEShcm/XT8bSX3QKoNPl1ZGqYN4UQRgZFyaow==}
dependencies:
'@volar/code-gen': 0.40.13
'@volar/typescript-faster': 0.40.13
'@volar/vue-language-core': 0.40.13
'@volar/typescript': 1.0.2
'@volar/vue-language-core': 1.0.2
dev: true
/@vue/compiler-core/3.2.40:
@ -3491,12 +3495,6 @@ packages:
estree-walker: 2.0.2
magic-string: 0.25.9
/@vue/reactivity/3.2.38:
resolution: {integrity: sha512-6L4myYcH9HG2M25co7/BSo0skKFHpAN8PhkNPM4xRVkyGl1K5M3Jx4rp5bsYhvYze2K4+l+pioN4e6ZwFLUVtw==}
dependencies:
'@vue/shared': 3.2.38
dev: true
/@vue/reactivity/3.2.40:
resolution: {integrity: sha512-N9qgGLlZmtUBMHF9xDT4EkD9RdXde1Xbveb+niWMXuHVWQP5BzgRmE3SFyUBBcyayG4y1lhoz+lphGRRxxK4RA==}
dependencies:
@ -3524,10 +3522,6 @@ packages:
'@vue/shared': 3.2.40
vue: 3.2.40
/@vue/shared/3.2.38:
resolution: {integrity: sha512-dTyhTIRmGXBjxJE+skC8tTWCGLCVc4wQgRRLt8+O9p5ewBAjoBwtCAkLPrtToSr1xltoe3st21Pv953aOZ7alg==}
dev: true
/@vue/shared/3.2.40:
resolution: {integrity: sha512-0PLQ6RUtZM0vO3teRfzGi4ltLUO5aO+kLgwh4Um3THSR03rpQWLTuRCkuO5A41ITzwdWeKdPHtSARuPkoo5pCQ==}
@ -5189,6 +5183,10 @@ packages:
/dayjs/1.11.5:
resolution: {integrity: sha512-CAdX5Q3YW3Gclyo5Vpqkgpj8fSdLQcRuzfX6mC6Phy0nfJ0eGYOeS7m4mt2plDWLAtA4TqTakvbboHvUxfe4iA==}
/de-indent/1.0.2:
resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==}
dev: true
/debounce/1.2.1:
resolution: {integrity: sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==}
dev: false
@ -9158,6 +9156,10 @@ packages:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
dev: true
/muggle-string/0.1.0:
resolution: {integrity: sha512-Tr1knR3d2mKvvWthlk7202rywKbiOm4rVFLsfAaSIhJ6dt9o47W4S+JMtWhd/PW9Wrdew2/S2fSvhz3E2gkfEg==}
dev: true
/multiparty/4.2.3:
resolution: {integrity: sha512-Ak6EUJZuhGS8hJ3c2fY6UW5MbkGUPMBEGd13djUzoY/BHqV/gTuFWtC6IuVA7A2+v3yjBS6c4or50xhzTQZImQ==}
engines: {node: '>= 0.10'}
@ -12761,14 +12763,21 @@ packages:
vue: 3.2.40
dev: false
/vue-tsc/0.40.13_typescript@4.8.4:
resolution: {integrity: sha512-xzuN3g5PnKfJcNrLv4+mAjteMd5wLm5fRhW0034OfNJZY4WhB07vhngea/XeGn7wNYt16r7syonzvW/54dcNiA==}
/vue-template-compiler/2.7.10:
resolution: {integrity: sha512-QO+8R9YRq1Gudm8ZMdo/lImZLJVUIAM8c07Vp84ojdDAf8HmPJc7XB556PcXV218k2AkKznsRz6xB5uOjAC4EQ==}
dependencies:
de-indent: 1.0.2
he: 1.2.0
dev: true
/vue-tsc/1.0.2_typescript@4.8.4:
resolution: {integrity: sha512-Atl8vBo1ThxZ8OjnCcquRCjRYczmQVF2nAjXVOqHA19Q4lvPX+bpbbDUhk8SDww6U10sttGc4Vjb+1xBwA4yow==}
hasBin: true
peerDependencies:
typescript: '*'
dependencies:
'@volar/vue-language-core': 0.40.13
'@volar/vue-typescript': 0.40.13
'@volar/vue-language-core': 1.0.2
'@volar/vue-typescript': 1.0.2
typescript: 4.8.4
dev: true

View File

@ -1,12 +1,3 @@
import { defineAsyncComponent } from 'vue'
import ErrorComponent from '@/components/misc/error.vue'
import LoadingComponent from '@/components/misc/loading.vue'
import {createAsyncComponent} from '@/helpers/createAsyncComponent'
const Editor = () => import('@/components/input/editor.vue')
export default defineAsyncComponent({
loader: Editor,
loadingComponent: LoadingComponent,
errorComponent: ErrorComponent,
timeout: 60000,
})
export default createAsyncComponent(() => import('@/components/input/editor.vue'))

View File

@ -7,6 +7,12 @@
</message>
</template>
<script lang="ts">
export default {
inheritAttrs: false,
}
</script>
<script lang="ts" setup>
import Message from '@/components/misc/message.vue'
import ButtonLink from '@/components/misc/ButtonLink.vue'

View File

@ -0,0 +1,243 @@
<template>
<input
type="text"
data-input
:disabled="disabled"
v-bind="attrs"
ref="root"
/>
</template>
<script lang="ts">
import Flatpickr from 'flatpickr';
import 'flatpickr/dist/flatpickr.css'
// FIXME: Not sure how to alias these correctly
// import Options = Flatpickr.Options doesn't work
type Hook = Flatpickr.Options.Hook
type HookKey = Flatpickr.Options.HookKey
type Options = Flatpickr.Options.Options
type DateOption = Flatpickr.Options.DateOption
function camelToKebab(string: string) {
return string.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
};
function arrayify<T extends unknown>(obj: T) {
return obj instanceof Array ? obj : [obj];
}
function nullify<T extends unknown>(value: T) {
return (value && (value as unknown[]).length) ? value : null;
}
function cloneObject<T extends {}>(obj: T): T {
return Object.assign({}, obj)
}
// Events to emit, copied from flatpickr source
const includedEvents = [
'onChange',
'onClose',
'onDestroy',
'onMonthChange',
'onOpen',
'onYearChange',
] as HookKey[]
// Let's not emit these events by default
const excludedEvents = [
'onValueUpdate',
'onDayCreate',
'onParseConfig',
'onReady',
'onPreCalendarPosition',
'onKeyDown',
] as HookKey[]
// Keep a copy of all events for later use
const allEvents = includedEvents.concat(excludedEvents);
export default {inheritAttrs: false}
</script>
<script setup lang="ts">
import {computed, onBeforeUnmount, onMounted, ref, toRefs, useAttrs, watch, watchEffect, type PropType} from 'vue';
const props = defineProps({
modelValue: {
type: [String, Number, Date, Array] as PropType<DateOption | DateOption[]>,
default: null,
required: true,
// validator(value) {
// return (
// value === null ||
// value instanceof Date ||
// typeof value === 'string' ||
// value instanceof String ||
// value instanceof Array ||
// typeof value === 'number'
// );
// }
},
// https://chmln.github.io/flatpickr/options/
config: {
type: Object as PropType<Options>,
default: () => ({
wrap: false,
defaultDate: null
})
},
events: {
type: Array as PropType<HookKey[]>,
default: () => includedEvents
},
disabled: {
type: Boolean,
default: false
}
})
const emit = defineEmits([
'blur',
'update:modelValue',
...allEvents.map(camelToKebab)
])
const {modelValue, config, disabled} = toRefs(props)
// bind listener like onBlur
const attrs = useAttrs()
const root = ref<HTMLInputElement | null>(null)
let fp = ref<Flatpickr.Instance | null>(null)
const safeConfig = ref<Options>(cloneObject(props.config))
onMounted(() => {
// Don't mutate original object on parent component
let newConfig = cloneObject(props.config);
if (
fp.value || // Return early if flatpickr is already loaded
!root.value // our input needs to be mounted
) {
return
}
props.events.forEach((hook) => {
// Respect global callbacks registered via setDefault() method
const globalCallbacks = Flatpickr.defaultConfig[hook] || [];
// Inject our own method along with user callback
const localCallback: Hook = (...args) => emit(camelToKebab(hook), ...args)
// Overwrite with merged array
newConfig[hook] = arrayify(newConfig[hook] || []).concat(
globalCallbacks,
localCallback
);
});
// Watch for value changed by date-picker itself and notify parent component
const onChange: Hook = (dates) => emit('update:modelValue', dates)
newConfig['onChange'] = arrayify(newConfig['onChange'] || []).concat(onChange)
// Flatpickr does not emit input event in some cases
// const onClose: Hook = (_selectedDates, dateStr) => emit('update:modelValue', dateStr)
// newConfig['onClose'] = arrayify(newConfig['onClose'] || []).concat(onClose)
// Set initial date without emitting any event
newConfig.defaultDate = props.modelValue || newConfig.defaultDate;
safeConfig.value = newConfig
/**
* Get the HTML node where flatpickr to be attached
* Bind on parent element if wrap is true
*/
const element = props.config.wrap
? root.value.parentNode as ParentNode
: root.value
// Init flatpickr
fp.value = Flatpickr(element, safeConfig.value);
})
onBeforeUnmount(() => fp.value?.destroy())
watch(config, () => {
if (!fp.value) return
// Workaround: Don't pass hooks to configs again otherwise
// previously registered hooks will stop working
// Notice: we are looping through all events
// This also means that new callbacks can not be passed once component has been initialized
allEvents.forEach((hook) => {
delete safeConfig.value?.[hook];
});
fp.value.set(safeConfig.value);
// Passing these properties in `set()` method will cause flatpickr to trigger some callbacks
const configCallbacks = ['locale', 'showMonths'] as (keyof Options)[]
// Workaround: Allow to change locale dynamically
configCallbacks.forEach(name => {
if (typeof safeConfig.value?.[name] !== 'undefined' && fp.value) {
fp.value.set(name, safeConfig.value[name]);
}
});
}, {deep:true})
// watch(root, () => {
// if (
// fp.value || // Return early if flatpickr is already loaded
// !root.value // our input needs to be mounted
// ) {
// return
// }
// })
const fpInput = computed(() => {
if (!fp.value) return
return fp.value.altInput || fp.value.input;
})
/**
* init blur event
* (is required by many validation libraries)
*/
function onBlur(event: Event) {
emit('blur', nullify((event.target as HTMLInputElement).value));
}
watchEffect(() => fpInput.value?.addEventListener('blur', onBlur))
onBeforeUnmount(() => fpInput.value?.removeEventListener('blur', onBlur))
/**
* Watch for the disabled property and sets the value to the real input.
*/
watchEffect(() => {
if (disabled.value) {
fpInput.value?.setAttribute('disabled', '');
} else {
fpInput.value?.removeAttribute('disabled');
}
})
/**
* Watch for changes from parent component and update DOM
*/
watch(
modelValue,
newValue => {
// Prevent updates if v-model value is same as input's current value
if (!root.value || newValue === nullify(root.value.value)) return;
// Make sure we have a flatpickr instanceand
// Notify flatpickr instance that there is a change in value
fp.value?.setDate(newValue, true);
},
{deep: true}
)
</script>

View File

@ -2,6 +2,12 @@
<div class="loader-container is-loading"></div>
</template>
<script lang="ts">
export default {
inheritAttrs: false,
}
</script>
<style scoped lang="scss">
.loader-container {
height: 100%;

View File

@ -22,7 +22,7 @@
<script setup lang="ts">
import {nextTick, ref} from 'vue'
import type {ITask} from '@/models/task'
import type {ITask} from '@/modelTypes/ITask'
const emit = defineEmits<{
(e: 'create-task', title: string): Promise<ITask>

View File

@ -1,10 +1,14 @@
<template>
<Loading class="gantt-container" v-if="taskService.loading || taskCollectionService.loading"/>
<Loading
v-if="taskService.loading || taskCollectionService.loading || dayjsLanguageLoading"
class="gantt-container"
/>
<div class="gantt-container" v-else>
<GGanttChart
dateFormat="YYYY-MM-DDTHH:mm:ssZ[Z]"
:chart-start="`${dateFrom} 00:00`"
:chart-end="`${dateTo} 23:59`"
:precision="PRECISION"
precision="day"
bar-start="startDate"
bar-end="endDate"
:grid="true"
@ -37,15 +41,19 @@
import {computed, ref, watch, watchEffect, shallowReactive, type PropType} from 'vue'
import {useRouter} from 'vue-router'
import {format, parse} from 'date-fns'
import dayjs from 'dayjs'
import {useDayjsLanguageSync} from '@/i18n'
import TaskCollectionService from '@/services/taskCollection'
import TaskService from '@/services/task'
import TaskModel, { getHexColor } from '@/models/task'
import type ListModel from '@/models/list'
import {colorIsDark} from '@/helpers/color/colorIsDark'
import {RIGHTS} from '@/constants/rights'
import type {ITask} from '@/modelTypes/ITask'
import type {IList} from '@/modelTypes/IList'
import {
extendDayjs,
GGanttChart,
@ -58,33 +66,30 @@ import TaskForm from '@/components/tasks/TaskForm.vue'
import {useBaseStore} from '@/stores/base'
extendDayjs()
export type DateRange = {
dateFrom: string,
dateTo: string,
}
const PRECISION = 'day' as const
const DATE_FORMAT = 'yyyy-LL-dd HH:mm'
export interface GanttChartProps {
listId: IList['id']
dateRange: DateRange
showTasksWithoutDates: boolean
}
export const DATE_FORMAT = 'yyyy-LL-dd HH:mm'
const props = withDefaults(defineProps<GanttChartProps>(), {
showTasksWithoutDates: false,
})
// setup dayjs for vue-ganttastic
const dayjsLanguageLoading = useDayjsLanguageSync(dayjs)
extendDayjs()
const baseStore = useBaseStore()
const router = useRouter()
const props = defineProps({
listId: {
type: Number as PropType<ListModel['id']>,
required: true,
},
dateFrom: {
type: String as PropType<any>,
required: true,
},
dateTo: {
type: String as PropType<any>,
required: true,
},
showTasksWithoutDates: {
type: Boolean,
default: false,
},
})
const taskCollectionService = shallowReactive(new TaskCollectionService())
const taskService = shallowReactive(new TaskService())
@ -100,14 +105,11 @@ const ganttChartWidth = computed(() => {
const canWrite = computed(() => baseStore.currentList.maxRight > RIGHTS.READ)
const tasks = ref<Map<TaskModel['id'], TaskModel>>(new Map())
const tasks = ref<Map<ITask['id'], ITask>>(new Map())
const ganttBars = ref<GanttBarObject[][]>([])
watch(
tasks,
// We need a "real" ref object for the gantt bars to instantly update the tasks when they are dragged on the chart.
// A computed won't work directly.
// function mapGanttBars() {
() => {
ganttBars.value = []
tasks.value.forEach(t => ganttBars.value.push(transformTaskToGanttBar(t)))
@ -115,14 +117,15 @@ watch(
{deep: true}
)
const defaultStartDate = format(new Date(), DATE_FORMAT)
const defaultEndDate = format(new Date((new Date()).setDate((new Date()).getDate() + 7)), DATE_FORMAT)
const defaultStartDate = new Date().toISOString()
const defaultEndDate = new Date((new Date()).setDate((new Date()).getDate() + 7)).toISOString()
function transformTaskToGanttBar(t: TaskModel) {
function transformTaskToGanttBar(t: ITask) {
const black = 'var(--grey-800)'
console.log(t)
return [{
startDate: t.startDate ? format(t.startDate, DATE_FORMAT) : defaultStartDate,
endDate: t.endDate ? format(t.endDate, DATE_FORMAT) : defaultEndDate,
startDate: t.startDate ? new Date(t.startDate).toISOString() : defaultStartDate,
endDate: t.endDate ? new Date(t.endDate).toISOString() : defaultEndDate,
ganttBarConfig: {
id: String(t.id),
label: t.title,
@ -137,8 +140,6 @@ function transformTaskToGanttBar(t: TaskModel) {
} as GanttBarObject]
}
// FIXME: unite with other filter params types
interface GetAllTasksParams {
sort_by: ('start_date' | 'done' | 'id')[],
@ -150,8 +151,8 @@ interface GetAllTasksParams {
filter_include_nulls: boolean,
}
async function getAllTasks(params: GetAllTasksParams, page = 1): Promise<TaskModel[]> {
const tasks = await taskCollectionService.getAll({listId: props.listId}, params, page) as TaskModel[]
async function getAllTasks(params: GetAllTasksParams, page = 1): Promise<ITask[]> {
const tasks = await taskCollectionService.getAll({listId: props.listId}, params, page) as ITask[]
if (page < taskCollectionService.totalPages) {
const nextTasks = await getAllTasks(params, page + 1)
return tasks.concat(nextTasks)
@ -170,7 +171,7 @@ async function loadTasks({
}) {
tasks.value = new Map()
const params = {
const params: GetAllTasksParams = {
sort_by: ['start_date', 'done', 'id'],
order_by: ['asc', 'asc', 'desc'],
filter_by: ['start_date', 'start_date'],
@ -191,7 +192,7 @@ watchEffect(() => loadTasks({
showTasksWithoutDates: props.showTasksWithoutDates,
}))
async function createTask(title: TaskModel['title']) {
async function createTask(title: ITask['title']) {
const newTask = await taskService.create(new TaskModel({
title,
listId: props.listId,
@ -212,14 +213,26 @@ async function updateTask(e: {
if (!task) return
task.startDate = e.bar.startDate
task.endDate = e.bar.endDate
console.log(task.startDate.toISOString())
console.log(dayjs(task.startDate), "YYYY-MM-DD HH:mm").toISOString()
console.log(dayjs(task.startDate, "YYYY-MM-DDTHH:mm:ssZ[Z]").toISOString())
console.log(task.startDate)
console.log(dayjs(e.bar.startDate).toDate())
console.log(e.bar.startDate)
console.log(task.endDate.toISOString())
console.log(task.endDate)
console.log(dayjs(e.bar.startDate).toDate())
console.log(e.bar.endDate)
// task.startDate = e.bar.startDate
// task.endDate = e.bar.endDate
const updatedTask = await taskService.update(task)
ganttBars.value.map(gantBar => {
return Number(gantBar[0].ganttBarConfig.id) === task.id
? transformTaskToGanttBar(updatedTask)
: gantBar
})
// ganttBars.value.map(gantBar => {
// return Number(gantBar[0].ganttBarConfig.id) === task.id
// ? transformTaskToGanttBar(updatedTask)
// : gantBar
// })
}
function openTask(e: {

View File

@ -0,0 +1,21 @@
import { defineAsyncComponent, type AsyncComponentLoader, type AsyncComponentOptions, type Component, type ComponentPublicInstance } from 'vue'
import ErrorComponent from '@/components/misc/error.vue'
import LoadingComponent from '@/components/misc/loading.vue'
const DEFAULT_TIMEOUT = 60000
export function createAsyncComponent<T extends Component = {
new (): ComponentPublicInstance;
}>(source: AsyncComponentLoader<T> | AsyncComponentOptions<T>): T {
if (typeof source === 'function') {
source = { loader: source }
}
return defineAsyncComponent({
...source,
loadingComponent: LoadingComponent,
errorComponent: ErrorComponent,
timeout: DEFAULT_TIMEOUT,
})
}

View File

@ -1,5 +1,7 @@
import {createDateFromString} from '@/helpers/time/createDateFromString'
import {format, formatDistanceToNow, formatISO as formatISOfns} from 'date-fns'
// FIXME: support all locales and load dynamically
import {enGB, de, fr, ru} from 'date-fns/locale'
import {i18n} from '@/i18n'

View File

@ -1,19 +1,9 @@
import {computed, ref, watch} from 'vue'
import {createI18n} from 'vue-i18n'
import langEN from './lang/en.json'
export const i18n = createI18n({
locale: 'en', // set locale
fallbackLocale: 'en',
legacy: true,
globalInjection: true,
allowComposition: true,
messages: {
en: langEN,
},
})
export const availableLanguages = {
en: 'English',
export const SUPPORTED_LOCALES = {
'en': 'English',
'de-DE': 'Deutsch',
'de-swiss': 'Schwizertütsch',
'ru-RU': 'Русский',
@ -24,41 +14,58 @@ export const availableLanguages = {
'pl-PL': 'Polski',
'nl-NL': 'Nederlands',
'pt-PT': 'Português',
}
'zh-CN': 'Chinese',
} as Record<string, string>
const loadedLanguages = ['en'] // our default language that is preloaded
export type SupportedLocale = keyof typeof SUPPORTED_LOCALES
const setI18nLanguage = (lang: string) => {
export const DEFAULT_LANGUAGE: SupportedLocale= 'en'
export type ISOLanguage = string
export const DAYJS_LOCALE_MAPPING = {
'de-swiss': 'de-AT',
} as Record<SupportedLocale, ISOLanguage>
export const i18n = createI18n({
locale: DEFAULT_LANGUAGE, // set locale
fallbackLocale: DEFAULT_LANGUAGE,
legacy: true,
globalInjection: true,
allowComposition: true,
inheritLocale: true,
messages: {
en: langEN,
} as Record<SupportedLocale, any>,
})
function setI18nLanguage(lang: SupportedLocale): SupportedLocale {
i18n.global.locale = lang
document.documentElement.lang =lang
document.documentElement.lang = lang
return lang
}
export const loadLanguageAsync = lang => {
export async function loadLanguageAsync(lang: SupportedLocale) {
if (!lang) {
return
throw new Error()
}
if (
// If the same language
i18n.global.locale === lang ||
// If the language was already loaded
loadedLanguages.includes(lang)
i18n.global.availableLocales.includes(lang)
) {
return setI18nLanguage(lang)
}
// If the language hasn't been loaded yet
return import(`./lang/${lang}.json`).then(
messages => {
i18n.global.setLocaleMessage(lang, messages.default)
loadedLanguages.push(lang)
return setI18nLanguage(lang)
},
)
const messages = await import(`./lang/${lang}.json`)
i18n.global.setLocaleMessage(lang, messages.default)
return setI18nLanguage(lang)
}
export const getCurrentLanguage = () => {
export function getCurrentLanguage(): SupportedLocale {
const savedLanguage = localStorage.getItem('language')
if (savedLanguage !== null) {
return savedLanguage
@ -66,20 +73,48 @@ export const getCurrentLanguage = () => {
const browserLanguage = navigator.language || navigator.userLanguage
for (const k in availableLanguages) {
if (browserLanguage[k] === browserLanguage || k.startsWith(browserLanguage + '-')) {
return k
}
}
const language: SupportedLocale | undefined = Object.keys(SUPPORTED_LOCALES).find(langKey => {
return langKey === browserLanguage || langKey.startsWith(browserLanguage + '-')
})
return 'en'
return language || DEFAULT_LANGUAGE
}
export const saveLanguage = (lang: string) => {
export function saveLanguage(lang: SupportedLocale) {
localStorage.setItem('language', lang)
setLanguage()
}
export const setLanguage = () => {
loadLanguageAsync(getCurrentLanguage())
export function setLanguage() {
return loadLanguageAsync(getCurrentLanguage())
}
import type dayjs from 'dayjs'
export function useDayjsLanguageSync(dayjsGlobal: typeof dayjs) {
const dayjsLanguageLoaded = ref(false)
watch(
() => i18n.global.locale,
async (currentLanguage: string) => {
if (!dayjsGlobal) {
return
}
const dayjsLanguageCode = DAYJS_LOCALE_MAPPING[currentLanguage.toLowerCase()] || currentLanguage.toLowerCase()
dayjsLanguageLoaded.value = dayjsGlobal.locale() === dayjsLanguageCode
if (dayjsLanguageLoaded.value) {
return
}
console.log('foo')
await import(`../../node_modules/dayjs/locale/${dayjsLanguageCode}.js`)
console.log('bar')
dayjsGlobal.locale(dayjsLanguageCode)
dayjsLanguageLoaded.value = true
},
{immediate: true},
)
// we export the loading state since that's easier to work with
const isLoading = computed(() => !dayjsLanguageLoaded.value)
return isLoading
}

View File

@ -34,7 +34,7 @@ import MigrationComponent from '../views/migrator/Migrate.vue'
import MigrateServiceComponent from '../views/migrator/MigrateService.vue'
// List Views
import ListList from '../views/list/ListList.vue'
import ListGantt from '../views/list/ListGantt.vue'
const ListGantt = () => import('../views/list/ListGantt.vue')
import ListTable from '../views/list/ListTable.vue'
import ListKanban from '../views/list/ListKanban.vue'
const ListInfo = () => import('../views/list/ListInfo.vue')
@ -378,7 +378,12 @@ const router = createRouter({
name: 'list.gantt',
component: ListGantt,
beforeEnter: (to) => saveListView(to.params.listId, to.name),
props: route => ({ listId: Number(route.params.listId as string) }),
props: route => ({
listId: Number(route.params.listId as string),
dateFrom: route.query.dateFrom as string,
dateTo: route.query.dateTo as string,
showTasksWithoutDates: Boolean(route.query.showTasksWithoutDates),
}),
},
{
path: '/lists/:listId/table',

View File

@ -2,8 +2,9 @@ import {AuthenticatedHTTPFactory} from '@/http-common'
import type {Method} from 'axios'
import {objectToSnakeCase} from '@/helpers/case'
import AbstractModel, { type IAbstract } from '@/models/abstractModel'
import type { Right } from '@/constants/rights'
import AbstractModel from '@/models/abstractModel'
import type {IAbstract} from '@/modelTypes/IAbstract'
import type {Right} from '@/constants/rights'
import type {IFile} from '@/modelTypes/IFile'
interface Paths {

View File

@ -2,7 +2,7 @@ import AbstractService from './abstractService'
import TaskModel from '../models/task'
import {formatISO} from 'date-fns'
export default class TaskCollectionService extends AbstractService {
export default class TaskCollectionService extends AbstractService<ITask> {
constructor() {
super({
getAll: '/lists/{listId}/tasks',

View File

@ -1 +1,4 @@
declare module 'vue-flatpickr-component';
declare module 'vue-flatpickr-component' {
import type {DefineComponent} from 'vue'
export default DefineComponent<>
}

View File

@ -6,12 +6,13 @@
<div class="field">
<label class="label" for="range">{{ $t('list.gantt.range') }}</label>
<div class="control">
<flat-pickr
<Foo
ref="flatPickerEl"
:config="flatPickerConfig"
class="input"
id="range"
:placeholder="$t('list.gantt.range')"
v-model="range"
v-model="flatPickerDateRange"
/>
</div>
</div>
@ -25,13 +26,15 @@
<template #default>
<div class="gantt-chart-container">
<card :padding="false" class="has-overflow">
<gantt-chart
:date-from="dateFrom"
:date-to="dateTo"
<pre>{{dateRange}}</pre>
<pre>{{new Date(dateRange.dateFrom).toLocaleDateString()}}</pre>
<pre>{{new Date(dateRange.dateTo).toLocaleDateString()}}</pre>
<!-- <gantt-chart
v-if="false"
:date-range="dateRange"
:list-id="props.listId"
:show-tasks-without-dates="showTasksWithoutDates"
/>
/> -->
</card>
</div>
@ -40,41 +43,206 @@
</template>
<script setup lang="ts">
import {ref, computed} from 'vue'
import flatPickr from 'vue-flatpickr-component'
import {computed, ref, type PropType} from 'vue'
import Foo from '@/components/misc/flatpickr/Flatpickr.vue'
// import type FlatPickr from 'vue-flatpickr-component'
import {useI18n} from 'vue-i18n'
import {format} from 'date-fns'
import {useRoute, useRouter} from 'vue-router'
import {useAuthStore} from '@/stores/auth'
import ListWrapper from './ListWrapper.vue'
import GanttChart from '@/components/tasks/gantt-chart.vue'
import Fancycheckbox from '@/components/input/fancycheckbox.vue'
import {format} from 'date-fns'
import {createAsyncComponent} from '@/helpers/createAsyncComponent'
type DateKebab = `${string}-${string}-${string}`
const GanttChart = createAsyncComponent(() => import('@/components/tasks/gantt-chart.vue'))
const props = defineProps({
listId: {
type: Number,
required: true,
},
dateFrom: {
type: String as PropType<DateKebab>,
},
dateTo: {
type: String as PropType<DateKebab>,
},
showTasksWithoutDates: {
type: Boolean,
default: false,
},
})
const showTasksWithoutDates = ref(false)
const router = useRouter()
const route = useRoute()
const showTasksWithoutDates = computed({
get: () => props.showTasksWithoutDates,
set: (value) => router.push({ query: {
...route.query,
showTasksWithoutDates: String(value),
}}),
})
function parseKebabDate(kebabDate: DateKebab | undefined, fallback: () => Date): Date {
try {
if (!kebabDate) {
throw new Error('No value')
}
const dateValues = kebabDate.split('-')
const [, monthString, dateString] = dateValues
const [year, month, date] = dateValues.map(val => Number(val))
const dateValuesAreValid = (
!Number.isNaN(year) &&
monthString.length >= 1 && monthString.length <= 2 &&
!Number.isNaN(month) &&
month >= 1 && month <= 12 &&
dateString.length >= 1 && dateString.length <= 31 &&
!Number.isNaN(date) &&
date >= 1 && date <= 31
)
if (!dateValuesAreValid) {
throw new Error('Invalid date values')
}
return new Date(year, month, date)
} catch(e) {
// ignore nonsense route queries
return fallback()
}
}
const DEFAULT_DATEFROM_DAY_OFFSET = 0
// const DEFAULT_DATEFROM_DAY_OFFSET = -15
const DEFAULT_DATETO_DAY_OFFSET = +55
// const DEFAULT_DATETO_DAY_OFFSET = +55
const now = new Date()
const defaultFrom = format(new Date((new Date()).setDate(now.getDate() - 15)), 'yyyy-LL-dd')
const defaultTo = format(new Date((new Date()).setDate(now.getDate() + 55)), 'yyyy-LL-dd')
const range = ref(`${defaultFrom} to ${defaultTo}`)
// TODO: only update once both dates are available (maybe use a watcher + refs instead?)
const dateFrom = computed(() => range.value?.split(' to ')[0] ?? defaultFrom)
const dateTo = computed(() => range.value?.split(' to ')[1] ?? defaultTo)
function getDefaultDateFrom() {
return new Date(now.getFullYear(), now.getMonth(), now.getDate() + DEFAULT_DATEFROM_DAY_OFFSET)
}
function getDefaultDateTo() {
return new Date(now.getFullYear(), now.getMonth(), now.getDate() + DEFAULT_DATETO_DAY_OFFSET)
}
let isChangingRoute = ref<ReturnType<typeof router.push> | false>(false)
const count = ref(0)
const dateRange = computed<{
dateFrom: string
dateTo: string
}>({
get: () => ({
dateFrom: parseKebabDate(props.dateFrom, getDefaultDateFrom).toISOString(),
dateTo: parseKebabDate(props.dateTo, getDefaultDateTo).toISOString(),
}),
async set(range: {
dateFrom: string
dateTo: string
} | null) {
if (range === null) {
const query = {...route.query}
delete query?.dateFrom
delete query?.dateTo
console.log('set range to null. query is: ', query)
router.push(query)
return
}
const {
dateFrom,
dateTo,
} = range
count.value = count.value + 1
if (count.value >= 4) {
console.log('triggered ', count, ' times, stopping.')
return
}
if (isChangingRoute.value !== false) {
console.log('called again while changing route')
await isChangingRoute.value
console.log('changing route finished, continuing...')
}
const queryDateFrom = format(new Date(dateFrom || getDefaultDateFrom()), 'yyyy-LL-dd')
const queryDateTo = format(new Date(dateTo || getDefaultDateTo()), 'yyyy-LL-dd')
console.log(dateFrom, 'dateFrom')
console.log(dateRange.value.dateFrom, 'dateRange.value.dateFrom')
console.log(dateTo, 'dateTo')
console.log(dateRange.value.dateTo, 'dateRange.value.dateTo')
if (queryDateFrom === route.query.dateFrom || queryDateTo === route.query.dateTo) {
console.log('is same date')
// only set if the value has changed
return
}
console.log('change url to', {
query: {
...route.query,
dateFrom: format(new Date(dateFrom), 'yyyy-LL-dd'),
dateTo: format(new Date(dateTo), 'yyyy-LL-dd'),
}
})
isChangingRoute.value = router.push({
query: {
...route.query,
dateFrom: format(new Date(dateFrom), 'yyyy-LL-dd'),
dateTo: format(new Date(dateTo), 'yyyy-LL-dd'),
}
})
},
})
const initialDateRange = [dateRange.value.dateFrom, dateRange.value.dateTo]
function getCurrentDateRangeFromFlatpicker() {
return flatPickerEl.value.fp.selectedDates.map((date: Date) => date?.toISOString())
}
const flatPickerEl = ref<typeof FlatPickr | null>(null)
const flatPickerDateRange = computed({
get: () => ([
dateRange.value.dateFrom,
dateRange.value.dateTo
]),
set(newVal) {
// set([dateFrom, dateTo]) {
// newVal from event does only contain the wrong format
console.log(newVal)
const [dateFrom, dateTo] = newVal
// const [dateFrom, dateTo] = getCurrentDateRangeFromFlatpicker()
if (
// only set after whole range has been selected
!dateTo ||
// only set if the value has changed
dateRange.value.dateFrom === dateFrom &&
dateRange.value.dateTo === dateTo
) {
return
}
// dateRange.value = {dateFrom, dateTo}
}
})
const ISO_DATE_FORMAT = "YYYY-MM-DDTHH:mm:ssZ[Z]"
const {t} = useI18n({useScope: 'global'})
const authStore = useAuthStore()
const flatPickerConfig = computed(() => ({
altFormat: t('date.altFormatShort'),
altInput: true,
dateFormat: 'Y-m-d',
// dateFornat: ISO_DATE_FORMAT,
// dateFormat: 'Y-m-d',
defaultDate: initialDateRange,
enableTime: false,
mode: 'range',
locale: {

View File

@ -158,7 +158,7 @@ import {PrefixMode} from '@/modules/parseTaskText'
import ListSearch from '@/components/tasks/partials/listSearch.vue'
import {availableLanguages} from '@/i18n'
import {SUPPORTED_LOCALES} from '@/i18n'
import {playSoundWhenDoneKey, playPopSound} from '@/helpers/playPop'
import {getQuickAddMagicMode, setQuickAddMagicMode} from '@/helpers/quickAddMagicMode'
import {createRandomID} from '@/helpers/randomId'
@ -227,7 +227,7 @@ const authStore = useAuthStore()
const settings = ref({...authStore.settings})
const id = ref(createRandomID())
const availableLanguageOptions = ref(
Object.entries(availableLanguages)
Object.entries(SUPPORTED_LOCALES)
.map(l => ({code: l[0], title: l[1]}))
.sort((a, b) => a.title.localeCompare(b.title)),
)

View File

@ -1,6 +1,6 @@
{
"extends": "@vue/tsconfig/tsconfig.web.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"include": ["env.d.ts", "src/**/*", "src/**/*.vue", "src/i18n/lang/*.json"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"composite": true,