feat: issues (#77)
* WIP * feat: readonly issues in connectors * fix created at on replies * filter out by resourceStringId * show label name if just one * generate gql * linting * linting
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6.73336 1.45469C7.57004 1.29277 8.43001 1.29277 9.26669 1.45469M9.26669 14.5454C8.43001 14.7073 7.57004 14.7073 6.73336 14.5454M11.7394 2.48069C12.447 2.96017 13.0558 3.57127 13.5327 4.28069M1.45469 9.26669C1.29277 8.43001 1.29277 7.57004 1.45469 6.73336M13.5194 11.7394C13.0399 12.447 12.4288 13.0558 11.7194 13.5327M14.5454 6.73336C14.7073 7.57004 14.7073 8.43001 14.5454 9.26669M2.48069 4.26069C2.96017 3.55304 3.57127 2.94421 4.28069 2.46736M4.26069 13.5194C3.55304 13.0399 2.94421 12.4288 2.46736 11.7194"
|
||||
stroke="#707070"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8 1.33301C11.6819 1.33301 14.667 4.3181 14.667 8C14.6669 11.6819 11.6819 14.667 8 14.667C4.3182 14.6669 1.33305 11.6818 1.33301 8C1.33301 4.31816 4.31818 1.3331 8 1.33301ZM10.5303 6.13672C10.2374 5.84383 9.76262 5.84383 9.46973 6.13672L7.33301 8.27246L6.53027 7.46973C6.23742 7.17705 5.76258 7.17705 5.46973 7.46973C5.17713 7.76259 5.17708 8.23745 5.46973 8.53027L6.80273 9.86426C6.94329 10.0047 7.13433 10.0839 7.33301 10.084C7.53165 10.084 7.72268 10.0046 7.86328 9.86426L10.5303 7.19727C10.8231 6.90445 10.8229 6.42963 10.5303 6.13672Z"
|
||||
fill="#15803D"
|
||||
/>
|
||||
<path
|
||||
d="M8 1.33301C11.6819 1.33301 14.667 4.3181 14.667 8C14.6669 11.6819 11.6819 14.667 8 14.667C4.3182 14.6669 1.33305 11.6818 1.33301 8C1.33301 4.31816 4.31818 1.3331 8 1.33301ZM10.5303 6.13672C10.2374 5.84383 9.76262 5.84383 9.46973 6.13672L7.33301 8.27246L6.53027 7.46973C6.23742 7.17705 5.76258 7.17705 5.46973 7.46973C5.17713 7.76259 5.17708 8.23745 5.46973 8.53027L6.80273 9.86426C6.94329 10.0047 7.13433 10.0839 7.33301 10.084C7.53165 10.084 7.72268 10.0046 7.86328 9.86426L10.5303 7.19727C10.8231 6.90445 10.8229 6.42963 10.5303 6.13672Z"
|
||||
fill="#16A34A"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8.00024 2.08337C11.2678 2.08355 13.9163 4.73279 13.9163 8.00037C13.9161 11.2678 11.2677 13.9162 8.00024 13.9164C4.73267 13.9164 2.08343 11.2679 2.08325 8.00037C2.08325 4.73268 4.73256 2.08337 8.00024 2.08337Z"
|
||||
stroke="#EAB308"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M8.75 4.83789C10.1832 5.17655 11.25 6.46328 11.25 8C11.25 9.53664 10.1831 10.8224 8.75 11.1611V4.83789Z"
|
||||
fill="#EAB308"
|
||||
/>
|
||||
<path
|
||||
d="M8.75 4.83789C10.1832 5.17655 11.25 6.46328 11.25 8C11.25 9.53664 10.1831 10.8224 8.75 11.1611V4.83789Z"
|
||||
stroke="#7C7C7D"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M8.75 4.83789C10.1832 5.17655 11.25 6.46328 11.25 8C11.25 9.53664 10.1831 10.8224 8.75 11.1611V4.83789Z"
|
||||
stroke="#EAB308"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,51 @@
|
||||
<!-- CommonTiptapViewer.vue -->
|
||||
<template>
|
||||
<!-- read-only output -->
|
||||
<div
|
||||
v-if="html"
|
||||
class="p-1 pl-3 group w-full whitespace-pre-wrap break-words"
|
||||
v-html="html"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { JSONContent } from '@tiptap/core'
|
||||
|
||||
const props = defineProps<{
|
||||
doc: JSONContent | null | undefined
|
||||
}>()
|
||||
|
||||
const escapeHtml = (str: string): string =>
|
||||
str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
|
||||
function renderNode(node?: JSONContent): string {
|
||||
if (!node) return ''
|
||||
|
||||
const children = (node.content ?? []).map(renderNode).join('')
|
||||
|
||||
switch (node.type) {
|
||||
case 'doc':
|
||||
return children
|
||||
|
||||
case 'paragraph':
|
||||
// empty paragraph → visual empty line
|
||||
return children ? `<p>${children}</p>` : '<p><br /></p>'
|
||||
|
||||
case 'text': {
|
||||
const text = escapeHtml(node.text ?? '')
|
||||
// if you need marks later (bold, italic, etc.), handle here
|
||||
return text
|
||||
}
|
||||
|
||||
case 'hardBreak':
|
||||
return '<br />'
|
||||
|
||||
default:
|
||||
// unknown node → just render its children
|
||||
return children
|
||||
}
|
||||
}
|
||||
|
||||
const html = computed(() => (props.doc ? renderNode(props.doc) : ''))
|
||||
</script>
|
||||
@@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<div class="p-0">
|
||||
<slot name="activator" :toggle="toggleDialog"></slot>
|
||||
<CommonDialog v-model:open="showIssuesDialog" :title="`Issues`" fullscreen="none">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<div v-if="selectedIssue" class="flex flex-col space-y-1.5">
|
||||
<div class="relative flex items-center h-8">
|
||||
<div class="absolute left-0">
|
||||
<FormButton
|
||||
color="outline"
|
||||
hide-text
|
||||
:icon-left="ArrowLeft"
|
||||
@click="selectedIssue = undefined"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mx-auto text-foreground-2 font-medium font-mono text-body-xs">
|
||||
{{ selectedIssue.identifier }}
|
||||
</div>
|
||||
<div class="absolute right-0">
|
||||
<FormButton
|
||||
v-tippy="'Open issue in browser'"
|
||||
color="outline"
|
||||
hide-text
|
||||
:icon-left="ArrowTopRightOnSquareIcon"
|
||||
@click="openIssueOnWeb(selectedIssue.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
<IssuesSelectedItem :issue="selectedIssue" />
|
||||
</div>
|
||||
|
||||
<div v-if="!selectedIssue" class="flex flex-col space-y-2">
|
||||
<IssuesItem
|
||||
v-for="issue in issues"
|
||||
:key="issue.id"
|
||||
:issue="issue"
|
||||
:model-card="modelCard"
|
||||
@select="selectedIssue = issue"
|
||||
@open-on-web="(issueId) => openIssueOnWeb(issueId)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CommonDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { IssuesItemFragment } from '~/lib/common/generated/gql/graphql'
|
||||
import type { IModelCard } from '~/lib/models/card'
|
||||
import { ArrowLeft } from 'lucide-vue-next'
|
||||
import { ArrowTopRightOnSquareIcon } from '@heroicons/vue/24/solid'
|
||||
|
||||
const props = defineProps<{
|
||||
issues: IssuesItemFragment[]
|
||||
modelCard: IModelCard
|
||||
}>()
|
||||
|
||||
const app = useNuxtApp()
|
||||
const showIssuesDialog = ref(false)
|
||||
const selectedIssue = ref<IssuesItemFragment | undefined>(undefined)
|
||||
|
||||
const toggleDialog = () => {
|
||||
showIssuesDialog.value = !showIssuesDialog.value
|
||||
}
|
||||
|
||||
const openIssueOnWeb = (issueId: string) => {
|
||||
app.$baseBinding.openUrl(
|
||||
`${props.modelCard.serverUrl}/projects/${props.modelCard?.projectId}/models/${props.modelCard.modelId}#threadId=${issueId}`
|
||||
)
|
||||
}
|
||||
|
||||
watch(showIssuesDialog, (open) => {
|
||||
if (!open) selectedIssue.value = undefined
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<button
|
||||
class="gap-1 border rounded-xl border-outline-3 p-1.5 pt-1 pl-3 group hover:shadow-md hover:cursor-pointer space-y-2"
|
||||
@click="emit('select'), highlightModel()"
|
||||
>
|
||||
<!-- Item Header -->
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="text-foreground-2 font-medium font-mono text-body-xs">
|
||||
{{ issue.identifier }}
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<FormButton
|
||||
v-if="store.hostAppName !== 'navisworks' && store.hostAppName !== 'etabs'"
|
||||
v-tippy="'Highlight'"
|
||||
color="subtle"
|
||||
:icon-left="CursorArrowRaysIcon"
|
||||
hide-text
|
||||
size="sm"
|
||||
@click.stop="highlightModel"
|
||||
/>
|
||||
<FormButton
|
||||
v-tippy="'Open issue in browser'"
|
||||
color="subtle"
|
||||
:icon-left="ArrowTopRightOnSquareIcon"
|
||||
hide-text
|
||||
size="sm"
|
||||
class="mr-1"
|
||||
@click.stop="emit('open-on-web', issue.id)"
|
||||
/>
|
||||
<UserAvatar :user="issue.assignee?.user" size="xs" class="rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Item Title & status -->
|
||||
<div class="flex items-center gap-1">
|
||||
<IssuesStatusIcon :status="issue.status" />
|
||||
<div class="line-clamp-2 font-medium text-body-2xs text-foreground">
|
||||
{{ issue.title ? issue.title : 'No title' }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Remaining secondary fields -->
|
||||
<div class="flex items-center gap-4 ml-0.5">
|
||||
<IssuesPriorityIcon :priority="issue.priority" />
|
||||
<IssuesLabels :labels="issue.labels" />
|
||||
<div v-if="formattedDate" class="flex items-center gap-1 h-6">
|
||||
<Calendar class="text-foreground-2 shrink-0" :stroke-width="1.5" :size="12" />
|
||||
<span class="text-body-3xs text-foreground-2 font-medium">
|
||||
{{ formattedDate }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-else class="flex items-center gap-1 h-6">
|
||||
<Calendar class="text-foreground-2 shrink-0" :stroke-width="1.5" :size="12" />
|
||||
<span class="text-body-3xs text-foreground-2 font-medium">No due date</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { IssuesItemFragment } from '~/lib/common/generated/gql/graphql'
|
||||
import { CursorArrowRaysIcon } from '@heroicons/vue/24/outline'
|
||||
import { Calendar } from 'lucide-vue-next'
|
||||
import dayjs from 'dayjs'
|
||||
import { useHostAppStore } from '~~/store/hostApp'
|
||||
import { ToastNotificationType } from '@speckle/ui-components'
|
||||
import type { IModelCard } from '~/lib/models/card'
|
||||
import type { SenderModelCard } from '~/lib/models/card/send'
|
||||
import { ArrowTopRightOnSquareIcon } from '@heroicons/vue/24/solid'
|
||||
|
||||
const store = useHostAppStore()
|
||||
|
||||
const props = defineProps<{
|
||||
modelCard: IModelCard
|
||||
issue: IssuesItemFragment
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select'): void
|
||||
(e: 'open-on-web', issueId: string): void
|
||||
}>()
|
||||
|
||||
const app = useNuxtApp()
|
||||
|
||||
type IssueViewerState = {
|
||||
ui: {
|
||||
filters: {
|
||||
selectedObjectApplicationIds?: Record<string, string>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const highlightModel = async () => {
|
||||
if (!props.issue.viewerState) {
|
||||
store.setNotification({
|
||||
title: 'Objects not found to highlight',
|
||||
type: ToastNotificationType.Info
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (props.modelCard.typeDiscriminator !== 'SenderModelCard') return
|
||||
|
||||
const sender = props.modelCard as SenderModelCard
|
||||
|
||||
type SelectedObjectMap = Record<string, string>
|
||||
|
||||
const selectedObjectApplicationIds = Object.values(
|
||||
((props.issue.viewerState as IssueViewerState).ui.filters
|
||||
.selectedObjectApplicationIds ?? {}) as SelectedObjectMap
|
||||
)
|
||||
|
||||
const appIdsToHighlight = (sender.sendFilter?.selectedObjectIds ?? []).filter((id) =>
|
||||
selectedObjectApplicationIds.includes(id)
|
||||
)
|
||||
|
||||
if (appIdsToHighlight.length > 0) {
|
||||
await app.$baseBinding.highlightObjects(appIdsToHighlight)
|
||||
} else {
|
||||
store.setNotification({
|
||||
title: 'Objects not found to highlight on this model.',
|
||||
type: ToastNotificationType.Info
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const formattedDate = computed((): string | null => {
|
||||
try {
|
||||
const date = props.issue.dueDate ? dayjs(props.issue.dueDate).toDate() : null
|
||||
|
||||
if (!(date instanceof Date)) return null
|
||||
|
||||
const time = date.getTime()
|
||||
if (isNaN(time)) return null
|
||||
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
}).format(date)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="flex items-center -space-x-1">
|
||||
<template
|
||||
v-for="labelItem in maxVisible ? labels.slice(0, maxVisible) : labels"
|
||||
:key="labelItem.id"
|
||||
>
|
||||
<div
|
||||
v-if="labelItem.hexColor"
|
||||
class="w-2 h-2 rounded-full shrink-0"
|
||||
:style="{ backgroundColor: labelItem.hexColor }"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Single label -->
|
||||
<span
|
||||
v-if="labels.length === 1"
|
||||
class="text-body-3xs font-medium flex items-center gap-1"
|
||||
:style="{ color: labels[0].hexColor || undefined }"
|
||||
>
|
||||
{{ labels[0].name }}
|
||||
</span>
|
||||
|
||||
<!-- Multiple labels -->
|
||||
<span v-else class="text-body-3xs text-foreground-2 font-medium">
|
||||
{{ labels.length }} label{{ labels.length !== 1 ? 's' : '' }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Label } from '~/lib/issues/types'
|
||||
|
||||
defineProps<{
|
||||
labels: Label[]
|
||||
maxVisible?: number
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<Tippy interactive placement="bottom" :offset="[0, 6]">
|
||||
<!-- Trigger -->
|
||||
<template #default>
|
||||
<IssuesLabelGroup :labels="labels" />
|
||||
</template>
|
||||
|
||||
<!-- Tooltip content -->
|
||||
<template v-if="labels.length > 0" #content>
|
||||
<div class="rounded-md shadow-lg p-0.5 text-xs space-y-1">
|
||||
<div
|
||||
v-for="label in labels"
|
||||
:key="label.id"
|
||||
class="flex items-center space-x-2"
|
||||
>
|
||||
<span
|
||||
class="w-2 h-2 rounded-full"
|
||||
:style="{ backgroundColor: label.hexColor }"
|
||||
/>
|
||||
<span>{{ label.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Tippy>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Tippy } from 'vue-tippy'
|
||||
import type { Label } from '~/lib/issues/types'
|
||||
|
||||
defineProps<{
|
||||
labels: Label[]
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<div class="flex items-center space-x-2">
|
||||
<div
|
||||
v-if="priority !== null && priority !== 'none'"
|
||||
v-tippy="showLabel ? undefined : priorityText"
|
||||
class="flex flex-col gap-0.5 items-start justify-center w-3 h-3"
|
||||
>
|
||||
<!-- Top line -->
|
||||
<div
|
||||
class="h-0.5 rounded-full bg-foreground-2 w-3"
|
||||
:class="priority !== 'high' && 'opacity-25'"
|
||||
/>
|
||||
<!-- Middle line -->
|
||||
<div
|
||||
class="h-0.5 rounded-full bg-foreground-2 w-2"
|
||||
:class="priority === 'low' && 'opacity-25'"
|
||||
/>
|
||||
<!-- Bottom line -->
|
||||
<div class="h-0.5 rounded-full bg-foreground-2 w-1" />
|
||||
</div>
|
||||
<!-- No priority: Two dashes -->
|
||||
<div v-else class="flex gap-0.5 items-center justify-center h-3 w-3">
|
||||
<div class="h-px rounded-full bg-foreground-3 w-1" />
|
||||
<div class="h-px rounded-full bg-foreground-3 w-1" />
|
||||
</div>
|
||||
|
||||
<span v-if="showLabel" class="text-body-3xs text-foreground-2 font-medium">
|
||||
{{ priorityText }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
priority: 'none' | 'low' | 'medium' | 'high' | null
|
||||
showLabel?: boolean
|
||||
}>(),
|
||||
{
|
||||
showLabel: false
|
||||
}
|
||||
)
|
||||
|
||||
const priorityText = computed(() => {
|
||||
switch (props.priority) {
|
||||
case 'high':
|
||||
return 'High'
|
||||
case 'medium':
|
||||
return 'Medium'
|
||||
case 'low':
|
||||
return 'Low'
|
||||
case 'none':
|
||||
return 'No priority'
|
||||
case null:
|
||||
return 'No priority'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<div class="flex flex-col space-y-1.5">
|
||||
<div class="flex flex-col items-start space-y-2 p-2">
|
||||
<div class="line-clamp-2 font-medium text-body text-foreground">
|
||||
{{ issue.title ? issue.title : 'No title' }}
|
||||
</div>
|
||||
<IssuesBasicTiptap
|
||||
v-if="issue.description?.doc"
|
||||
class="border rounded-xl border-outline-3"
|
||||
:doc="issue.description?.doc"
|
||||
></IssuesBasicTiptap>
|
||||
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<IssuesStatusIcon :status="issue.status" show-label />
|
||||
<IssuesPriorityIcon :priority="issue.priority" show-label />
|
||||
<div class="flex items-center justify-between space-x-1">
|
||||
<UserAvatar :user="issue.assignee?.user" size="xs" />
|
||||
<span class="text-body-3xs text-foreground-2 font-medium">
|
||||
{{ issue.assignee ? issue.assignee?.user.name : 'No assignee' }}
|
||||
</span>
|
||||
</div>
|
||||
<IssuesLabels :labels="issue.labels" />
|
||||
<div v-if="formattedDate" class="flex items-center gap-1 h-6">
|
||||
<Calendar class="text-foreground-2 shrink-0" :stroke-width="1.5" :size="12" />
|
||||
<span class="text-body-3xs text-foreground-2 font-medium">
|
||||
{{ formattedDate }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-else class="flex items-center gap-1 h-6">
|
||||
<Calendar class="text-foreground-2 shrink-0" :stroke-width="1.5" :size="12" />
|
||||
<span class="text-body-3xs text-foreground-2 font-medium">No due date</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="issue.activities && issue.activities.totalCount > 0"
|
||||
class="flex items-center gap-2 p-1 min-w-0"
|
||||
>
|
||||
<UserAvatar
|
||||
:user="issue.activities?.items?.[0]?.actor?.user"
|
||||
size="xs"
|
||||
class="shrink-0"
|
||||
/>
|
||||
|
||||
<div class="text-body-2xs text-foreground-2 leading-tight min-w-0">
|
||||
<span class="font-medium">
|
||||
{{ issue.activities?.items?.[0]?.actor?.user.name }}
|
||||
</span>
|
||||
<span>
|
||||
created this issue ·
|
||||
{{ dayjs(issue.activities?.items?.[0].createdAt).from(dayjs()) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="issue.replies && issue.replies.totalCount > 0"
|
||||
class="flex flex-col justify-between space-y-2 w-full"
|
||||
>
|
||||
<div
|
||||
v-for="reply in issue.replies.items"
|
||||
:key="reply.id"
|
||||
class="flex flex-col items-start border rounded-xl border-outline-3 p-1 w-full"
|
||||
>
|
||||
<div class="flex items-center gap-2 w-full">
|
||||
<UserAvatar :user="reply.author?.user" size="xs" class="shrink-0" />
|
||||
<div class="text-body-2xs text-foreground-2 leading-tight min-w-0">
|
||||
<span class="font-medium">
|
||||
{{ reply.author?.user.name }}
|
||||
</span>
|
||||
<span>
|
||||
replied ·
|
||||
{{ dayjs(reply.createdAt).from(dayjs()) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<IssuesBasicTiptap
|
||||
v-if="reply.description?.doc"
|
||||
class="ml-4"
|
||||
:doc="reply.description?.doc"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { IssuesItemFragment } from '~/lib/common/generated/gql/graphql'
|
||||
import dayjs from 'dayjs'
|
||||
import { Calendar } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps<{
|
||||
issue: IssuesItemFragment
|
||||
}>()
|
||||
|
||||
const formattedDate = computed((): string | null => {
|
||||
try {
|
||||
const date = props.issue.dueDate ? dayjs(props.issue.dueDate).toDate() : null
|
||||
|
||||
if (!(date instanceof Date)) return null
|
||||
|
||||
const time = date.getTime()
|
||||
if (isNaN(time)) return null
|
||||
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
}).format(date)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<div
|
||||
v-tippy="showLabel ? undefined : statusText"
|
||||
class="flex items-center gap-1 rounded-md hover:bg-foreground-1"
|
||||
>
|
||||
<GlobalIconStatusOpen v-if="status === 'open'" class="w-3 h-3 shrink-0" />
|
||||
<GlobalIconStatusReview
|
||||
v-else-if="status === 'readyForReview'"
|
||||
class="w-3 h-3 shrink-0"
|
||||
/>
|
||||
<GlobalIconStatusResolved
|
||||
v-else-if="status === 'resolved'"
|
||||
class="w-3 h-3 shrink-0"
|
||||
/>
|
||||
|
||||
<span v-if="showLabel" class="text-body-3xs text-foreground-2 font-medium">
|
||||
{{ statusText }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import GlobalIconStatusOpen from '~/components/global/icon/StatusOpen.vue'
|
||||
import GlobalIconStatusReview from '~/components/global/icon/StatusReview.vue'
|
||||
import GlobalIconStatusResolved from '~/components/global/icon/StatusResolved.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
status: 'open' | 'readyForReview' | 'resolved'
|
||||
showLabel?: boolean
|
||||
}>(),
|
||||
{
|
||||
showLabel: false
|
||||
}
|
||||
)
|
||||
|
||||
const statusText = computed(() => {
|
||||
switch (props.status) {
|
||||
case 'open':
|
||||
return 'Open'
|
||||
case 'readyForReview':
|
||||
return 'Ready for review'
|
||||
case 'resolved':
|
||||
return 'Resolved'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -32,6 +32,18 @@
|
||||
</button>
|
||||
</template>
|
||||
</ReportBase>
|
||||
<IssuesDialog
|
||||
v-if="issues && issues.length > 0"
|
||||
:model-card="modelCard"
|
||||
:issues="issues"
|
||||
>
|
||||
<template #activator="{ toggle }">
|
||||
<button class="action action-normal" @click="toggle()">
|
||||
<div class="truncate max-[275px]:text-xs">Issues</div>
|
||||
<div><Cog6ToothIcon class="w-5 h-5" /></div>
|
||||
</button>
|
||||
</template>
|
||||
</IssuesDialog>
|
||||
<button
|
||||
v-for="item in items"
|
||||
:key="item.name"
|
||||
@@ -57,6 +69,10 @@ import {
|
||||
} from '@heroicons/vue/24/outline'
|
||||
import type { IModelCard } from '~/lib/models/card'
|
||||
import { useMixpanel } from '~/lib/core/composables/mixpanel'
|
||||
import { issuesListQuery } from '~/lib/issues/graphql/queries'
|
||||
import { useAccountStore } from '~/store/accounts'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useQuery } from '@vue/apollo-composable'
|
||||
|
||||
const { trackEvent } = useMixpanel()
|
||||
|
||||
@@ -113,6 +129,22 @@ const items = [
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const accountStore = useAccountStore()
|
||||
const { activeAccount } = storeToRefs(accountStore)
|
||||
const accountId = computed(() => activeAccount.value.accountInfo.id)
|
||||
|
||||
const { result: issuesResult } = useQuery(
|
||||
issuesListQuery,
|
||||
() => ({ projectId: props.modelCard.projectId }),
|
||||
() => ({
|
||||
clientId: accountId.value,
|
||||
debounce: 500,
|
||||
fetchPolicy: 'network-only'
|
||||
})
|
||||
)
|
||||
|
||||
const issues = computed(() => issuesResult?.value?.project.issues.items)
|
||||
</script>
|
||||
<style scoped lang="postcss">
|
||||
.action {
|
||||
|
||||
@@ -67,6 +67,22 @@
|
||||
size="sm"
|
||||
@click="deleteSettings"
|
||||
/> -->
|
||||
<IssuesDialog
|
||||
v-if="issues && issues.length > 0"
|
||||
:model-card="modelCard"
|
||||
:issues="issues"
|
||||
>
|
||||
<template #activator="{ toggle }">
|
||||
<FormButton
|
||||
v-tippy="'Issues'"
|
||||
color="subtle"
|
||||
:icon-left="MessageCircleMore"
|
||||
hide-text
|
||||
size="sm"
|
||||
@click="toggle()"
|
||||
/>
|
||||
</template>
|
||||
</IssuesDialog>
|
||||
<FormButton
|
||||
v-if="store.hostAppName !== 'navisworks' && store.hostAppName !== 'etabs'"
|
||||
v-tippy="'Highlight'"
|
||||
@@ -179,7 +195,7 @@
|
||||
>
|
||||
<div
|
||||
v-tippy="
|
||||
`${latestCommentNotification.comment?.author.name} just left a
|
||||
`${latestCommentNotification.comment?.author?.name} just left a
|
||||
comment.`
|
||||
"
|
||||
class="flex items-center space-x-1"
|
||||
@@ -189,8 +205,8 @@
|
||||
:users="[latestCommentNotification.comment?.author as AvatarUserWithId]"
|
||||
/>
|
||||
<span class="line-clamp-1">
|
||||
{{ latestCommentNotification.comment?.author.name }} just left a
|
||||
comment.
|
||||
{{ latestCommentNotification.comment?.author?.name }} just left a
|
||||
comment on the issue.
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
@@ -236,6 +252,8 @@ import type { ProjectCommentsUpdatedMessage } from '~/lib/common/generated/gql/g
|
||||
import { useFunctionRunsStatusSummary } from '~/lib/automate/runStatus'
|
||||
import { CursorArrowRaysIcon, XCircleIcon, TrashIcon } from '@heroicons/vue/24/outline'
|
||||
import type { AvatarUserWithId } from '@speckle/ui-components'
|
||||
import { issuesListQuery } from '~/lib/issues/graphql/queries'
|
||||
import { MessageCircleMore } from 'lucide-vue-next'
|
||||
|
||||
const app = useNuxtApp()
|
||||
const store = useHostAppStore()
|
||||
@@ -327,6 +345,25 @@ const summary = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
const { result: issuesResult, refetch: refetchIssues } = useQuery(
|
||||
issuesListQuery,
|
||||
() => ({ projectId: props.modelCard.projectId }),
|
||||
() => ({
|
||||
clientId,
|
||||
debounce: 500,
|
||||
fetchPolicy: 'network-only'
|
||||
})
|
||||
)
|
||||
|
||||
const issues = computed(() =>
|
||||
issuesResult?.value?.project.issues.items.filter(
|
||||
(issue) =>
|
||||
issue.status !== 'resolved' &&
|
||||
issue.resourceIdString &&
|
||||
(issue.resourceIdString as string).includes(props.modelCard.modelId)
|
||||
)
|
||||
)
|
||||
|
||||
provide<IModelCard>('cardBase', props.modelCard)
|
||||
|
||||
const highlightModel = () => {
|
||||
@@ -486,6 +523,7 @@ onCommentResult((res) => {
|
||||
latestCommentNotification.value = res.data
|
||||
?.projectCommentsUpdated as ProjectCommentsUpdatedMessage
|
||||
startCommentClearTimeout()
|
||||
refetchIssues()
|
||||
})
|
||||
|
||||
const viewComment = () => {
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'text-foreground-on-primary flex shrink-0 items-center justify-center overflow-hidden rounded-full font-semibold uppercase transition',
|
||||
sizeClasses,
|
||||
bgClasses,
|
||||
borderClasses,
|
||||
hoverClasses,
|
||||
activeClasses
|
||||
]"
|
||||
>
|
||||
<slot>
|
||||
<div
|
||||
v-if="user?.avatar"
|
||||
class="h-full w-full bg-cover bg-center bg-no-repeat"
|
||||
:style="{ backgroundImage: `url('${user.avatar}')` }"
|
||||
/>
|
||||
<div
|
||||
v-else-if="initials"
|
||||
class="flex h-full w-full select-none items-center justify-center"
|
||||
>
|
||||
{{ initials }}
|
||||
</div>
|
||||
<div v-else><UserCircleIcon :class="iconClasses" /></div>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { UserCircleIcon } from '@heroicons/vue/20/solid'
|
||||
type UserAvatar = {
|
||||
name: string
|
||||
avatar?: string | null | undefined
|
||||
}
|
||||
|
||||
type UserAvatarSize = 'xs' | 'sm' | 'base' | 'lg' | 'xl' | 'editable'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
user?: UserAvatar
|
||||
size?: UserAvatarSize
|
||||
hoverEffect?: boolean
|
||||
active?: boolean
|
||||
noBorder?: boolean
|
||||
noBackground?: boolean
|
||||
}>(),
|
||||
{
|
||||
user: undefined,
|
||||
size: 'base',
|
||||
hoverEffect: false
|
||||
}
|
||||
)
|
||||
|
||||
const initials = computed(() => {
|
||||
if (!props.user?.name.length) return
|
||||
const parts = props.user.name.split(' ')
|
||||
const firstLetter = parts[0]?.[0] || ''
|
||||
const secondLetter = parts[1]?.[0] || ''
|
||||
|
||||
if (props.size === 'sm' || props.size === 'xs') return firstLetter
|
||||
return firstLetter + secondLetter
|
||||
})
|
||||
|
||||
const borderClasses = computed(() => {
|
||||
if (props.noBorder) return ''
|
||||
return 'border-2 border-foundation'
|
||||
})
|
||||
|
||||
const bgClasses = computed(() => {
|
||||
if (props.noBackground) return ''
|
||||
return 'bg-primary'
|
||||
})
|
||||
|
||||
const hoverClasses = computed(() => {
|
||||
if (props.hoverEffect)
|
||||
return 'hover:border-primary focus:border-primary active:scale-95'
|
||||
return ''
|
||||
})
|
||||
|
||||
const activeClasses = computed(() => {
|
||||
if (props.active) return 'border-primary'
|
||||
return ''
|
||||
})
|
||||
|
||||
const heightClasses = computed(() => {
|
||||
const size = props.size
|
||||
switch (size) {
|
||||
case 'xs':
|
||||
return 'h-5'
|
||||
case 'sm':
|
||||
return 'h-6'
|
||||
case 'lg':
|
||||
return 'h-10'
|
||||
case 'xl':
|
||||
return 'h-14'
|
||||
case 'editable':
|
||||
return 'h-60'
|
||||
case 'base':
|
||||
default:
|
||||
return 'h-8'
|
||||
}
|
||||
})
|
||||
|
||||
const widthClasses = computed(() => {
|
||||
const size = props.size
|
||||
switch (size) {
|
||||
case 'xs':
|
||||
return 'w-5'
|
||||
case 'sm':
|
||||
return 'w-6'
|
||||
case 'lg':
|
||||
return 'w-10'
|
||||
case 'xl':
|
||||
return 'w-14'
|
||||
case 'editable':
|
||||
return 'w-60'
|
||||
case 'base':
|
||||
default:
|
||||
return 'w-8'
|
||||
}
|
||||
})
|
||||
|
||||
const textClasses = computed(() => {
|
||||
const size = props.size
|
||||
switch (size) {
|
||||
case 'xs':
|
||||
return 'text-tiny'
|
||||
case 'sm':
|
||||
return 'text-xs'
|
||||
case 'lg':
|
||||
return 'text-md'
|
||||
case 'xl':
|
||||
return 'text-2xl'
|
||||
case 'editable':
|
||||
return 'h1'
|
||||
case 'base':
|
||||
default:
|
||||
return 'text-sm'
|
||||
}
|
||||
})
|
||||
|
||||
const iconClasses = computed(() => {
|
||||
const size = props.size
|
||||
switch (size) {
|
||||
case 'xs':
|
||||
return 'w-3 h-3'
|
||||
case 'sm':
|
||||
return 'w-3 h-3'
|
||||
case 'lg':
|
||||
return 'w-5 h-5'
|
||||
case 'xl':
|
||||
return 'w-8 h-8'
|
||||
case 'editable':
|
||||
return 'w-20 h-20'
|
||||
case 'base':
|
||||
default:
|
||||
return 'w-4 h-4'
|
||||
}
|
||||
})
|
||||
|
||||
const sizeClasses = computed(
|
||||
() => `${widthClasses.value} ${heightClasses.value} ${textClasses.value}`
|
||||
)
|
||||
</script>
|
||||
Reference in New Issue
Block a user