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,
|
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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user