feat: improved types #2547

Merged
konrad merged 17 commits from dpschen/frontend:feature/improved-types into main 2022-11-02 16:06:57 +00:00
63 changed files with 444 additions and 340 deletions

View File

@ -37,6 +37,10 @@ module.exports = {
'@typescript-eslint/no-unused-vars': ['error', { vars: 'all', args: 'after-used', ignoreRestSiblings: true }],
'vue/multi-word-component-names': 0,
// disabled until we have support for reactivityTransform
// See https://github.com/vuejs/eslint-plugin-vue/issues/1948
// see also setting in `vite.config`
'vue/no-setup-props-destructure': 0,
},
'parser': 'vue-eslint-parser',
'parserOptions': {

View File

@ -86,6 +86,7 @@
"autoprefixer": "10.4.13",
"browserslist": "4.21.4",
"caniuse-lite": "1.0.30001427",
"csstype": "3.1.1",
"cypress": "10.11.0",
"esbuild": "0.15.12",
"eslint": "8.26.0",

View File

@ -41,6 +41,7 @@ specifiers:
camel-case: 4.1.2
caniuse-lite: 1.0.30001427
codemirror: 5.65.9
csstype: 3.1.1
cypress: 10.11.0
date-fns: 2.29.3
dayjs: 1.11.6
@ -157,6 +158,7 @@ devDependencies:
autoprefixer: 10.4.13_postcss@8.4.18
browserslist: 4.21.4
caniuse-lite: 1.0.30001427
csstype: 3.1.1
cypress: 10.11.0
esbuild: 0.15.12
eslint: 8.26.0
@ -5219,6 +5221,10 @@ packages:
/csstype/2.6.19:
resolution: {integrity: sha512-ZVxXaNy28/k3kJg0Fou5MiYpp88j7H9hLZp8PDC3jV0WFjfH5E9xHb56L0W59cPbKbcHXeP4qyT8PrHp8t6LcQ==}
/csstype/3.1.1:
resolution: {integrity: sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw==}
dev: true
/cyclist/1.0.1:
resolution: {integrity: sha512-NJGVKPS81XejHcLhaLJS7plab0fK3slPh11mESeeDq2W4ZI5kUKK/LRRdVDvjJseojbPB7ZwjnyOybg3Igea/A==}
dev: true

View File

@ -1,18 +1,53 @@
<!-- a disabled link of any kind is not a link -->
<!-- we have a router link -->
<!-- just a normal link -->
<!-- a button it shall be -->
<!-- note that we only pass the click listener here -->
<template>
dpschen marked this conversation as resolved
Review

I had to change the way how the BaseButton works for better type support.
It's a bit more repeatition but also very clear now what happens I guess.

Intersting sidenote: I originally placed the comments above inline to the switching elements but the vue renderer had some problems with that. Something about the component being a fragment which it should not be since it has just one root node. But seems like if you add a comment additionally to the root node (in case of a if / else that we have here), the comment counts as a node of the component. That makes sense, but is not intuitive, since you normally don't count a comment as content and prevents you from making good comments where they should be :)

I had to change the way how the BaseButton works for better type support. It's a bit more repeatition but also very clear now what happens I guess. Intersting sidenote: I originally placed the comments above inline to the switching elements but the vue renderer had some problems with that. Something about the component being a fragment which it should not be since it has just one root node. But seems like if you add a comment additionally to the root node (in case of a `if / else` that we have here), the comment counts as a node of the component. That makes sense, but is not intuitive, since you normally don't count a comment as content and prevents you from making good comments where they should be :)
<component
:is="componentNodeName"
<div
v-if="disabled === true && (to !== undefined || href !== undefined)"

Are there any cases where it makes sense for this to be a div?

Are there any cases where it makes sense for this to be a div?

Yes.
Out of a semantic perspective a disabled button / link is not a button / link.

Yes. Out of a semantic perspective a disabled button / link is not a button / link.
class="base-button"
:class="{ 'base-button--type-button': isButton }"
v-bind="elementBindings"
:disabled="disabled || undefined"
:aria-disabled="disabled || undefined"
ref="button"
>
<slot/>
</component>
</div>
<router-link
v-else-if="to !== undefined"
:to="to"
class="base-button"
ref="button"
>
<slot/>
</router-link>
<a v-else-if="href !== undefined"
class="base-button"
:href="href"
rel="noreferrer noopener nofollow"
target="_blank"
dpschen marked this conversation as resolved Outdated

This looks like it's missing the href attribute.

This looks like it's missing the `href` attribute.

Good catch! Fixed

Good catch! Fixed
ref="button"
>
<slot/>
</a>
<button
v-else
:type="type"
class="base-button base-button--type-button"
:disabled="disabled || undefined"
ref="button"
@click="(event: MouseEvent) => emit('click', event)"
>
<slot/>
</button>
</template>
<script lang="ts">
export default { inheritAttrs: false }
const BASE_BUTTON_TYPES_MAP = {
BUTTON: 'button',
SUBMIT: 'submit',
} as const

We export the types in order to use them in inheriting components

We export the types in order to use them in inheriting components
export type BaseButtonTypes = typeof BASE_BUTTON_TYPES_MAP[keyof typeof BASE_BUTTON_TYPES_MAP] | undefined
</script>
<script lang="ts" setup>
@ -20,77 +55,36 @@ export default { inheritAttrs: false }
// by doing so we make it easy abstract the functionality from style and enable easier and semantic
// correct button and link usage. Also see: https://css-tricks.com/a-complete-guide-to-links-and-buttons/#accessibility-considerations
// the component tries to heuristically determine what it should be checking the props (see the
// componentNodeName and elementBindings ref for this).
// the component tries to heuristically determine what it should be checking the props
// NOTE: Do NOT use buttons with @click to push routes. => Use router-links instead!
import { ref, watchEffect, computed, useAttrs, type PropType } from 'vue'
import {unrefElement} from '@vueuse/core'
import {ref, type HTMLAttributes} from 'vue'
import type {RouteLocationNamedRaw} from 'vue-router'
const BASE_BUTTON_TYPES_MAP = Object.freeze({
button: 'button',
submit: 'submit',
})
type BaseButtonTypes = keyof typeof BASE_BUTTON_TYPES_MAP
const props = defineProps({
type: {
type: String as PropType<BaseButtonTypes>,
default: 'button',
},
disabled: {
type: Boolean,
default: false,
},
})
const componentNodeName = ref<Node['nodeName']>('button')
interface ElementBindings {
type?: string;
rel?: string;
target?: string;
export interface BaseButtonProps extends HTMLAttributes {
dpschen marked this conversation as resolved Outdated

As of now I had to add all possible combinations.
I tried to change the props based on the type but vue wouldn't accept that.
It boils down to that fact that vue currently doesn't support dynamic component types.

It might be that with the latest vue-tsc changes this is possible again.
Stricly speaking right now this component allows impossible prop combinations, these should be validated and filtered. But I hoped that I could spare that effort and solve it instead by establishing common knowledge on how this component is supposed to be used.

As of now I had to add all possible combinations. I tried to change the props [based on the type](https://www.typescriptlang.org/docs/handbook/2/conditional-types.html) but vue wouldn't accept that. It boils down to that fact that vue currently doesn't support dynamic component types. It might be that with the latest vue-tsc changes this is possible again. Stricly speaking right now this component allows impossible prop combinations, these should be validated and filtered. But I hoped that I could spare that effort and solve it instead by establishing common knowledge on how this component is supposed to be used.

I think that's fine. Invalid combinations will show up in PR reviews.

I think that's fine. Invalid combinations will show up in PR reviews.
type?: BaseButtonTypes
disabled?: boolean
to?: RouteLocationNamedRaw
href?: string
}
const elementBindings = ref({})
export interface BaseButtonEmits {
(e: 'click', payload: MouseEvent): void
}
const attrs = useAttrs()
watchEffect(() => {
// by default this component is a button element with the attribute of the type "button" (default prop value)
let nodeName = 'button'
let bindings: ElementBindings = {type: props.type}
const {
type = BASE_BUTTON_TYPES_MAP.BUTTON,
disabled = false,
} = defineProps<BaseButtonProps>()
// if we find a "to" prop we set it as router-link
if ('to' in attrs) {
nodeName = 'router-link'
bindings = {}
}
const emit = defineEmits<BaseButtonEmits>()
// if there is a href we assume the user wants an external link via a link element
// we also set a predefined value for the attribute rel, but make it possible to overwrite this by the user.
if ('href' in attrs) {
nodeName = 'a'
bindings = {
rel: 'noreferrer noopener nofollow',
target: '_blank',
}
}
componentNodeName.value = nodeName
elementBindings.value = {
...bindings,
...attrs,
}
})
const isButton = computed(() => componentNodeName.value === 'button')
const button = ref()
const button = ref<HTMLElement | null>(null)
function focus() {
button.value.focus()
unrefElement(button)?.focus()
}
defineExpose({

View File

@ -26,7 +26,7 @@ if (navigator && navigator.serviceWorker) {
)
}
function showRefreshUI(e) {
function showRefreshUI(e: Event) {
console.log('recieved refresh event', e)
registration.value = e.detail
updateAvailable.value = true

View File

@ -9,64 +9,61 @@
}
]"
>
<icon
v-if="showIconOnly"
:icon="icon"
:style="{'color': iconColor !== '' ? iconColor : false}"
/>
<span class="icon is-small" v-else-if="icon !== ''">
<template v-if="icon">
<icon
v-if="showIconOnly"
:icon="icon"
:style="{'color': iconColor !== '' ? iconColor : false}"
/>
</span>
<span class="icon is-small" v-else>
<icon
:icon="icon"
:style="{'color': iconColor !== '' ? iconColor : false}"
/>
</span>
</template>
<slot />
</BaseButton>
</template>
<script lang="ts">
const BUTTON_TYPES_MAP = {
primary: 'is-primary',
secondary: 'is-outlined',
tertiary: 'is-text is-inverted underline-none',
} as const
export type ButtonTypes = keyof typeof BUTTON_TYPES_MAP
export default { name: 'x-button' }
</script>
<script setup lang="ts">
import {computed, useSlots, type PropType} from 'vue'
import BaseButton from '@/components/base/BaseButton.vue'
import {computed, useSlots} from 'vue'
import BaseButton, {type BaseButtonProps} from '@/components/base/BaseButton.vue'
import type { IconProp } from '@fortawesome/fontawesome-svg-core'
const BUTTON_TYPES_MAP = Object.freeze({
primary: 'is-primary',
secondary: 'is-outlined',
tertiary: 'is-text is-inverted underline-none',
})
// extending the props of the BaseButton
export interface ButtonProps extends BaseButtonProps {
dpschen marked this conversation as resolved Outdated

By extending the BaseButtonProps we inherit them and get proper autocomplete! :)

By extending the BaseButtonProps we inherit them and get proper autocomplete! :)
variant?: ButtonTypes
icon?: IconProp
iconColor?: string
loading?: boolean
shadow?: boolean
}
type ButtonTypes = keyof typeof BUTTON_TYPES_MAP
const {
variant = 'primary',
icon = '',
iconColor = '',
loading = false,
shadow = true,
} = defineProps<ButtonProps>()
const props = defineProps({
variant: {
type: String as PropType<ButtonTypes>,
default: 'primary',
},
icon: {
type: [String, Array],
default: '',
},
iconColor: {
type: String,
default: '',
},
loading: {
type: Boolean,
default: false,
},
shadow: {
type: Boolean,
default: true,
},
})
const variantClass = computed(() => BUTTON_TYPES_MAP[props.variant])
const variantClass = computed(() => BUTTON_TYPES_MAP[variant])
const slots = useSlots()
const showIconOnly = computed(() => props.icon !== '' && typeof slots.default === 'undefined')
const showIconOnly = computed(() => icon !== '' && typeof slots.default === 'undefined')
</script>
<style lang="scss" scoped>

View File

@ -193,7 +193,7 @@ function toggleDatePopup() {
}
const datepickerPopup = ref<HTMLElement | null>(null)
function hideDatePopup(e) {
function hideDatePopup(e: MouseEvent) {
if (show.value) {
closeWhenClickedOutside(e, datepickerPopup.value, close)
}

View File

@ -115,6 +115,7 @@ const props = defineProps({
default: true,
},
bottomActions: {
type: Array,
default: () => [],
},
emptyText: {

View File

@ -100,37 +100,52 @@ function elementInResults(elem: string | any, label: string, query: string): boo
}
const props = defineProps({
// When true, shows a loading spinner
/**
* When true, shows a loading spinner
*/
loading: {
type: Boolean,
default: false,
},
// The placeholder of the search input
/**
* The placeholder of the search input
*/
placeholder: {
type: String,
default: '',
},
// The search results where the @search listener needs to put the results into
/**
* The search results where the @search listener needs to put the results into
*/
searchResults: {
type: Array as PropType<{[id: string]: any}>,
default: () => [],
},
// The name of the property of the searched object to show the user.
// If empty the component will show all raw data of an entry.
/**
* The name of the property of the searched object to show the user.
* If empty the component will show all raw data of an entry.
*/
label: {
type: String,
default: '',
},
// The object with the value, updated every time an entry is selected.
/**
* The object with the value, updated every time an entry is selected.
*/
modelValue: {
type: [Object] as PropType<{[key: string]: any}>,
default: null,
},
// If true, will provide an "add this as a new value" entry which fires an @create event when clicking on it.
/**
* If true, will provide an "add this as a new value" entry which fires an @create event when clicking on it.
*/
creatable: {
type: Boolean,
default: false,
},
// The text shown next to the new value option.
/**
* The text shown next to the new value option.
*/
createPlaceholder: {
type: String,
default() {
@ -138,7 +153,9 @@ const props = defineProps({
return t('input.multiselect.createPlaceholder')
},
},
// The text shown next to an option.
/**
* The text shown next to an option.
*/
selectPlaceholder: {
type: String,
default() {
@ -146,22 +163,30 @@ const props = defineProps({
return t('input.multiselect.selectPlaceholder')
},
},
// If true, allows for selecting multiple items. v-model will be an array with all selected values in that case.
/**
* If true, allows for selecting multiple items. v-model will be an array with all selected values in that case.
*/
multiple: {
type: Boolean,
default: false,
},
// If true, displays the search results inline instead of using a dropdown.
/**
* If true, displays the search results inline instead of using a dropdown.
*/
inline: {
type: Boolean,
default: false,
},
// If true, shows search results when no query is specified.
/**
* If true, shows search results when no query is specified.
*/
showEmpty: {
type: Boolean,
default: true,
},
// The delay in ms after which the search event will be fired. Used to avoid hitting the network on every keystroke.
/**
* The delay in ms after which the search event will be fired. Used to avoid hitting the network on every keystroke.
*/
searchDelay: {
type: Number,
default: 200,
@ -174,17 +199,25 @@ const props = defineProps({
const emit = defineEmits<{
(e: 'update:modelValue', value: null): void
// @search: Triggered every time the search query input changes
/**
* Triggered every time the search query input changes
*/
(e: 'search', query: string): void
// @select: Triggered every time an option from the search results is selected. Also triggers a change in v-model.
(e: 'select', value: null): void
// @create: If nothing or no exact match was found and `creatable` is true, this event is triggered with the current value of the search query.
/**
* Triggered every time an option from the search results is selected. Also triggers a change in v-model.
*/
(e: 'select', value: {[key: string]: any}): void
/**
* If nothing or no exact match was found and `creatable` is true, this event is triggered with the current value of the search query.
*/
(e: 'create', query: string): void
// @remove: If `multiple` is enabled, this will be fired every time an item is removed from the array of selected items.
/**
* If `multiple` is enabled, this will be fired every time an item is removed from the array of selected items.
*/
(e: 'remove', value: null): void
}>()
const query = ref('')
const query = ref<string | {[key: string]: any}>('')
const searchTimeout = ref<ReturnType<typeof setTimeout> | null>(null)
const localLoading = ref(false)
const showSearchResults = ref(false)

View File

@ -70,6 +70,8 @@ import {
} from '@fortawesome/free-regular-svg-icons'
import {FontAwesomeIcon} from '@fortawesome/vue-fontawesome'
import type { FontAwesomeIcon as FontAwesomeIconFixedTypes } from '@/types/vue-fontawesome'
library.add(faAlignLeft)
library.add(faAngleRight)
library.add(faArchive)
@ -136,4 +138,5 @@ library.add(faTrashAlt)
library.add(faUser)
library.add(faUsers)
export default FontAwesomeIcon
// overwriting the wrong types
export default FontAwesomeIcon as unknown as FontAwesomeIconFixedTypes

View File

@ -35,6 +35,9 @@
</template>
<script setup lang="ts">
import type {PropType} from 'vue'
import type {IconProp} from '@fortawesome/fontawesome-svg-core'
import BaseButton from '@/components/base/BaseButton.vue'
defineProps({
@ -51,7 +54,7 @@ defineProps({
default: false,
},
closeIcon: {
type: String,
type: String as PropType<IconProp>,
default: 'times',
},
shadow: {

View File

@ -6,10 +6,10 @@
</template>
<script lang="ts" setup>
import type { Color } from 'csstype'
import type { DataType } from 'csstype'
defineProps< {
color: Color,
color: DataType.Color,
}>()
</script>

View File

@ -46,6 +46,9 @@
</template>
<script setup lang="ts">
import type {PropType} from 'vue'
import type {IconProp} from '@fortawesome/fontawesome-svg-core'
defineProps({
title: {
type: String,
@ -55,7 +58,7 @@ defineProps({
type: String,
},
primaryIcon: {
type: String,
type: String as PropType<IconProp>,
default: 'plus',
},
primaryDisabled: {

View File

@ -1,46 +1,24 @@
<template>
<component
:is="componentNodeName"
v-bind="elementBindings"
:to="to"
class="dropdown-item">
<BaseButton class="dropdown-item">
dpschen marked this conversation as resolved Outdated

By reusing the BaseButton the dropdown-item got much simpler :)

By reusing the BaseButton the dropdown-item got much simpler :)

Wow it really looks a lot simpler now.

Wow it really looks a lot simpler now.
<span class="icon" v-if="icon">
<icon :icon="icon"/>
<Icon :icon="icon"/>
</span>
<span>
<slot></slot>
<slot />
</span>
</component>
</BaseButton>
</template>
<script lang="ts" setup>
import {ref, useAttrs, watchEffect} from 'vue'
import BaseButton, { type BaseButtonProps } from '@/components/base//BaseButton.vue'
import Icon from '@/components/misc/Icon'
import type { IconProp } from '@fortawesome/fontawesome-svg-core'
const props = defineProps<{
to?: object,
icon?: string | string[],
}>()
export interface DropDownItemProps extends BaseButtonProps {
icon?: IconProp,
}
const componentNodeName = ref<Node['nodeName']>('a')
const elementBindings = ref({})
const attrs = useAttrs()
watchEffect(() => {
let nodeName = 'a'
if (props.to) {
nodeName = 'router-link'
}
if ('href' in attrs) {
nodeName = 'BaseButton'
}
componentNodeName.value = nodeName
elementBindings.value = {
...attrs,
}
})
defineProps<DropDownItemProps>()
</script>
<style scoped lang="scss">
@ -91,5 +69,4 @@ button.dropdown-item {
}
}
}
</style>

View File

@ -17,14 +17,15 @@
</template>
<script setup lang="ts">
import {ref} from 'vue'
import {ref, type PropType} from 'vue'
import {onClickOutside} from '@vueuse/core'
import type {IconProp} from '@fortawesome/fontawesome-svg-core'
import BaseButton from '@/components/base/BaseButton.vue'
defineProps({
triggerIcon: {
type: String,
type: String as PropType<IconProp>,
dpschen marked this conversation as resolved Outdated

Using IconProp enables autocomplete for the icons props

Using `IconProp` enables autocomplete for the icons props
default: 'ellipsis-h',
},
})

View File

@ -1,20 +1,20 @@
<template>
<slot name="trigger" :isOpen="open" :toggle="toggle"></slot>
<div class="popup" :class="{'is-open': open, 'has-overflow': props.hasOverflow && open}" ref="popup">
<div
class="popup"
:class="{
'is-open': open,
'has-overflow': props.hasOverflow && open
}"
ref="popup"
>
<slot name="content" :isOpen="open"/>
</div>
</template>
<script setup lang="ts">
import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside'
import {onBeforeUnmount, onMounted, ref} from 'vue'
const open = ref(false)
const popup = ref(null)
const toggle = () => {
open.value = !open.value
}
import {ref} from 'vue'
import {onClickOutside} from '@vueuse/core'
dpschen marked this conversation as resolved Outdated

I wonder if we even need the helper function I built back in the day?

I wonder if we even need the helper function I built back in the day?

I already try to remove it piece by piece :)

I already try to remove it piece by piece :)
const props = defineProps({
hasOverflow: {
@ -23,24 +23,22 @@ const props = defineProps({
},
})
function hidePopup(e) {
const open = ref(false)
const popup = ref<HTMLElement | null>(null)
function close() {
open.value = false
}
function toggle() {
open.value = !open.value
}
onClickOutside(popup, () => {
if (!open.value) {
return
}
// we actually want to use popup.$el, not its value.
// eslint-disable-next-line vue/no-ref-as-operand
closeWhenClickedOutside(e, popup.value, () => {
open.value = false
})
}
onMounted(() => {
document.addEventListener('click', hidePopup)
})
onBeforeUnmount(() => {
document.removeEventListener('click', hidePopup)
close()
})
</script>

View File

@ -13,7 +13,7 @@
v-else-if="type === 'dropdown'"
v-tooltip="tooltipText"
@click="changeSubscription"
:class="{'is-disabled': disabled}"
:disabled="disabled"
dpschen marked this conversation as resolved Outdated

Since dropdown inherits from basebutton it automatically inherits props like disabled

Since dropdown inherits from basebutton it automatically inherits props like disabled
:icon="iconName"
>
{{ buttonText }}
@ -44,6 +44,7 @@ import SubscriptionModel from '@/models/subscription'
import type {ISubscription} from '@/modelTypes/ISubscription'
import {success} from '@/message'
import type { IconProp } from '@fortawesome/fontawesome-svg-core'
const props = defineProps({
entity: String,
@ -104,7 +105,7 @@ const tooltipText = computed(() => {
})
const buttonText = computed(() => props.modelValue ? t('task.subscription.unsubscribe') : t('task.subscription.subscribe'))
const iconName = computed(() => props.modelValue ? ['far', 'bell-slash'] : 'bell')
const iconName = computed<IconProp>(() => props.modelValue ? ['far', 'bell-slash'] : 'bell')
const disabled = computed(() => props.modelValue && subscriptionEntity.value !== props.entity)
function changeSubscription() {

View File

@ -76,7 +76,7 @@ const notifications = computed(() => {
})
const userInfo = computed(() => authStore.info)
let interval: number
let interval: ReturnType<typeof setInterval>
onMounted(() => {
loadNotifications()

View File

@ -214,7 +214,7 @@ async function addTask() {
return rel
})
await Promise.all(relations)
} catch (e: { message?: string }) {
} catch (e: any) {
newTaskTitle.value = taskTitleBackup
if (e?.message === 'NO_LIST') {
errorMessage.value = t('list.create.addListRequired')

View File

@ -165,7 +165,6 @@ import BaseButton from '@/components/base/BaseButton.vue'
import AttachmentService from '@/services/attachment'
import {SUPPORTED_IMAGE_SUFFIX} from '@/models/attachment'
import type AttachmentModel from '@/models/attachment'
import type {IAttachment} from '@/modelTypes/IAttachment'
import type {ITask} from '@/modelTypes/ITask'
@ -227,9 +226,9 @@ function uploadFilesToTask(files: File[] | FileList) {
uploadFiles(attachmentService, props.task.id, files)
}
const attachmentToDelete = ref<AttachmentModel | null>(null)
const attachmentToDelete = ref<IAttachment | null>(null)
function setAttachmentToDelete(attachment: AttachmentModel | null) {
function setAttachmentToDelete(attachment: IAttachment | null) {
attachmentToDelete.value = attachment
}
@ -250,7 +249,7 @@ async function deleteAttachment() {
const attachmentImageBlobUrl = ref<string | null>(null)
async function viewOrDownload(attachment: AttachmentModel) {
async function viewOrDownload(attachment: IAttachment) {
if (SUPPORTED_IMAGE_SUFFIX.some((suffix) => attachment.file.name.endsWith(suffix))) {
attachmentImageBlobUrl.value = await attachmentService.getBlobUrl(attachment)
} else {

View File

@ -4,7 +4,7 @@
<Done class="heading__done" :is-done="task.done"/>
<ColorBubble
v-if="task.hexColor !== ''"
:color="task.getHexColor()"
:color="getHexColor(task.hexColor)"
class="mt-1 ml-2"
/>
<h1
@ -48,6 +48,7 @@ import {useCopyToClipboard} from '@/composables/useCopyToClipboard'
import {useTaskStore} from '@/stores/tasks'
import type {ITask} from '@/modelTypes/ITask'
import {getHexColor} from '@/models/task'
const props = defineProps({
task: {

View File

@ -9,9 +9,9 @@
v-model="list"
:select-placeholder="$t('list.searchSelect')"
>
<template #searchResult="props">
<span class="list-namespace-title search-result">{{ namespace(props.option.namespaceId) }} ></span>
{{ props.option.title }}
<template #searchResult="{option}">
<span class="list-namespace-title search-result">{{ namespace((option as IList).namespaceId) }} ></span>
{{ (option as IList).title }}
</template>
</Multiselect>
</template>
@ -25,6 +25,7 @@ import type {IList} from '@/modelTypes/IList'
import Multiselect from '@/components/input/multiselect.vue'
import {useListStore} from '@/stores/lists'
import {useNamespaceStore} from '@/stores/namespaces'
import type { INamespace } from '@/modelTypes/INamespace'
const props = defineProps({
modelValue: {
@ -65,7 +66,7 @@ function select(l: IList | null) {
emit('update:modelValue', list)
}
function namespace(namespaceId: number) {
function namespace(namespaceId: INamespace['id']) {
const namespace = namespaceStore.getNamespaceById(namespaceId)
return namespace !== null
? namespace.title

View File

@ -2,7 +2,7 @@ import type {Directive} from 'vue'
import {install, uninstall} from '@github/hotkey'
import {isAppleDevice} from '@/helpers/isAppleDevice'
const directive: Directive = {
const directive = <Directive<HTMLElement,string>>{
mounted(el, {value}) {
if(value === '') {
return

View File

@ -3,17 +3,15 @@ import {snakeCase} from 'snake-case'
/**
* Transforms field names to camel case.
* @param object
* @returns {*}
*/
export function objectToCamelCase(object) {
export function objectToCamelCase(object: Record<string, any>) {
// When calling recursively, this can be called without being and object or array in which case we just return the value
if (typeof object !== 'object') {
return object
}
const parsedObject = {}
const parsedObject: Record<string, any> = {}
for (const m in object) {
parsedObject[camelCase(m)] = object[m]
@ -25,7 +23,7 @@ export function objectToCamelCase(object) {
// Call it again for arrays
if (Array.isArray(object[m])) {
parsedObject[camelCase(m)] = object[m].map(o => objectToCamelCase(o))
parsedObject[camelCase(m)] = object[m].map((o: Record<string, any>) => objectToCamelCase(o))
// Because typeof [] === 'object' is true for arrays, we leave the loop here to prevent converting arrays to objects.
continue
}
@ -40,17 +38,15 @@ export function objectToCamelCase(object) {
/**
* Transforms field names to snake case - used before making an api request.
* @param object
* @returns {*}
*/
export function objectToSnakeCase(object) {
export function objectToSnakeCase(object: Record<string, any>) {
// When calling recursively, this can be called without being and object or array in which case we just return the value
if (typeof object !== 'object') {
return object
}
const parsedObject = {}
const parsedObject: Record<string, any> = {}
for (const m in object) {
parsedObject[snakeCase(m)] = object[m]
@ -65,7 +61,7 @@ export function objectToSnakeCase(object) {
// Call it again for arrays
if (Array.isArray(object[m])) {
parsedObject[snakeCase(m)] = object[m].map(o => objectToSnakeCase(o))
parsedObject[snakeCase(m)] = object[m].map((o: Record<string, any>) => objectToSnakeCase(o))
// Because typeof [] === 'object' is true for arrays, we leave the loop here to prevent converting arrays to objects.
continue
}

View File

@ -5,11 +5,11 @@
* @param rootElement
* @param closeCallback A closure function to call when the click event happened outside of the rootElement.
*/
export const closeWhenClickedOutside = (event, rootElement, closeCallback) => {
export const closeWhenClickedOutside = (event: MouseEvent, rootElement: HTMLElement, closeCallback: () => void) => {
// We walk up the tree to see if any parent of the clicked element is the root element.
// If it is not, we call the close callback. We're doing all this hassle to only call the
// closing callback when a click happens outside of the rootElement.
let parent = event.target.parentElement
let parent = (event.target as HTMLElement)?.parentElement
while (parent !== rootElement) {
if (parent === null || parent.parentElement === null) {
parent = null

View File

@ -35,7 +35,7 @@ export function setupMarkdownRenderer(checkboxId: string) {
return isLocal ? html : html.replace(/^<a /, '<a target="_blank" rel="noreferrer noopener nofollow" ')
},
},
highlight(code, language) {
highlight(code: string, language: string) {
const validLanguage = hljs.getLanguage(language) ? language : 'plaintext'
return hljs.highlight(code, {language: validLanguage}).value
},

View File

@ -1,12 +1,12 @@
/**
* Make date objects from timestamps
*/
export function parseDateOrNull(date) {
export function parseDateOrNull(date: string | Date) {
if (date instanceof Date) {
return date
}
if ((typeof date === 'string' || date instanceof String) && !date.startsWith('0001')) {
if ((typeof date === 'string') && !date.startsWith('0001')) {

We should stop using String(). Regardless date strings normally come from toISOString() which produces a type string

We should stop using `String()`. Regardless date strings normally come from `toISOString()` which produces a type string

There might be cases though where a date string comes from the api and needs to be parsed here. (or at leat that was the reason why I added this function originally)

There might be cases though where a date string comes from the api and needs to be parsed here. (or at leat that was the reason why I added this function originally)

Unsure if we talk here about the same. I'm talking about the general use of String() to convert something to a string. Afaik we don't do that anywhere for the data that's coming from the api. We do use the parseDateOrNull function but it should would without the instanceof String. If you think it should stay, I'm happy to revert this change.

Unsure if we talk here about the same. I'm talking about the general use of `String()` to convert something to a string. Afaik we don't do that anywhere for the data that's coming from the api. We do use the `parseDateOrNull` function but it should would without the `instanceof String`. If you think it should stay, I'm happy to revert this change.
return new Date(date)
}

View File

@ -1,21 +1,29 @@
// Save the current list view to local storage
// We use local storage and not a store here to make it persistent across reloads.
export const saveListView = (listId, routeName) => {
import type { IList } from '@/modelTypes/IList'
type ListView = Record<IList['id'], string>
const DEFAULT_LIST_VIEW = 'list.list' as const
/**
* Save the current list view to local storage
*/
export function saveListView(listId: IList['id'], routeName: string) {
if (routeName.includes('settings.')) {
return
}
if (!listId) {
return
}
// We use local storage and not the store here to make it persistent across reloads.
const savedListView = localStorage.getItem('listView')
let savedListViewJson = false
let savedListViewJson: ListView | false = false
if (savedListView !== null) {
savedListViewJson = JSON.parse(savedListView)
savedListViewJson = JSON.parse(savedListView) as ListView
}
let listView = {}
let listView: ListView = {}
if (savedListViewJson) {
listView = savedListViewJson
}
@ -24,7 +32,7 @@ export const saveListView = (listId, routeName) => {
localStorage.setItem('listView', JSON.stringify(listView))
}
export const getListView = listId => {
export const getListView = (listId: IList['id']) => {
// Remove old stored settings
const savedListView = localStorage.getItem('listView')
if (savedListView !== null && savedListView.startsWith('list.')) {
@ -32,13 +40,13 @@ export const getListView = listId => {
}
if (!savedListView) {
return 'list.list'
return DEFAULT_LIST_VIEW
}
const savedListViewJson = JSON.parse(savedListView)
const savedListViewJson: ListView = JSON.parse(savedListView)
if (!savedListViewJson[listId]) {
return 'list.list'
return DEFAULT_LIST_VIEW
}
return savedListViewJson[listId]

View File

@ -10,7 +10,7 @@ const days = {
friday: 5,
saturday: 6,
sunday: 0,
}
} as Record<string, number>
for (const n in days) {
test(`today on a ${n}`, () => {
@ -32,7 +32,7 @@ const nextMonday = {
friday: 3,
saturday: 2,
sunday: 1,
}
} as Record<string, number>
for (const n in nextMonday) {
test(`next monday on a ${n}`, () => {
@ -48,7 +48,7 @@ const thisWeekend = {
friday: 1,
saturday: 0,
sunday: 0,
}
} as Record<string, number>
for (const n in thisWeekend) {
test(`this weekend on a ${n}`, () => {
@ -64,7 +64,7 @@ const laterThisWeek = {
friday: 0,
saturday: 0,
sunday: 0,
}
} as Record<string, number>
for (const n in laterThisWeek) {
test(`later this week on a ${n}`, () => {
@ -80,7 +80,7 @@ const laterNextWeek = {
friday: 7 + 0,
saturday: 7 + 0,
sunday: 7 + 0,
}
} as Record<string, number>
for (const n in laterNextWeek) {
test(`later next week on a ${n} (this week)`, () => {

View File

@ -1,4 +1,6 @@
export function calculateDayInterval(dateString: string, currentDay = (new Date().getDay())) {
type Day<T extends number = number> = T
export function calculateDayInterval(dateString: string, currentDay = (new Date().getDay())): Day {
switch (dateString) {
case 'today':
return 0

View File

@ -6,7 +6,7 @@
* @param dateString
* @returns {Date}
*/
export const createDateFromString = dateString => {
export function createDateFromString(dateString: string | Date) {
if (dateString instanceof Date) {
return dateString
}

View File

@ -8,7 +8,7 @@ import {i18n} from '@/i18n'
const locales = {en: enGB, de, ch: de, fr, ru}
export function dateIsValid(date) {
export function dateIsValid(date: Date | null) {
if (date === null) {
return false
}

View File

@ -36,7 +36,7 @@ export interface ITask extends IAbstract {
percentDone: number
relatedTasks: Partial<Record<IRelationKind, ITask[]>>
attachments: IAttachment[]
coverImageAttachmentId: IAttachment['id']
coverImageAttachmentId: IAttachment['id'] | null
identifier: string
index: number
isFavorite: boolean

View File

@ -20,4 +20,7 @@ export interface IUser extends IAbstract {
created: Date
updated: Date
settings: IUserSettings
isLocalUser: boolean
dpschen marked this conversation as resolved Outdated

Is it correct to add these properties? They seemed to be missing.

Is it correct to add these properties? They seemed to be missing.

yes, that's correct.

yes, that's correct.
deletionScheduledAt: string | Date | null
}

View File

@ -8,6 +8,7 @@ export interface IUserSettings extends IAbstract {
discoverableByName: boolean
discoverableByEmail: boolean
overdueTasksRemindersEnabled: boolean
overdueTasksRemindersTime: any
defaultListId: undefined | IList['id']
weekStart: 0 | 1 | 2 | 3 | 4 | 5 | 6
timezone: string

View File

@ -6,7 +6,7 @@ export default class EmailUpdateModel extends AbstractModel<IEmailUpdate> implem
newEmail = ''
password = ''
constructor(data : Partial<IEmailUpdate>) {
constructor(data : Partial<IEmailUpdate> = {}) {
super()
this.assignData(data)
}

View File

@ -6,7 +6,7 @@ export default class PasswordUpdateModel extends AbstractModel<IPasswordUpdate>
newPassword = ''
oldPassword = ''
constructor(data: Partial<IPasswordUpdate>) {
constructor(data: Partial<IPasswordUpdate> = {}) {
super()
this.assignData(data)
}

View File

@ -79,10 +79,12 @@ export default class TaskModel extends AbstractModel<ITask> implements ITask {
percentDone = 0
relatedTasks: Partial<Record<IRelationKind, ITask[]>> = {}
attachments: IAttachment[] = []
coverImageAttachmentId: IAttachment['id'] = null
identifier = ''
index = 0
isFavorite = false
subscription: ISubscription = null
coverImageAttachmentId: IAttachment['id'] = null
position = 0
kanbanPosition = 0

View File

@ -28,6 +28,9 @@ export default class UserModel extends AbstractModel<IUser> implements IUser {
updated: Date
settings: IUserSettings
isLocalUser: boolean
konrad marked this conversation as resolved Outdated

I assumed that the type here is boolean.

I assumed that the type here is boolean.

That's correct, same as in the interface.

That's correct, same as in the interface.

I removed the comment.

I removed the comment.
deletionScheduledAt: null
constructor(data: Partial<IUser> = {}) {
super()
this.assignData(data)

View File

@ -9,6 +9,7 @@ export default class UserSettingsModel extends AbstractModel<IUserSettings> impl
discoverableByName = false
discoverableByEmail = false
overdueTasksRemindersEnabled = true
overdueTasksRemindersTime = undefined
defaultListId = undefined
weekStart = 0 as IUserSettings['weekStart']
timezone = ''

View File

@ -1,4 +1,4 @@
interface ListHistory {
export interface ListHistory {
id: number;
}

View File

@ -4,7 +4,8 @@ import {parseTaskText, PrefixMode} from './parseTaskText'
import {getDateFromText, parseDate} from '../helpers/time/parseDate'
import {calculateDayInterval} from '../helpers/time/calculateDayInterval'
import {PRIORITIES} from '@/constants/priorities'
import { MILLISECONDS_A_DAY } from '@/constants/date'
import {MILLISECONDS_A_DAY} from '@/constants/date'
import type {IRepeatAfter} from '@/types/IRepeatAfter'
describe('Parse Task Text', () => {
beforeEach(() => {
@ -31,9 +32,9 @@ describe('Parse Task Text', () => {
expect(result.text).toBe('Lorem Ipsum')
const now = new Date()
expect(result.date.getFullYear()).toBe(now.getFullYear())
expect(result.date.getMonth()).toBe(now.getMonth())
expect(result.date.getDate()).toBe(now.getDate())
expect(result?.date?.getFullYear()).toBe(now.getFullYear())
expect(result?.date?.getMonth()).toBe(now.getMonth())
expect(result?.date?.getDate()).toBe(now.getDate())
expect(result.labels).toHaveLength(1)
expect(result.labels[0]).toBe('label')
expect(result.list).toBe('list')
@ -61,18 +62,18 @@ describe('Parse Task Text', () => {
expect(result.text).toBe('Lorem Ipsum')
const now = new Date()
expect(result.date.getFullYear()).toBe(now.getFullYear())
expect(result.date.getMonth()).toBe(now.getMonth())
expect(result.date.getDate()).toBe(now.getDate())
expect(result?.date?.getFullYear()).toBe(now.getFullYear())
expect(result?.date?.getMonth()).toBe(now.getMonth())
expect(result?.date?.getDate()).toBe(now.getDate())
})
it('should recognize today', () => {
const result = parseTaskText('Lorem Ipsum today')
expect(result.text).toBe('Lorem Ipsum')
const now = new Date()
expect(result.date.getFullYear()).toBe(now.getFullYear())
expect(result.date.getMonth()).toBe(now.getMonth())
expect(result.date.getDate()).toBe(now.getDate())
expect(result?.date?.getFullYear()).toBe(now.getFullYear())
expect(result?.date?.getMonth()).toBe(now.getMonth())
expect(result?.date?.getDate()).toBe(now.getDate())
})
describe('should recognize today with a time', () => {
const cases = {
@ -93,11 +94,11 @@ describe('Parse Task Text', () => {
expect(result.text).toBe('Lorem Ipsum')
const now = new Date()
expect(result.date.getFullYear()).toBe(now.getFullYear())
expect(result.date.getMonth()).toBe(now.getMonth())
expect(result.date.getDate()).toBe(now.getDate())
expect(`${result.date.getHours()}:${result.date.getMinutes()}`).toBe(cases[c as keyof typeof cases])
expect(result.date.getSeconds()).toBe(0)
expect(result?.date?.getFullYear()).toBe(now.getFullYear())
expect(result?.date?.getMonth()).toBe(now.getMonth())
expect(result?.date?.getDate()).toBe(now.getDate())
expect(`${result?.date?.getHours()}:${result?.date?.getMinutes()}`).toBe(cases[c as keyof typeof cases])
expect(result?.date?.getSeconds()).toBe(0)
})
}
})
@ -107,9 +108,9 @@ describe('Parse Task Text', () => {
expect(result.text).toBe('Lorem Ipsum')
const tomorrow = new Date()
tomorrow.setDate(tomorrow.getDate() + 1)
expect(result.date.getFullYear()).toBe(tomorrow.getFullYear())
expect(result.date.getMonth()).toBe(tomorrow.getMonth())
expect(result.date.getDate()).toBe(tomorrow.getDate())
expect(result?.date?.getFullYear()).toBe(tomorrow.getFullYear())
expect(result?.date?.getMonth()).toBe(tomorrow.getMonth())
expect(result?.date?.getDate()).toBe(tomorrow.getDate())
})
it('should recognize next monday', () => {
const result = parseTaskText('Lorem Ipsum next monday')
@ -119,9 +120,9 @@ describe('Parse Task Text', () => {
expect(result.text).toBe('Lorem Ipsum')
const nextMonday = new Date()
nextMonday.setDate(nextMonday.getDate() + untilNextMonday)
expect(result.date.getFullYear()).toBe(nextMonday.getFullYear())
expect(result.date.getMonth()).toBe(nextMonday.getMonth())
expect(result.date.getDate()).toBe(nextMonday.getDate())
expect(result?.date?.getFullYear()).toBe(nextMonday.getFullYear())
expect(result?.date?.getMonth()).toBe(nextMonday.getMonth())
expect(result?.date?.getDate()).toBe(nextMonday.getDate())
})
it('should recognize next monday and ignore casing', () => {
const result = parseTaskText('Lorem Ipsum nExt Monday')
@ -131,9 +132,9 @@ describe('Parse Task Text', () => {
expect(result.text).toBe('Lorem Ipsum')
const nextMonday = new Date()
nextMonday.setDate(nextMonday.getDate() + untilNextMonday)
expect(result.date.getFullYear()).toBe(nextMonday.getFullYear())
expect(result.date.getMonth()).toBe(nextMonday.getMonth())
expect(result.date.getDate()).toBe(nextMonday.getDate())
expect(result?.date?.getFullYear()).toBe(nextMonday.getFullYear())
expect(result?.date?.getMonth()).toBe(nextMonday.getMonth())
expect(result?.date?.getDate()).toBe(nextMonday.getDate())
})
it('should recognize this weekend', () => {
const result = parseTaskText('Lorem Ipsum this weekend')
@ -143,9 +144,9 @@ describe('Parse Task Text', () => {
expect(result.text).toBe('Lorem Ipsum')
const thisWeekend = new Date()
thisWeekend.setDate(thisWeekend.getDate() + untilThisWeekend)
expect(result.date.getFullYear()).toBe(thisWeekend.getFullYear())
expect(result.date.getMonth()).toBe(thisWeekend.getMonth())
expect(result.date.getDate()).toBe(thisWeekend.getDate())
expect(result?.date?.getFullYear()).toBe(thisWeekend.getFullYear())
expect(result?.date?.getMonth()).toBe(thisWeekend.getMonth())
expect(result?.date?.getDate()).toBe(thisWeekend.getDate())
})
it('should recognize later this week', () => {
const result = parseTaskText('Lorem Ipsum later this week')
@ -155,9 +156,9 @@ describe('Parse Task Text', () => {
expect(result.text).toBe('Lorem Ipsum')
const laterThisWeek = new Date()
laterThisWeek.setDate(laterThisWeek.getDate() + untilLaterThisWeek)
expect(result.date.getFullYear()).toBe(laterThisWeek.getFullYear())
expect(result.date.getMonth()).toBe(laterThisWeek.getMonth())
expect(result.date.getDate()).toBe(laterThisWeek.getDate())
expect(result?.date?.getFullYear()).toBe(laterThisWeek.getFullYear())
expect(result?.date?.getMonth()).toBe(laterThisWeek.getMonth())
expect(result?.date?.getDate()).toBe(laterThisWeek.getDate())
})
it('should recognize later next week', () => {
const result = parseTaskText('Lorem Ipsum later next week')
@ -167,9 +168,9 @@ describe('Parse Task Text', () => {
expect(result.text).toBe('Lorem Ipsum')
const laterNextWeek = new Date()
laterNextWeek.setDate(laterNextWeek.getDate() + untilLaterNextWeek)
expect(result.date.getFullYear()).toBe(laterNextWeek.getFullYear())
expect(result.date.getMonth()).toBe(laterNextWeek.getMonth())
expect(result.date.getDate()).toBe(laterNextWeek.getDate())
expect(result?.date?.getFullYear()).toBe(laterNextWeek.getFullYear())
expect(result?.date?.getMonth()).toBe(laterNextWeek.getMonth())
expect(result?.date?.getDate()).toBe(laterNextWeek.getDate())
})
it('should recognize next week', () => {
const result = parseTaskText('Lorem Ipsum next week')
@ -179,9 +180,9 @@ describe('Parse Task Text', () => {
expect(result.text).toBe('Lorem Ipsum')
const nextWeek = new Date()
nextWeek.setDate(nextWeek.getDate() + untilNextWeek)
expect(result.date.getFullYear()).toBe(nextWeek.getFullYear())
expect(result.date.getMonth()).toBe(nextWeek.getMonth())
expect(result.date.getDate()).toBe(nextWeek.getDate())
expect(result?.date?.getFullYear()).toBe(nextWeek.getFullYear())
expect(result?.date?.getMonth()).toBe(nextWeek.getMonth())
expect(result?.date?.getDate()).toBe(nextWeek.getDate())
})
it('should recognize next month', () => {
const result = parseTaskText('Lorem Ipsum next month')
@ -190,9 +191,9 @@ describe('Parse Task Text', () => {
const nextMonth = new Date()
nextMonth.setDate(1)
nextMonth.setMonth(nextMonth.getMonth() + 1)
expect(result.date.getFullYear()).toBe(nextMonth.getFullYear())
expect(result.date.getMonth()).toBe(nextMonth.getMonth())
expect(result.date.getDate()).toBe(nextMonth.getDate())
expect(result?.date?.getFullYear()).toBe(nextMonth.getFullYear())
expect(result?.date?.getMonth()).toBe(nextMonth.getMonth())
expect(result?.date?.getDate()).toBe(nextMonth.getDate())
})
it('should recognize a date', () => {
const result = parseTaskText('Lorem Ipsum 06/26/2021')
@ -200,9 +201,9 @@ describe('Parse Task Text', () => {
expect(result.text).toBe('Lorem Ipsum')
const date = new Date()
date.setFullYear(2021, 5, 26)
expect(result.date.getFullYear()).toBe(date.getFullYear())
expect(result.date.getMonth()).toBe(date.getMonth())
expect(result.date.getDate()).toBe(date.getDate())
expect(result?.date?.getFullYear()).toBe(date.getFullYear())
expect(result?.date?.getMonth()).toBe(date.getMonth())
expect(result?.date?.getDate()).toBe(date.getDate())
})
it('should recognize end of month', () => {
const result = parseTaskText('Lorem Ipsum end of month')
@ -210,9 +211,9 @@ describe('Parse Task Text', () => {
expect(result.text).toBe('Lorem Ipsum')
const curDate = new Date()
const date = new Date(curDate.getFullYear(), curDate.getMonth() + 1, 0)
expect(result.date.getFullYear()).toBe(date.getFullYear())
expect(result.date.getMonth()).toBe(date.getMonth())
expect(result.date.getDate()).toBe(date.getDate())
expect(result?.date?.getFullYear()).toBe(date.getFullYear())
expect(result?.date?.getMonth()).toBe(date.getMonth())
expect(result?.date?.getDate()).toBe(date.getDate())
})
const cases = {
@ -244,7 +245,7 @@ describe('Parse Task Text', () => {
'Sunday': 7,
'sun': 7,
'Sun': 7,
}
} as Record<string, number>
for (const c in cases) {
it(`should recognize ${c} as weekday`, () => {
const result = parseTaskText(`Lorem Ipsum ${c}`)
@ -252,7 +253,7 @@ describe('Parse Task Text', () => {
expect(result.text).toBe('Lorem Ipsum')
const nextDate = new Date()
nextDate.setDate(nextDate.getDate() + ((cases[c] + 7 - nextDate.getDay()) % 7))
expect(`${result.date.getFullYear()}-${result.date.getMonth()}-${result.date.getDate()}`).toBe(`${nextDate.getFullYear()}-${nextDate.getMonth()}-${nextDate.getDate()}`)
expect(`${result?.date?.getFullYear()}-${result?.date?.getMonth()}-${result?.date?.getDate()}`).toBe(`${nextDate.getFullYear()}-${nextDate.getMonth()}-${nextDate.getDate()}`)
})
}
it('should recognize weekdays with time', () => {
@ -261,8 +262,8 @@ describe('Parse Task Text', () => {
expect(result.text).toBe('Lorem Ipsum')
const nextThursday = new Date()
nextThursday.setDate(nextThursday.getDate() + ((4 + 7 - nextThursday.getDay()) % 7))
expect(`${result.date.getFullYear()}-${result.date.getMonth()}-${result.date.getDate()}`).toBe(`${nextThursday.getFullYear()}-${nextThursday.getMonth()}-${nextThursday.getDate()}`)
expect(`${result.date.getHours()}:${result.date.getMinutes()}`).toBe('14:0')
expect(`${result?.date?.getFullYear()}-${result?.date?.getMonth()}-${result?.date?.getDate()}`).toBe(`${nextThursday.getFullYear()}-${nextThursday.getMonth()}-${nextThursday.getDate()}`)
expect(`${result?.date?.getHours()}:${result?.date?.getMinutes()}`).toBe('14:0')
})
it('should recognize dates of the month in the past but next month', () => {
const time = new Date(2022, 0, 15)
@ -271,8 +272,8 @@ describe('Parse Task Text', () => {
const result = parseTaskText(`Lorem Ipsum ${time.getDate() - 1}th`)
expect(result.text).toBe('Lorem Ipsum')
expect(result.date.getDate()).toBe(time.getDate() - 1)
expect(result.date.getMonth()).toBe(time.getMonth() + 1)
expect(result?.date?.getDate()).toBe(time.getDate() - 1)
expect(result?.date?.getMonth()).toBe(time.getMonth() + 1)
})
it('should recognize dates of the month in the past but next month when february is the next month', () => {
const jan = new Date(2022, 0, 30)
@ -282,8 +283,8 @@ describe('Parse Task Text', () => {
const expectedDate = new Date(2022, 2, jan.getDate() - 1)
expect(result.text).toBe('Lorem Ipsum')
expect(result.date.getDate()).toBe(expectedDate.getDate())
expect(result.date.getMonth()).toBe(expectedDate.getMonth())
expect(result?.date?.getDate()).toBe(expectedDate.getDate())
expect(result?.date?.getMonth()).toBe(expectedDate.getMonth())
})
it('should recognize dates of the month in the past but next month when the next month has less days than this one', () => {
const mar = new Date(2022, 2, 32)
@ -293,15 +294,15 @@ describe('Parse Task Text', () => {
const expectedDate = new Date(2022, 4, 31)
expect(result.text).toBe('Lorem Ipsum')
expect(result.date.getDate()).toBe(expectedDate.getDate())
expect(result.date.getMonth()).toBe(expectedDate.getMonth())
expect(result?.date?.getDate()).toBe(expectedDate.getDate())
expect(result?.date?.getMonth()).toBe(expectedDate.getMonth())
})
it('should recognize dates of the month in the future', () => {
const nextDay = new Date(+new Date() + MILLISECONDS_A_DAY)
const result = parseTaskText(`Lorem Ipsum ${nextDay.getDate()}nd`)
expect(result.text).toBe('Lorem Ipsum')
expect(result.date.getDate()).toBe(nextDay.getDate())
expect(result?.date?.getDate()).toBe(nextDay.getDate())
})
it('should only recognize weekdays with a space before or after them 1', () => {
const result = parseTaskText('Lorem Ipsum renewed')
@ -382,7 +383,7 @@ describe('Parse Task Text', () => {
'saturday': 6,
'sun': 7,
'sunday': 7,
}
} as Record<string, number>
const prefix = [
'next ',
@ -399,9 +400,9 @@ describe('Parse Task Text', () => {
next.setDate(next.getDate() + distance)
expect(result.text).toBe('Lorem Ipsum')
expect(result.date.getFullYear()).toBe(next.getFullYear())
expect(result.date.getMonth()).toBe(next.getMonth())
expect(result.date.getDate()).toBe(next.getDate())
expect(result?.date?.getFullYear()).toBe(next.getFullYear())
expect(result?.date?.getMonth()).toBe(next.getMonth())
expect(result?.date?.getDate()).toBe(next.getDate())
})
}
})
@ -462,7 +463,7 @@ describe('Parse Task Text', () => {
'dolor sit amet oct 21': '2021-10-21',
'dolor sit amet nov 21': '2021-11-21',
'dolor sit amet dec 21': '2021-12-21',
}
} as Record<string, string | null>
for (const c in cases) {
it(`should parse '${c}' as '${cases[c]}'`, () => {
@ -472,7 +473,7 @@ describe('Parse Task Text', () => {
return
}
expect(`${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`).toBe(cases[c])
expect(`${date?.getFullYear()}-${date.getMonth() + 1}-${date?.getDate()}`).toBe(cases[c])
})
}
})
@ -510,7 +511,7 @@ describe('Parse Task Text', () => {
'Something at 10:00 in 5 days': '2021-6-29 10:0',
'Something at 10:00 17th': '2021-7-17 10:0',
'Something at 10:00 sep 17th': '2021-9-17 10:0',
}
} as Record<string, string>
for (const c in cases) {
it(`should parse '${c}' as '${cases[c]}'`, () => {
@ -695,15 +696,15 @@ describe('Parse Task Text', () => {
'every eight hours': {type: 'hours', amount: 8},
'every nine hours': {type: 'hours', amount: 9},
'every ten hours': {type: 'hours', amount: 10},
}
} as Record<string, IRepeatAfter>
for (const c in cases) {
it(`should parse ${c} as recurring date every ${cases[c].amount} ${cases[c].type}`, () => {
const result = parseTaskText(`Lorem Ipsum ${c}`)
expect(result.text).toBe('Lorem Ipsum')
expect(result.repeats.type).toBe(cases[c].type)
expect(result.repeats.amount).toBe(cases[c].amount)
expect(result?.repeats?.type).toBe(cases[c].type)
expect(result?.repeats?.amount).toBe(cases[c].amount)
})
}
})

View File

@ -1,6 +1,8 @@
import type { App } from 'vue'
import type { Router } from 'vue-router'
import {VERSION} from './version.json'
export default async function setupSentry(app, router) {
export default async function setupSentry(app: App, router: Router) {
const Sentry = await import('@sentry/vue')
const {Integrations} = await import('@sentry/tracing')

View File

@ -7,7 +7,7 @@ import type { IAttachment } from '@/modelTypes/IAttachment'
import {downloadBlob} from '@/helpers/downloadBlob'
export default class AttachmentService extends AbstractService<AttachmentModel> {
export default class AttachmentService extends AbstractService<IAttachment> {
constructor() {
super({
create: '/tasks/{taskId}/attachments',

View File

@ -6,7 +6,7 @@ import AbstractService from '../abstractService'
export default class AbstractMigrationFileService extends AbstractService {
serviceUrlKey = ''
constructor(serviceUrlKey: '') {
constructor(serviceUrlKey: string) {
super({
create: '/migration/' + serviceUrlKey + '/migrate',
})

View File

@ -84,7 +84,7 @@ export function useSavedFilter(listId?: MaybeRef<IList['id']>) {
const filterService = shallowReactive(new SavedFilterService())
const filter = ref(new SavedFilterModel())
const filter = ref<ISavedFilter>(new SavedFilterModel())
const filters = computed({
get: () => filter.value.filters,
set(value) {
@ -92,7 +92,7 @@ export function useSavedFilter(listId?: MaybeRef<IList['id']>) {
},
})
// loadSavedFilter
// load SavedFilter
watch(() => unref(listId), async (watchedListId) => {
if (watchedListId === undefined) {
return

View File

@ -86,7 +86,7 @@ export const useBaseStore = defineStore('base', () => {
}
async function handleSetCurrentList(
{list, forceUpdate = false}: {list: IList | null, forceUpdate: boolean},
{list, forceUpdate = false}: {list: IList | null, forceUpdate?: boolean},
) {
if (list === null) {
setCurrentList({})

View File

@ -1,12 +1,10 @@
import type { StoreDefinition } from 'pinia'
export interface LoadingState {
isLoading: boolean
}
const LOADING_TIMEOUT = 100
export const setModuleLoading = <LoadingStore extends StoreDefinition<string, LoadingState>>(store: LoadingStore, loadFunc : ((isLoading: boolean) => void) | null = null) => {
export const setModuleLoading = <Store extends LoadingState>(store: Store, loadFunc : ((isLoading: boolean) => void) | null = null) => {
const timeout = setTimeout(() => {
if (loadFunc === null) {
store.isLoading = true

View File

@ -364,7 +364,7 @@ export const useKanbanStore = defineStore('kanban', {
}
},
async updateBucket(updatedBucketData: IBucket) {
async updateBucket(updatedBucketData: Partial<IBucket>) {
const cancel = setModuleLoading(this)
const bucketIndex = findIndexById(this.buckets, updatedBucketData.id)

View File

@ -180,7 +180,7 @@ export const useListStore = defineStore('list', () => {
export function useList(listId: MaybeRef<IList['id']>) {
const listService = shallowReactive(new ListService())
const {loading: isLoading} = toRefs(listService)
const list: ListModel = reactive(new ListModel())
const list: IList = reactive(new ListModel())
const {t} = useI18n({useScope: 'global'})
watch(

21
src/types/global-components.d.ts vendored Normal file
View File

@ -0,0 +1,21 @@
// import FontAwesomeIcon from '@/components/misc/Icon'
import type { FontAwesomeIcon as FontAwesomeIconFixedTypes } from './vue-fontawesome'
import type XButton from '@/components/input/button.vue'
import type Modal from '@/components/misc/modal.vue'
import type Card from '@/components/misc/card.vue'
// Here we define globally imported components
// See:
// https://github.com/johnsoncodehk/volar/blob/2ca8fd3434423c7bea1c8e08132df3b9ce84eea7/extensions/vscode-vue-language-features/README.md#usage
// Under the hidden collapsible "Define Global Components"
declare module '@vue/runtime-core' {
export interface GlobalComponents {
Icon: FontAwesomeIconFixedTypes
XButton: typeof XButton,
Modal: typeof Modal,
Card: typeof Card,
}
}
export {}

View File

@ -0,0 +1,40 @@
// copied and slightly modified from unmerged pull request that corrects types
// https://github.com/FortAwesome/vue-fontawesome/pull/355
konrad marked this conversation as resolved Outdated

We need a reminder to remove this once the PR is merged (is that even possible entirely?)

We need a reminder to remove this once the PR is merged (is that even possible entirely?)

I subscribed to the thread of that PR. So I should get a notification if there are any changes.

I subscribed to the thread of that PR. So I should get a notification if there are any changes.
import type { FaSymbol, FlipProp, IconLookup, IconProp, PullProp, SizeProp, Transform } from '@fortawesome/fontawesome-svg-core'
import type { DefineComponent } from 'vue'
interface FontAwesomeIconProps {
border?: boolean
fixedWidth?: boolean
flip?: FlipProp
icon: IconProp
mask?: IconLookup
listItem?: boolean
pull?: PullProp
pulse?: boolean
rotation?: 90 | 180 | 270 | '90' | '180' | '270'
swapOpacity?: boolean
size?: SizeProp
spin?: boolean
transform?: Transform
symbol?: FaSymbol
title?: string | string[]
inverse?: boolean
}
interface FontAwesomeLayersProps {
fixedWidth?: boolean
}
interface FontAwesomeLayersTextProps {
value: string | number
transform?: object | string
counter?: boolean
position?: 'bottom-left' | 'bottom-right' | 'top-left' | 'top-right'
}
export type FontAwesomeIcon = DefineComponent<FontAwesomeIconProps>
export type FontAwesomeLayers = DefineComponent<FontAwesomeLayersProps>
export type FontAwesomeLayersText = DefineComponent<FontAwesomeLayersTextProps>

View File

@ -14,7 +14,6 @@
</router-link>
</message>
<add-task
:listId="defaultListId"
@taskAdded="updateTaskList"
class="is-max-width-desktop"
/>
@ -76,6 +75,7 @@ import {useConfigStore} from '@/stores/config'
import {useNamespaceStore} from '@/stores/namespaces'
import {useAuthStore} from '@/stores/auth'
import {useTaskStore} from '@/stores/tasks'
import type {IList} from '@/modelTypes/IList'
const salutation = useDaytimeSalutation()
@ -94,12 +94,11 @@ const listHistory = computed(() => {
return getHistory()
.map(l => listStore.getListById(l.id))
.filter(l => l !== null)
.filter((l): l is IList => l !== null)
})
const migratorsEnabled = computed(() => configStore.availableMigrators?.length > 0)
const hasTasks = computed(() => baseStore.hasTasks)
const defaultListId = computed(() => authStore.settings.defaultListId)
const defaultNamespaceId = computed(() => namespaceStore.namespaces?.[0]?.id || 0)
const hasLists = computed(() => namespaceStore.namespaces?.[0]?.lists.length > 0)
const loading = computed(() => taskStore.isLoading)

View File

@ -66,7 +66,7 @@ async function newLabel() {
showError.value = false
const labelStore = useLabelStore()
const newLabel = labelStore.createLabel(label.value)
const newLabel = await labelStore.createLabel(label.value)
router.push({
name: 'labels.index',
params: {id: newLabel.id},

View File

@ -71,11 +71,13 @@ import {useI18n} from 'vue-i18n'
import {useTitle} from '@/composables/useTitle'
import {useNamespaceStore} from '@/stores/namespaces'
import type {INamespace} from '@/modelTypes/INamespace'
const {t} = useI18n({useScope: 'global'})
const namespaceStore = useNamespaceStore()
const namespaceService = ref(new NamespaceService())
const namespace = ref(new NamespaceModel())
const namespace = ref<INamespace>(new NamespaceModel())
const editorActive = ref(false)
const title = ref('')
useTitle(() => title.value)

View File

@ -558,7 +558,7 @@ const canWrite = computed(() => (
const color = computed(() => {
const color = task.getHexColor
? task.getHexColor()
: false
: undefined
return color === TASK_DEFAULT_COLOR
? ''

View File

@ -50,14 +50,14 @@ async function authenticateWithCode() {
if (localStorage.getItem('authenticating')) {
return
}
localStorage.setItem('authenticating', true)
localStorage.setItem('authenticating', 'true')
errorMessage.value = ''
if (typeof route.query.error !== 'undefined') {
localStorage.removeItem('authenticating')
errorMessage.value = typeof route.query.message !== 'undefined'
? route.query.message
? route.query.message as string
: t('user.auth.openIdGeneralError')
return
}

View File

@ -130,8 +130,8 @@ async function submit() {
try {
await authStore.register(toRaw(credentials))
} catch (e) {
errorMessage.value = e.message
} catch (e: any) {
errorMessage.value = e?.message
}
}
</script>

View File

@ -23,7 +23,7 @@
<x-button
v-if="!isCropAvatar"
:loading="avatarService.loading || loading"
@click="$refs.avatarUploadInput.click()"
@click="avatarUploadInput.click()"
>
{{ $t('user.settings.avatar.uploadAvatar') }}
</x-button>

View File

@ -41,7 +41,7 @@
<td>{{ tk.id }}</td>
<td>{{ formatDateShort(tk.created) }}</td>
<td class="has-text-right">
<x-button type="secondary" @click="deleteToken(tk)">
<x-button variant="secondary" @click="deleteToken(tk)">
{{ $t('misc.delete') }}
</x-button>
</td>

View File

@ -246,7 +246,7 @@ watch(
const listStore = useListStore()
const defaultList = computed({
get: () => listStore.getListById(settings.value.defaultListId),
get: () => listStore.getListById(settings.value.defaultListId) || undefined,
set(l) {
settings.value.defaultListId = l ? l.id : DEFAULT_LIST_ID
},

View File

@ -79,13 +79,14 @@ import {success} from '@/message'
import {useTitle} from '@/composables/useTitle'
import {useConfigStore} from '@/stores/config'
import type {ITotp} from '@/modelTypes/ITotp'
const {t} = useI18n({useScope: 'global'})
useTitle(() => `${t('user.settings.totp.title')} - ${t('user.settings.title')}`)
const totpService = shallowReactive(new TotpService())
const totp = ref(new TotpModel())
const totp = ref<ITotp>(new TotpModel())
const totpQR = ref('')
const totpEnrolled = ref(false)
const totpConfirmPasscode = ref('')

View File

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

Unsure about this

Unsure about this

Biggest reason why I can't judge this: Even after the merge of this branch we still have so many type errors that I don't know which errors appear / disappear when I toggle this option.

Biggest reason why I can't judge this: Even after the merge of this branch we still have so many type errors that I don't know which errors appear / disappear when I toggle this option.

Is there any difference in the number of errors?

Is there any difference in the number of errors?

Without "strictTemplates": true (I guess the default is false ;) )

Found 917 errors in 154 files.

With "strictTemplates": true

Found 949 errors in 159 files.

As said: I do not know which errors these are. It might also be that some disappear while others emerge.

Without `"strictTemplates": true` (I guess the default is false ;) ) ``` Found 917 errors in 154 files. ``` With `"strictTemplates": true` ``` Found 949 errors in 159 files. ``` As said: I do not know which errors these are. It might also be that some disappear while others emerge.
"jsxTemplates": true
}
}