feat: UI changes for document auto sync [AI-153] (#14258)

# Pull Request Template

## Description

FE code for document sync

Adds:
- UI to show counts (stats) of available web pages, stale and synced
documents and last synced at
- Bulk action and manual ways to sync web documents
- index to stats related columns

## Type of change

Please delete options that are not relevant.

- [x] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.

https://linear.app/chatwoot/issue/AI-153/fe-document-auto-sync

Documents dashboard:
<img width="2160" height="986" alt="CleanShot 2026-05-11 at 17 57 09@2x"
src="https://github.com/user-attachments/assets/6d934764-964c-4656-b005-1b4f0329e553"
/>

Filters:
<img width="1138" height="564" alt="CleanShot 2026-05-11 at 17 58 13@2x"
src="https://github.com/user-attachments/assets/cee780e6-eb8f-4aed-8cc5-b674244a821b"
/>

Needs update:
<img width="2222" height="966" alt="CleanShot 2026-05-11 at 17 57 53@2x"
src="https://github.com/user-attachments/assets/70c85ddd-7eb1-4328-ba14-7929e67e7b36"
/>

pdfs:
<img width="2180" height="558" alt="CleanShot 2026-05-11 at 17 58 30@2x"
src="https://github.com/user-attachments/assets/975b5c9f-bd1c-4979-9870-8f926d7f6e11"
/>

bulk actions:
<img width="2244" height="992" alt="CleanShot 2026-05-11 at 17 58 57@2x"
src="https://github.com/user-attachments/assets/bdb3c63f-d2de-41dc-a6d5-8821d3303be0"
/>

single url sync:
<img width="2264" height="722" alt="CleanShot 2026-05-11 at 17 59 19@2x"
src="https://github.com/user-attachments/assets/7d7323a5-0fcb-4be9-8635-55e56964999b"
/>



## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>
This commit is contained in:
Aakash Bakhle
2026-05-11 20:13:29 +05:30
committed by GitHub
co-authored by Sivin Varghese iamsivin Muhsin Keloth Sony Mathew Vishnu Narayanan
parent 3489298726
commit f6be0d80ef
25 changed files with 1155 additions and 101 deletions
@@ -0,0 +1,113 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
import BulkDeleteDialog from 'dashboard/components-next/captain/pageComponents/BulkDeleteDialog.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
selectedIds: { type: Set, default: () => new Set() },
documents: { type: Array, default: () => [] },
});
const emit = defineEmits([
'update:selectedIds',
'bulkSyncQueued',
'bulkDeleteSucceeded',
]);
const { t } = useI18n();
const store = useStore();
const bulkDeleteDialog = ref(null);
const isSyncableDocument = doc =>
!doc.pdf_document && doc.status === 'available' && !doc.sync_in_progress;
const syncableSelectedIds = computed(() => {
if (!props.selectedIds.size) return [];
return props.documents
.filter(doc => props.selectedIds.has(doc.id) && isSyncableDocument(doc))
.map(doc => doc.id);
});
const hasSyncableSelection = computed(
() => syncableSelectedIds.value.length > 0
);
const selectAllLabel = computed(() => {
const count = props.documents.length;
const isAllSelected = props.selectedIds.size === count && count > 0;
return isAllSelected
? t('CAPTAIN.DOCUMENTS.UNSELECT_ALL', { count })
: t('CAPTAIN.DOCUMENTS.SELECT_ALL', { count });
});
const selectedCountLabel = computed(() =>
t('CAPTAIN.DOCUMENTS.SELECTED', { count: props.selectedIds.size })
);
const handleBulkSync = async () => {
const ids = syncableSelectedIds.value;
if (!ids.length) return;
try {
const response = await store.dispatch('captainBulkActions/handleBulkSync', {
ids,
});
const queuedCount = response?.count ?? response?.ids?.length ?? 0;
let message = t('CAPTAIN.DOCUMENTS.BULK_SYNC.ZERO_MESSAGE');
if (queuedCount === 1) {
message = t('CAPTAIN.DOCUMENTS.BULK_SYNC.SUCCESS_MESSAGE_ONE');
} else if (queuedCount > 1) {
message = t('CAPTAIN.DOCUMENTS.BULK_SYNC.SUCCESS_MESSAGE', {
count: queuedCount,
});
}
useAlert(message);
emit('update:selectedIds', new Set());
if (queuedCount > 0) emit('bulkSyncQueued');
} catch (error) {
useAlert(t('CAPTAIN.DOCUMENTS.BULK_SYNC.ERROR_MESSAGE'));
}
};
</script>
<template>
<div>
<BulkSelectBar
:model-value="selectedIds"
:all-items="documents"
:select-all-label="selectAllLabel"
:selected-count-label="selectedCountLabel"
:delete-label="$t('CAPTAIN.DOCUMENTS.BULK_DELETE_BUTTON')"
class="w-fit"
:class="{ 'mb-2': selectedIds.size > 0 }"
@update:model-value="emit('update:selectedIds', $event)"
@bulk-delete="bulkDeleteDialog.dialogRef.open()"
>
<template v-if="hasSyncableSelection" #secondaryActions>
<Button
:label="$t('CAPTAIN.DOCUMENTS.BULK_SYNC_BUTTON')"
sm
slate
ghost
icon="i-lucide-refresh-cw"
class="!px-1.5"
@click="handleBulkSync"
/>
</template>
</BulkSelectBar>
<BulkDeleteDialog
ref="bulkDeleteDialog"
:bulk-ids="selectedIds"
type="AssistantDocument"
@delete-success="emit('bulkDeleteSucceeded')"
/>
</div>
</template>
@@ -5,14 +5,17 @@ import { useI18n } from 'vue-i18n';
import { dynamicTime } from 'shared/helpers/timeHelper';
import { usePolicy } from 'dashboard/composables/usePolicy';
import {
isPdfDocument,
isSafeHttpLink,
formatDocumentLink,
getDocumentDisplayPath,
} from 'shared/helpers/documentHelper';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import DocumentSyncStatus from 'dashboard/components-next/captain/assistant/DocumentSyncStatus.vue';
const props = defineProps({
id: {
@@ -31,10 +34,38 @@ const props = defineProps({
type: String,
required: true,
},
pdfDocument: {
type: Boolean,
default: false,
},
createdAt: {
type: Number,
required: true,
},
status: {
type: String,
default: null,
},
syncStatus: {
type: String,
default: null,
},
lastSyncedAt: {
type: Number,
default: null,
},
lastSyncErrorCode: {
type: String,
default: null,
},
syncInProgress: {
type: Boolean,
default: false,
},
syncStaleAfterHours: {
type: Number,
default: null,
},
isSelected: {
type: Boolean,
default: false,
@@ -64,6 +95,20 @@ const modelValue = computed({
set: () => emit('select', props.id),
});
const isPdf = computed(() => props.pdfDocument);
const hasSafeLink = computed(() => isSafeHttpLink(props.externalLink));
const canManage = computed(() => checkPermissions(['administrator']));
const isAvailable = computed(() => props.status === 'available');
const canSync = computed(
() => canManage.value && !isPdf.value && isAvailable.value
);
const isSyncing = computed(() => props.syncStatus === 'syncing');
const isFailed = computed(() => props.syncStatus === 'failed');
const isRetryableSync = computed(
() => isFailed.value || (isSyncing.value && !props.syncInProgress)
);
const showSyncStatus = computed(() => !isPdf.value);
const menuItems = computed(() => {
const allOptions = [
{
@@ -74,7 +119,19 @@ const menuItems = computed(() => {
},
];
if (checkPermissions(['administrator'])) {
if (canSync.value) {
allOptions.push({
label: isRetryableSync.value
? t('CAPTAIN.DOCUMENTS.OPTIONS.RETRY_SYNC')
: t('CAPTAIN.DOCUMENTS.OPTIONS.SYNC_NOW'),
value: 'sync',
action: 'sync',
icon: 'i-lucide-refresh-cw',
disabled: props.syncInProgress,
});
}
if (canManage.value) {
allOptions.push({
label: t('CAPTAIN.DOCUMENTS.OPTIONS.DELETE_DOCUMENT'),
value: 'delete',
@@ -86,17 +143,25 @@ const menuItems = computed(() => {
return allOptions;
});
const createdAt = computed(() => dynamicTime(props.createdAt));
const createdAtLabel = computed(() => dynamicTime(props.createdAt));
const displayLink = computed(() => formatDocumentLink(props.externalLink));
const displayLink = computed(() =>
isPdf.value
? formatDocumentLink(props.externalLink)
: getDocumentDisplayPath(props.externalLink)
);
const linkIcon = computed(() =>
isPdfDocument(props.externalLink) ? 'i-ph-file-pdf' : 'i-ph-link-simple'
isPdf.value ? 'i-ph-file-pdf' : 'i-ph-link-simple'
);
const handleAction = ({ action, value }) => {
toggleDropdown(false);
emit('action', { action, value, id: props.id });
};
const handleRetry = () => {
emit('action', { action: 'sync', id: props.id });
};
</script>
<template>
@@ -141,17 +206,41 @@ const handleAction = ({ action, value }) => {
<span
class="flex gap-1 items-center text-sm truncate shrink-0 text-n-slate-11"
>
<i class="i-woot-captain" />
<Icon icon="i-woot-captain" />
{{ assistant?.name || '' }}
</span>
<a
v-if="!isPdf && hasSafeLink"
:href="externalLink"
:title="externalLink"
target="_blank"
rel="noopener noreferrer"
class="flex flex-1 gap-1 justify-start items-center text-sm truncate text-n-slate-11 hover:text-n-slate-12 hover:underline"
@click.stop
>
<Icon :icon="linkIcon" class="shrink-0" />
<span class="truncate">{{ displayLink }}</span>
<Icon icon="i-lucide-external-link size-3 shrink-0 opacity-70" />
</a>
<span
v-else
class="flex flex-1 gap-1 justify-start items-center text-sm truncate text-n-slate-11"
>
<i :class="linkIcon" class="shrink-0" />
<Icon :icon="linkIcon" class="shrink-0" />
<span class="truncate">{{ displayLink }}</span>
</span>
<div class="text-sm shrink-0 text-n-slate-11 line-clamp-1">
{{ createdAt }}
<DocumentSyncStatus
v-if="showSyncStatus"
:status="syncStatus"
:last-synced-at="lastSyncedAt"
:error-code="lastSyncErrorCode"
:sync-in-progress="syncInProgress"
:stale-after-hours="syncStaleAfterHours"
:show-retry="canSync && isRetryableSync"
@retry="handleRetry"
/>
<div v-else class="text-sm shrink-0 text-n-slate-11 line-clamp-1">
{{ createdAtLabel }}
</div>
</div>
</CardLayout>
@@ -0,0 +1,76 @@
<script setup>
import { computed, ref } from 'vue';
import { debounce } from '@chatwoot/utils';
import DocumentFiltersBar from 'dashboard/components-next/captain/assistant/DocumentFiltersBar.vue';
const emit = defineEmits(['change']);
const source = ref('all');
const status = ref(null);
const sort = ref('recently_updated');
const query = ref('');
const hasActiveFilters = computed(
() =>
source.value !== 'all' ||
Boolean(status.value) ||
Boolean(query.value.trim())
);
const buildParams = (page = 1) => {
const params = { page };
if (source.value !== 'all') params.source = source.value;
if (status.value) params.filter = status.value;
if (sort.value) params.sort = sort.value;
if (query.value.trim()) params.searchKey = query.value.trim();
return params;
};
const emitChange = () => emit('change');
const debouncedEmitChange = debounce(emitChange, 300);
const handleSourceSelect = sourceKey => {
source.value = sourceKey;
if (sourceKey !== 'web') status.value = null;
emitChange();
};
const handleStatusSelect = statusKey => {
status.value = statusKey;
if (statusKey) source.value = 'web';
emitChange();
};
const handleSortSelect = sortKey => {
sort.value = sortKey;
emitChange();
};
const handleSearch = value => {
query.value = value;
debouncedEmitChange();
};
const reset = () => {
source.value = 'all';
status.value = null;
sort.value = 'recently_updated';
query.value = '';
};
defineExpose({ buildParams, reset, hasActiveFilters });
</script>
<template>
<DocumentFiltersBar
:active-source-filter="source"
:active-status-filter="status"
:active-sort="sort"
:search-query="query"
@select-source="handleSourceSelect"
@select-status="handleStatusSelect"
@select-sort="handleSortSelect"
@search="handleSearch"
/>
</template>
@@ -0,0 +1,165 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const props = defineProps({
activeSourceFilter: { type: String, default: 'all' },
activeStatusFilter: { type: String, default: null },
activeSort: { type: String, default: 'recently_updated' },
searchQuery: { type: String, default: '' },
});
const emit = defineEmits([
'selectSource',
'selectStatus',
'selectSort',
'search',
]);
const { t } = useI18n();
const openMenu = ref(null);
const MENU_CONFIG = [
{
key: 'source',
activeKey: 'activeSourceFilter',
dropdownClass: 'min-w-48',
options: [
{ labelKey: 'SOURCE.ALL', value: 'all', icon: 'i-lucide-files' },
{ labelKey: 'SOURCE.WEB', value: 'web', icon: 'i-lucide-link' },
{ labelKey: 'SOURCE.PDF', value: 'pdf', icon: 'i-lucide-file-text' },
],
},
{
key: 'status',
activeKey: 'activeStatusFilter',
dropdownClass: 'min-w-52',
options: [
{ labelKey: 'STATUS.ANY', value: null, icon: 'i-lucide-circle-dashed' },
{
labelKey: 'STATUS.UPDATED',
value: 'synced',
icon: 'i-lucide-check-circle',
},
{
labelKey: 'STATUS.NEEDS_UPDATE',
value: 'stale',
icon: 'i-lucide-clock',
},
{
labelKey: 'STATUS.UPDATING',
value: 'syncing',
icon: 'i-lucide-refresh-cw',
},
{
labelKey: 'STATUS.FAILED',
value: 'failed',
icon: 'i-lucide-circle-x',
},
],
},
{
key: 'sort',
activeKey: 'activeSort',
dropdownClass: 'min-w-56',
options: [
{
labelKey: 'SORT.RECENTLY_UPDATED',
value: 'recently_updated',
icon: 'i-lucide-arrow-down-up',
},
{
labelKey: 'SORT.RECENTLY_CREATED',
value: 'recently_created',
icon: 'i-lucide-clock',
},
],
},
];
const filterMenus = computed(() =>
MENU_CONFIG.filter(
menu => !(menu.key === 'status' && props.activeSourceFilter === 'pdf')
).map(menu => {
const active = props[menu.activeKey];
const items = menu.options.map(opt => ({
label: t(`CAPTAIN.DOCUMENTS.FILTERS.${opt.labelKey}`),
value: opt.value,
icon: opt.icon,
action: menu.key,
isSelected: opt.value === active,
}));
return {
...menu,
items,
selected: items.find(item => item.isSelected) || items[0],
};
})
);
const closeMenu = () => {
openMenu.value = null;
};
const toggleMenu = menu => {
openMenu.value = openMenu.value === menu ? null : menu;
};
const handleMenuAction = ({ action, value }) => {
closeMenu();
if (action === 'source') emit('selectSource', value);
else if (action === 'status') emit('selectStatus', value);
else if (action === 'sort') emit('selectSort', value);
};
</script>
<template>
<div
v-on-clickaway="closeMenu"
class="flex flex-col gap-3 w-full lg:flex-row lg:items-center lg:justify-between"
>
<div class="flex flex-wrap items-center gap-2">
<div v-for="menu in filterMenus" :key="menu.key" class="relative">
<Button
:icon="menu.selected.icon"
slate
outline
:class="{ 'bg-n-slate-9/10': openMenu === menu.key }"
@click="toggleMenu(menu.key)"
>
<span class="min-w-0 truncate">{{ menu.selected.label }}</span>
<Icon icon="i-lucide-chevron-down" class="shrink-0 size-4" />
</Button>
<DropdownMenu
v-if="openMenu === menu.key"
:menu-items="menu.items"
:class="menu.dropdownClass"
class="top-full mt-2 ltr:left-0 rtl:right-0"
@action="handleMenuAction"
/>
</div>
</div>
<Input
:model-value="searchQuery"
type="search"
:placeholder="t('CAPTAIN.DOCUMENTS.FILTERS.SEARCH_PLACEHOLDER')"
:custom-input-class="['ltr:!pl-9 rtl:!pr-9']"
class="w-full lg:max-w-72"
@input="emit('search', $event.target.value)"
>
<template #prefix>
<Icon
icon="i-lucide-search"
class="absolute size-4 text-n-slate-11 top-1/2 -translate-y-1/2 ltr:left-3 rtl:right-3"
/>
</template>
</Input>
</div>
</template>
@@ -0,0 +1,153 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { dynamicTime } from 'shared/helpers/timeHelper';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const props = defineProps({
status: {
type: String,
default: null,
},
lastSyncedAt: {
type: Number,
default: null,
},
errorCode: {
type: String,
default: null,
},
syncInProgress: {
type: Boolean,
default: false,
},
staleAfterHours: {
type: Number,
default: null,
},
showRetry: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['retry']);
const { t } = useI18n();
const SECONDS_PER_HOUR = 3600;
const SYNCING = 'syncing';
const FAILED = 'failed';
const ERROR_CODE_LABELS = {
not_found: 'CAPTAIN.DOCUMENTS.SYNC_ERRORS.NOT_FOUND',
access_denied: 'CAPTAIN.DOCUMENTS.SYNC_ERRORS.ACCESS_DENIED',
timeout: 'CAPTAIN.DOCUMENTS.SYNC_ERRORS.TIMEOUT',
content_empty: 'CAPTAIN.DOCUMENTS.SYNC_ERRORS.CONTENT_EMPTY',
fetch_failed: 'CAPTAIN.DOCUMENTS.SYNC_ERRORS.FETCH_FAILED',
sync_error: 'CAPTAIN.DOCUMENTS.SYNC_ERRORS.SYNC_ERROR',
};
const DEFAULT_ERROR_LABEL = 'CAPTAIN.DOCUMENTS.SYNC_ERRORS.DEFAULT';
const hasSyncingStatus = computed(() => props.status === SYNCING);
const isSyncing = computed(
() => hasSyncingStatus.value && props.syncInProgress
);
const isStaleSync = computed(
() => hasSyncingStatus.value && !props.syncInProgress
);
const isFailed = computed(() => props.status === FAILED);
const canRetry = computed(() => isFailed.value || isStaleSync.value);
const hasBeenSynced = computed(() => Boolean(props.lastSyncedAt));
const ageInHours = computed(() => {
if (!props.lastSyncedAt) return null;
const nowSeconds = Date.now() / 1000;
return (nowSeconds - props.lastSyncedAt) / SECONDS_PER_HOUR;
});
const staleAfterHours = computed(() => Number(props.staleAfterHours));
const hasStaleThreshold = computed(
() => Number.isFinite(staleAfterHours.value) && staleAfterHours.value > 0
);
const isStale = computed(
() =>
hasStaleThreshold.value &&
ageInHours.value !== null &&
ageInHours.value >= staleAfterHours.value
);
const errorLabel = computed(() =>
t(ERROR_CODE_LABELS[props.errorCode] || DEFAULT_ERROR_LABEL)
);
const label = computed(() => {
if (isSyncing.value) return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.SYNCING');
if (isStaleSync.value) return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.STALE_SYNC');
if (isFailed.value) return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.FAILED');
if (hasBeenSynced.value)
return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.SYNCED', {
time: dynamicTime(props.lastSyncedAt),
});
return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.NEVER_SYNCED');
});
const fullLabel = computed(() => {
if (isSyncing.value) return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.SYNCING');
if (isStaleSync.value) return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.STALE_SYNC');
if (isFailed.value)
return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.FAILED', {
error: errorLabel.value,
});
if (hasBeenSynced.value)
return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.SYNCED', {
time: dynamicTime(props.lastSyncedAt),
});
return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.NEVER_SYNCED');
});
const tone = computed(() => {
if (isSyncing.value) return 'amber';
if (isStaleSync.value) return 'amber';
if (isFailed.value) return 'ruby';
if (isStale.value) return 'amber';
return 'slate';
});
const textClass = computed(() => {
if (tone.value === 'amber') return 'text-n-amber-11';
if (tone.value === 'ruby') return 'text-n-ruby-11';
return 'text-n-slate-11';
});
const statusIcon = computed(() => {
if (isFailed.value || isStale.value || isStaleSync.value) {
return 'i-lucide-circle-alert';
}
return 'i-lucide-refresh-cw';
});
</script>
<template>
<span
class="flex gap-1.5 items-center text-sm truncate shrink-0 tabular-nums"
:class="textClass"
:title="fullLabel"
>
<Spinner v-if="isSyncing" class="text-n-amber-11 size-3" />
<Icon v-else :icon="statusIcon" class="shrink-0 size-3.5" />
<span class="truncate">{{ label }}</span>
<Button
v-if="showRetry && canRetry"
:label="t('CAPTAIN.DOCUMENTS.OPTIONS.RETRY_SYNC')"
xs
link
ruby
icon="i-lucide-refresh-cw"
class="hover:!no-underline !gap-1 ms-1"
@click.stop="emit('retry')"
/>
</span>
</template>
@@ -15,7 +15,7 @@ defineProps({
},
});
const emit = defineEmits(['close']);
const emit = defineEmits(['close', 'createSuccess']);
const { t } = useI18n();
const store = useStore();
@@ -26,6 +26,7 @@ const i18nKey = 'CAPTAIN.DOCUMENTS.CREATE';
const handleSubmit = async newDocument => {
try {
await store.dispatch('captainDocuments/create', newDocument);
emit('createSuccess');
useAlert(t(`${i18nKey}.SUCCESS_MESSAGE`));
dialogRef.value.close();
} catch (error) {