feat: add generic side drawer component [CW-7757] (#15188)

This adds a reusable side drawer component
(`components-next/drawer/Drawer.vue`), similar in spirit to Dialog and
Popover, so drawers across the app share one implementation. The drawer
renders as a floating card anchored to the inline-end edge with a
slide-in/slide-out animation, and handles the backdrop, Escape key,
click-outside, and focus restore. The layout inside is fully
slot-driven, with a `close` function passed through the slot.

The Captain overview and report drilldown drawers now use this
component, and the Captain document details view moved from a centered
dialog to this drawer.

## Closes

CW-7757

## What changed

- New `Drawer.vue`: teleported floating card with backdrop, RTL-aware
slide transition, focus management, and a default slot receiving
`close`; emits `afterLeave` so consumers mounted with `v-if` can unmount
after the exit animation
- `AssistantDrilldownDrawer` and `ReportDrilldownDrawer` refactored to
consume it, keeping their own headers and content
- `DocumentDetails` migrated from Dialog to the drawer, dropping the
imperative `dialogRef.open()` plumbing and fixed-height inner scroll
areas
- Shared `DRAWER.CLOSE` i18n key replaces the per-drawer close labels



https://github.com/user-attachments/assets/58db6c34-8b6f-46e2-bdb1-f2d3675f1a94

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
This commit is contained in:
Shivam Mishra
2026-07-31 15:02:59 +05:30
committed by GitHub
parent 7981e2cc75
commit bc7ae88d5e
10 changed files with 251 additions and 389 deletions

View File

@@ -30,8 +30,9 @@ const captainDocument = {
pdf_document: false, pdf_document: false,
}; };
const DialogStub = { const SidePanelStub = {
name: 'Dialog', name: 'SidePanel',
methods: { open() {}, close() {} },
template: '<div><slot /></div>', template: '<div><slot /></div>',
}; };
@@ -59,7 +60,7 @@ describe('DocumentDetails', () => {
global: { global: {
directives: { dompurifyHtml: {} }, directives: { dompurifyHtml: {} },
stubs: { stubs: {
Dialog: DialogStub, SidePanel: SidePanelStub,
TabBar: TabBarStub, TabBar: TabBarStub,
PaginationFooter: PaginationFooterStub, PaginationFooter: PaginationFooterStub,
}, },

View File

@@ -12,7 +12,7 @@ import {
getDocumentDisplayPath, getDocumentDisplayPath,
} from 'shared/helpers/documentHelper'; } from 'shared/helpers/documentHelper';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue'; import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue'; import SidePanel from 'dashboard/components-next/side-panel/SidePanel.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue'; import Icon from 'dashboard/components-next/icon/Icon.vue';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue'; import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
@@ -33,7 +33,9 @@ const TAB_KEYS = {
const RESPONSES_PER_PAGE = 25; const RESPONSES_PER_PAGE = 25;
const { t } = useI18n(); const { t } = useI18n();
const store = useStore(); const store = useStore();
const dialogRef = ref(null); // The parent mounts this component with v-if, so the panel opens on mount and
// the parent unmounts it only after the slide-out finishes (afterLeave).
const panelRef = ref(null);
const documentDetails = computed(() => props.captainDocument); const documentDetails = computed(() => props.captainDocument);
const showRawContent = ref(false); const showRawContent = ref(false);
const activeTabIndex = ref(0); const activeTabIndex = ref(0);
@@ -126,9 +128,9 @@ const syncedAtLabel = computed(() => {
); );
}); });
const handleClose = () => { const documentTitle = computed(
emit('close'); () => documentDetails.value.name || documentDetails.value.external_link
}; );
const handleCopyContent = async () => { const handleCopyContent = async () => {
try { try {
@@ -156,22 +158,18 @@ const handlePageChange = page => {
}; };
onMounted(() => { onMounted(() => {
panelRef.value.open();
fetchResponses(); fetchResponses();
}); });
defineExpose({ dialogRef });
</script> </script>
<template> <template>
<Dialog <SidePanel
ref="dialogRef" ref="panelRef"
type="edit" :title="documentTitle"
:title="documentDetails.name || documentDetails.external_link"
:description="t('CAPTAIN.DOCUMENTS.DETAILS.DESCRIPTION')" :description="t('CAPTAIN.DOCUMENTS.DETAILS.DESCRIPTION')"
:show-cancel-button="false"
:show-confirm-button="false"
overflow-y-auto
width="3xl" width="3xl"
@close="handleClose" @after-leave="emit('close')"
> >
<div <div
v-if="isFetching" v-if="isFetching"
@@ -230,7 +228,7 @@ defineExpose({ dialogRef });
@tab-changed="handleTabChanged" @tab-changed="handleTabChanged"
/> />
<div class="h-[32rem] overflow-y-auto"> <div>
<section <section
v-if="activeTabKey === TAB_KEYS.CONTENT" v-if="activeTabKey === TAB_KEYS.CONTENT"
class="flex flex-col gap-3" class="flex flex-col gap-3"
@@ -313,7 +311,7 @@ defineExpose({ dialogRef });
</div> </div>
<div <div
v-else v-else
class="h-[26rem] overflow-y-auto rounded-lg border border-n-weak bg-n-alpha-1 p-4" class="rounded-lg border border-n-weak bg-n-alpha-1 p-4"
> >
<pre <pre
v-if="showRawContent || isUnreadableContent" v-if="showRawContent || isUnreadableContent"
@@ -370,5 +368,5 @@ defineExpose({ dialogRef });
</section> </section>
</div> </div>
</div> </div>
</Dialog> </SidePanel>
</template> </template>

View File

@@ -1,13 +1,12 @@
<script setup> <script setup>
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { useEventListener } from '@vueuse/core';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import CaptainAssistant from 'dashboard/api/captain/assistant'; import CaptainAssistant from 'dashboard/api/captain/assistant';
import { useReportDrilldown } from 'dashboard/routes/dashboard/settings/reports/composables/useReportDrilldown'; import { useReportDrilldown } from 'dashboard/routes/dashboard/settings/reports/composables/useReportDrilldown';
import ReportDrilldownCard from 'dashboard/routes/dashboard/settings/reports/components/ReportDrilldownCard.vue'; import ReportDrilldownCard from 'dashboard/routes/dashboard/settings/reports/components/ReportDrilldownCard.vue';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue'; import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue'; import SidePanel from 'dashboard/components-next/side-panel/SidePanel.vue';
const props = defineProps({ const props = defineProps({
open: { type: Boolean, default: false }, open: { type: Boolean, default: false },
@@ -20,8 +19,8 @@ const props = defineProps({
const emit = defineEmits(['close']); const emit = defineEmits(['close']);
const panelRef = ref(null);
const { t } = useI18n(); const { t } = useI18n();
const drawerRef = ref(null);
const { const {
records, records,
meta, meta,
@@ -35,9 +34,6 @@ const {
loadMore, loadMore,
} = useReportDrilldown(params => CaptainAssistant.getDrilldown(params)); } = useReportDrilldown(params => CaptainAssistant.getDrilldown(params));
let previousActiveElement = null;
const isOpen = computed(() => props.open);
const title = computed(() => props.metricName || ''); const title = computed(() => props.metricName || '');
const subtitle = computed(() => { const subtitle = computed(() => {
@@ -50,32 +46,6 @@ const subtitle = computed(() => {
const recordKey = record => `${record.conversation?.id}-${record.occurred_at}`; const recordKey = record => `${record.conversation?.id}-${record.occurred_at}`;
const restoreFocus = () => {
if (previousActiveElement?.isConnected) {
previousActiveElement.focus();
}
previousActiveElement = null;
};
const closeDrawer = () => {
close();
emit('close');
restoreFocus();
};
const rememberActiveElement = () => {
if (previousActiveElement) return;
previousActiveElement =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;
};
const focusDrawer = () => {
nextTick(() => drawerRef.value?.focus());
};
const fetchDrilldown = () => { const fetchDrilldown = () => {
if (!props.metric || !props.assistantId) return; if (!props.metric || !props.assistantId) return;
@@ -86,32 +56,18 @@ const fetchDrilldown = () => {
}); });
}; };
const onKeydown = event => {
if (!isOpen.value) return;
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
closeDrawer();
}
};
useEventListener(document, 'keydown', onKeydown);
watch( watch(
() => props.open, () => props.open,
isDrawerOpen => { isDrawerOpen => {
if (!isDrawerOpen) { if (!isDrawerOpen) {
panelRef.value?.close();
close(); close();
restoreFocus();
return; return;
} }
rememberActiveElement(); panelRef.value?.open();
fetchDrilldown(); fetchDrilldown();
focusDrawer(); }
},
{ immediate: true }
); );
watch( watch(
@@ -127,108 +83,70 @@ watch(
watch( watch(
() => props.assistantId, () => props.assistantId,
() => { () => {
if (props.open) closeDrawer(); if (props.open) emit('close');
} }
); );
onBeforeUnmount(() => {
restoreFocus();
});
</script> </script>
<template> <template>
<TeleportWithDirection to="body"> <SidePanel ref="panelRef" :title="title" width="xl" @close="emit('close')">
<Transition name="report-drilldown-fade"> <template #header>
<div <div class="min-w-0">
v-if="isOpen" <h3 class="truncate text-base font-medium text-n-slate-12">
class="fixed inset-0 z-50 bg-black/30" {{ title }}
role="presentation" </h3>
@click.self="closeDrawer" <p
> v-if="metricValue"
<aside class="mt-1 text-xl font-semibold text-n-slate-12"
ref="drawerRef"
class="fixed inset-y-0 end-0 flex w-full max-w-xl flex-col bg-n-solid-1 shadow-xl outline outline-1 outline-n-container"
role="dialog"
aria-modal="true"
:aria-label="title"
tabindex="-1"
> >
<header {{ metricValue }}
class="flex items-start justify-between gap-4 border-b border-n-weak px-6 py-5" </p>
> <div
<div class="min-w-0"> class="text-sm text-n-slate-11"
<h2 class="truncate text-base font-medium text-n-slate-12"> :class="{
{{ title }} 'mt-2': metricValue,
</h2> 'mt-1': !metricValue,
<p }"
v-if="metricValue" >
class="mt-1 text-xl font-semibold text-n-slate-12" {{ subtitle }}
> </div>
{{ metricValue }}
</p>
<div
class="text-sm text-n-slate-11"
:class="{
'mt-2': metricValue,
'mt-1': !metricValue,
}"
>
{{ subtitle }}
</div>
</div>
<Button
ghost
slate
size="sm"
icon="i-ph-x"
:aria-label="$t('CAPTAIN.OVERVIEW.DRILLDOWN.CLOSE')"
@click="closeDrawer"
/>
</header>
<div class="min-h-0 flex-1 overflow-y-auto px-5 py-3">
<div
v-if="isFetching"
class="flex h-40 items-center justify-center"
>
<Spinner />
</div>
<div
v-else-if="hasError"
class="flex h-40 items-center justify-center text-sm text-n-ruby-11"
>
{{ $t('CAPTAIN.OVERVIEW.DRILLDOWN.ERROR') }}
</div>
<div
v-else-if="!hasRecords"
class="flex h-40 items-center justify-center text-sm text-n-slate-10"
>
{{ $t('CAPTAIN.OVERVIEW.DRILLDOWN.EMPTY') }}
</div>
<div v-else class="flex flex-col gap-2">
<ReportDrilldownCard
v-for="record in records"
:key="recordKey(record)"
:record="record"
/>
<Button
v-if="hasMore"
faded
slate
size="sm"
class="mx-auto mt-2"
:label="$t('CAPTAIN.OVERVIEW.DRILLDOWN.LOAD_MORE')"
:is-loading="isFetchingMore"
@click="loadMore"
/>
</div>
</div>
</aside>
</div> </div>
</Transition> </template>
</TeleportWithDirection> <div v-if="isFetching" class="flex h-40 items-center justify-center">
<Spinner />
</div>
<div
v-else-if="hasError"
class="flex h-40 items-center justify-center text-sm text-n-ruby-11"
>
{{ $t('CAPTAIN.OVERVIEW.DRILLDOWN.ERROR') }}
</div>
<div
v-else-if="!hasRecords"
class="flex h-40 items-center justify-center text-sm text-n-slate-10"
>
{{ $t('CAPTAIN.OVERVIEW.DRILLDOWN.EMPTY') }}
</div>
<div v-else class="flex flex-col gap-2">
<ReportDrilldownCard
v-for="record in records"
:key="recordKey(record)"
:record="record"
/>
<Button
v-if="hasMore"
faded
slate
size="sm"
class="mx-auto mt-2"
:label="$t('CAPTAIN.OVERVIEW.DRILLDOWN.LOAD_MORE')"
:is-loading="isFetchingMore"
@click="loadMore"
/>
</div>
</SidePanel>
</template> </template>

View File

@@ -15,8 +15,8 @@ const props = defineProps({
}, },
width: { width: {
type: String, type: String,
default: 'lg', default: 'xl',
validator: value => ['sm', 'md', 'lg', 'xl'].includes(value), validator: value => ['md', 'lg', 'xl', '2xl', '3xl'].includes(value),
}, },
closeOnClickOutside: { closeOnClickOutside: {
type: Boolean, type: Boolean,
@@ -24,13 +24,16 @@ const props = defineProps({
}, },
}); });
const emit = defineEmits(['close']); // `afterLeave` fires once the slide-out transition finishes, so consumers
// mounted with v-if can wait for it before unmounting the panel.
const emit = defineEmits(['close', 'afterLeave']);
const MAX_WIDTH_CLASSES = { const MAX_WIDTH_CLASSES = {
sm: 'max-w-md', md: 'max-w-md',
md: 'max-w-xl', lg: 'max-w-lg',
lg: 'max-w-3xl', xl: 'max-w-xl',
xl: 'max-w-5xl', '2xl': 'max-w-2xl',
'3xl': 'max-w-3xl',
}; };
const isOpen = ref(false); const isOpen = ref(false);
@@ -103,10 +106,11 @@ defineExpose({ open, close });
</Transition> </Transition>
<Transition <Transition
enter-active-class="transition-transform duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]" enter-active-class="transition-transform duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]"
enter-from-class="translate-x-full rtl:-translate-x-full" enter-from-class="translate-x-[calc(100%+0.75rem)] rtl:translate-x-[calc(-100%-0.75rem)]"
leave-active-class="transition-transform duration-200 ease-in" leave-active-class="transition-transform duration-200 ease-in"
leave-to-class="translate-x-full rtl:-translate-x-full" leave-to-class="translate-x-[calc(100%+0.75rem)] rtl:translate-x-[calc(-100%-0.75rem)]"
@after-enter="onAfterEnter" @after-enter="onAfterEnter"
@after-leave="emit('afterLeave')"
> >
<aside <aside
v-if="isOpen" v-if="isOpen"
@@ -115,29 +119,34 @@ defineExpose({ open, close });
aria-modal="true" aria-modal="true"
:aria-label="title" :aria-label="title"
tabindex="-1" tabindex="-1"
class="fixed z-50 flex flex-col w-full shadow-xl outline-none inset-y-0 end-0 bg-n-solid-1 will-change-transform" class="fixed z-50 flex flex-col w-[calc(100%-1.5rem)] overflow-hidden rounded-xl shadow-lg outline outline-1 outline-n-container inset-y-3 end-3 bg-n-solid-1 will-change-transform"
:class="maxWidthClass" :class="maxWidthClass"
> >
<header <header
class="flex items-center justify-between flex-shrink-0 gap-4 px-6 py-5 border-b border-n-weak" class="flex justify-between flex-shrink-0 gap-4 px-6 py-5 border-b border-n-weak"
:class="$slots.header ? 'items-start' : 'items-center'"
> >
<div class="min-w-0"> <slot name="header">
<h3 class="text-base font-medium truncate text-n-slate-12"> <div class="min-w-0">
{{ title }} <h3 class="text-base font-medium truncate text-n-slate-12">
</h3> {{ title }}
<p v-if="description" class="mt-1 mb-0 text-sm text-n-slate-11"> </h3>
{{ description }} <p v-if="description" class="mt-1 mb-0 text-sm text-n-slate-11">
</p> {{ description }}
</p>
</div>
</slot>
<div class="flex items-center gap-1 shrink-0 -me-2">
<slot name="header-actions" />
<Button
ghost
slate
sm
icon="i-lucide-x"
:aria-label="$t('GENERAL.CLOSE')"
@click="close"
/>
</div> </div>
<Button
ghost
slate
sm
icon="i-lucide-x"
class="-me-2"
:aria-label="$t('GENERAL.CLOSE')"
@click="close"
/>
</header> </header>
<div class="flex-1 min-h-0 px-6 py-5 overflow-y-auto"> <div class="flex-1 min-h-0 px-6 py-5 overflow-y-auto">
<slot /> <slot />

View File

@@ -447,7 +447,6 @@
} }
}, },
"DRILLDOWN": { "DRILLDOWN": {
"CLOSE": "Close details",
"EMPTY": "No records found for this metric.", "EMPTY": "No records found for this metric.",
"ERROR": "Could not load records. Please try again.", "ERROR": "Could not load records. Please try again.",
"LOAD_MORE": "Load more", "LOAD_MORE": "Load more",

View File

@@ -129,7 +129,6 @@
"ERROR": "Could not load records. Please try again.", "ERROR": "Could not load records. Please try again.",
"ADMIN_ONLY": "Only administrators can drill down into report records.", "ADMIN_ONLY": "Only administrators can drill down into report records.",
"LOAD_MORE": "Load more", "LOAD_MORE": "Load more",
"CLOSE": "Close details",
"PREVIOUS_BUCKET": "Previous bar", "PREVIOUS_BUCKET": "Previous bar",
"NEXT_BUCKET": "Next bar", "NEXT_BUCKET": "Next bar",
"UNKNOWN_CONTACT": "Unknown contact", "UNKNOWN_CONTACT": "Unknown contact",

View File

@@ -54,11 +54,9 @@ const handleDelete = () => {
const showDocumentDetails = ref(false); const showDocumentDetails = ref(false);
const showCreateDialog = ref(false); const showCreateDialog = ref(false);
const createDocumentDialog = ref(null); const createDocumentDialog = ref(null);
const documentDetailsDialog = ref(null);
const handleShowDocumentDetails = () => { const handleShowDocumentDetails = () => {
showDocumentDetails.value = true; showDocumentDetails.value = true;
nextTick(() => documentDetailsDialog.value.dialogRef.open());
}; };
const handleCreateDocument = () => { const handleCreateDocument = () => {
showCreateDialog.value = true; showCreateDialog.value = true;
@@ -430,7 +428,6 @@ onUnmounted(() => {
<DocumentDetails <DocumentDetails
v-if="showDocumentDetails" v-if="showDocumentDetails"
ref="documentDetailsDialog"
:captain-document="selectedDocument" :captain-document="selectedDocument"
@close="handleDocumentDetailsClose" @close="handleDocumentDetailsClose"
/> />

View File

@@ -291,7 +291,7 @@ defineExpose({ open, close });
</script> </script>
<template> <template>
<SidePanel ref="panelRef" width="lg" :title="$t(titleKey)"> <SidePanel ref="panelRef" width="3xl" :title="$t(titleKey)">
<div v-if="automation" class="flex flex-col w-full gap-6"> <div v-if="automation" class="flex flex-col w-full gap-6">
<div class="flex flex-col"> <div class="flex flex-col">
<woot-input <woot-input

View File

@@ -1,11 +1,11 @@
<script setup> <script setup>
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { useEventListener } from '@vueuse/core'; import { useEventListener } from '@vueuse/core';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { formatTime } from '@chatwoot/utils'; import { formatTime } from '@chatwoot/utils';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue'; import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue'; import SidePanel from 'dashboard/components-next/side-panel/SidePanel.vue';
import { useReportDrilldown } from '../composables/useReportDrilldown'; import { useReportDrilldown } from '../composables/useReportDrilldown';
import ReportDrilldownCard from './ReportDrilldownCard.vue'; import ReportDrilldownCard from './ReportDrilldownCard.vue';
@@ -29,8 +29,8 @@ const props = defineProps({
const emit = defineEmits(['close', 'navigate']); const emit = defineEmits(['close', 'navigate']);
const panelRef = ref(null);
const { t } = useI18n(); const { t } = useI18n();
const drawerRef = ref(null);
const { const {
records, records,
meta, meta,
@@ -44,10 +44,6 @@ const {
loadMore, loadMore,
} = useReportDrilldown(); } = useReportDrilldown();
let previousActiveElement = null;
const isOpen = computed(() => props.open);
const title = computed(() => props.metricName || ''); const title = computed(() => props.metricName || '');
const bucketValue = computed(() => { const bucketValue = computed(() => {
@@ -100,37 +96,11 @@ const subtitle = computed(() =>
.join(' ⋅ ') .join(' ⋅ ')
); );
const restoreFocus = () => {
if (previousActiveElement?.isConnected) {
previousActiveElement.focus();
}
previousActiveElement = null;
};
const closeDrawer = () => {
close();
emit('close');
restoreFocus();
};
const recordKey = record => const recordKey = record =>
`${record.record_type}-${record.message?.id || record.conversation?.id}-${ `${record.record_type}-${record.message?.id || record.conversation?.id}-${
record.occurred_at record.occurred_at
}`; }`;
const rememberActiveElement = () => {
if (previousActiveElement) return;
previousActiveElement =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;
};
const focusDrawer = () => {
nextTick(() => drawerRef.value?.focus());
};
const fetchDrilldown = () => { const fetchDrilldown = () => {
openDrilldown({ openDrilldown({
metric: props.metric, metric: props.metric,
@@ -152,13 +122,9 @@ const navigate = direction => {
}; };
const onKeydown = event => { const onKeydown = event => {
if (!isOpen.value) return; if (!props.open) return;
if (event.key === 'Escape') { if (event.key === 'ArrowLeft') {
event.preventDefault();
event.stopPropagation();
closeDrawer();
} else if (event.key === 'ArrowLeft') {
navigate(-1); navigate(-1);
} else if (event.key === 'ArrowRight') { } else if (event.key === 'ArrowRight') {
navigate(1); navigate(1);
@@ -171,16 +137,14 @@ watch(
() => props.open, () => props.open,
isDrawerOpen => { isDrawerOpen => {
if (!isDrawerOpen) { if (!isDrawerOpen) {
panelRef.value?.close();
close(); close();
restoreFocus();
return; return;
} }
rememberActiveElement(); panelRef.value?.open();
fetchDrilldown(); fetchDrilldown();
focusDrawer(); }
},
{ immediate: true }
); );
watch( watch(
@@ -189,127 +153,89 @@ watch(
if (props.open) fetchDrilldown(); if (props.open) fetchDrilldown();
} }
); );
onBeforeUnmount(() => {
restoreFocus();
});
</script> </script>
<template> <template>
<TeleportWithDirection to="body"> <SidePanel ref="panelRef" :title="title" width="xl" @close="emit('close')">
<Transition name="report-drilldown-fade"> <template #header>
<div <div class="min-w-0">
v-if="isOpen" <h3 class="truncate text-base font-medium text-n-slate-12">
class="fixed inset-0 z-50 bg-black/30" {{ title }}
role="presentation" </h3>
@click.self="closeDrawer" <p
> v-if="bucketValue"
<aside class="mt-1 text-xl font-semibold text-n-slate-12"
ref="drawerRef"
class="fixed inset-y-0 end-0 flex w-full max-w-xl flex-col bg-n-solid-1 shadow-xl outline outline-1 outline-n-container"
role="dialog"
aria-modal="true"
:aria-label="title"
tabindex="-1"
> >
<header {{ bucketValue }}
class="flex items-start justify-between gap-4 border-b border-n-weak px-6 py-5" </p>
> <div
<div class="min-w-0"> class="text-sm text-n-slate-11"
<h2 class="truncate text-base font-medium text-n-slate-12"> :class="{
{{ title }} 'mt-2': bucketValue,
</h2> 'mt-1': !bucketValue,
<p }"
v-if="bucketValue" >
class="mt-1 text-xl font-semibold text-n-slate-12" {{ subtitle }}
> </div>
{{ bucketValue }}
</p>
<div
class="text-sm text-n-slate-11"
:class="{
'mt-2': bucketValue,
'mt-1': !bucketValue,
}"
>
{{ subtitle }}
</div>
</div>
<div class="flex shrink-0 items-center gap-1">
<Button
ghost
slate
size="sm"
icon="i-ph-caret-left"
class="rtl:rotate-180"
:disabled="!canPrev"
:aria-label="$t('REPORT.DRILLDOWN.PREVIOUS_BUCKET')"
@click="navigate(-1)"
/>
<Button
ghost
slate
size="sm"
icon="i-ph-caret-right"
class="rtl:rotate-180"
:disabled="!canNext"
:aria-label="$t('REPORT.DRILLDOWN.NEXT_BUCKET')"
@click="navigate(1)"
/>
<Button
ghost
slate
size="sm"
icon="i-ph-x"
:aria-label="$t('REPORT.DRILLDOWN.CLOSE')"
@click="closeDrawer"
/>
</div>
</header>
<div class="min-h-0 flex-1 overflow-y-auto px-5 py-3">
<div
v-if="isFetching"
class="flex h-40 items-center justify-center"
>
<Spinner />
</div>
<div
v-else-if="hasError"
class="flex h-40 items-center justify-center text-sm text-n-ruby-11"
>
{{ $t('REPORT.DRILLDOWN.ERROR') }}
</div>
<div
v-else-if="!hasRecords"
class="flex h-40 items-center justify-center text-sm text-n-slate-10"
>
{{ $t('REPORT.DRILLDOWN.EMPTY') }}
</div>
<div v-else class="flex flex-col gap-2">
<ReportDrilldownCard
v-for="record in records"
:key="recordKey(record)"
:record="record"
/>
<Button
v-if="hasMore"
faded
slate
size="sm"
class="mx-auto mt-2"
:label="$t('REPORT.DRILLDOWN.LOAD_MORE')"
:is-loading="isFetchingMore"
@click="loadMore"
/>
</div>
</div>
</aside>
</div> </div>
</Transition> </template>
</TeleportWithDirection> <template #header-actions>
<Button
ghost
slate
size="sm"
icon="i-ph-caret-left"
class="rtl:rotate-180"
:disabled="!canPrev"
:aria-label="$t('REPORT.DRILLDOWN.PREVIOUS_BUCKET')"
@click="navigate(-1)"
/>
<Button
ghost
slate
size="sm"
icon="i-ph-caret-right"
class="rtl:rotate-180"
:disabled="!canNext"
:aria-label="$t('REPORT.DRILLDOWN.NEXT_BUCKET')"
@click="navigate(1)"
/>
</template>
<div v-if="isFetching" class="flex h-40 items-center justify-center">
<Spinner />
</div>
<div
v-else-if="hasError"
class="flex h-40 items-center justify-center text-sm text-n-ruby-11"
>
{{ $t('REPORT.DRILLDOWN.ERROR') }}
</div>
<div
v-else-if="!hasRecords"
class="flex h-40 items-center justify-center text-sm text-n-slate-10"
>
{{ $t('REPORT.DRILLDOWN.EMPTY') }}
</div>
<div v-else class="flex flex-col gap-2">
<ReportDrilldownCard
v-for="record in records"
:key="recordKey(record)"
:record="record"
/>
<Button
v-if="hasMore"
faded
slate
size="sm"
class="mx-auto mt-2"
:label="$t('REPORT.DRILLDOWN.LOAD_MORE')"
:is-loading="isFetchingMore"
@click="loadMore"
/>
</div>
</SidePanel>
</template> </template>

View File

@@ -73,9 +73,11 @@ describe('ReportDrilldownDrawer.vue', () => {
}, },
]; ];
const mountDrawer = options => // The panel opens imperatively from a watch on `open`, so mount closed (as
mount(ReportDrilldownDrawer, { // the parents do) and open via setProps.
props: { open: true, ...request, ...options?.props }, const mountDrawer = async options => {
const wrapper = mount(ReportDrilldownDrawer, {
props: { ...request, ...options?.props, open: false },
attachTo: options?.attachTo, attachTo: options?.attachTo,
global: { global: {
stubs: { stubs: {
@@ -101,6 +103,9 @@ describe('ReportDrilldownDrawer.vue', () => {
}, },
}, },
}); });
await wrapper.setProps({ open: true });
return wrapper;
};
beforeEach(() => { beforeEach(() => {
ReportsAPI.getDrilldown.mockResolvedValue({ ReportsAPI.getDrilldown.mockResolvedValue({
@@ -122,7 +127,7 @@ describe('ReportDrilldownDrawer.vue', () => {
}); });
it('loads and renders drilldown cards for the request', async () => { it('loads and renders drilldown cards for the request', async () => {
const wrapper = mountDrawer(); const wrapper = await mountDrawer();
await flushPromises(); await flushPromises();
expect(ReportsAPI.getDrilldown).toHaveBeenCalledWith( expect(ReportsAPI.getDrilldown).toHaveBeenCalledWith(
@@ -138,7 +143,7 @@ describe('ReportDrilldownDrawer.vue', () => {
}); });
it('shows the bucket aggregate value for average metrics', async () => { it('shows the bucket aggregate value for average metrics', async () => {
const wrapper = mountDrawer({ const wrapper = await mountDrawer({
props: { props: {
metric: 'avg_first_response_time', metric: 'avg_first_response_time',
metricName: 'First response time', metricName: 'First response time',
@@ -163,7 +168,7 @@ describe('ReportDrilldownDrawer.vue', () => {
payload, payload,
}, },
}); });
const wrapper = mountDrawer({ const wrapper = await mountDrawer({
props: { props: {
metric: 'reply_time', metric: 'reply_time',
isAverageMetric: true, isAverageMetric: true,
@@ -188,7 +193,7 @@ describe('ReportDrilldownDrawer.vue', () => {
payload, payload,
}, },
}); });
const wrapper = mountDrawer({ const wrapper = await mountDrawer({
props: { props: {
metric: 'avg_first_response_time', metric: 'avg_first_response_time',
isAverageMetric: true, isAverageMetric: true,
@@ -202,7 +207,7 @@ describe('ReportDrilldownDrawer.vue', () => {
}); });
it('shows the plain count as the bucket value for count metrics', async () => { it('shows the plain count as the bucket value for count metrics', async () => {
const wrapper = mountDrawer({ props: { bucketValue: 128 } }); const wrapper = await mountDrawer({ props: { bucketValue: 128 } });
await flushPromises(); await flushPromises();
expect(wrapper.text()).toContain('128'); expect(wrapper.text()).toContain('128');
@@ -221,7 +226,7 @@ describe('ReportDrilldownDrawer.vue', () => {
payload, payload,
}, },
}); });
const wrapper = mountDrawer({ const wrapper = await mountDrawer({
props: { metric: 'conversations_count', bucketValue: 5 }, props: { metric: 'conversations_count', bucketValue: 5 },
}); });
await flushPromises(); await flushPromises();
@@ -242,7 +247,7 @@ describe('ReportDrilldownDrawer.vue', () => {
payload, payload,
}, },
}); });
const wrapper = mountDrawer({ const wrapper = await mountDrawer({
props: { metric: 'resolutions_count', bucketValue: 8 }, props: { metric: 'resolutions_count', bucketValue: 8 },
}); });
await flushPromises(); await flushPromises();
@@ -251,16 +256,18 @@ describe('ReportDrilldownDrawer.vue', () => {
}); });
it('anchors the drawer to the inline-end edge so it flips in RTL', async () => { it('anchors the drawer to the inline-end edge so it flips in RTL', async () => {
const wrapper = mountDrawer(); const wrapper = await mountDrawer();
await flushPromises(); await flushPromises();
const drawer = wrapper.get('[role="dialog"]'); const drawer = wrapper.get('[role="dialog"]');
expect(drawer.classes()).toContain('end-0'); expect(drawer.classes()).toContain('end-3');
expect(drawer.classes()).not.toContain('right-0'); expect(drawer.classes()).not.toContain('right-3');
}); });
it('flips the navigation caret icons in RTL', async () => { it('flips the navigation caret icons in RTL', async () => {
const wrapper = mountDrawer({ props: { canPrev: true, canNext: true } }); const wrapper = await mountDrawer({
props: { canPrev: true, canNext: true },
});
await flushPromises(); await flushPromises();
expect( expect(
@@ -272,16 +279,16 @@ describe('ReportDrilldownDrawer.vue', () => {
}); });
it('emits close when the drawer close button is clicked', async () => { it('emits close when the drawer close button is clicked', async () => {
const wrapper = mountDrawer(); const wrapper = await mountDrawer();
await flushPromises(); await flushPromises();
await wrapper.get('[aria-label="REPORT.DRILLDOWN.CLOSE"]').trigger('click'); await wrapper.get('[aria-label="GENERAL.CLOSE"]').trigger('click');
expect(wrapper.emitted('close')).toBeTruthy(); expect(wrapper.emitted('close')).toBeTruthy();
}); });
it('emits navigate when the next button is clicked', async () => { it('emits navigate when the next button is clicked', async () => {
const wrapper = mountDrawer({ props: { canNext: true } }); const wrapper = await mountDrawer({ props: { canNext: true } });
await flushPromises(); await flushPromises();
await wrapper await wrapper
@@ -292,7 +299,7 @@ describe('ReportDrilldownDrawer.vue', () => {
}); });
it('does not emit navigate past the available range', async () => { it('does not emit navigate past the available range', async () => {
const wrapper = mountDrawer({ props: { canPrev: false } }); const wrapper = await mountDrawer({ props: { canPrev: false } });
await flushPromises(); await flushPromises();
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft' })); document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft' }));
@@ -303,13 +310,16 @@ describe('ReportDrilldownDrawer.vue', () => {
it('moves focus into the drawer when opened', async () => { it('moves focus into the drawer when opened', async () => {
const target = document.createElement('div'); const target = document.createElement('div');
document.body.appendChild(target); document.body.appendChild(target);
const wrapper = mountDrawer({ attachTo: target }); const wrapper = await mountDrawer({ attachTo: target });
await flushPromises(); await flushPromises();
await nextTick();
expect(document.activeElement).toBe( // The panel focuses itself once the enter transition settles (after-enter
wrapper.find('[role="dialog"]').element // resolves on animation frames), so poll instead of awaiting microtasks.
); await vi.waitFor(() => {
expect(document.activeElement).toBe(
wrapper.find('[role="dialog"]').element
);
});
wrapper.unmount(); wrapper.unmount();
target.remove(); target.remove();
@@ -318,11 +328,14 @@ describe('ReportDrilldownDrawer.vue', () => {
it('closes on Escape even when focus is outside the drawer', async () => { it('closes on Escape even when focus is outside the drawer', async () => {
const target = document.createElement('div'); const target = document.createElement('div');
document.body.appendChild(target); document.body.appendChild(target);
const wrapper = mountDrawer({ attachTo: target }); const wrapper = await mountDrawer({ attachTo: target });
await flushPromises(); await flushPromises();
document.body.focus(); document.body.focus();
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); // Bubbles like a real key press so it reaches the panel's window listener.
document.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })
);
expect(wrapper.emitted('close')).toBeTruthy(); expect(wrapper.emitted('close')).toBeTruthy();
@@ -337,11 +350,13 @@ describe('ReportDrilldownDrawer.vue', () => {
document.body.appendChild(target); document.body.appendChild(target);
opener.focus(); opener.focus();
const wrapper = mountDrawer({ attachTo: target }); const wrapper = await mountDrawer({ attachTo: target });
await flushPromises(); await flushPromises();
await nextTick(); await nextTick();
await wrapper.get('[aria-label="REPORT.DRILLDOWN.CLOSE"]').trigger('click'); await wrapper.get('[aria-label="GENERAL.CLOSE"]').trigger('click');
await wrapper.setProps({ open: false });
await nextTick();
expect(document.activeElement).toBe(opener); expect(document.activeElement).toBe(opener);