1
0

feat: improved types (#2547)

Reviewed-on: https://kolaente.dev/vikunja/frontend/pulls/2547
Reviewed-by: konrad <k@knt.li>
This commit is contained in:
konrad
2022-11-02 16:06:55 +00:00
63 changed files with 444 additions and 340 deletions

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>
<component
:is="componentNodeName"
<div
v-if="disabled === true && (to !== undefined || href !== undefined)"
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"
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
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 {
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 {
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">
<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>,
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'
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"
: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