Merge pull request #3813 from RitvikSardana/feat/pinned-comments

feat(ticket): pinned comments
This commit is contained in:
RitvikSardana
2026-09-30 14:14:24 +05:30
committed by GitHub
20 changed files with 724 additions and 219 deletions

View File

@@ -374,7 +374,6 @@ const ticket = createDocumentResource({
- Use ESLint with Vue 3 recommended rules and Prettier for consistency
- Write meaningful commit messages and maintain clean git history
- Document complex logic with JSDoc/TSDoc comments
- Only add comments that explain why something is done, not what is done
- Keep a comment to 2 lines at most. If it needs more to explain, simplify the code instead
- Keep dependencies updated and audit for security vulnerabilities

View File

@@ -168,6 +168,7 @@ const filterContacts = createResource({
label: full_name || email_id || name,
value: email_id || name,
email: email_id,
name,
})),
});
@@ -202,19 +203,12 @@ const options = computed(() => {
if (props.existingUsers?.length) {
list = list.filter((o) => !props.existingUsers.includes(o.value));
}
if (query.value) {
const q = query.value.toLowerCase();
list = list.filter(
(o) =>
o.label?.toLowerCase().includes(q) ||
o.value?.toLowerCase().includes(q)
);
}
return list;
return list.filter(matchesQuery);
}
// contacts mode
let list = filterContacts.data || [];
// contacts mode; the search is debounced, so narrow the last results to the
// typed text, or a quick Enter picks a contact that no longer matches
let list = (filterContacts.data || []).filter(matchesQuery);
if (values.value?.length) {
list = list.filter((o) => !values.value.includes(o.value));
}
@@ -233,6 +227,13 @@ const options = computed(() => {
return list;
});
function matchesQuery(option) {
const q = query.value.toLowerCase();
return [option.label, option.value, option.name].some((field) =>
field?.toLowerCase().includes(q)
);
}
function isExistingUser(email) {
if (!email) return false;
return props.existingUsers?.some(

View File

@@ -7,9 +7,7 @@
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M6.17683 1C4.89138 1 3.81543 1.97482 3.68896 3.25405L3.19111 8.2899C3.16577 8.54615 3.0749 8.79156 2.92723 9.00252L1.98134 10.3538C1.51739 11.0166 1.99155 11.9273 2.80057 11.9273H7.53349V14.4999C7.53349 14.7761 7.75735 14.9999 8.03349 14.9999C8.30963 14.9999 8.53349 14.7761 8.53349 14.4999V11.9273H13.2016C14.0107 11.9273 14.4848 11.0166 14.0209 10.3538L13.068 8.99254C12.9236 8.78634 12.8335 8.54713 12.8059 8.29695L12.246 3.22567C12.1062 1.95882 11.0357 1 9.76113 1H6.17683ZM4.68411 3.35243C4.75999 2.5849 5.40556 2 6.17683 2H9.76113C10.5259 2 11.1682 2.57529 11.2521 3.3354L11.8119 8.40668C11.858 8.82365 12.0082 9.22233 12.2488 9.566L13.2016 10.9273H2.80057L3.74646 9.57598C3.99257 9.22439 4.14403 8.81536 4.18625 8.38828L4.68411 3.35243Z"
d="M14.3571 4.11463C15.2627 5.02037 15.3379 6.46463 14.5304 7.45889L11.0218 11.7782C10.8612 11.976 10.7534 12.2116 10.709 12.4625L10.4211 14.0915C10.2805 14.888 9.30139 15.1974 8.72926 14.6259L5.42782 11.3245L3.60895 13.1434C3.4137 13.3386 3.0971 13.3386 2.90184 13.1434C2.70677 12.9481 2.70667 12.6315 2.90184 12.4363L4.72071 10.6174L1.37369 7.27037C0.802362 6.69823 1.1117 5.71912 1.90817 5.57856L3.5254 5.29268C3.78193 5.24738 4.0231 5.13661 4.22353 4.9702L8.47445 1.44019C9.46782 0.615802 10.9258 0.683583 11.8387 1.59626L14.3571 4.11463ZM11.1316 2.30336C10.5838 1.75583 9.70918 1.71545 9.1132 2.21014L4.86227 5.73877C4.52813 6.01619 4.12641 6.2026 3.69872 6.27808L2.0808 6.56327L9.43637 13.9175L9.72363 12.2892C9.79752 11.8705 9.977 11.4777 10.245 11.1477L13.7536 6.82843C14.2381 6.23189 14.1934 5.36521 13.65 4.82174L11.1316 2.30336Z"
fill="currentColor"
/>
</svg>

View File

@@ -1,25 +1,45 @@
<template>
<svg
width="15"
height="14"
viewBox="0 0 15 14"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<!-- cuts a gap around the slash so any background shows through it -->
<mask
:id="maskId"
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="16"
height="16"
>
<rect width="16" height="16" fill="white" />
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M5.1783 0C3.89284 0 2.81689 0.974825 2.69042 2.25405L2.19257 7.2899C2.16724 7.54615 2.07636 7.79156 1.92869 8.00252L0.982801 9.35379C0.518857 10.0166 0.99301 10.9273 1.80203 10.9273H6.53506V13.4998C6.53506 13.776 6.75892 13.9998 7.03506 13.9998C7.31121 13.9998 7.53506 13.776 7.53506 13.4998V10.9273H12.2031C13.0121 10.9273 13.4863 10.0166 13.0223 9.35379L12.0695 7.99254C11.9251 7.78634 11.835 7.54713 11.8074 7.29695L11.2475 2.22567C11.1076 0.958818 10.0371 0 8.76259 0H5.1783ZM3.68557 2.35243C3.76145 1.5849 4.40702 1 5.1783 1H8.76259C9.52732 1 10.1696 1.57529 10.2535 2.3354L10.8134 7.40668C10.8594 7.82365 11.0097 8.22233 11.2502 8.566L12.2031 9.92725H1.80203L2.74793 8.57598C2.99404 8.22439 3.1455 7.81536 3.18772 7.38828L3.68557 2.35243Z"
fill="currentColor"
d="M1.5 1.5L14.5 14.5"
stroke="black"
stroke-width="3"
stroke-linecap="round"
/>
<rect
x="0.792893"
width="2"
height="17"
rx="1"
transform="rotate(-45 0.792893 0)"
</mask>
<path
d="M14.3571 4.11463C15.2627 5.02037 15.3379 6.46463 14.5304 7.45889L11.0218 11.7782C10.8612 11.976 10.7534 12.2116 10.709 12.4625L10.4211 14.0915C10.2805 14.888 9.30139 15.1974 8.72926 14.6259L5.42782 11.3245L3.60895 13.1434C3.4137 13.3386 3.0971 13.3386 2.90184 13.1434C2.70677 12.9481 2.70667 12.6315 2.90184 12.4363L4.72071 10.6174L1.37369 7.27037C0.802362 6.69823 1.1117 5.71912 1.90817 5.57856L3.5254 5.29268C3.78193 5.24738 4.0231 5.13661 4.22353 4.9702L8.47445 1.44019C9.46782 0.615802 10.9258 0.683583 11.8387 1.59626L14.3571 4.11463ZM11.1316 2.30336C10.5838 1.75583 9.70918 1.71545 9.1132 2.21014L4.86227 5.73877C4.52813 6.01619 4.12641 6.2026 3.69872 6.27808L2.0808 6.56327L9.43637 13.9175L9.72363 12.2892C9.79752 11.8705 9.977 11.4777 10.245 11.1477L13.7536 6.82843C14.2381 6.23189 14.1934 5.36521 13.65 4.82174L11.1316 2.30336Z"
fill="currentColor"
class="stroke stroke-ink-base"
:mask="`url(#${maskId})`"
/>
<path
d="M1.5 1.5L14.5 14.5"
stroke="currentColor"
stroke-width="1"
stroke-linecap="round"
/>
</svg>
</template>
<script setup lang="ts">
import { useId } from "vue";
// per instance: a hidden icon's mask would blank every icon sharing its id
const maskId = useId();
</script>

View File

@@ -1,12 +1,30 @@
<template>
<Tabs
:modelValue="activeTab"
:tabs="tabs"
@update:modelValue="changeTabTo"
size="md"
class="flex-1 overflow-hidden [&_[role='tablist']]:px-5 [&_[role='tablist']]:py-1.5 [&_[role='tablist']]:flex-shrink-0 [&_[role='tabpanel'][data-state='active']]:flex-1 [&_[role='tabpanel'][data-state='active']]:flex [&_[role='tabpanel'][data-state='active']]:flex-col [&_[role='tabpanel'][data-state='active']]:overflow-auto [&_[role='tabpanel'][data-state='active']]:min-h-0"
class="flex flex-1 flex-col overflow-hidden"
>
<!-- the row carries the underline so it runs beneath the actions too -->
<div
class="flex shrink-0 items-center gap-2 border-b border-outline-gray-1 px-5"
>
<TabList size="md" class="flex-1 !border-b-0 py-1.5">
<TabTrigger
v-for="tab in tabs"
:key="tab.value"
:value="tab.value"
:label="tab.label"
:icon-left="tab.iconLeft"
/>
</TabList>
<TicketCallActions v-if="activeTab === 'call'" />
</div>
<TabPanel
v-for="tab in tabs"
:key="tab.value"
:value="tab.value"
class="min-h-0 flex-1 flex-col overflow-auto data-[state=active]:flex"
>
<template #tab-panel="{ tab }">
<TicketAnalyticsTab v-if="tab.value === 'analytics'" />
<TicketTimeline
v-else
@@ -15,7 +33,7 @@
:tab-label="tab.label"
@email:reply="(e) => communicationAreaRef?.replyToEmail(e)"
/>
</template>
</TabPanel>
</Tabs>
<!-- Comm Area -->
<CommunicationArea
@@ -38,11 +56,12 @@ import {
PhoneIcon,
} from "@/components/icons";
import TicketAnalyticsTab from "@/components/ticket-agent/analytics/TicketAnalyticsTab.vue";
import TicketCallActions from "@/components/ticket/TicketCallActions.vue";
import { useActiveTabManager } from "@/composables/useActiveTabManager";
import { reloadTicketFeed } from "@/composables/useTicket";
import { useTelephonyStore } from "@/stores/telephony";
import { TabObject, TicketSymbol } from "@/types";
import { Tabs } from "frappe-ui";
import { TabList, TabPanel, TabTrigger, Tabs } from "frappe-ui";
import { storeToRefs } from "pinia";
import { computed, ComputedRef, inject, ref } from "vue";
import LucideChartNoAxesColumn from "~icons/lucide/chart-no-axes-column";

View File

@@ -0,0 +1,128 @@
<template>
<Transition
:css="animated"
enter-active-class="motion-safe:transition-[opacity,transform] motion-safe:duration-200 motion-safe:ease-out"
enter-from-class="opacity-0 -translate-y-1"
leave-active-class="motion-safe:transition-opacity motion-safe:duration-150 motion-safe:ease-in"
leave-to-class="opacity-0"
>
<!-- the after: fade stands in for the feed's own top fade, which the bar covers -->
<button
v-if="comments.length"
type="button"
class="flex items-center gap-2.5 bg-surface-gray-1 px-5 py-2.5 text-left after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-3 after:bg-gradient-to-b after:from-surface-base"
:aria-label="__('Go to pinned comment {0}', [current + 1])"
@click="select"
>
<!-- one full-height line split evenly per pin; past two pins -my-0.5
reaches into the padding so the segments stay tall enough to read -->
<span
class="relative flex flex-col gap-0.5 self-stretch"
:class="{ '-my-0.5': comments.length > 2 }"
>
<span
v-for="comment in comments"
:key="comment.name"
class="w-[3px] flex-1 rounded-full bg-surface-gray-4"
/>
<span
class="absolute inset-x-0 top-0 rounded-full bg-surface-violet-6 motion-safe:transition-transform motion-safe:duration-150 motion-safe:ease-out"
:style="indicatorStyle"
/>
</span>
<!-- -top-px optically centres Inter, whose glyphs sit low -->
<span class="relative -top-px flex min-w-0 flex-1 items-center gap-2">
<PinIcon class="size-4 shrink-0 text-ink-gray-8" />
<!-- one grid cell, so the leaving and entering previews overlap while they slide -->
<span class="grid min-w-0 flex-1 overflow-hidden">
<Transition
enter-active-class="motion-safe:transition motion-safe:duration-150 motion-safe:ease-out"
leave-active-class="motion-safe:transition motion-safe:duration-150 motion-safe:ease-out"
:enter-from-class="`opacity-0 ${
toOlder ? '-translate-y-full' : 'translate-y-full'
}`"
:leave-to-class="`opacity-0 ${
toOlder ? 'translate-y-full' : '-translate-y-full'
}`"
>
<p
:key="current"
class="truncate text-p-base text-ink-gray-5 [grid-area:1/1]"
>
<span class="font-medium tabular-nums text-ink-gray-7"
>{{ current + 1 }}:</span
>
{{ preview }}
</p>
</Transition>
</span>
</span>
</button>
</Transition>
</template>
<script setup lang="ts">
import { PinIcon } from "@/components/icons";
import type { PinnedComment } from "@/composables/useTicket";
import { htmlToText } from "@/utils";
import { computed, ref, watch } from "vue";
/** Telegram-style pinned bar, one pin at a time, newest first. A click waits
* for `jump` to reach the shown pin, then steps to the next older one. */
const props = defineProps<{
comments: PinnedComment[];
jump: (name: string) => Promise<void>;
}>();
const SEGMENT_GAP = "2px";
// pause after the jump lands before the bar steps on, so the move reads as a result
const ADVANCE_DELAY_MS = 300;
const current = ref(props.comments.length - 1);
let jumping = false;
// the first pins arrive with the page, and that should not animate in
const animated = ref(props.comments.length > 0);
// older pins sit higher in the feed, so their preview slides in from above
const toOlder = ref(true);
const preview = computed(() =>
htmlToText(props.comments[current.value]?.content ?? "")
.replace(/\s+/g, " ")
.trim()
);
// the violet piece covers segment `current` of the gray track and glides between them
const indicatorStyle = computed(() => {
const count = props.comments.length;
return {
height: `calc((100% - ${count - 1} * ${SEGMENT_GAP}) / ${count})`,
// a translate percentage is the piece's own height, one segment
transform: `translateY(calc(${current.value} * (100% + ${SEGMENT_GAP})))`,
};
});
async function select() {
const pin = props.comments[current.value];
if (jumping || !pin) return;
jumping = true;
try {
await props.jump(pin.name);
await new Promise((resolve) => setTimeout(resolve, ADVANCE_DELAY_MS));
toOlder.value = current.value > 0;
const count = props.comments.length;
current.value = (current.value - 1 + count) % count;
} finally {
jumping = false;
}
}
watch(
() => props.comments.length,
(count) => (current.value = count - 1)
);
watch(
() => props.comments.length,
() => (animated.value = true),
{ once: true, flush: "post" }
);
</script>

View File

@@ -1,9 +1,16 @@
<template>
<ActivityHeader :title="tabLabel" />
<TimelineContainer>
<div class="relative flex min-h-0 flex-1 flex-col">
<!-- floats over the feed, so pins coming and going never move the rows -->
<PinnedCommentsBar
class="absolute inset-x-0 top-0 z-10"
:comments="pinnedComments"
:jump="goToPinned"
/>
<TimelineContainer ref="timelineContainer">
<ActivityTimeline
ref="timelineRef"
class="px-5 pt-1 pb-6"
class="px-5 pb-6"
:class="pinnedComments.length ? 'scroll-pt-14 pt-14' : 'mt-4 pt-1'"
:activities="filtered"
:loading="_loading"
:paginate="paginate"
@@ -28,6 +35,8 @@
<TimelineCommentRow
:activity="activity"
:extras="extrasFor(activity)"
:pinned="pinnedNames.has(activity.data.name)"
:pin-count="pinnedNames.size"
@update="refresh"
/>
</template>
@@ -74,6 +83,7 @@
</template>
</ActivityTimeline>
</TimelineContainer>
</div>
<TicketSplitModal
:modelValue="Boolean(splitCommunication)"
@update:modelValue="splitCommunication = ''"
@@ -101,6 +111,9 @@ const VERSION_FIELDS = [
// tab instances don't refetch the same row
const enrichedRows = new Set<string>();
// clicking the pinned bar scrolls the feed to that comment over this long
const PIN_JUMP_MS = 300;
export const SHARED_VISIBLE_TYPES: VisibleTypes = [
"email",
"comment",
@@ -119,9 +132,12 @@ import {
ReplyAllIcon,
ReplyIcon,
} from "@/components/icons";
import ActivityHeader from "@/components/ticket/ActivityHeader.vue";
import TicketSplitModal from "@/components/ticket/TicketSplitModal.vue";
import { registerTicketFeed, useTicket } from "@/composables/useTicket";
import {
registerTicketFeed,
useTicket,
type PinnedComment,
} from "@/composables/useTicket";
import { useAuthStore } from "@/stores/auth";
import { globalStore } from "@/stores/globalStore";
import { useUserStore } from "@/stores/user";
@@ -153,6 +169,8 @@ import {
} from "vue";
import { useRoute, useRouter } from "vue-router";
import LucideSplit from "~icons/lucide/split";
import { glideToRow } from "./glideToRow";
import PinnedCommentsBar from "./PinnedCommentsBar.vue";
import TimelineCommentRow, { CommentExtras } from "./TimelineCommentRow.vue";
const props = defineProps<{
@@ -172,6 +190,8 @@ const ticket = inject(TicketSymbol)!;
const splitCommunication = ref("");
const timelineRef =
useTemplateRef<InstanceType<typeof ActivityTimeline>>("timelineRef");
const timelineContainer =
useTemplateRef<InstanceType<typeof TimelineContainer>>("timelineContainer");
const { activities, loading, paginate, reload } = useActivityTimeline(
"HD Ticket",
@@ -180,6 +200,20 @@ const { activities, loading, paginate, reload } = useActivityTimeline(
);
const { calls, commentExtras: extras } = useTicket(props.ticketId);
const pinnedNames = computed(() => new Set(extras.data?.pinned_comments ?? []));
// the feed already holds every comment, so the bar reads their text from it
const pinnedComments = computed<PinnedComment[]>(() => {
const contents = new Map(
activities.value
.filter(
(activity): activity is CommentActivity => activity.type === "comment"
)
.map((activity) => [activity.data.name, activity.data.content])
);
return (extras.data?.pinned_comments ?? [])
.filter((name) => contents.has(name))
.map((name) => ({ name, content: contents.get(name) as string }));
});
const _loading = computed(() => loading.value || calls.loading);
@@ -239,7 +273,10 @@ function extrasFor(activity: CommentActivity): CommentExtras {
if (activity.pending)
return { reactions: [], attachments: activity.data.attachments ?? [] };
return (
extras.data?.[activity.data.name] ?? { reactions: [], attachments: [] }
extras.data?.comments[activity.data.name] ?? {
reactions: [],
attachments: [],
}
);
}
@@ -248,6 +285,27 @@ function refresh() {
extras.reload();
}
// Emails and Calls feeds hold no comments, so the jump lands on Comments
// resolves once the row is on screen, so the pinned bar advances after the jump
async function goToPinned(name: string) {
const scroller = feedScroller();
// Emails and Calls feeds hold no comments; the Comments tab takes the deep link
if (!["activity", "comment"].includes(props.tab) || !scroller) {
router.replace({
query: { ...route.query, highlight: `comment-${name}` },
hash: "#comment",
});
return;
}
await glideToRow(scroller, `comment:${name}`, PIN_JUMP_MS);
timelineRef.value?.scrollToRow(`comment:${name}`);
}
function feedScroller() {
const container = timelineContainer.value?.$el as HTMLElement | undefined;
return container?.querySelector<HTMLElement>(".activity-timeline");
}
// after send/comment the newest row belongs on screen (old feed parity)
async function refreshAndScroll() {
await Promise.allSettled([reload(), extras.reload()]);
@@ -419,12 +477,25 @@ async function enrichLiveComment(payload: unknown) {
(f: { file_url: string }) => !content.includes(f.file_url)
);
if (!attachments?.length || !extras.data) return;
extras.data[name] = {
...(extras.data[name] ?? { reactions: [] }),
extras.data.comments[name] = {
...(extras.data.comments[name] ?? { reactions: [] }),
attachments,
};
}
// another agent pinned, unpinned, edited or deleted a pinned comment
function reloadPinsOnChange(payload: unknown) {
const { doc, key, action } = (payload ?? {}) as {
doc?: Record<string, unknown>;
key?: string;
action?: string;
};
if (key !== "comments" || action === "add") return;
if (doc?.reference_name !== props.ticketId) return;
if (doc.is_pinned || pinnedNames.value.has(doc.name as string))
extras.reload();
}
let unregisterFeed: () => void;
onMounted(() => {
@@ -436,12 +507,14 @@ onMounted(() => {
}
);
$socket.on("docinfo_update", enrichLiveComment);
$socket.on("docinfo_update", reloadPinsOnChange);
});
onBeforeUnmount(() => {
unregisterFeed?.();
$socket.off("helpdesk:comment-reaction-update");
$socket.off("docinfo_update", enrichLiveComment);
$socket.off("docinfo_update", reloadPinsOnChange);
});
defineExpose({ reload: refreshAndScroll });

View File

@@ -5,7 +5,25 @@
@save="saveContent"
@discard="editing = false"
>
<template v-if="!editing" #actions>
<!-- the framework header has no spot beside the name, so the row draws its own -->
<template #header>
<div class="flex h-10 items-center justify-between gap-2">
<div class="flex min-w-0 items-center gap-2 leading-6">
<span class="truncate text-base font-medium text-ink-gray-6">
{{ activity.author.fullname }}
</span>
<Badge v-if="pinned" theme="gray" :label="__('Pinned')">
<template #prefix><PinIcon class="size-3" /></template>
</Badge>
</div>
<div class="flex items-center gap-2">
<Tooltip :text="postedAt.format(TOOLTIP_DATE_FORMAT)">
<span class="whitespace-nowrap text-sm leading-6 text-ink-gray-5">
{{ postedAt.fromNow() }}
</span>
</Tooltip>
<!-- -me-1.5 matches the tighter edge the framework gives a header with actions -->
<div v-if="!editing" class="-me-1.5">
<Dropdown
align="end"
:options="kebabOptions"
@@ -17,6 +35,9 @@
variant="ghost"
/>
</Dropdown>
</div>
</div>
</div>
</template>
<template #footer>
<div v-if="extras.attachments.length" class="flex flex-wrap gap-2 mt-2">
@@ -37,6 +58,7 @@
</template>
<script setup lang="ts">
import { PinIcon, UnpinIcon } from "@/components/icons";
import { useAuthStore } from "@/stores/auth";
import { useConfigStore } from "@/stores/config";
import { useUserStore } from "@/stores/user";
@@ -47,9 +69,17 @@ import {
CommentItem,
type CommentActivity,
} from "@framework/ui/ActivityTimeline";
import { Button, Dropdown, createResource, toast } from "frappe-ui";
import {
Badge,
Button,
Dropdown,
Tooltip,
createResource,
dayjsLocal,
toast,
} from "frappe-ui";
import { storeToRefs } from "pinia";
import { computed, ref } from "vue";
import { computed, h, ref } from "vue";
import TimelineReactions from "./TimelineReactions.vue";
export interface CommentExtras {
@@ -57,9 +87,16 @@ export interface CommentExtras {
attachments: Array<{ file_name: string; file_url: string }>;
}
// mirrors MAX_PINNED_COMMENTS in helpdesk/extends/comment.py, which enforces it
const MAX_PINNED_COMMENTS = 5;
// same as the framework's timeline, so every row's time reads alike
const TOOLTIP_DATE_FORMAT = "ddd, MMM D, YYYY h:mm A";
const props = defineProps<{
activity: CommentActivity;
extras: CommentExtras;
pinned: boolean;
pinCount: number;
}>();
const emit = defineEmits<{ update: [] }>();
@@ -84,6 +121,10 @@ const isOwner = computed(() => {
const isMergeMarker = computed(() =>
/has been merged with ticket #\d+/.test(props.activity.data.content)
);
const postedAt = computed(() => dayjsLocal(props.activity.timestamp));
const pinLimitReached = computed(
() => !props.pinned && props.pinCount >= MAX_PINNED_COMMENTS
);
const kebabOptions = computed(() => [
{
@@ -91,6 +132,15 @@ const kebabOptions = computed(() => [
icon: "lucide-link",
onClick: () => copyActivityLink("comment", props.activity.data.name),
},
{
label: props.pinned ? __("Unpin") : __("Pin"),
icon: h(props.pinned ? UnpinIcon : PinIcon, { class: "size-4" }),
onClick: togglePin,
...(pinLimitReached.value && {
disabled: true,
slots: { label: pinLimitLabel },
}),
},
// only the author edits or deletes; anyone may share the link
...(isOwner.value && !isMergeMarker.value
? [
@@ -149,6 +199,33 @@ function saveContent(content: string) {
);
}
// disabled menu items drop pointer events, so the label opts back in for the tooltip
function pinLimitLabel() {
const reason = __("{0} pinned already. Unpin one to pin this.", [
MAX_PINNED_COMMENTS,
]);
return h(Tooltip, { text: reason, side: "right" }, () =>
h("div", { class: "pointer-events-auto truncate" }, __("Pin"))
);
}
// any agent may pin; the server caps pins per ticket and says so on failure
function togglePin() {
saveComment.submit(
{
doctype: "Comment",
name: props.activity.data.name,
fieldname: "is_pinned",
value: props.pinned ? 0 : 1,
},
{
onSuccess: () => emit("update"),
onError: (error: { messages?: string[] }) =>
toast.error(error.messages?.[0] ?? __("Could not pin the comment")),
}
);
}
function toggleReaction(emoji: string) {
reaction.submit({ comment: props.activity.data.name, emoji });
}

View File

@@ -0,0 +1,36 @@
/** Eases the row to mid-view and resolves on arrival; the browser's smooth
* scroll has no reliable "done", and the pinned bar waits on it. */
export function glideToRow(
scroller: HTMLElement,
rowId: string,
durationMs: number
): Promise<void> {
const row = scroller.querySelector(`[id="${CSS.escape(rowId)}"]`);
if (!row) return Promise.resolve();
const from = scroller.scrollTop;
const to = from + centerOffset(scroller, row);
if (to === from || matchMedia("(prefers-reduced-motion: reduce)").matches) {
scroller.scrollTop = to;
return Promise.resolve();
}
return new Promise((resolve) => {
const start = performance.now();
const step = (now: number) => {
const progress = Math.min((now - start) / durationMs, 1);
scroller.scrollTop = from + (to - from) * easeOutCubic(progress);
if (progress < 1) requestAnimationFrame(step);
else resolve();
};
requestAnimationFrame(step);
});
}
function centerOffset(scroller: HTMLElement, row: Element): number {
const view = scroller.getBoundingClientRect();
const box = row.getBoundingClientRect();
return box.top + box.height / 2 - (view.top + view.height / 2);
}
function easeOutCubic(progress: number): number {
return 1 - (1 - progress) ** 3;
}

View File

@@ -1,78 +0,0 @@
<template>
<div
class="md:mx-5 md:my-4 flex items-center justify-between text-md-medium mx-6 !mb-0 !my-3"
>
<div class="flex h-8 items-center text-xl-semibold text-ink-gray-8">
{{ title }}
</div>
<Dropdown
v-if="title == 'Calls'"
:options="callActions"
@click.stop
align="end"
>
<template v-slot="{ open }">
<Button variant="subtle" class="flex items-center gap-1">
<template #prefix>
<LucidePlus class="h-4 w-4" />
</template>
<span>{{ __("New") }}</span>
<template #suffix>
<component
:is="open ? LucideChevronUp : LucideChevronDown"
class="h-4 w-4"
/>
</template>
</Button>
</template>
</Dropdown>
</div>
<CallLogModal
v-model="showCallLogModal"
:ticketId="ticket.value?.name"
@after-insert="refreshTicket"
/>
</template>
<script setup lang="ts">
import LucideChevronUp from "~icons/lucide/chevron-up";
import LucideChevronDown from "~icons/lucide/chevron-down";
import LucidePlus from "~icons/lucide/plus";
import { PhoneIcon } from "@/components/icons";
import CallLogModal from "@/pages/call-logs/CallLogModal.vue";
import { __ } from "@/translation";
import { TicketSymbol } from "@/types";
import { Dropdown } from "frappe-ui";
import { computed, h, inject, ref } from "vue";
defineProps({
title: {
type: String,
required: true,
},
});
const makeCall = inject<() => void>("makeCall");
const refreshTicket = inject<() => void>("refreshTicket");
const showCallLogModal = ref(false);
const ticket = inject(TicketSymbol)!;
const callActions = computed(() => {
let actions = [
{
icon: h(PhoneIcon, { class: "h-4 w-4" }),
label: __("Make a Call"),
onClick: () => makeCall(),
},
{
icon: "lucide-edit-3",
label: __("Log a Call"),
onClick: () => {
showCallLogModal.value = true;
},
},
];
return actions;
});
</script>
<style scoped></style>

View File

@@ -0,0 +1,52 @@
<template>
<Dropdown :options="callActions" align="end">
<template v-slot="{ open }">
<Button variant="subtle" :label="__('New')">
<template #prefix>
<LucidePlus class="h-4 w-4" />
</template>
<template #suffix>
<component
:is="open ? LucideChevronUp : LucideChevronDown"
class="h-4 w-4"
/>
</template>
</Button>
</template>
</Dropdown>
<CallLogModal
v-model="showCallLogModal"
:ticketId="ticket.value?.name"
@after-insert="refreshTicket"
/>
</template>
<script setup lang="ts">
import { PhoneIcon } from "@/components/icons";
import CallLogModal from "@/pages/call-logs/CallLogModal.vue";
import { __ } from "@/translation";
import { TicketSymbol } from "@/types";
import { Button, Dropdown } from "frappe-ui";
import { h, inject, ref } from "vue";
import LucideChevronDown from "~icons/lucide/chevron-down";
import LucideChevronUp from "~icons/lucide/chevron-up";
import LucidePlus from "~icons/lucide/plus";
const makeCall = inject<() => void>("makeCall");
const refreshTicket = inject<() => void>("refreshTicket");
const showCallLogModal = ref(false);
const ticket = inject(TicketSymbol)!;
const callActions = [
{
icon: h(PhoneIcon, { class: "h-4 w-4" }),
label: __("Make a Call"),
onClick: () => makeCall?.(),
},
{
icon: "lucide-edit-3",
label: __("Log a Call"),
onClick: () => (showCallLogModal.value = true),
},
];
</script>

View File

@@ -20,11 +20,22 @@ interface MapValue {
analytics: Resource<TicketAnalytics>;
// shared by every timeline tab instance, fetched once per ticket
calls: Resource<Record<string, any>[]>;
commentExtras: Resource<Record<string, CommentExtras>>;
commentExtras: Resource<TicketCommentExtras>;
// lent by the mounted timeline; see registerTicketFeed
reloadFeed?: () => void;
}
export interface TicketCommentExtras {
comments: Record<string, CommentExtras>;
// oldest first, so a pin's position is its "N:" in the pinned bar
pinned_comments: string[];
}
export interface PinnedComment {
name: string;
content: string;
}
const ticketMap: Record<string, MapValue> = reactive({});
export const useTicket = (ticketId: string): MapValue => {

View File

@@ -95,8 +95,9 @@
@update:modelValue="changeTabTo"
class="flex flex-col flex-1 overflow-hidden"
>
<div class="flex shrink-0">
<!-- Scroll here so the active underline isn't clipped. -->
<div class="shrink-0 overflow-x-auto hide-scrollbar">
<div class="min-w-0 flex-1 overflow-x-auto hide-scrollbar">
<TabList size="md" class="w-max min-w-full px-3 py-1.5">
<TabTrigger
v-for="tab in visibleTabs"
@@ -107,6 +108,14 @@
/>
</TabList>
</div>
<!-- own border, so the tab underline runs on beneath it -->
<div
v-if="activeTab === 'call'"
class="flex items-center border-b border-outline-gray-1 pe-3"
>
<TicketCallActions />
</div>
</div>
<TabPanel
v-for="tab in visibleTabs"
:key="tab.value"
@@ -274,6 +283,7 @@ import {
IndicatorIcon,
PhoneIcon,
} from "@/components/icons";
import TicketCallActions from "@/components/ticket/TicketCallActions.vue";
import TicketTimeline from "@/components/ticket-agent/timeline/TicketTimeline.vue";
import CustomActions from "@/components/CustomActions.vue";

View File

@@ -220,3 +220,81 @@ test.describe("reactions", () => {
await expect.poll(reactionLogs).toEqual([{ subject: "1 person reacted to your comment" }]);
});
});
test("pinned comments cycle in a bar that never shifts the feed", async ({ page, api, ticket }) => {
// a feed long enough to scroll; a short one is top-aligned, so the bar pushes it down
for (let i = 0; i < 10; i++) await addComment(api, ticket.name, `Filler ${i}`);
const older = `Older pin ${uid()}`;
const newer = `Newer pin ${uid()}`;
await addComment(api, ticket.name, older);
await addComment(api, ticket.name, newer);
await openTicket(page, ticket.name, "#comment");
const panel = page.locator("[role=tabpanel][data-state=active]");
const rowWith = (text: string) => panel.locator(".activity", { hasText: text });
const bar = panel.getByRole("button", { name: /Go to pinned comment/ });
const topOf = (text: string) => rowWith(text).evaluate((row) => row.getBoundingClientRect().top);
const toggle = async (text: string, action: "Pin" | "Unpin") => {
await rowWith(text).getByRole("button").first().click();
await page.getByRole("menuitem", { name: action, exact: true }).click();
};
await expect(rowWith(newer)).toBeVisible();
const restingTop = await topOf(newer);
await toggle(older, "Pin");
await expect(bar).toContainText(older);
await toggle(newer, "Pin");
await expect(bar).toContainText(newer);
await expect(panel.getByText("Pinned", { exact: true })).toHaveCount(2);
expect(await topOf(newer)).toBe(restingTop);
// newest first; each click jumps to the shown pin, then steps to the older one
await bar.click();
await expect(bar).toContainText(older);
await bar.click();
await expect(rowWith(older)).toBeInViewport();
await expect(bar).toContainText(newer);
await rowWith(newer).scrollIntoViewIfNeeded();
const pinnedTop = await topOf(newer);
await toggle(older, "Unpin");
await expect(bar).toContainText(newer);
await toggle(newer, "Unpin");
await expect(bar).toHaveCount(0);
expect(await topOf(newer)).toBe(pinnedTop);
});
test("a sixth pin is blocked until one of the five is unpinned", async ({ page, api, ticket }) => {
const notes = Array.from({ length: 6 }, (_, i) => `Note ${i + 1} ${uid()}`);
const names: string[] = [];
for (const note of notes) names.push(await addComment(api, ticket.name, note));
for (const name of names.slice(0, 5)) await api.update("Comment", name, { is_pinned: 1 });
await openTicket(page, ticket.name, "#comment");
const panel = page.locator("[role=tabpanel][data-state=active]");
const rowWith = (text: string) => panel.locator(".activity", { hasText: text });
const markers = panel.getByText("Pinned", { exact: true });
const pinItem = page.getByRole("menuitem", { name: "Pin", exact: true });
const openMenu = (text: string) => rowWith(text).getByRole("button").first().click();
await expect(markers).toHaveCount(5);
await expect(panel.getByRole("button", { name: "Go to pinned comment 5" })).toBeVisible();
await openMenu(notes[5]);
await expect(pinItem).toHaveAttribute("aria-disabled", "true");
// the item ignores the pointer; only its label takes the hover for the reason
await pinItem.getByText("Pin", { exact: true }).hover();
await expect(page.getByText("5 pinned already. Unpin one to pin this.").first()).toBeVisible();
// the first Escape closes the tooltip, the second the menu
await page.keyboard.press("Escape");
await page.keyboard.press("Escape");
await expect(page.getByRole("menu")).toHaveCount(0);
await openMenu(notes[0]);
await page.getByRole("menuitem", { name: "Unpin", exact: true }).click();
await expect(markers).toHaveCount(4);
await openMenu(notes[5]);
await expect(pinItem).not.toHaveAttribute("aria-disabled", "true");
await pinItem.click();
await expect(rowWith(notes[5]).getByText("Pinned", { exact: true })).toBeVisible();
await expect(markers).toHaveCount(5);
});

View File

@@ -9,8 +9,9 @@ from helpdesk.utils import is_agent
@frappe.whitelist()
def get_comment_extras(ticket: str) -> dict[str, dict]:
"""One batched call per ticket open: {comment_name: {reactions, attachments}}.
def get_comment_extras(ticket: str) -> dict:
"""One batched call per ticket open: {comments: {comment_name: {reactions,
attachments}}, pinned_comments: [names, oldest first]}.
Re-fetched by the client on helpdesk:comment-reaction-update."""
ensure_agent_can_read(ticket)
rows = frappe.get_list(
@@ -20,18 +21,23 @@ def get_comment_extras(ticket: str) -> dict[str, dict]:
"reference_name": ticket,
"comment_type": "Comment",
},
fields=["name", "owner", "content"],
fields=["name", "owner", "content", "is_pinned"],
order_by="creation asc",
limit_page_length=0,
)
# the ticket gate above is the whole read rule for agent comments
names = [row.name for row in rows]
extras = {name: {"reactions": [], "attachments": []} for name in names}
result = {
"comments": extras,
"pinned_comments": [row.name for row in rows if row.is_pinned],
}
if not names:
return extras
return result
add_attachments(extras, {row.name: row.content or "" for row in rows})
if frappe.db.get_single_value("HD Settings", "enable_comment_reactions"):
add_reactions(extras, names)
return extras
return result
@frappe.whitelist()

View File

@@ -9,6 +9,8 @@ from frappe import _
from helpdesk.utils import is_admin, is_agent
MAX_PINNED_COMMENTS = 5
def before_insert(doc, method: str | None = None):
"""The insert path ignores permissions, so the write gate has to live here."""
@@ -20,6 +22,29 @@ def before_insert(doc, method: str | None = None):
)
def validate(doc, method: str | None = None):
"""Caps pinned comments per ticket so the pinned bar stays readable."""
if doc.reference_doctype != "HD Ticket" or not doc.is_pinned:
return
if not doc.has_value_changed("is_pinned"):
return
pinned = frappe.db.count(
"Comment",
{
"reference_doctype": "HD Ticket",
"reference_name": doc.reference_name,
"is_pinned": 1,
"name": ["!=", doc.name],
},
)
if pinned >= MAX_PINNED_COMMENTS:
frappe.throw(
_("A ticket can have at most {0} pinned comments").format(
MAX_PINNED_COMMENTS
)
)
def on_trash(doc, method: str | None = None):
"""Core only clears notifications by document, not by source."""
if doc.reference_doctype != "HD Ticket":

View File

@@ -374,6 +374,8 @@ def duplicate_list_retain_timestamp(doctype, activities: list, target: str, cont
elif doctype == "Comment":
duplicate_doc.reference_name = target
# the target keeps its own pins; a copied pin could break its cap and abort the merge
duplicate_doc.is_pinned = 0
attachments = get_attachments(
"Communication",
activity,

View File

@@ -103,6 +103,7 @@ doc_events = {
},
"Comment": {
"before_insert": "helpdesk.extends.comment.before_insert",
"validate": "helpdesk.extends.comment.validate",
"on_trash": "helpdesk.extends.comment.on_trash",
},
}

View File

@@ -363,7 +363,7 @@ class TestCommentTrustBoundary(CoreCommentsTestCase):
frappe.set_user(AGENT_ONE)
self.assertTrue(frappe.has_permission("Comment", "read", by_admin.name))
extras = get_comment_extras(ticket.name)
extras = get_comment_extras(ticket.name)["comments"]
self.assertIn(mine.name, extras)
self.assertIn(by_admin.name, extras)

View File

@@ -0,0 +1,47 @@
import frappe
from frappe.client import set_value
from helpdesk.api.timeline import get_comment_extras
from helpdesk.extends.comment import MAX_PINNED_COMMENTS
from helpdesk.helpdesk.doctype.hd_ticket.api import merge_ticket
from helpdesk.test_utils import make_ticket
from helpdesk.tests.test_core_comments import AGENT_TWO, CoreCommentsTestCase
class TestPinnedComments(CoreCommentsTestCase):
def test_pin_limit_per_ticket(self):
"""Any agent pins through the standard API, up to the cap. Editing a
pinned comment at the cap still saves; unpinning frees a slot."""
ticket = make_ticket()
comments = [
self.make_comment(ticket, f"note {i}")
for i in range(MAX_PINNED_COMMENTS + 1)
]
frappe.set_user(AGENT_TWO)
for comment in comments[:MAX_PINNED_COMMENTS]:
set_value("Comment", comment.name, "is_pinned", 1)
with self.assertRaises(frappe.ValidationError):
set_value("Comment", comments[-1].name, "is_pinned", 1)
set_value("Comment", comments[0].name, "content", "edited while pinned")
set_value("Comment", comments[0].name, "is_pinned", 0)
set_value("Comment", comments[-1].name, "is_pinned", 1)
# the pinned bar numbers pins by this order, oldest first
pinned = get_comment_extras(ticket.name)["pinned_comments"]
self.assertEqual(pinned, [comment.name for comment in comments[1:]])
def test_pins_are_counted_per_ticket(self):
"""A full ticket leaves others free to pin, and still takes a merge
from a ticket with pins of its own."""
full, other = make_ticket(), make_ticket()
for i in range(MAX_PINNED_COMMENTS):
comment = self.make_comment(full, f"note {i}")
set_value("Comment", comment.name, "is_pinned", 1)
comment = self.make_comment(other, "fresh ticket")
set_value("Comment", comment.name, "is_pinned", 1)
self.assertTrue(frappe.db.get_value("Comment", comment.name, "is_pinned"))
merge_ticket(source=other.name, target=full.name)
pinned = get_comment_extras(full.name)["pinned_comments"]
self.assertEqual(len(pinned), MAX_PINNED_COMMENTS)