多维表格
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

624 lines
21 KiB

<script setup lang="ts">
import { type CommentType, ProjectRoles } from 'nocodb-sdk'
const props = defineProps<{
loading: boolean
primaryKey: string | null
}>()
const {
loadComments,
deleteComment,
comments,
resolveComment,
audits,
isAuditLoading,
saveComment: _saveComment,
updateComment,
} = useExpandedFormStoreOrThrow()
const { isExpandedFormCommentMode } = storeToRefs(useConfigStore())
const commentsWrapperEl = ref<HTMLDivElement>()
const commentInputRef = ref<any>()
const comment = ref('')
const { copy } = useClipboard()
const route = useRoute()
const { dashboardUrl } = useDashboard()
const { user, appInfo } = useGlobal()
const basesStore = useBases()
const { basesUser } = storeToRefs(basesStore)
const meta = inject(MetaInj, ref())
const baseUsers = computed(() => (meta.value?.base_id ? basesUser.value.get(meta.value?.base_id) || [] : []))
const isExpandedFormLoading = computed(() => props.loading)
const tab = ref<'comments' | 'audits'>('comments')
const { isUIAllowed } = useRoles()
const router = useRouter()
const hasEditPermission = computed(() => isUIAllowed('commentEdit'))
const editCommentValue = ref<CommentType>()
const isEditing = ref<boolean>(false)
const isCommentMode = ref(false)
const hoveredCommentId = ref<null | string>(null)
async function onEditComment() {
if (!isEditing.value || !editCommentValue.value?.comment) return
while (editCommentValue.value.comment.endsWith('<br />') || editCommentValue.value.comment.endsWith('\n')) {
if (editCommentValue.value.comment.endsWith('<br />')) {
editCommentValue.value.comment = editCommentValue.value.comment.slice(0, -6)
} else {
editCommentValue.value.comment = editCommentValue.value.comment.slice(0, -2)
}
}
isCommentMode.value = true
const tempCom = {
...editCommentValue.value,
}
isEditing.value = false
editCommentValue.value = undefined
await updateComment(tempCom.id!, {
comment: tempCom.comment,
})
loadComments()
}
function onCancel(e: KeyboardEvent) {
if (!isEditing.value) return
e.preventDefault()
e.stopPropagation()
editCommentValue.value = undefined
loadComments()
isEditing.value = false
editCommentValue.value = undefined
}
function editComment(comment: CommentType) {
editCommentValue.value = {
...comment,
}
isEditing.value = true
nextTick(() => {
scrollToComment(comment.id)
})
}
const value = computed({
get() {
return editCommentValue.value?.comment || ''
},
set(val) {
if (!editCommentValue.value) return
editCommentValue.value.comment = val
},
})
function scrollComments() {
if (commentsWrapperEl.value) {
commentsWrapperEl.value.scrollTo({
top: commentsWrapperEl.value.scrollHeight,
behavior: 'smooth',
})
}
}
const saveComment = async () => {
if (!comment.value.trim()) return
while (comment.value.endsWith('<br />') || comment.value.endsWith('\n')) {
if (comment.value.endsWith('<br />')) {
comment.value = comment.value.slice(0, -6)
} else {
comment.value = comment.value.slice(0, -2)
}
}
isCommentMode.value = true
// Optimistic Insert
comments.value = [
...comments.value,
{
id: `temp-${new Date().getTime()}`,
comment: comment.value,
created_at: new Date().toISOString(),
created_by: user.value?.id,
created_by_email: user.value?.email,
created_display_name: user.value?.display_name ?? '',
},
]
const tempCom = comment.value
comment.value = ''
commentInputRef?.value?.setEditorContent('', true)
await nextTick(() => {
scrollComments()
})
try {
await _saveComment(tempCom)
await nextTick(() => {
isExpandedFormCommentMode.value = true
})
scrollComments()
} catch (e) {
console.error(e)
}
}
const copyComment = async (comment: CommentType) => {
await copy(
encodeURI(
`${dashboardUrl?.value}#/${route.params.typeOrId}/${route.params.baseId}/${meta.value?.id}?rowId=${props.primaryKey}&commentId=${comment.id}`,
),
)
}
function scrollToComment(commentId: string) {
const commentEl = document.querySelector(`.${commentId}`)
if (commentEl) {
commentEl.scrollIntoView({
behavior: 'smooth',
block: 'center',
})
}
}
watch(commentsWrapperEl, () => {
setTimeout(() => {
nextTick(() => {
const query = router.currentRoute.value.query
const commentId = query.commentId
if (commentId) {
router.push({
query: {
rowId: query.rowId,
},
})
scrollToComment(commentId as string)
hoveredCommentId.value = commentId as string
onClickOutside(document.querySelector(`.${hoveredCommentId.value}`)! as HTMLDivElement, handleResetHoverEffect)
} else {
scrollComments()
}
})
}, 100)
})
const createdBy = (
comment: CommentType & {
created_display_name?: string
},
) => {
if (comment.created_by === user.value?.id) {
return 'You'
} else if (comment.created_display_name?.trim()) {
return comment.created_display_name || 'Shared source'
} else if (comment.created_by_email) {
return comment.created_by_email
} else {
return 'Shared source'
}
}
const getUserRole = (email: string) => {
const user = baseUsers.value.find((user) => user.email === email)
if (!user) return ProjectRoles.NO_ACCESS
return user.roles || ProjectRoles.NO_ACCESS
}
const editedAt = (comment: CommentType) => {
if (comment.updated_at !== comment.created_at && comment.updated_at) {
const str = timeAgo(comment.updated_at).replace(' ', '_')
return `[(edited)](a~~~###~~~Edited_${str}) `
}
return ''
}
function handleResetHoverEffect() {
if (!hoveredCommentId.value) return
hoveredCommentId.value = null
}
</script>
<template>
<div class="flex flex-col bg-white !h-full w-full rounded-br-2xl">
<NcTabs v-model:activeKey="tab" class="h-full">
<a-tab-pane key="comments" class="w-full h-full">
<template #tab>
<div v-e="['c:row-expand:comment']" class="flex items-center gap-2">
<GeneralIcon icon="messageCircle" class="w-4 h-4" />
<span class="<lg:hidden"> Comments </span>
</div>
</template>
<div
class="h-full"
:class="{
'pb-1': tab !== 'comments' && !appInfo.ee,
}"
>
<div v-if="isExpandedFormLoading" class="flex flex-col items-center justify-center w-full h-full">
<GeneralLoader size="xlarge" />
</div>
<div v-else class="flex flex-col h-full">
<div v-if="comments.length === 0" class="flex flex-col my-1 text-center justify-center h-full nc-scrollbar-thin">
<div class="text-center text-3xl text-gray-700">
<GeneralIcon icon="commentHere" />
</div>
<div class="font-medium text-center my-6 text-gray-500">{{ $t('activity.startCommenting') }}</div>
</div>
<div v-else ref="commentsWrapperEl" class="flex flex-col h-full py-1 nc-scrollbar-thin">
<!-- The scrollbar doesn't work when flex-end is used. https://issues.chromium.org/issues/41130651
Hence using a div to fix the issue
https://stackoverflow.com/questions/36130760/use-justify-content-flex-end-and-to-have-vertical-scrollbar
-->
<div class="scroll-fix"></div>
<div
v-for="comment of comments"
:key="comment.id"
:class="`${comment.id}`"
class="nc-comment-item"
@mouseover="handleResetHoverEffect"
>
<div
:class="{
'hover:bg-gray-100': editCommentValue?.id !== comment!.id,
'nc-hovered-comment bg-gray-100': hoveredCommentId === comment!.id
}"
class="group gap-3 overflow-hidden px-3 py-2 transition-colors"
>
<div class="flex items-start justify-between">
<div class="flex items-start gap-3">
<GeneralUserIcon
:email="comment.created_by_email"
:name="comment.created_display_name"
class="mt-0.5"
size="medium"
/>
<div class="flex h-[28px] items-center gap-3">
<NcDropdown placement="topLeft" :trigger="['hover']">
<span class="text-ellipsis text-gray-800 font-medium !text-[13px] max-w-42 overflow-hidden" :style="{}">
{{ createdBy(comment) }}
</span>
<template #overlay>
<div class="bg-white rounded-lg">
<div class="flex items-center gap-4 py-3 px-2">
<GeneralUserIcon
class="!w-8 !h-8 border-1 border-gray-200 rounded-full"
:name="comment.created_display_name"
:email="comment.created_by_email"
/>
<div class="flex flex-col">
<div class="font-semibold text-gray-800">
{{ createdBy(comment) }}
</div>
<div class="text-xs text-gray-600">
{{ comment.created_by_email }}
</div>
</div>
</div>
<div
class="px-3 rounded-b-lg !text-[13px] items-center text-gray-600 flex gap-1 bg-gray-100 py-1.5"
>
Has <RolesBadge size="sm" :border="false" :role="getUserRole(comment.created_by_email!)" />
role in base
</div>
</div>
</template>
</NcDropdown>
<div class="text-xs text-gray-500">
{{ timeAgo(comment.created_at!) }}
</div>
</div>
</div>
<div class="flex items-center">
<NcDropdown
v-if="!editCommentValue"
class="nc-comment-more-actions !hidden !group-hover:block"
overlay-class-name="!min-w-[160px]"
placement="bottomRight"
>
<NcButton
class="nc-expand-form-more-actions !hover:bg-gray-200 !w-7 !h-7 !bg-transparent"
size="xsmall"
type="text"
>
<GeneralIcon class="text-md" icon="threeDotVertical" />
</NcButton>
<template #overlay>
<NcMenu>
<NcMenuItem
v-if="user && comment.created_by_email === user.email"
v-e="['c:comment-expand:comment:edit']"
class="text-gray-700"
@click="editComment(comment)"
>
<div class="flex gap-2 items-center">
<component :is="iconMap.rename" class="cursor-pointer" />
{{ $t('general.edit') }}
</div>
</NcMenuItem>
<NcMenuItem
v-e="['c:comment-expand:comment:copy']"
class="text-gray-700"
@click="copyComment(comment)"
>
<div class="flex gap-2 items-center">
<component :is="iconMap.copy" class="cursor-pointer" />
{{ $t('general.copy') }} URL
</div>
</NcMenuItem>
<template v-if="user && comment.created_by_email === user.email">
<NcDivider />
<NcMenuItem
v-e="['c:row-expand:comment:delete']"
class="!text-red-500 !hover:bg-red-50"
@click="deleteComment(comment.id!)"
>
<div class="flex gap-2 items-center">
<component :is="iconMap.delete" class="cursor-pointer" />
{{ $t('general.delete') }}
</div>
</NcMenuItem>
</template>
</NcMenu>
</template>
</NcDropdown>
<div v-if="appInfo.ee">
<NcTooltip v-if="!comment.resolved_by">
<NcButton
class="nc-resolve-comment-btn !w-7 !h-7 !bg-transparent !hover:bg-gray-200 !hidden !group-hover:block"
size="xsmall"
type="text"
@click="resolveComment(comment.id!)"
>
<GeneralIcon class="text-md" icon="checkCircle" />
</NcButton>
<template #title>Click to resolve </template>
</NcTooltip>
<NcTooltip v-else>
<template #title>{{ `Resolved by ${comment.resolved_display_name}` }}</template>
<NcButton
class="!h-7 !w-7 !bg-transparent !hover:bg-gray-200 text-semibold"
size="xsmall"
type="text"
@click="resolveComment(comment.id)"
>
<GeneralIcon class="text-md rounded-full bg-[#17803D] text-white" icon="checkFill" />
</NcButton>
</NcTooltip>
</div>
</div>
</div>
<div
:class="{
'mt-3': comment.id === editCommentValue?.id,
}"
class="flex-1 flex flex-col gap-1 max-w-[calc(100%)]"
>
<SmartsheetExpandedFormRichComment
v-if="comment.id === editCommentValue?.id"
v-model:value="value"
autofocus
:hide-options="false"
class="expanded-form-comment-edit-input cursor-text expanded-form-comment-input !py-2 !px-2 !m-0 w-full !border-1 !border-gray-200 !rounded-lg !bg-white !text-gray-800 !text-small !leading-18px !max-h-[240px]"
data-testid="expanded-form-comment-input"
sync-value-change
@save="onEditComment"
@keydown.esc="onCancel"
@blur="
() => {
editCommentValue = undefined
isEditing = false
}
"
@keydown.enter.exact.prevent="onEditComment"
/>
<div v-else class="space-y-1 pl-9">
<SmartsheetExpandedFormRichComment
:value="`${comment.comment} ${editedAt(comment)}`"
class="!text-small !leading-18px !text-gray-800 -ml-1"
read-only
sync-value-change
/>
</div>
</div>
</div>
</div>
</div>
<div v-if="hasEditPermission" class="px-3 pb-3 nc-comment-input !rounded-br-2xl gap-2 flex">
<SmartsheetExpandedFormRichComment
ref="commentInputRef"
v-model:value="comment"
:hide-options="false"
placeholder="Comment..."
class="expanded-form-comment-input !py-2 !px-2 cursor-text border-1 rounded-lg w-full bg-transparent !text-gray-800 !text-small !leading-18px !max-h-[240px]"
:autofocus="isExpandedFormCommentMode"
data-testid="expanded-form-comment-input"
@focus="isExpandedFormCommentMode = false"
@keydown.stop
@save="saveComment"
@keydown.enter.exact.prevent="saveComment"
/>
</div>
</div>
</div>
</a-tab-pane>
<a-tab-pane key="audits" class="w-full" :disabled="appInfo.ee">
<template #tab>
<NcTooltip v-if="appInfo.ee" class="tab flex-1">
<template #title>{{ $t('title.comingSoon') }}</template>
<div v-e="['c:row-expand:audit']" class="flex items-center gap-2 text-gray-400">
<GeneralIcon icon="audit" class="w-4 h-4" />
<span class="<lg:hidden"> Audits </span>
</div>
</NcTooltip>
<div v-else v-e="['c:row-expand:audit']" class="flex items-center gap-2">
<GeneralIcon icon="audit" class="w-4 h-4" />
<span class="<lg:hidden"> Audits </span>
</div>
</template>
<div
class="h-full"
:class="{
'pb-1': !appInfo.ee,
}"
>
<div v-if="isExpandedFormLoading || isAuditLoading" class="flex flex-col items-center justify-center w-full h-full">
<GeneralLoader size="xlarge" />
</div>
<div v-else ref="commentsWrapperEl" class="flex flex-col h-full py-1 nc-scrollbar-thin !overflow-y-auto">
<template v-if="audits.length === 0">
<div class="flex flex-col text-center justify-center h-full">
<div class="text-center text-3xl text-gray-600">
<MdiHistory />
</div>
<div class="font-bold text-center my-1 text-gray-600">See changes to this record</div>
</div>
</template>
<div v-for="audit of audits" :key="audit.id" class="nc-audit-item">
<div class="group gap-3 overflow-hidden flex items-start p-3">
<GeneralUserIcon size="medium" :email="audit.user" :name="audit.display_name" />
<div class="flex-1 flex flex-col gap-1 max-w-[calc(100%_-_24px)]">
<div class="flex flex-wrap items-center min-h-7">
<NcTooltip class="truncate max-w-42 mr-2" show-on-truncate-only>
<template #title>
{{ audit.display_name?.trim() || audit.user || 'Shared source' }}
</template>
<span class="text-ellipsis break-keep inline whitespace-nowrap overflow-hidden font-bold text-gray-800">
{{ audit.display_name?.trim() || audit.user || 'Shared source' }}
</span>
</NcTooltip>
<div class="text-xs text-gray-400">
{{ timeAgo(audit.created_at) }}
</div>
</div>
<div v-dompurify-html="audit.details" class="text-sm font-medium"></div>
</div>
</div>
</div>
</div>
</div>
</a-tab-pane>
</NcTabs>
</div>
</template>
<style lang="scss" scoped>
.tab {
@apply max-w-1/2;
}
.scroll-fix {
flex: 1 1 auto;
}
.nc-audit-item {
@apply border-b-1 gap-3 border-gray-200;
}
.nc-audit-item:last-child {
@apply border-b-0;
}
.tab .tab-title {
@apply min-w-0 flex justify-center gap-2 font-semibold items-center;
word-break: 'keep-all';
white-space: 'nowrap';
display: 'inline';
}
.text-decoration-line-through {
text-decoration: line-through;
}
:deep(.red.lighten-4) {
@apply bg-red-100 rounded-md line-through;
}
:deep(.green.lighten-4) {
@apply bg-green-100 rounded-md !mr-3 !leading-6;
}
:deep(.ant-tabs) {
@apply !overflow-visible;
.ant-tabs-nav {
@apply px-3 bg-white;
.ant-tabs-nav-list {
@apply w-[99%] mx-auto gap-6;
.ant-tabs-tab {
@apply flex-1 flex items-center justify-center pt-3 pb-2.5;
& + .ant-tabs-tab {
@apply !ml-0;
}
}
}
}
.ant-tabs-content-holder {
.ant-tabs-content {
@apply h-full;
}
}
}
:deep(.expanded-form-comment-input) {
@apply transition-all duration-150 min-h-8;
box-shadow: none;
&:focus,
&:focus-within {
@apply min-h-16 !bg-white border-brand-500;
box-shadow: 0px 0px 0px 2px rgba(51, 102, 255, 0.24);
}
&::placeholder {
@apply !text-gray-400;
}
}
:deep(.expanded-form-comment-edit-input .nc-comment-rich-editor) {
@apply bg-white;
}
.nc-hovered-comment {
.nc-expand-form-more-actions,
.nc-resolve-comment-btn {
@apply !block;
}
}
</style>