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:
@@ -30,8 +30,9 @@ const captainDocument = {
|
||||
pdf_document: false,
|
||||
};
|
||||
|
||||
const DialogStub = {
|
||||
name: 'Dialog',
|
||||
const SidePanelStub = {
|
||||
name: 'SidePanel',
|
||||
methods: { open() {}, close() {} },
|
||||
template: '<div><slot /></div>',
|
||||
};
|
||||
|
||||
@@ -59,7 +60,7 @@ describe('DocumentDetails', () => {
|
||||
global: {
|
||||
directives: { dompurifyHtml: {} },
|
||||
stubs: {
|
||||
Dialog: DialogStub,
|
||||
SidePanel: SidePanelStub,
|
||||
TabBar: TabBarStub,
|
||||
PaginationFooter: PaginationFooterStub,
|
||||
},
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
getDocumentDisplayPath,
|
||||
} from 'shared/helpers/documentHelper';
|
||||
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 Button from 'dashboard/components-next/button/Button.vue';
|
||||
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
|
||||
@@ -33,7 +33,9 @@ const TAB_KEYS = {
|
||||
const RESPONSES_PER_PAGE = 25;
|
||||
const { t } = useI18n();
|
||||
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 showRawContent = ref(false);
|
||||
const activeTabIndex = ref(0);
|
||||
@@ -126,9 +128,9 @@ const syncedAtLabel = computed(() => {
|
||||
);
|
||||
});
|
||||
|
||||
const handleClose = () => {
|
||||
emit('close');
|
||||
};
|
||||
const documentTitle = computed(
|
||||
() => documentDetails.value.name || documentDetails.value.external_link
|
||||
);
|
||||
|
||||
const handleCopyContent = async () => {
|
||||
try {
|
||||
@@ -156,22 +158,18 @@ const handlePageChange = page => {
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
panelRef.value.open();
|
||||
fetchResponses();
|
||||
});
|
||||
defineExpose({ dialogRef });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
type="edit"
|
||||
:title="documentDetails.name || documentDetails.external_link"
|
||||
<SidePanel
|
||||
ref="panelRef"
|
||||
:title="documentTitle"
|
||||
:description="t('CAPTAIN.DOCUMENTS.DETAILS.DESCRIPTION')"
|
||||
:show-cancel-button="false"
|
||||
:show-confirm-button="false"
|
||||
overflow-y-auto
|
||||
width="3xl"
|
||||
@close="handleClose"
|
||||
@after-leave="emit('close')"
|
||||
>
|
||||
<div
|
||||
v-if="isFetching"
|
||||
@@ -230,7 +228,7 @@ defineExpose({ dialogRef });
|
||||
@tab-changed="handleTabChanged"
|
||||
/>
|
||||
|
||||
<div class="h-[32rem] overflow-y-auto">
|
||||
<div>
|
||||
<section
|
||||
v-if="activeTabKey === TAB_KEYS.CONTENT"
|
||||
class="flex flex-col gap-3"
|
||||
@@ -313,7 +311,7 @@ defineExpose({ dialogRef });
|
||||
</div>
|
||||
<div
|
||||
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
|
||||
v-if="showRawContent || isUnreadableContent"
|
||||
@@ -370,5 +368,5 @@ defineExpose({ dialogRef });
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</SidePanel>
|
||||
</template>
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
|
||||
import { useEventListener } from '@vueuse/core';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import CaptainAssistant from 'dashboard/api/captain/assistant';
|
||||
import { useReportDrilldown } from 'dashboard/routes/dashboard/settings/reports/composables/useReportDrilldown';
|
||||
import ReportDrilldownCard from 'dashboard/routes/dashboard/settings/reports/components/ReportDrilldownCard.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.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({
|
||||
open: { type: Boolean, default: false },
|
||||
@@ -20,8 +19,8 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
const panelRef = ref(null);
|
||||
const { t } = useI18n();
|
||||
const drawerRef = ref(null);
|
||||
const {
|
||||
records,
|
||||
meta,
|
||||
@@ -35,9 +34,6 @@ const {
|
||||
loadMore,
|
||||
} = useReportDrilldown(params => CaptainAssistant.getDrilldown(params));
|
||||
|
||||
let previousActiveElement = null;
|
||||
|
||||
const isOpen = computed(() => props.open);
|
||||
const title = computed(() => props.metricName || '');
|
||||
|
||||
const subtitle = computed(() => {
|
||||
@@ -50,32 +46,6 @@ const subtitle = computed(() => {
|
||||
|
||||
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 = () => {
|
||||
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(
|
||||
() => props.open,
|
||||
isDrawerOpen => {
|
||||
if (!isDrawerOpen) {
|
||||
panelRef.value?.close();
|
||||
close();
|
||||
restoreFocus();
|
||||
return;
|
||||
}
|
||||
|
||||
rememberActiveElement();
|
||||
panelRef.value?.open();
|
||||
fetchDrilldown();
|
||||
focusDrawer();
|
||||
},
|
||||
{ immediate: true }
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
@@ -127,108 +83,70 @@ watch(
|
||||
watch(
|
||||
() => props.assistantId,
|
||||
() => {
|
||||
if (props.open) closeDrawer();
|
||||
if (props.open) emit('close');
|
||||
}
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
restoreFocus();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TeleportWithDirection to="body">
|
||||
<Transition name="report-drilldown-fade">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="fixed inset-0 z-50 bg-black/30"
|
||||
role="presentation"
|
||||
@click.self="closeDrawer"
|
||||
>
|
||||
<aside
|
||||
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"
|
||||
<SidePanel ref="panelRef" :title="title" width="xl" @close="emit('close')">
|
||||
<template #header>
|
||||
<div class="min-w-0">
|
||||
<h3 class="truncate text-base font-medium text-n-slate-12">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<p
|
||||
v-if="metricValue"
|
||||
class="mt-1 text-xl font-semibold text-n-slate-12"
|
||||
>
|
||||
<header
|
||||
class="flex items-start justify-between gap-4 border-b border-n-weak px-6 py-5"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<h2 class="truncate text-base font-medium text-n-slate-12">
|
||||
{{ title }}
|
||||
</h2>
|
||||
<p
|
||||
v-if="metricValue"
|
||||
class="mt-1 text-xl font-semibold text-n-slate-12"
|
||||
>
|
||||
{{ 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>
|
||||
{{ metricValue }}
|
||||
</p>
|
||||
<div
|
||||
class="text-sm text-n-slate-11"
|
||||
:class="{
|
||||
'mt-2': metricValue,
|
||||
'mt-1': !metricValue,
|
||||
}"
|
||||
>
|
||||
{{ subtitle }}
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</TeleportWithDirection>
|
||||
</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('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>
|
||||
|
||||
@@ -15,8 +15,8 @@ const props = defineProps({
|
||||
},
|
||||
width: {
|
||||
type: String,
|
||||
default: 'lg',
|
||||
validator: value => ['sm', 'md', 'lg', 'xl'].includes(value),
|
||||
default: 'xl',
|
||||
validator: value => ['md', 'lg', 'xl', '2xl', '3xl'].includes(value),
|
||||
},
|
||||
closeOnClickOutside: {
|
||||
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 = {
|
||||
sm: 'max-w-md',
|
||||
md: 'max-w-xl',
|
||||
lg: 'max-w-3xl',
|
||||
xl: 'max-w-5xl',
|
||||
md: 'max-w-md',
|
||||
lg: 'max-w-lg',
|
||||
xl: 'max-w-xl',
|
||||
'2xl': 'max-w-2xl',
|
||||
'3xl': 'max-w-3xl',
|
||||
};
|
||||
|
||||
const isOpen = ref(false);
|
||||
@@ -103,10 +106,11 @@ defineExpose({ open, close });
|
||||
</Transition>
|
||||
<Transition
|
||||
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-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-leave="emit('afterLeave')"
|
||||
>
|
||||
<aside
|
||||
v-if="isOpen"
|
||||
@@ -115,29 +119,34 @@ defineExpose({ open, close });
|
||||
aria-modal="true"
|
||||
:aria-label="title"
|
||||
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"
|
||||
>
|
||||
<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">
|
||||
<h3 class="text-base font-medium truncate text-n-slate-12">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<p v-if="description" class="mt-1 mb-0 text-sm text-n-slate-11">
|
||||
{{ description }}
|
||||
</p>
|
||||
<slot name="header">
|
||||
<div class="min-w-0">
|
||||
<h3 class="text-base font-medium truncate text-n-slate-12">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<p v-if="description" class="mt-1 mb-0 text-sm text-n-slate-11">
|
||||
{{ 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>
|
||||
<Button
|
||||
ghost
|
||||
slate
|
||||
sm
|
||||
icon="i-lucide-x"
|
||||
class="-me-2"
|
||||
:aria-label="$t('GENERAL.CLOSE')"
|
||||
@click="close"
|
||||
/>
|
||||
</header>
|
||||
<div class="flex-1 min-h-0 px-6 py-5 overflow-y-auto">
|
||||
<slot />
|
||||
|
||||
@@ -447,7 +447,6 @@
|
||||
}
|
||||
},
|
||||
"DRILLDOWN": {
|
||||
"CLOSE": "Close details",
|
||||
"EMPTY": "No records found for this metric.",
|
||||
"ERROR": "Could not load records. Please try again.",
|
||||
"LOAD_MORE": "Load more",
|
||||
|
||||
@@ -129,7 +129,6 @@
|
||||
"ERROR": "Could not load records. Please try again.",
|
||||
"ADMIN_ONLY": "Only administrators can drill down into report records.",
|
||||
"LOAD_MORE": "Load more",
|
||||
"CLOSE": "Close details",
|
||||
"PREVIOUS_BUCKET": "Previous bar",
|
||||
"NEXT_BUCKET": "Next bar",
|
||||
"UNKNOWN_CONTACT": "Unknown contact",
|
||||
|
||||
@@ -54,11 +54,9 @@ const handleDelete = () => {
|
||||
const showDocumentDetails = ref(false);
|
||||
const showCreateDialog = ref(false);
|
||||
const createDocumentDialog = ref(null);
|
||||
const documentDetailsDialog = ref(null);
|
||||
|
||||
const handleShowDocumentDetails = () => {
|
||||
showDocumentDetails.value = true;
|
||||
nextTick(() => documentDetailsDialog.value.dialogRef.open());
|
||||
};
|
||||
const handleCreateDocument = () => {
|
||||
showCreateDialog.value = true;
|
||||
@@ -430,7 +428,6 @@ onUnmounted(() => {
|
||||
|
||||
<DocumentDetails
|
||||
v-if="showDocumentDetails"
|
||||
ref="documentDetailsDialog"
|
||||
:captain-document="selectedDocument"
|
||||
@close="handleDocumentDetailsClose"
|
||||
/>
|
||||
|
||||
@@ -291,7 +291,7 @@ defineExpose({ open, close });
|
||||
</script>
|
||||
|
||||
<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 class="flex flex-col">
|
||||
<woot-input
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<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 { formatTime } from '@chatwoot/utils';
|
||||
import Button from 'dashboard/components-next/button/Button.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 ReportDrilldownCard from './ReportDrilldownCard.vue';
|
||||
|
||||
@@ -29,8 +29,8 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['close', 'navigate']);
|
||||
|
||||
const panelRef = ref(null);
|
||||
const { t } = useI18n();
|
||||
const drawerRef = ref(null);
|
||||
const {
|
||||
records,
|
||||
meta,
|
||||
@@ -44,10 +44,6 @@ const {
|
||||
loadMore,
|
||||
} = useReportDrilldown();
|
||||
|
||||
let previousActiveElement = null;
|
||||
|
||||
const isOpen = computed(() => props.open);
|
||||
|
||||
const title = computed(() => props.metricName || '');
|
||||
|
||||
const bucketValue = computed(() => {
|
||||
@@ -100,37 +96,11 @@ const subtitle = computed(() =>
|
||||
.join(' ⋅ ')
|
||||
);
|
||||
|
||||
const restoreFocus = () => {
|
||||
if (previousActiveElement?.isConnected) {
|
||||
previousActiveElement.focus();
|
||||
}
|
||||
previousActiveElement = null;
|
||||
};
|
||||
|
||||
const closeDrawer = () => {
|
||||
close();
|
||||
emit('close');
|
||||
restoreFocus();
|
||||
};
|
||||
|
||||
const recordKey = record =>
|
||||
`${record.record_type}-${record.message?.id || record.conversation?.id}-${
|
||||
record.occurred_at
|
||||
}`;
|
||||
|
||||
const rememberActiveElement = () => {
|
||||
if (previousActiveElement) return;
|
||||
|
||||
previousActiveElement =
|
||||
document.activeElement instanceof HTMLElement
|
||||
? document.activeElement
|
||||
: null;
|
||||
};
|
||||
|
||||
const focusDrawer = () => {
|
||||
nextTick(() => drawerRef.value?.focus());
|
||||
};
|
||||
|
||||
const fetchDrilldown = () => {
|
||||
openDrilldown({
|
||||
metric: props.metric,
|
||||
@@ -152,13 +122,9 @@ const navigate = direction => {
|
||||
};
|
||||
|
||||
const onKeydown = event => {
|
||||
if (!isOpen.value) return;
|
||||
if (!props.open) return;
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
closeDrawer();
|
||||
} else if (event.key === 'ArrowLeft') {
|
||||
if (event.key === 'ArrowLeft') {
|
||||
navigate(-1);
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
navigate(1);
|
||||
@@ -171,16 +137,14 @@ watch(
|
||||
() => props.open,
|
||||
isDrawerOpen => {
|
||||
if (!isDrawerOpen) {
|
||||
panelRef.value?.close();
|
||||
close();
|
||||
restoreFocus();
|
||||
return;
|
||||
}
|
||||
|
||||
rememberActiveElement();
|
||||
panelRef.value?.open();
|
||||
fetchDrilldown();
|
||||
focusDrawer();
|
||||
},
|
||||
{ immediate: true }
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
@@ -189,127 +153,89 @@ watch(
|
||||
if (props.open) fetchDrilldown();
|
||||
}
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
restoreFocus();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TeleportWithDirection to="body">
|
||||
<Transition name="report-drilldown-fade">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="fixed inset-0 z-50 bg-black/30"
|
||||
role="presentation"
|
||||
@click.self="closeDrawer"
|
||||
>
|
||||
<aside
|
||||
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"
|
||||
<SidePanel ref="panelRef" :title="title" width="xl" @close="emit('close')">
|
||||
<template #header>
|
||||
<div class="min-w-0">
|
||||
<h3 class="truncate text-base font-medium text-n-slate-12">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<p
|
||||
v-if="bucketValue"
|
||||
class="mt-1 text-xl font-semibold text-n-slate-12"
|
||||
>
|
||||
<header
|
||||
class="flex items-start justify-between gap-4 border-b border-n-weak px-6 py-5"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<h2 class="truncate text-base font-medium text-n-slate-12">
|
||||
{{ title }}
|
||||
</h2>
|
||||
<p
|
||||
v-if="bucketValue"
|
||||
class="mt-1 text-xl font-semibold text-n-slate-12"
|
||||
>
|
||||
{{ 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>
|
||||
{{ bucketValue }}
|
||||
</p>
|
||||
<div
|
||||
class="text-sm text-n-slate-11"
|
||||
:class="{
|
||||
'mt-2': bucketValue,
|
||||
'mt-1': !bucketValue,
|
||||
}"
|
||||
>
|
||||
{{ subtitle }}
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</TeleportWithDirection>
|
||||
</template>
|
||||
<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>
|
||||
|
||||
@@ -73,9 +73,11 @@ describe('ReportDrilldownDrawer.vue', () => {
|
||||
},
|
||||
];
|
||||
|
||||
const mountDrawer = options =>
|
||||
mount(ReportDrilldownDrawer, {
|
||||
props: { open: true, ...request, ...options?.props },
|
||||
// The panel opens imperatively from a watch on `open`, so mount closed (as
|
||||
// the parents do) and open via setProps.
|
||||
const mountDrawer = async options => {
|
||||
const wrapper = mount(ReportDrilldownDrawer, {
|
||||
props: { ...request, ...options?.props, open: false },
|
||||
attachTo: options?.attachTo,
|
||||
global: {
|
||||
stubs: {
|
||||
@@ -101,6 +103,9 @@ describe('ReportDrilldownDrawer.vue', () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
await wrapper.setProps({ open: true });
|
||||
return wrapper;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
ReportsAPI.getDrilldown.mockResolvedValue({
|
||||
@@ -122,7 +127,7 @@ describe('ReportDrilldownDrawer.vue', () => {
|
||||
});
|
||||
|
||||
it('loads and renders drilldown cards for the request', async () => {
|
||||
const wrapper = mountDrawer();
|
||||
const wrapper = await mountDrawer();
|
||||
await flushPromises();
|
||||
|
||||
expect(ReportsAPI.getDrilldown).toHaveBeenCalledWith(
|
||||
@@ -138,7 +143,7 @@ describe('ReportDrilldownDrawer.vue', () => {
|
||||
});
|
||||
|
||||
it('shows the bucket aggregate value for average metrics', async () => {
|
||||
const wrapper = mountDrawer({
|
||||
const wrapper = await mountDrawer({
|
||||
props: {
|
||||
metric: 'avg_first_response_time',
|
||||
metricName: 'First response time',
|
||||
@@ -163,7 +168,7 @@ describe('ReportDrilldownDrawer.vue', () => {
|
||||
payload,
|
||||
},
|
||||
});
|
||||
const wrapper = mountDrawer({
|
||||
const wrapper = await mountDrawer({
|
||||
props: {
|
||||
metric: 'reply_time',
|
||||
isAverageMetric: true,
|
||||
@@ -188,7 +193,7 @@ describe('ReportDrilldownDrawer.vue', () => {
|
||||
payload,
|
||||
},
|
||||
});
|
||||
const wrapper = mountDrawer({
|
||||
const wrapper = await mountDrawer({
|
||||
props: {
|
||||
metric: 'avg_first_response_time',
|
||||
isAverageMetric: true,
|
||||
@@ -202,7 +207,7 @@ describe('ReportDrilldownDrawer.vue', () => {
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
expect(wrapper.text()).toContain('128');
|
||||
@@ -221,7 +226,7 @@ describe('ReportDrilldownDrawer.vue', () => {
|
||||
payload,
|
||||
},
|
||||
});
|
||||
const wrapper = mountDrawer({
|
||||
const wrapper = await mountDrawer({
|
||||
props: { metric: 'conversations_count', bucketValue: 5 },
|
||||
});
|
||||
await flushPromises();
|
||||
@@ -242,7 +247,7 @@ describe('ReportDrilldownDrawer.vue', () => {
|
||||
payload,
|
||||
},
|
||||
});
|
||||
const wrapper = mountDrawer({
|
||||
const wrapper = await mountDrawer({
|
||||
props: { metric: 'resolutions_count', bucketValue: 8 },
|
||||
});
|
||||
await flushPromises();
|
||||
@@ -251,16 +256,18 @@ describe('ReportDrilldownDrawer.vue', () => {
|
||||
});
|
||||
|
||||
it('anchors the drawer to the inline-end edge so it flips in RTL', async () => {
|
||||
const wrapper = mountDrawer();
|
||||
const wrapper = await mountDrawer();
|
||||
await flushPromises();
|
||||
|
||||
const drawer = wrapper.get('[role="dialog"]');
|
||||
expect(drawer.classes()).toContain('end-0');
|
||||
expect(drawer.classes()).not.toContain('right-0');
|
||||
expect(drawer.classes()).toContain('end-3');
|
||||
expect(drawer.classes()).not.toContain('right-3');
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
expect(
|
||||
@@ -272,16 +279,16 @@ describe('ReportDrilldownDrawer.vue', () => {
|
||||
});
|
||||
|
||||
it('emits close when the drawer close button is clicked', async () => {
|
||||
const wrapper = mountDrawer();
|
||||
const wrapper = await mountDrawer();
|
||||
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();
|
||||
});
|
||||
|
||||
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 wrapper
|
||||
@@ -292,7 +299,7 @@ describe('ReportDrilldownDrawer.vue', () => {
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft' }));
|
||||
@@ -303,13 +310,16 @@ describe('ReportDrilldownDrawer.vue', () => {
|
||||
it('moves focus into the drawer when opened', async () => {
|
||||
const target = document.createElement('div');
|
||||
document.body.appendChild(target);
|
||||
const wrapper = mountDrawer({ attachTo: target });
|
||||
const wrapper = await mountDrawer({ attachTo: target });
|
||||
await flushPromises();
|
||||
await nextTick();
|
||||
|
||||
expect(document.activeElement).toBe(
|
||||
wrapper.find('[role="dialog"]').element
|
||||
);
|
||||
// The panel focuses itself once the enter transition settles (after-enter
|
||||
// resolves on animation frames), so poll instead of awaiting microtasks.
|
||||
await vi.waitFor(() => {
|
||||
expect(document.activeElement).toBe(
|
||||
wrapper.find('[role="dialog"]').element
|
||||
);
|
||||
});
|
||||
|
||||
wrapper.unmount();
|
||||
target.remove();
|
||||
@@ -318,11 +328,14 @@ describe('ReportDrilldownDrawer.vue', () => {
|
||||
it('closes on Escape even when focus is outside the drawer', async () => {
|
||||
const target = document.createElement('div');
|
||||
document.body.appendChild(target);
|
||||
const wrapper = mountDrawer({ attachTo: target });
|
||||
const wrapper = await mountDrawer({ attachTo: target });
|
||||
await flushPromises();
|
||||
|
||||
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();
|
||||
|
||||
@@ -337,11 +350,13 @@ describe('ReportDrilldownDrawer.vue', () => {
|
||||
document.body.appendChild(target);
|
||||
opener.focus();
|
||||
|
||||
const wrapper = mountDrawer({ attachTo: target });
|
||||
const wrapper = await mountDrawer({ attachTo: target });
|
||||
await flushPromises();
|
||||
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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user