feat: show task card preview when hovering over task title in list and table view (#1863)
This commit is contained in:
parent
953623c132
commit
2bc2311212
|
|
@ -207,9 +207,11 @@
|
|||
/>
|
||||
</td>
|
||||
<td v-if="activeColumns.title">
|
||||
<RouterLink :to="taskDetailRoutes[t.id]">
|
||||
{{ t.title }}
|
||||
</RouterLink>
|
||||
<TaskGlanceTooltip :task="t">
|
||||
<RouterLink :to="taskDetailRoutes[t.id]">
|
||||
{{ t.title }}
|
||||
</RouterLink>
|
||||
</TaskGlanceTooltip>
|
||||
</td>
|
||||
<td v-if="activeColumns.priority">
|
||||
<PriorityLabel
|
||||
|
|
@ -235,13 +237,7 @@
|
|||
:date="t.dueDate"
|
||||
/>
|
||||
<td v-if="activeColumns.commentCount">
|
||||
<span
|
||||
v-if="t.commentCount && t.commentCount > 0"
|
||||
class="comment-badge"
|
||||
>
|
||||
<Icon icon="comment" />
|
||||
{{ t.commentCount }}
|
||||
</span>
|
||||
<CommentCount :task="t" />
|
||||
</td>
|
||||
<DateTableCell
|
||||
v-if="activeColumns.startDate"
|
||||
|
|
@ -298,7 +294,9 @@ import Done from '@/components/misc/Done.vue'
|
|||
import User from '@/components/misc/User.vue'
|
||||
import PriorityLabel from '@/components/tasks/partials/PriorityLabel.vue'
|
||||
import Labels from '@/components/tasks/partials/Labels.vue'
|
||||
import TaskGlanceTooltip from '@/components/tasks/partials/TaskGlanceTooltip.vue'
|
||||
import DateTableCell from '@/components/tasks/partials/DateTableCell.vue'
|
||||
import CommentCount from '@/components/tasks/partials/CommentCount.vue'
|
||||
import FancyCheckbox from '@/components/input/FancyCheckbox.vue'
|
||||
import Sort from '@/components/tasks/partials/Sort.vue'
|
||||
import FilterPopup from '@/components/project/partials/FilterPopup.vue'
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
<template>
|
||||
<span
|
||||
v-if="task.commentCount && task.commentCount > 0"
|
||||
v-tooltip="tooltip"
|
||||
class="comment-count"
|
||||
>
|
||||
<Icon :icon="['far', 'comments']" />
|
||||
<span class="comment-count-badge">{{ task.commentCount }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
import type {ITask} from '@/modelTypes/ITask'
|
||||
|
||||
const props = defineProps<{
|
||||
task: ITask
|
||||
}>()
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
|
||||
const tooltip = computed(() => t('task.attributes.comment', props.task.commentCount))
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.comment-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--grey-500);
|
||||
|
||||
.comment-count-badge {
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
|
@ -84,16 +84,12 @@
|
|||
v-if="task.attachments.length > 0"
|
||||
class="icon"
|
||||
>
|
||||
<Icon icon="paperclip" />
|
||||
</span>
|
||||
<span
|
||||
v-if="task.commentCount && task.commentCount > 0"
|
||||
v-tooltip="$t('task.attributes.comment', task.commentCount)"
|
||||
class="project-task-icon comment-count-icon"
|
||||
>
|
||||
<Icon :icon="['far', 'comments']" />
|
||||
<span class="comment-count-badge">{{ task.commentCount }}</span>
|
||||
<Icon icon="paperclip" />
|
||||
</span>
|
||||
<CommentCount
|
||||
:task="task"
|
||||
class="project-task-icon"
|
||||
/>
|
||||
<span
|
||||
v-if="!isEditorContentEmpty(task.description)"
|
||||
class="icon"
|
||||
|
|
@ -122,6 +118,7 @@ import ProgressBar from '@/components/misc/ProgressBar.vue'
|
|||
import Done from '@/components/misc/Done.vue'
|
||||
import Labels from '@/components/tasks/partials/Labels.vue'
|
||||
import ChecklistSummary from './ChecklistSummary.vue'
|
||||
import CommentCount from './CommentCount.vue'
|
||||
|
||||
import {getHexColor} from '@/models/task'
|
||||
import type {ITask} from '@/modelTypes/ITask'
|
||||
|
|
@ -405,21 +402,10 @@ $task-background: var(--white);
|
|||
block-size: 0.5rem;
|
||||
}
|
||||
|
||||
.comment-count-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--grey-500);
|
||||
:deep(.comment-count) {
|
||||
background: var(--grey-100);
|
||||
border-radius: $radius;
|
||||
padding: 0.25rem;
|
||||
padding: 0.25rem;
|
||||
margin-inline-end: .25rem;
|
||||
|
||||
.comment-count-badge {
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -49,14 +49,16 @@
|
|||
class="pis-2 mie-1"
|
||||
/>
|
||||
|
||||
<RouterLink
|
||||
ref="taskLinkRef"
|
||||
:to="taskDetailRoute"
|
||||
class="task-link"
|
||||
tabindex="-1"
|
||||
>
|
||||
{{ task.title }}
|
||||
</RouterLink>
|
||||
<TaskGlanceTooltip :task="task">
|
||||
<RouterLink
|
||||
ref="taskLinkRef"
|
||||
:to="taskDetailRoute"
|
||||
class="task-link"
|
||||
tabindex="-1"
|
||||
>
|
||||
{{ task.title }}
|
||||
</RouterLink>
|
||||
</TaskGlanceTooltip>
|
||||
</span>
|
||||
|
||||
<Labels
|
||||
|
|
@ -120,14 +122,10 @@
|
|||
>
|
||||
<Icon icon="history" />
|
||||
</span>
|
||||
<span
|
||||
v-if="task.commentCount && task.commentCount > 0"
|
||||
class="project-task-icon comment-count-icon"
|
||||
:title="`${task.commentCount} ${task.commentCount === 1 ? 'comment' : 'comments'}`"
|
||||
>
|
||||
<Icon :icon="['far', 'comments']" />
|
||||
<span class="comment-count-badge">{{ task.commentCount }}</span>
|
||||
</span>
|
||||
<CommentCount
|
||||
:task="task"
|
||||
class="project-task-icon"
|
||||
/>
|
||||
</span>
|
||||
|
||||
<ChecklistSummary :task="task" />
|
||||
|
|
@ -198,8 +196,10 @@ import type {ITask} from '@/modelTypes/ITask'
|
|||
|
||||
import PriorityLabel from '@/components/tasks/partials/PriorityLabel.vue'
|
||||
import Labels from '@/components/tasks/partials/Labels.vue'
|
||||
import TaskGlanceTooltip from '@/components/tasks/partials/TaskGlanceTooltip.vue'
|
||||
import DeferTask from '@/components/tasks/partials/DeferTask.vue'
|
||||
import ChecklistSummary from '@/components/tasks/partials/ChecklistSummary.vue'
|
||||
import CommentCount from '@/components/tasks/partials/CommentCount.vue'
|
||||
|
||||
import ProgressBar from '@/components/misc/ProgressBar.vue'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
|
|
@ -573,22 +573,4 @@ defineExpose({
|
|||
border: 1px solid var(--grey-200);
|
||||
}
|
||||
}
|
||||
|
||||
.comment-count-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--grey-500);
|
||||
|
||||
.comment-count-badge {
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,313 @@
|
|||
<template>
|
||||
<span
|
||||
ref="triggerRef"
|
||||
class="task-glance-trigger"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
|
||||
<Teleport to="body">
|
||||
<CustomTransition name="fade">
|
||||
<div
|
||||
v-if="showTooltip"
|
||||
ref="tooltipRef"
|
||||
class="task-glance-tooltip"
|
||||
role="tooltip"
|
||||
>
|
||||
<div class="task-glance-content">
|
||||
<div class="task-glance-header">
|
||||
<div class="task-glance-title-section">
|
||||
<span class="task-identifier">{{ taskIdentifier }}</span>
|
||||
<span class="task-title">{{ task.title }}</span>
|
||||
</div>
|
||||
<div class="task-glance-indicators">
|
||||
<span
|
||||
v-if="task.attachments.length > 0"
|
||||
class="task-glance-icon"
|
||||
>
|
||||
<Icon icon="paperclip" />
|
||||
</span>
|
||||
<CommentCount
|
||||
:task="task"
|
||||
class="task-glance-icon"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="descriptionPreview"
|
||||
class="task-glance-description"
|
||||
>
|
||||
{{ descriptionPreview }}
|
||||
</div>
|
||||
|
||||
<ChecklistSummary
|
||||
:task="task"
|
||||
class="task-glance-checklist-summary"
|
||||
/>
|
||||
|
||||
<Labels
|
||||
v-if="task.labels.length > 0"
|
||||
:labels="task.labels"
|
||||
class="task-glance-labels"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="task.dueDate"
|
||||
class="task-glance-due"
|
||||
>
|
||||
<Icon icon="calendar" />
|
||||
<span>{{ $t('task.detail.due', {at: formatDisplayDate(task.dueDate)}) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="task-glance-meta">
|
||||
<div class="task-glance-created">
|
||||
<i18n-t
|
||||
keypath="task.detail.created"
|
||||
scope="global"
|
||||
>
|
||||
<span>{{ formatDisplayDate(task.created) }}</span>
|
||||
{{ getDisplayName(task.createdBy) }}
|
||||
</i18n-t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CustomTransition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, computed, onUnmounted, nextTick} from 'vue'
|
||||
import {computePosition, flip, offset, shift} from '@floating-ui/dom'
|
||||
|
||||
import type {ITask} from '@/modelTypes/ITask'
|
||||
import {getTaskIdentifier} from '@/models/task'
|
||||
import {formatDisplayDate} from '@/helpers/time/formatDate'
|
||||
import {getDisplayName} from '@/models/user'
|
||||
import {isEditorContentEmpty} from '@/helpers/editorContentEmpty'
|
||||
|
||||
import Labels from '@/components/tasks/partials/Labels.vue'
|
||||
import ChecklistSummary from '@/components/tasks/partials/ChecklistSummary.vue'
|
||||
import CommentCount from '@/components/tasks/partials/CommentCount.vue'
|
||||
import CustomTransition from '@/components/misc/CustomTransition.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
task: ITask
|
||||
}>()
|
||||
|
||||
const HOVER_DELAY = 1000 // 1 second
|
||||
const MAX_DESCRIPTION_LENGTH = 150
|
||||
|
||||
const triggerRef = ref<HTMLElement | null>(null)
|
||||
const tooltipRef = ref<HTMLElement | null>(null)
|
||||
const showTooltip = ref(false)
|
||||
let hoverTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const taskIdentifier = computed(() => getTaskIdentifier(props.task))
|
||||
|
||||
const descriptionPreview = computed(() => {
|
||||
if (isEditorContentEmpty(props.task.description)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
// Create a temporary div to extract plain text from HTML
|
||||
const tempDiv = document.createElement('div')
|
||||
tempDiv.innerHTML = props.task.description
|
||||
const plainText = tempDiv.textContent || tempDiv.innerText || ''
|
||||
|
||||
const trimmedText = plainText.trim()
|
||||
if (trimmedText.length <= MAX_DESCRIPTION_LENGTH) {
|
||||
return trimmedText
|
||||
}
|
||||
|
||||
return trimmedText.substring(0, MAX_DESCRIPTION_LENGTH) + '…'
|
||||
})
|
||||
|
||||
async function updatePosition() {
|
||||
if (!triggerRef.value || !tooltipRef.value) {
|
||||
return
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
|
||||
const {x, y} = await computePosition(triggerRef.value, tooltipRef.value, {
|
||||
strategy: 'absolute',
|
||||
placement: 'top',
|
||||
middleware: [
|
||||
offset(8),
|
||||
flip({
|
||||
fallbackPlacements: ['bottom', 'top-start', 'top-end', 'bottom-start', 'bottom-end'],
|
||||
padding: 8,
|
||||
}),
|
||||
shift({padding: 8}),
|
||||
],
|
||||
})
|
||||
|
||||
// Set position directly on the element
|
||||
if (tooltipRef.value) {
|
||||
tooltipRef.value.style.left = `${x}px`
|
||||
tooltipRef.value.style.top = `${y}px`
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseEnter() {
|
||||
// Clear any existing timeout
|
||||
if (hoverTimeout) {
|
||||
clearTimeout(hoverTimeout)
|
||||
}
|
||||
|
||||
// Set timeout to show tooltip after 1 second
|
||||
hoverTimeout = setTimeout(async () => {
|
||||
showTooltip.value = true
|
||||
// Wait for the tooltip to be rendered in the DOM
|
||||
await nextTick()
|
||||
await updatePosition()
|
||||
}, HOVER_DELAY)
|
||||
}
|
||||
|
||||
function handleMouseLeave() {
|
||||
// Clear timeout if user moves away before 1 second
|
||||
if (hoverTimeout) {
|
||||
clearTimeout(hoverTimeout)
|
||||
hoverTimeout = null
|
||||
}
|
||||
|
||||
// Hide tooltip
|
||||
showTooltip.value = false
|
||||
}
|
||||
|
||||
// Cleanup on unmount
|
||||
onUnmounted(() => {
|
||||
if (hoverTimeout) {
|
||||
clearTimeout(hoverTimeout)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.task-glance-trigger {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.task-glance-tooltip {
|
||||
position: absolute;
|
||||
inset-block-start: 0;
|
||||
inset-inline-start: 0;
|
||||
z-index: 9999;
|
||||
max-inline-size: 400px;
|
||||
background: var(--white);
|
||||
border: 1px solid var(--grey-200);
|
||||
border-radius: $radius;
|
||||
box-shadow: var(--shadow-lg);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.task-glance-content {
|
||||
padding: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.task-glance-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.task-glance-title-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
flex: 1;
|
||||
min-inline-size: 0; /* Allow text to wrap */
|
||||
}
|
||||
|
||||
.task-identifier {
|
||||
font-size: 0.75rem;
|
||||
color: var(--grey-500);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.task-title {
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.task-glance-description {
|
||||
color: var(--grey-700);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.4;
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.task-glance-labels {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.task-glance-due {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--grey-700);
|
||||
|
||||
.icon {
|
||||
inline-size: 1rem;
|
||||
block-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.task-glance-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding-block-start: 0.25rem;
|
||||
border-block-start: 1px solid var(--grey-200);
|
||||
font-size: 0.8rem;
|
||||
color: var(--grey-600);
|
||||
}
|
||||
|
||||
.task-glance-created {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.task-glance-indicators {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0; /* Prevent icons from shrinking */
|
||||
|
||||
:deep(.checklist-summary) {
|
||||
padding-inline-end: 6px;
|
||||
|
||||
&:not(:last-child) {
|
||||
padding-inline-end: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.task-glance-icon {
|
||||
color: var(--grey-500);
|
||||
font-size: 0.875rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-inline-end: 6px;
|
||||
|
||||
&:not(:last-of-type) {
|
||||
margin-inline-end: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.task-glance-checklist-summary {
|
||||
margin-block-start: -0.25rem;
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in New Issue