Merge remote-tracking branch 'origin/develop' into feature/cw-7513

# Conflicts:
#	app/javascript/dashboard/featureFlags.js
#	config/features.yml
This commit is contained in:
Tanmay Deep Sharma
2026-07-15 12:31:43 +05:30
115 changed files with 8773 additions and 146 deletions
@@ -17,7 +17,7 @@ import Input from 'dashboard/components-next/input/Input.vue';
import Policy from 'dashboard/components/policy.vue';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
import RelatedResponses from 'dashboard/components-next/captain/pageComponents/document/RelatedResponses.vue';
import DocumentDetails from 'dashboard/components-next/captain/pageComponents/document/DocumentDetails.vue';
import CreateDocumentDialog from 'dashboard/components-next/captain/pageComponents/document/CreateDocumentDialog.vue';
import DocumentPageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/DocumentPageEmptyState.vue';
import FeatureSpotlightPopover from 'dashboard/components-next/feature-spotlight/FeatureSpotlightPopover.vue';
@@ -51,22 +51,22 @@ const handleDelete = () => {
deleteDocumentDialog.value.dialogRef.open();
};
const showRelatedResponses = ref(false);
const showDocumentDetails = ref(false);
const showCreateDialog = ref(false);
const createDocumentDialog = ref(null);
const relationQuestionDialog = ref(null);
const documentDetailsDialog = ref(null);
const handleShowRelatedDocument = () => {
showRelatedResponses.value = true;
nextTick(() => relationQuestionDialog.value.dialogRef.open());
const handleShowDocumentDetails = () => {
showDocumentDetails.value = true;
nextTick(() => documentDetailsDialog.value.dialogRef.open());
};
const handleCreateDocument = () => {
showCreateDialog.value = true;
nextTick(() => createDocumentDialog.value.dialogRef.open());
};
const handleRelatedResponseClose = () => {
showRelatedResponses.value = false;
const handleDocumentDetailsClose = () => {
showDocumentDetails.value = false;
};
const handleCreateDialogClose = () => {
@@ -235,8 +235,8 @@ const handleAction = ({ action, id }) => {
nextTick(() => {
if (action === 'delete') {
handleDelete();
} else if (action === 'viewRelatedQuestions') {
handleShowRelatedDocument();
} else if (action === 'viewDetails') {
handleShowDocumentDetails();
} else if (action === 'sync') {
handleSync(id);
}
@@ -416,6 +416,7 @@ onUnmounted(() => {
:last-sync-error-code="doc.last_sync_error_code"
:sync-in-progress="doc.sync_in_progress"
:sync-stale-after-hours="syncIntervalHours"
:responses-count="doc.responses_count"
:is-selected="canManageDocuments && bulkSelectedIds.has(doc.id)"
:selectable="canManageDocuments"
:show-selection-control="shouldShowSelectionControl(doc.id)"
@@ -427,11 +428,11 @@ onUnmounted(() => {
</div>
</template>
<RelatedResponses
v-if="showRelatedResponses"
ref="relationQuestionDialog"
<DocumentDetails
v-if="showDocumentDetails"
ref="documentDetailsDialog"
:captain-document="selectedDocument"
@close="handleRelatedResponseClose"
@close="handleDocumentDetailsClose"
/>
<CreateDocumentDialog
v-if="showCreateDialog"
@@ -52,9 +52,11 @@ const helpURL = getHelpUrlForFeature(props.featureName);
v-if="title"
class="flex items-center justify-between w-full gap-4 min-h-8 mb-2"
>
<h1 class="text-heading-1 text-n-slate-12">
{{ title }}
</h1>
<slot name="title">
<h1 class="text-heading-1 text-n-slate-12">
{{ title }}
</h1>
</slot>
</div>
<div
v-if="description || $slots.description || linkText || helpURL"
@@ -0,0 +1,378 @@
<script setup>
import {
computed,
onActivated,
onBeforeUnmount,
onDeactivated,
ref,
} from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useStoreGetters } from 'dashboard/composables/store';
import Button from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
import SettingsLayout from '../SettingsLayout.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import DataImportsAPI from 'dashboard/api/dataImports';
import NewImportDialog from './NewImportDialog.vue';
import { importSourceFor } from './importSources';
import {
POLL_INTERVAL_MS,
formatDate,
formatStatus,
importedCount,
isActiveImport,
isActiveIntercomImport,
statusDotClass,
} from './importStatus';
const { t } = useI18n();
const getters = useStoreGetters();
const router = useRouter();
const dataImports = ref([]);
const isLoading = ref(true);
const isRefreshing = ref(false);
const isPolling = ref(false);
const showImportDrawer = ref(false);
const activeTab = ref('import');
let pollTimer;
let isPageActive = false;
const accountId = getters.getCurrentAccountId;
const tabs = computed(() => [
{ key: 'import', label: t('DATA_IMPORTS.TABS.IMPORT') },
{ key: 'export', label: t('DATA_IMPORTS.TABS.EXPORT') },
]);
const activeTabIndex = computed(() =>
tabs.value.findIndex(tab => tab.key === activeTab.value)
);
const hasActiveImport = computed(() => dataImports.value.some(isActiveImport));
const hasActiveIntercomImport = computed(() =>
dataImports.value.some(isActiveIntercomImport)
);
const dataImportRoute = dataImport => ({
name: 'settings_data_import_show',
params: { accountId: accountId.value, dataImportId: dataImport.id },
});
const importTypesFor = dataImport =>
dataImport.import_types?.length
? dataImport.import_types
: [dataImport.data_type];
const importTypeLabel = dataImport =>
importTypesFor(dataImport)
.map(type => {
if (type === 'contacts') return t('DATA_IMPORTS.TYPES.CONTACTS');
if (type === 'conversations') {
return t('DATA_IMPORTS.TYPES.CONVERSATIONS');
}
return type;
})
.join(', ');
const fetchImports = async () => {
const response = await DataImportsAPI.get();
dataImports.value = response.data.payload || [];
};
const stopPolling = () => {
if (!pollTimer) return;
window.clearInterval(pollTimer);
pollTimer = null;
};
const refreshImportsInBackground = async () => {
if (
!isPageActive ||
isPolling.value ||
!hasActiveImport.value ||
document.hidden
) {
return;
}
isPolling.value = true;
try {
await fetchImports();
} finally {
isPolling.value = false;
if (!hasActiveImport.value) stopPolling();
}
};
const startPolling = () => {
stopPolling();
if (!isPageActive || !hasActiveImport.value) return;
pollTimer = window.setInterval(refreshImportsInBackground, POLL_INTERVAL_MS);
};
const refresh = async ({ showLoader = true } = {}) => {
if (showLoader) isLoading.value = true;
else isRefreshing.value = true;
try {
await fetchImports();
} finally {
isLoading.value = false;
isRefreshing.value = false;
if (isPageActive) {
if (hasActiveImport.value && !pollTimer) startPolling();
if (!hasActiveImport.value) stopPolling();
}
}
};
const openImport = dataImport => {
router.push(dataImportRoute(dataImport));
};
const openImportDrawer = () => {
if (!hasActiveIntercomImport.value) showImportDrawer.value = true;
};
const onImportCreated = dataImportId => {
showImportDrawer.value = false;
router.push({
name: 'settings_data_import_show',
params: { accountId: accountId.value, dataImportId },
});
};
const onTabChanged = tab => {
activeTab.value = tab.key;
};
const handleVisibilityChange = () => {
if (isPageActive && !document.hidden && hasActiveImport.value) {
refreshImportsInBackground();
}
};
onActivated(async () => {
isPageActive = true;
await refresh();
if (!isPageActive) return;
startPolling();
document.addEventListener('visibilitychange', handleVisibilityChange);
});
onDeactivated(() => {
isPageActive = false;
stopPolling();
document.removeEventListener('visibilitychange', handleVisibilityChange);
});
onBeforeUnmount(() => {
isPageActive = false;
stopPolling();
document.removeEventListener('visibilitychange', handleVisibilityChange);
});
</script>
<template>
<SettingsLayout
:is-loading="isLoading"
:loading-message="$t('DATA_IMPORTS.LOADING')"
>
<template #header>
<BaseSettingsHeader
:title="$t('DATA_IMPORTS.HEADER')"
:description="$t('DATA_IMPORTS.DESCRIPTION')"
>
<template #tabs>
<TabBar
:tabs="tabs"
:initial-active-tab="activeTabIndex"
@tab-changed="onTabChanged"
/>
</template>
<template v-if="activeTab === 'import' && dataImports.length" #count>
<span class="text-body-main text-n-slate-11">
{{ $t('DATA_IMPORTS.TABLE.COUNT', { count: dataImports.length }) }}
</span>
</template>
<template v-if="activeTab === 'import'" #actions>
<span
v-if="hasActiveImport"
class="hidden items-center gap-1.5 text-body-main text-n-slate-11 sm:inline-flex"
>
<span class="size-2 rounded-full bg-n-teal-9 animate-pulse" />
{{
$t('DATA_IMPORTS.MONITOR.LIVE', {
seconds: POLL_INTERVAL_MS / 1000,
})
}}
</span>
<Button
ghost
slate
size="sm"
icon="i-lucide-refresh-cw"
:is-loading="isRefreshing"
:aria-label="$t('DATA_IMPORTS.MONITOR.REFRESH')"
:title="$t('DATA_IMPORTS.MONITOR.REFRESH')"
@click="refresh({ showLoader: false })"
/>
<Button
size="sm"
:label="$t('DATA_IMPORTS.TABLE.NEW_IMPORT')"
:disabled="hasActiveIntercomImport"
:title="
hasActiveIntercomImport
? $t('DATA_IMPORTS.DRAWER.ACTIVE_IMPORT')
: undefined
"
@click="openImportDrawer"
/>
</template>
</BaseSettingsHeader>
</template>
<template #body>
<div
v-if="activeTab === 'export'"
class="flex min-h-80 flex-col items-center justify-center gap-4 rounded-xl border border-n-weak bg-n-solid-1 px-6 py-16 text-center"
>
<span
class="flex size-12 items-center justify-center rounded-full bg-n-alpha-2"
>
<Icon icon="i-lucide-upload" class="size-5 text-n-slate-11" />
</span>
<div class="flex flex-col gap-1">
<h3 class="text-heading-2 text-n-slate-12">
{{ $t('DATA_IMPORTS.EXPORT.TITLE') }}
</h3>
<p class="max-w-sm text-body-main text-n-slate-11">
{{ $t('DATA_IMPORTS.EXPORT.DESCRIPTION') }}
</p>
</div>
<span
class="inline-flex items-center gap-1.5 rounded-md bg-n-alpha-2 px-2 py-1 text-label-small text-n-slate-11"
>
<Icon icon="i-lucide-clock" class="size-3.5" />
{{ $t('DATA_IMPORTS.EXPORT.COMING_SOON') }}
</span>
</div>
<div
v-else-if="!dataImports.length"
class="flex min-h-80 flex-col items-center justify-center gap-4 rounded-xl border border-n-weak bg-n-solid-1 px-6 py-16 text-center"
>
<span
class="flex size-12 items-center justify-center rounded-full bg-n-alpha-2"
>
<Icon icon="i-lucide-database" class="size-5 text-n-slate-11" />
</span>
<div class="flex flex-col gap-1">
<h3 class="text-heading-2 text-n-slate-12">
{{ $t('DATA_IMPORTS.TABLE.EMPTY') }}
</h3>
<p class="max-w-sm text-body-main text-n-slate-11">
{{ $t('DATA_IMPORTS.TABLE.EMPTY_DESCRIPTION') }}
</p>
</div>
<Button
size="sm"
icon="i-lucide-download"
:label="$t('DATA_IMPORTS.TABLE.NEW_IMPORT')"
@click="openImportDrawer"
/>
</div>
<div v-else class="divide-y divide-n-weak border-t border-n-weak">
<div
v-for="dataImport in dataImports"
:key="dataImport.id"
class="group flex cursor-pointer items-center justify-between gap-4 py-4"
role="button"
tabindex="0"
@click="openImport(dataImport)"
@keydown.enter="openImport(dataImport)"
@keydown.space.prevent="openImport(dataImport)"
>
<div class="flex min-w-0 items-center gap-3">
<img
v-if="importSourceFor(dataImport).icon"
v-tooltip.top="importSourceFor(dataImport).label"
:src="importSourceFor(dataImport).icon"
alt=""
class="size-10 justify-center bg-n-alpha-3 rounded-xl shrink-0 object-contain border border-n-strong"
/>
<span
v-else
v-tooltip.top="importSourceFor(dataImport).label"
class="size-10 justify-center bg-n-alpha-3 rounded-xl ring ring-n-solid-1 border border-n-strong shadow-sm grid place-items-center"
>
<Icon
:icon="importSourceFor(dataImport).iconClass"
class="size-4"
/>
</span>
<div class="flex min-w-0 flex-col gap-1">
<div class="flex items-center gap-2">
<span class="truncate text-heading-3 text-n-slate-12">
{{ dataImport.name || $t('DATA_IMPORTS.TABLE.UNNAMED') }}
</span>
<span class="flex shrink-0 items-center gap-1.5">
<span
class="size-2 rounded-full"
:class="[
statusDotClass(dataImport.status),
{ 'animate-pulse': isActiveImport(dataImport) },
]"
/>
<span
class="whitespace-nowrap capitalize text-body-main text-n-slate-11"
>
{{ formatStatus(dataImport.status) }}
</span>
</span>
</div>
<div
class="flex flex-wrap items-center gap-2 text-body-main text-n-slate-11"
>
<span>{{ importTypeLabel(dataImport) }}</span>
<div class="h-3 w-px rounded-lg bg-n-strong" />
<span class="tabular-nums">
{{
$t('DATA_IMPORTS.TABLE.IMPORTED_COUNT', {
count: importedCount(dataImport),
})
}}
</span>
<div class="h-3 w-px rounded-lg bg-n-strong" />
<span>{{ formatDate(dataImport.created_at) }}</span>
</div>
</div>
</div>
<Button
v-tooltip.top="$t('DATA_IMPORTS.TABLE.VIEW')"
icon="i-lucide-eye"
slate
sm
class="shrink-0"
@click.stop="openImport(dataImport)"
/>
</div>
</div>
</template>
</SettingsLayout>
<NewImportDialog
:show="showImportDrawer"
:has-active-import="hasActiveIntercomImport"
@close="showImportDrawer = false"
@created="onImportCreated"
/>
</template>
@@ -0,0 +1,210 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import Select from 'dashboard/components-next/select/Select.vue';
import DataImportsAPI from 'dashboard/api/dataImports';
import { IMPORT_SOURCES } from './importSources';
const props = defineProps({
show: { type: Boolean, default: false },
hasActiveImport: { type: Boolean, default: false },
});
const emit = defineEmits(['close', 'created']);
const { t } = useI18n();
const dialogRef = ref(null);
const sourceProvider = ref('intercom');
const importName = ref(t('DATA_IMPORTS.DEFAULT_IMPORT_NAME'));
const accessToken = ref('');
const selectedImportTypes = ref(['contacts', 'conversations']);
const validationState = ref('idle');
const validationMessage = ref('');
const isCreating = ref(false);
let validationRequestId = 0;
const closeDrawer = () => emit('close');
const sourceOptions = computed(() =>
IMPORT_SOURCES.map(({ value, label }) => ({ value, label }))
);
const tokenMessageType = computed(() => {
if (validationState.value === 'valid') return 'success';
if (validationState.value === 'invalid') return 'error';
return 'info';
});
const canCreate = computed(
() =>
validationState.value === 'valid' &&
selectedImportTypes.value.length > 0 &&
!props.hasActiveImport &&
!isCreating.value
);
const validationPayload = () => ({
source_provider: sourceProvider.value,
access_token: accessToken.value.trim(),
import_types: selectedImportTypes.value,
});
const invalidateValidation = () => {
validationRequestId += 1;
validationState.value = 'idle';
validationMessage.value = '';
};
const validateSource = async () => {
if (!accessToken.value.trim() || !selectedImportTypes.value.length) {
invalidateValidation();
return;
}
validationRequestId += 1;
const requestId = validationRequestId;
validationState.value = 'validating';
validationMessage.value = t('DATA_IMPORTS.DRAWER.VALIDATING');
try {
await DataImportsAPI.validateSource(validationPayload());
if (requestId !== validationRequestId) return;
validationState.value = 'valid';
validationMessage.value = t('DATA_IMPORTS.DRAWER.VALID_KEY');
} catch (error) {
if (requestId !== validationRequestId) return;
validationState.value = 'invalid';
validationMessage.value =
error?.response?.data?.message || t('DATA_IMPORTS.DRAWER.INVALID_KEY');
}
};
const toggleImportType = type => {
selectedImportTypes.value = selectedImportTypes.value.includes(type)
? selectedImportTypes.value.filter(item => item !== type)
: [...selectedImportTypes.value, type];
};
const createImport = async () => {
if (!canCreate.value) return;
isCreating.value = true;
try {
const response = await DataImportsAPI.create({
...validationPayload(),
name: importName.value.trim() || t('DATA_IMPORTS.DEFAULT_IMPORT_NAME'),
});
useAlert(t('DATA_IMPORTS.ALERTS.IMPORT_STARTED'));
emit('created', response.data.id);
} catch (error) {
useAlert(
error?.response?.data?.message || t('DATA_IMPORTS.ALERTS.IMPORT_FAILED')
);
} finally {
isCreating.value = false;
}
};
watch(accessToken, invalidateValidation);
watch(selectedImportTypes, () => {
invalidateValidation();
if (accessToken.value.trim() && selectedImportTypes.value.length) {
validateSource();
}
});
watch(
() => props.show,
show => {
if (show) {
dialogRef.value?.open();
return;
}
dialogRef.value?.close();
accessToken.value = '';
validationState.value = 'idle';
validationMessage.value = '';
}
);
</script>
<template>
<Dialog
ref="dialogRef"
:title="$t('DATA_IMPORTS.DRAWER.TITLE')"
:confirm-button-label="$t('DATA_IMPORTS.DRAWER.IMPORT')"
:cancel-button-label="$t('DATA_IMPORTS.DRAWER.CANCEL')"
:disable-confirm-button="!canCreate"
:is-loading="isCreating || validationState === 'validating'"
width="md"
@confirm="createImport"
@close="closeDrawer"
>
<div class="flex flex-col gap-4">
<label class="flex flex-col gap-1.5 text-heading-3 text-n-slate-12">
{{ $t('DATA_IMPORTS.DRAWER.SOURCE') }}
<Select
v-model="sourceProvider"
class="!w-full [&>select]:w-full"
:options="sourceOptions"
/>
</label>
<Input
v-model="importName"
:label="$t('DATA_IMPORTS.DRAWER.NAME')"
:placeholder="$t('DATA_IMPORTS.DRAWER.NAME_PLACEHOLDER')"
/>
<Input
v-model="accessToken"
type="password"
autocomplete="off"
:label="$t('DATA_IMPORTS.DRAWER.ACCESS_KEY')"
:placeholder="$t('DATA_IMPORTS.DRAWER.ACCESS_KEY_PLACEHOLDER')"
:message="validationMessage"
:message-type="tokenMessageType"
@blur="validateSource"
/>
<fieldset class="flex flex-col gap-2.5">
<legend class="mb-1.5 text-heading-3 text-n-slate-12">
{{ $t('DATA_IMPORTS.DRAWER.DATA_TYPES') }}
</legend>
<label
class="inline-flex cursor-pointer items-center gap-2 text-body-main text-n-slate-12"
>
<Checkbox
:model-value="selectedImportTypes.includes('contacts')"
@change="toggleImportType('contacts')"
/>
{{ $t('DATA_IMPORTS.TYPES.CONTACTS') }}
</label>
<label
class="inline-flex cursor-pointer items-center gap-2 text-body-main text-n-slate-12"
>
<Checkbox
:model-value="selectedImportTypes.includes('conversations')"
@change="toggleImportType('conversations')"
/>
{{ $t('DATA_IMPORTS.TYPES.CONVERSATIONS') }}
</label>
</fieldset>
<p
v-if="hasActiveImport"
class="rounded-lg bg-n-amber-2 px-3 py-2 text-body-main text-n-amber-11"
>
{{ $t('DATA_IMPORTS.DRAWER.ACTIVE_IMPORT') }}
</p>
</div>
</Dialog>
</template>
@@ -0,0 +1,238 @@
<script setup>
import {
computed,
onActivated,
onBeforeUnmount,
onDeactivated,
ref,
} from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { useAlert } from 'dashboard/composables';
import DataImportsAPI from 'dashboard/api/dataImports';
import { POLL_INTERVAL_MS, isActiveImport } from './importStatus';
import SettingsLayout from '../SettingsLayout.vue';
import ImportDetailHeader from './components/ImportDetailHeader.vue';
import ImportSummaryTiles from './components/ImportSummaryTiles.vue';
import ImportProgress from './components/ImportProgress.vue';
import ImportErrorsSection from './components/ImportErrorsSection.vue';
import ImportSkipLogsSection from './components/ImportSkipLogsSection.vue';
const { t } = useI18n();
const route = useRoute();
const dataImport = ref(null);
const isLoading = ref(true);
const isRefreshing = ref(false);
const isPolling = ref(false);
const isAbandoning = ref(false);
const isDownloadingErrorLogs = ref(false);
const isDownloadingSkipLogs = ref(false);
const isChangingSkipLogsType = ref(false);
const selectedSkipLogsType = ref('');
const errorsOpen = ref(true);
const skipLogsOpen = ref(true);
let pollTimer;
let isPageActive = false;
const hasActiveImport = computed(() => isActiveImport(dataImport.value));
const stopPolling = () => {
if (!pollTimer) return;
window.clearInterval(pollTimer);
pollTimer = null;
};
const fetchImport = async ({
showLoader = false,
manual = false,
requestedSkipLogsType = selectedSkipLogsType.value,
} = {}) => {
if (showLoader) {
isLoading.value = true;
} else if (manual) {
isRefreshing.value = true;
}
try {
const response = await DataImportsAPI.show(route.params.dataImportId, {
skip_logs_type: requestedSkipLogsType || undefined,
});
dataImport.value = response.data;
selectedSkipLogsType.value =
response.data.skip_logs_filters?.selected_source_object_type ||
requestedSkipLogsType ||
'';
} finally {
if (showLoader) isLoading.value = false;
if (manual) isRefreshing.value = false;
if (!hasActiveImport.value) stopPolling();
}
};
const changeSkipLogsType = async type => {
if (type === selectedSkipLogsType.value || isChangingSkipLogsType.value) {
return;
}
selectedSkipLogsType.value = type;
isChangingSkipLogsType.value = true;
try {
await fetchImport({ requestedSkipLogsType: type });
} finally {
isChangingSkipLogsType.value = false;
}
};
const refreshImportInBackground = async () => {
if (
!isPageActive ||
isPolling.value ||
!hasActiveImport.value ||
document.hidden
) {
return;
}
isPolling.value = true;
try {
await fetchImport();
} finally {
isPolling.value = false;
if (!hasActiveImport.value) stopPolling();
}
};
const abandonImport = async () => {
isAbandoning.value = true;
try {
const response = await DataImportsAPI.abandon(dataImport.value.id);
dataImport.value = response.data;
stopPolling();
useAlert(t('DATA_IMPORTS.ALERTS.IMPORT_ABANDONED'));
} finally {
isAbandoning.value = false;
}
};
const downloadCsv = (response, filename) => {
const url = window.URL.createObjectURL(
new Blob([response.data], { type: 'text/csv' })
);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
window.URL.revokeObjectURL(url);
};
const downloadErrorLogs = async () => {
isDownloadingErrorLogs.value = true;
try {
const response = await DataImportsAPI.downloadErrorLogs(
dataImport.value.id
);
downloadCsv(response, `data-import-${dataImport.value.id}-error-logs.csv`);
} finally {
isDownloadingErrorLogs.value = false;
}
};
const downloadSkipLogs = async () => {
isDownloadingSkipLogs.value = true;
try {
const response = await DataImportsAPI.downloadSkipLogs(dataImport.value.id);
downloadCsv(response, `data-import-${dataImport.value.id}-skip-logs.csv`);
} finally {
isDownloadingSkipLogs.value = false;
}
};
const startPolling = () => {
stopPolling();
if (!isPageActive || !hasActiveImport.value) return;
pollTimer = window.setInterval(refreshImportInBackground, POLL_INTERVAL_MS);
};
const handleVisibilityChange = () => {
if (isPageActive && !document.hidden && hasActiveImport.value) {
refreshImportInBackground();
}
};
onActivated(async () => {
isPageActive = true;
await fetchImport({ showLoader: true });
if (!isPageActive) return;
// Collapse empty sections by default; expand the ones with records.
errorsOpen.value = Boolean(dataImport.value?.import_errors_count);
skipLogsOpen.value = Boolean(dataImport.value?.skip_logs_count);
startPolling();
document.addEventListener('visibilitychange', handleVisibilityChange);
});
onDeactivated(() => {
isPageActive = false;
stopPolling();
document.removeEventListener('visibilitychange', handleVisibilityChange);
});
onBeforeUnmount(() => {
isPageActive = false;
stopPolling();
document.removeEventListener('visibilitychange', handleVisibilityChange);
});
</script>
<template>
<SettingsLayout
:is-loading="isLoading"
:loading-message="$t('DATA_IMPORTS.LOADING')"
>
<template #header>
<ImportDetailHeader
:data-import="dataImport"
:is-refreshing="isRefreshing"
:is-abandoning="isAbandoning"
:is-polling="isPolling"
@refresh="fetchImport({ manual: true })"
@abandon="abandonImport"
/>
</template>
<template #body>
<div v-if="dataImport" class="flex flex-col gap-3">
<ImportSummaryTiles :data-import="dataImport" />
<ImportProgress
v-if="dataImport.import_types?.length"
:data-import="dataImport"
:title="$t('DATA_IMPORTS.DETAIL.PROGRESS')"
/>
<ImportErrorsSection
:data-import="dataImport"
:is-open="errorsOpen"
:is-downloading="isDownloadingErrorLogs"
@toggle="errorsOpen = !errorsOpen"
@download="downloadErrorLogs"
/>
<ImportSkipLogsSection
:data-import="dataImport"
:is-open="skipLogsOpen"
:is-downloading="isDownloadingSkipLogs"
:selected-type="selectedSkipLogsType"
:is-changing-type="isChangingSkipLogsType"
@toggle="skipLogsOpen = !skipLogsOpen"
@download="downloadSkipLogs"
@change-type="changeSkipLogsType"
/>
</div>
</template>
</SettingsLayout>
</template>
@@ -0,0 +1,128 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import BaseSettingsHeader from '../../components/BaseSettingsHeader.vue';
import {
POLL_INTERVAL_MS,
importStageKey,
isAbandonableImport,
isActiveImport,
statusDotClass as getStatusDotClass,
} from '../importStatus';
const props = defineProps({
dataImport: {
type: Object,
default: null,
},
isRefreshing: {
type: Boolean,
default: false,
},
isAbandoning: {
type: Boolean,
default: false,
},
isPolling: {
type: Boolean,
default: false,
},
});
defineEmits(['refresh', 'abandon']);
const { t } = useI18n();
const pollIntervalSeconds = POLL_INTERVAL_MS / 1000;
const title = computed(
() => props.dataImport?.name || t('DATA_IMPORTS.TABLE.UNNAMED')
);
const stageLabels = computed(() => ({
unknown: t('DATA_IMPORTS.MONITOR.STAGES.unknown'),
queued: t('DATA_IMPORTS.MONITOR.STAGES.queued'),
contacts: t('DATA_IMPORTS.MONITOR.STAGES.contacts'),
conversations: t('DATA_IMPORTS.MONITOR.STAGES.conversations'),
finalizing: t('DATA_IMPORTS.MONITOR.STAGES.finalizing'),
completed: t('DATA_IMPORTS.MONITOR.STAGES.completed'),
completed_with_errors: t('DATA_IMPORTS.MONITOR.STAGES.completed_with_errors'),
failed: t('DATA_IMPORTS.MONITOR.STAGES.failed'),
abandoned: t('DATA_IMPORTS.MONITOR.STAGES.abandoned'),
}));
const monitorTitle = computed(
() =>
stageLabels.value[importStageKey(props.dataImport)] ||
stageLabels.value.unknown
);
const statusDotClass = computed(() =>
getStatusDotClass(props.dataImport?.status)
);
const hasActiveImport = computed(() => isActiveImport(props.dataImport));
const canAbandonImport = computed(() => isAbandonableImport(props.dataImport));
</script>
<template>
<BaseSettingsHeader
:title="title"
:back-button-label="$t('DATA_IMPORTS.DETAIL.BACK')"
>
<template #title>
<div class="flex w-full items-center justify-between gap-4">
<h1 class="min-w-0 truncate text-heading-1 text-n-slate-12">
{{ title }}
</h1>
<div class="flex shrink-0 items-center gap-2">
<Button
v-if="hasActiveImport"
outline
slate
size="sm"
icon="i-lucide-refresh-cw"
:is-loading="isRefreshing"
:aria-label="$t('DATA_IMPORTS.MONITOR.REFRESH')"
:title="$t('DATA_IMPORTS.MONITOR.REFRESH')"
@click="$emit('refresh')"
/>
<Button
v-if="canAbandonImport"
ruby
size="sm"
:is-loading="isAbandoning"
:label="$t('DATA_IMPORTS.TABLE.ABANDON')"
@click="$emit('abandon')"
/>
</div>
</div>
</template>
<template #description>
<span class="inline-flex items-center gap-1.5 align-middle">
<span
class="size-2 rounded-full"
:class="[statusDotClass, { 'animate-pulse': hasActiveImport }]"
/>
{{ monitorTitle }}
</span>
<template v-if="hasActiveImport">
<span
class="mx-2 inline-block h-3 w-px rounded-lg bg-n-strong align-middle"
/>
<span class="text-n-teal-11">
{{
isPolling
? $t('DATA_IMPORTS.MONITOR.REFRESHING')
: $t('DATA_IMPORTS.MONITOR.LIVE', {
seconds: pollIntervalSeconds,
})
}}
</span>
</template>
</template>
</BaseSettingsHeader>
</template>
@@ -0,0 +1,78 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { BaseTableRow, BaseTableCell } from 'dashboard/components-next/table';
import ImportLogSection from './ImportLogSection.vue';
import { formatDate, sourceObjectLabel } from '../importStatus';
const props = defineProps({
dataImport: {
type: Object,
required: true,
},
isOpen: {
type: Boolean,
default: false,
},
isDownloading: {
type: Boolean,
default: false,
},
});
defineEmits(['toggle', 'download']);
const { t } = useI18n();
const errors = computed(() => props.dataImport?.import_errors || []);
const headers = computed(() => [
t('DATA_IMPORTS.DETAIL.ERROR_CODE'),
t('DATA_IMPORTS.DETAIL.SOURCE_OBJECT'),
t('DATA_IMPORTS.DETAIL.MESSAGE'),
t('DATA_IMPORTS.DETAIL.CREATED'),
]);
</script>
<template>
<ImportLogSection
:title="$t('DATA_IMPORTS.DETAIL.ERRORS')"
:count="dataImport.import_errors_count"
:is-open="isOpen"
:is-downloading="isDownloading"
:download-label="$t('DATA_IMPORTS.DETAIL.DOWNLOAD_ERROR_LOGS')"
:headers="headers"
:items="errors"
:empty-message="$t('DATA_IMPORTS.DETAIL.NO_ERRORS')"
@toggle="$emit('toggle')"
@download="$emit('download')"
>
<template #row="{ items }">
<BaseTableRow v-for="error in items" :key="error.id" :item="error">
<template #default>
<BaseTableCell>
<span class="text-body-main text-n-slate-12">
{{ error.error_code }}
</span>
</BaseTableCell>
<BaseTableCell>
<span class="text-body-main text-n-slate-12">
{{ sourceObjectLabel(error) }}
</span>
</BaseTableCell>
<BaseTableCell>
<span class="text-body-main text-n-slate-11">
{{ error.message || '-' }}
</span>
</BaseTableCell>
<BaseTableCell>
<span class="whitespace-nowrap text-body-main text-n-slate-11">
{{ formatDate(error.created_at) }}
</span>
</BaseTableCell>
</template>
</BaseTableRow>
</template>
</ImportLogSection>
</template>
@@ -0,0 +1,105 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import { BaseTable } from 'dashboard/components-next/table';
defineProps({
title: {
type: String,
required: true,
},
count: {
type: Number,
default: 0,
},
isOpen: {
type: Boolean,
default: false,
},
isDownloading: {
type: Boolean,
default: false,
},
downloadLabel: {
type: String,
default: '',
},
headers: {
type: Array,
default: () => [],
},
items: {
type: Array,
default: () => [],
},
emptyMessage: {
type: String,
default: '',
},
});
defineEmits(['toggle', 'download']);
</script>
<template>
<section class="overflow-hidden rounded-xl border border-n-weak bg-n-solid-1">
<div class="flex items-center justify-between gap-3 px-4 py-3">
<button
type="button"
class="flex min-w-0 items-center gap-2 !p-0"
:aria-expanded="isOpen"
@click="$emit('toggle')"
>
<h2 class="text-heading-3 text-n-slate-12">{{ title }}</h2>
<span
v-if="count"
class="rounded-md bg-n-alpha-2 px-1.5 text-label-small tabular-nums text-n-slate-11"
>
{{ count }}
</span>
<Icon
icon="i-lucide-chevron-down"
class="size-4 shrink-0 text-n-slate-10 transition-transform duration-200"
:class="{ '-rotate-90 rtl:rotate-90': !isOpen }"
/>
</button>
<Button
ghost
slate
xs
icon="i-lucide-download"
:is-loading="isDownloading"
:disabled="!count"
:label="downloadLabel"
@click="$emit('download')"
/>
</div>
<div
class="grid transition-[grid-template-rows] duration-300 ease-in-out"
:class="isOpen ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'"
>
<div class="min-h-0 overflow-hidden">
<div class="border-t border-n-weak">
<slot name="filters" />
<p
v-if="!items.length"
class="px-4 py-8 text-center text-body-main text-n-slate-11"
>
{{ emptyMessage }}
</p>
<div v-else class="overflow-x-auto">
<BaseTable
class="[&_td:first-child]:ps-4 [&_th:first-child]:ps-4 [&_th]:text-n-slate-11 [&_thead]:border-t-0"
:headers="headers"
:items="items"
>
<template #row="{ items: rows }">
<slot name="row" :items="rows" />
</template>
</BaseTable>
</div>
</div>
</div>
</div>
</section>
</template>
@@ -0,0 +1,100 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
const props = defineProps({
dataImport: {
type: Object,
required: true,
},
title: {
type: String,
required: true,
},
});
const { t } = useI18n();
const items = computed(() => {
const importTypes = props.dataImport?.import_types || [];
const groups = [];
if (importTypes.includes('contacts')) {
groups.push({ key: 'contacts', label: t('DATA_IMPORTS.TYPES.CONTACTS') });
}
if (importTypes.includes('conversations')) {
groups.push(
{ key: 'conversations', label: t('DATA_IMPORTS.TYPES.CONVERSATIONS') },
{ key: 'messages', label: t('DATA_IMPORTS.TYPES.MESSAGES') }
);
}
return groups.map(({ key, label }) => {
const stats = props.dataImport?.stats?.[key] || {};
const imported = Number(stats.imported || 0);
const hasTotal = Object.prototype.hasOwnProperty.call(stats, 'total');
const total = hasTotal ? Number(stats.total) : null;
const percent =
hasTotal && total > 0
? Math.min(100, Math.round((imported / total) * 100))
: null;
return {
key,
label,
percent,
importedLabel: imported.toLocaleString(),
caption: hasTotal
? t('DATA_IMPORTS.DETAIL.PROGRESS_OF_TOTAL', {
total: total.toLocaleString(),
})
: t('DATA_IMPORTS.DETAIL.PROGRESS_IMPORTED'),
};
});
});
// Fit the grid to the number of groups so no empty cells show.
const columnsClass = computed(() => {
if (items.value.length >= 3) return 'sm:grid-cols-3';
if (items.value.length === 2) return 'sm:grid-cols-2';
return 'sm:grid-cols-1';
});
</script>
<template>
<section class="overflow-hidden rounded-xl border border-n-weak bg-n-solid-1">
<h2 class="border-b border-n-weak px-4 py-3 text-heading-3 text-n-slate-12">
{{ title }}
</h2>
<div class="grid grid-cols-1 gap-px bg-n-weak" :class="columnsClass">
<div
v-for="item in items"
:key="item.key"
class="flex flex-col gap-2 bg-n-solid-1 px-4 py-3"
>
<span class="text-label-small text-n-slate-11">{{ item.label }}</span>
<div class="flex items-end justify-between gap-2">
<span
class="text-xl font-semibold tracking-tight tabular-nums text-n-slate-12"
>
{{ item.importedLabel }}
</span>
<span
v-if="item.percent !== null"
class="text-label-small tabular-nums text-n-slate-11"
>
{{ `${item.percent}%` }}
</span>
</div>
<div
v-if="item.percent !== null"
class="h-1.5 w-full overflow-hidden rounded-full bg-n-alpha-2"
>
<div
class="h-full rounded-full bg-n-brand transition-all duration-500"
:style="{ width: `${item.percent}%` }"
/>
</div>
<span class="text-label-small text-n-slate-10">{{ item.caption }}</span>
</div>
</div>
</section>
</template>
@@ -0,0 +1,127 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import { BaseTableRow, BaseTableCell } from 'dashboard/components-next/table';
import ImportLogSection from './ImportLogSection.vue';
import { formatDate, sourceObjectLabel } from '../importStatus';
const props = defineProps({
dataImport: {
type: Object,
required: true,
},
isOpen: {
type: Boolean,
default: false,
},
isDownloading: {
type: Boolean,
default: false,
},
selectedType: {
type: String,
default: '',
},
isChangingType: {
type: Boolean,
default: false,
},
});
defineEmits(['toggle', 'download', 'changeType']);
const { t } = useI18n();
const skipLogs = computed(() => props.dataImport?.skip_logs || []);
const headers = computed(() => [
t('DATA_IMPORTS.DETAIL.KIND'),
t('DATA_IMPORTS.DETAIL.SOURCE_OBJECT'),
t('DATA_IMPORTS.DETAIL.MESSAGE'),
t('DATA_IMPORTS.DETAIL.CREATED'),
]);
const typeOptions = computed(() => {
const counts = props.dataImport?.skip_logs_filters?.counts_by_type || {};
return [
{
value: '',
label: t('DATA_IMPORTS.DETAIL.ALL_SKIP_LOGS'),
count: props.dataImport?.skip_logs_count || 0,
},
{
value: 'contact',
label: t('DATA_IMPORTS.TYPES.CONTACTS'),
count: counts.contact || 0,
},
{
value: 'conversation',
label: t('DATA_IMPORTS.TYPES.CONVERSATIONS'),
count: counts.conversation || 0,
},
{
value: 'message',
label: t('DATA_IMPORTS.TYPES.MESSAGES'),
count: counts.message || 0,
},
];
});
</script>
<template>
<ImportLogSection
:title="$t('DATA_IMPORTS.DETAIL.SKIP_LOGS')"
:count="dataImport.skip_logs_count"
:is-open="isOpen"
:is-downloading="isDownloading"
:download-label="$t('DATA_IMPORTS.DETAIL.DOWNLOAD_SKIP_LOGS')"
:headers="headers"
:items="skipLogs"
:empty-message="$t('DATA_IMPORTS.DETAIL.NO_SKIP_LOGS')"
@toggle="$emit('toggle')"
@download="$emit('download')"
>
<template v-if="dataImport.skip_logs_count" #filters>
<div class="flex flex-wrap gap-2 border-b border-n-weak px-4 py-3">
<Button
v-for="option in typeOptions"
:key="option.value || 'all'"
:variant="option.value === selectedType ? 'solid' : 'faded'"
color="slate"
size="xs"
:disabled="!option.count || isChangingType"
:label="`${option.label} (${option.count})`"
@click="$emit('changeType', option.value)"
/>
</div>
</template>
<template #row="{ items }">
<BaseTableRow v-for="skipLog in items" :key="skipLog.id" :item="skipLog">
<template #default>
<BaseTableCell>
<span class="capitalize text-body-main text-n-slate-12">
{{ skipLog.kind || '-' }}
</span>
</BaseTableCell>
<BaseTableCell>
<span class="text-body-main text-n-slate-11">
{{ sourceObjectLabel(skipLog) }}
</span>
</BaseTableCell>
<BaseTableCell>
<span class="text-body-main text-n-slate-11">
{{ skipLog.message || '-' }}
</span>
</BaseTableCell>
<BaseTableCell>
<span class="whitespace-nowrap text-body-main text-n-slate-11">
{{ formatDate(skipLog.created_at) }}
</span>
</BaseTableCell>
</template>
</BaseTableRow>
</template>
</ImportLogSection>
</template>
@@ -0,0 +1,110 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import formatDistanceStrict from 'date-fns/formatDistanceStrict';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import { formatDate, isActiveImport } from '../importStatus';
import { importSourceFor } from '../importSources';
const props = defineProps({
dataImport: {
type: Object,
required: true,
},
});
const { t } = useI18n();
const importTypeLabel = type => {
if (type === 'contacts') return t('DATA_IMPORTS.TYPES.CONTACTS');
if (type === 'conversations') return t('DATA_IMPORTS.TYPES.CONVERSATIONS');
return type;
};
const importTypesLabel = computed(() => {
const importTypes = props.dataImport?.import_types?.length
? props.dataImport.import_types
: [props.dataImport?.data_type].filter(Boolean);
return importTypes.map(importTypeLabel).join(', ');
});
const runDuration = computed(() => {
const startedAt =
props.dataImport?.started_at || props.dataImport?.created_at;
if (!startedAt) return '-';
const finishedAt =
props.dataImport?.completed_at ||
props.dataImport?.abandoned_at ||
(isActiveImport(props.dataImport)
? new Date()
: props.dataImport?.updated_at);
if (!finishedAt) return '-';
return formatDistanceStrict(new Date(startedAt), new Date(finishedAt));
});
const items = computed(() => [
{
key: 'source',
icon: 'i-lucide-plug',
label: t('DATA_IMPORTS.DETAIL.SOURCE'),
value: importSourceFor(props.dataImport).label,
},
{
key: 'import_types',
icon: 'i-lucide-layers',
label: t('DATA_IMPORTS.DETAIL.IMPORT_TYPES'),
value: importTypesLabel.value || '-',
},
{
key: 'created_at',
icon: 'i-lucide-calendar',
label: t('DATA_IMPORTS.DETAIL.CREATED'),
value: formatDate(props.dataImport?.created_at),
},
{
key: 'duration',
icon: 'i-lucide-clock',
label: t('DATA_IMPORTS.DETAIL.DURATION'),
value: runDuration.value,
tooltip: t('DATA_IMPORTS.DETAIL.LAST_UPDATED_TOOLTIP', {
time: formatDate(props.dataImport?.updated_at),
}),
},
{
key: 'initiated_by',
icon: 'i-lucide-user',
label: t('DATA_IMPORTS.DETAIL.INITIATED_BY'),
value:
props.dataImport?.initiated_by?.name ||
props.dataImport?.initiated_by?.email ||
'-',
},
]);
</script>
<template>
<dl
class="grid grid-cols-2 gap-px overflow-hidden rounded-xl border border-n-weak bg-n-weak sm:grid-cols-3 lg:grid-cols-5"
>
<div
v-for="item in items"
:key="item.key"
class="flex min-w-0 flex-col gap-1 bg-n-solid-1 px-4 py-3"
>
<dt class="flex items-center gap-1.5 text-label-small text-n-slate-10">
<Icon :icon="item.icon" class="size-3.5 shrink-0" />
{{ item.label }}
</dt>
<dd
v-tooltip.top="item.tooltip"
class="truncate text-heading-3 text-n-slate-12"
>
{{ item.value }}
</dd>
</div>
</dl>
</template>
@@ -0,0 +1,34 @@
import { FEATURE_FLAGS } from '../../../../featureFlags';
import { frontendURL } from '../../../../helper/URLHelper';
import SettingsWrapper from '../SettingsWrapper.vue';
import Index from './Index.vue';
import Show from './Show.vue';
export default {
routes: [
{
path: frontendURL('accounts/:accountId/settings/data'),
component: SettingsWrapper,
children: [
{
path: '',
name: 'settings_data_imports',
component: Index,
meta: {
featureFlag: FEATURE_FLAGS.DATA_IMPORT,
permissions: ['administrator'],
},
},
{
path: ':dataImportId',
name: 'settings_data_import_show',
component: Show,
meta: {
featureFlag: FEATURE_FLAGS.DATA_IMPORT,
permissions: ['administrator'],
},
},
],
},
],
};
@@ -0,0 +1,17 @@
export const IMPORT_SOURCES = [
{
value: 'intercom',
label: 'Intercom',
icon: '/dashboard/images/integrations/intercom.png',
},
];
const DEFAULT_IMPORT_SOURCE = {
value: 'file',
label: 'File import',
iconClass: 'i-lucide-file-text',
};
export const importSourceFor = dataImport =>
IMPORT_SOURCES.find(source => source.value === dataImport?.source_provider) ||
DEFAULT_IMPORT_SOURCE;
@@ -0,0 +1,84 @@
export const POLL_INTERVAL_MS = 5000;
export const ACTIVE_IMPORT_STATUSES = ['pending', 'processing'];
export const isActiveImport = dataImport =>
ACTIVE_IMPORT_STATUSES.includes(dataImport?.status);
export const isIntercomImport = dataImport =>
dataImport?.data_type === 'intercom' &&
dataImport?.source_provider === 'intercom';
export const isActiveIntercomImport = dataImport =>
isIntercomImport(dataImport) && isActiveImport(dataImport);
export const isAbandonableImport = dataImport =>
isActiveIntercomImport(dataImport);
export const importedCount = dataImport => {
if (!isIntercomImport(dataImport)) {
return Number(dataImport?.processed_records || 0);
}
return ['contacts', 'conversations', 'messages'].reduce(
(total, key) => total + Number(dataImport?.stats?.[key]?.imported || 0),
0
);
};
export const importStageKey = dataImport => {
if (!dataImport) return 'unknown';
if (dataImport.status === 'completed') return 'completed';
if (dataImport.status === 'completed_with_errors') {
return 'completed_with_errors';
}
if (dataImport.status === 'failed') return 'failed';
if (dataImport.status === 'abandoned') return 'abandoned';
if (dataImport.status === 'pending') return 'queued';
const importTypes = dataImport.import_types?.length
? dataImport.import_types
: [dataImport.data_type];
const cursor = dataImport.cursor || {};
if (importTypes.includes('contacts') && !cursor.contacts?.completed) {
return 'contacts';
}
if (
importTypes.includes('conversations') &&
!cursor.conversations?.completed
) {
return 'conversations';
}
return 'finalizing';
};
export const formatStatus = value => value?.replaceAll('_', ' ') || '-';
export const sourceObjectLabel = record =>
[record.source_object_type, record.source_object_id]
.filter(Boolean)
.join(': ') || '-';
export const formatDate = value => {
if (!value) return '-';
return new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(value));
};
const STATUS_DOT_CLASS = {
pending: 'bg-n-amber-9',
processing: 'bg-n-blue-9',
completed: 'bg-n-teal-9',
completed_with_errors: 'bg-n-amber-9',
failed: 'bg-n-ruby-9',
abandoned: 'bg-n-slate-9',
};
export const statusDotClass = status =>
STATUS_DOT_CLASS[status] || 'bg-n-slate-9';
@@ -0,0 +1,92 @@
import {
formatDate,
importedCount,
isActiveIntercomImport,
statusDotClass,
} from '../importStatus';
describe('importStatus', () => {
describe('isActiveIntercomImport', () => {
it('only treats pending or processing Intercom imports as active', () => {
expect(
isActiveIntercomImport({
data_type: 'intercom',
source_provider: 'intercom',
status: 'processing',
})
).toBe(true);
expect(
isActiveIntercomImport({
data_type: 'contacts',
source_provider: null,
status: 'processing',
})
).toBe(false);
expect(
isActiveIntercomImport({
data_type: 'intercom',
source_provider: 'intercom',
status: 'completed',
})
).toBe(false);
});
});
describe('importedCount', () => {
it('sums Intercom imported stats', () => {
expect(
importedCount({
data_type: 'intercom',
source_provider: 'intercom',
processed_records: 20,
stats: {
contacts: { imported: 2 },
conversations: { imported: 3 },
messages: { imported: 10 },
},
})
).toBe(15);
});
it('uses processed records for legacy imports', () => {
expect(
importedCount({
data_type: 'contacts',
source_provider: null,
processed_records: 7,
stats: {},
})
).toBe(7);
});
});
describe('statusDotClass', () => {
it('maps each status to its dot color class', () => {
expect(statusDotClass('pending')).toBe('bg-n-amber-9');
expect(statusDotClass('processing')).toBe('bg-n-blue-9');
expect(statusDotClass('completed')).toBe('bg-n-teal-9');
expect(statusDotClass('completed_with_errors')).toBe('bg-n-amber-9');
expect(statusDotClass('failed')).toBe('bg-n-ruby-9');
expect(statusDotClass('abandoned')).toBe('bg-n-slate-9');
});
it('falls back to slate for unknown or missing status', () => {
expect(statusDotClass('unknown')).toBe('bg-n-slate-9');
expect(statusDotClass(undefined)).toBe('bg-n-slate-9');
});
});
describe('formatDate', () => {
it('returns a dash for empty values', () => {
expect(formatDate(null)).toBe('-');
expect(formatDate('')).toBe('-');
expect(formatDate(undefined)).toBe('-');
});
it('formats a valid date into a readable string', () => {
const formatted = formatDate('2026-07-10T18:09:00Z');
expect(formatted).not.toBe('-');
expect(formatted).toContain('2026');
});
});
});
@@ -0,0 +1,121 @@
import { flushPromises, mount } from '@vue/test-utils';
import { KeepAlive, defineComponent, h, nextTick, ref } from 'vue';
import DataImportsAPI from 'dashboard/api/dataImports';
import Index from '../Index.vue';
import Show from '../Show.vue';
vi.mock('dashboard/api/dataImports', () => ({
default: {
get: vi.fn(),
show: vi.fn(),
},
}));
vi.mock('dashboard/composables/store', () => ({
useStoreGetters: () => ({ getCurrentAccountId: { value: 1 } }),
}));
vi.mock('dashboard/composables', () => ({
useAlert: vi.fn(),
}));
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: key => key }),
}));
vi.mock('vue-router', async importOriginal => ({
...(await importOriginal()),
useRoute: () => ({ params: { dataImportId: 1 } }),
useRouter: () => ({ push: vi.fn() }),
}));
const deferredRequest = () => {
let resolve;
const promise = new Promise(resolvePromise => {
resolve = resolvePromise;
});
return { promise, resolve };
};
const mountKeptAlive = component => {
const Host = defineComponent({
setup() {
const visible = ref(true);
return { visible };
},
render() {
return h(KeepAlive, null, {
default: () => (this.visible ? h(component) : null),
});
},
});
return mount(Host, {
global: {
stubs: {
SettingsLayout: true,
BaseSettingsHeader: true,
Button: true,
Icon: true,
TabBar: true,
NewImportDialog: true,
ImportDetailHeader: true,
ImportSummaryTiles: true,
ImportProgress: true,
ImportErrorsSection: true,
ImportSkipLogsSection: true,
},
mocks: {
$t: key => key,
},
},
});
};
describe('data import polling lifecycle', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
});
it('does not start list polling after the page deactivates', async () => {
const request = deferredRequest();
DataImportsAPI.get.mockReturnValue(request.promise);
const wrapper = mountKeptAlive(Index);
await nextTick();
wrapper.vm.visible = false;
await nextTick();
request.resolve({ data: { payload: [{ status: 'processing' }] } });
await flushPromises();
await vi.advanceTimersByTimeAsync(5000);
expect(DataImportsAPI.get).toHaveBeenCalledTimes(1);
wrapper.unmount();
});
it('does not start detail polling after the page deactivates', async () => {
const request = deferredRequest();
DataImportsAPI.show.mockReturnValue(request.promise);
const wrapper = mountKeptAlive(Show);
await nextTick();
wrapper.vm.visible = false;
await nextTick();
request.resolve({
data: {
status: 'processing',
skip_logs_filters: {},
},
});
await flushPromises();
await vi.advanceTimersByTimeAsync(5000);
expect(DataImportsAPI.show).toHaveBeenCalledTimes(1);
wrapper.unmount();
});
});
@@ -1,5 +1,5 @@
<script setup>
import { computed, ref } from 'vue';
import { computed, onActivated, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { picoSearch } from '@scmmishra/pico-search';
@@ -27,6 +27,10 @@ const searchQuery = ref('');
const inboxes = useMapGetter('inboxes/getInboxes');
onActivated(() => {
store.dispatch('inboxes/get');
});
const inboxesList = computed(() => {
return inboxes.value?.slice().sort((a, b) => a.name.localeCompare(b.name));
});
@@ -26,6 +26,7 @@ import profile from './profile/profile.routes';
import security from './security/security.routes';
import conversationWorkflow from './conversationWorkflow/conversationWorkflow.routes';
import captain from './captain/captain.routes';
import data from './data/data.routes';
export default {
routes: [
@@ -57,6 +58,7 @@ export default {
...canned.routes,
...inbox.routes,
...integrations.routes,
...data.routes,
...labels.routes,
...macros.routes,
...reports.routes,