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:
co-authored by
Sivin Varghese
iamsivin
Muhsin Keloth
Sony Mathew
Vishnu Narayanan
parent
3489298726
commit
f6be0d80ef
@@ -6,15 +6,22 @@ class CaptainDocument extends ApiClient {
|
||||
super('captain/documents', { accountScoped: true });
|
||||
}
|
||||
|
||||
get({ page = 1, searchKey, assistantId } = {}) {
|
||||
get({ page = 1, searchKey, assistantId, filter, source, sort } = {}) {
|
||||
return axios.get(this.url, {
|
||||
params: {
|
||||
page,
|
||||
searchKey,
|
||||
search_key: searchKey,
|
||||
assistant_id: assistantId,
|
||||
filter,
|
||||
source,
|
||||
sort,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
sync(id) {
|
||||
return axios.post(`${this.url}/${id}/sync`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new CaptainDocument();
|
||||
|
||||
@@ -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>
|
||||
+2
-1
@@ -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) {
|
||||
|
||||
@@ -41,6 +41,7 @@ export const FEATURE_FLAGS = {
|
||||
CAPTAIN_CUSTOM_TOOLS: 'custom_tools',
|
||||
CAPTAIN_V2: 'captain_integration_v2',
|
||||
CAPTAIN_TASKS: 'captain_tasks',
|
||||
CAPTAIN_DOCUMENT_AUTO_SYNC: 'captain_document_auto_sync',
|
||||
SAML: 'saml',
|
||||
QUOTED_EMAIL_REPLY: 'quoted_email_reply',
|
||||
COMPANIES: 'companies',
|
||||
|
||||
@@ -742,6 +742,7 @@
|
||||
"SELECT_ALL": "Select all ({count})",
|
||||
"UNSELECT_ALL": "Unselect all ({count})",
|
||||
"BULK_DELETE_BUTTON": "Delete",
|
||||
"BULK_SYNC_BUTTON": "Refresh",
|
||||
"BULK_DELETE": {
|
||||
"TITLE": "Delete documents?",
|
||||
"DESCRIPTION": "Are you sure you want to delete the selected documents? This action cannot be undone.",
|
||||
@@ -749,6 +750,51 @@
|
||||
"SUCCESS_MESSAGE": "Documents deleted successfully",
|
||||
"ERROR_MESSAGE": "There was an error deleting the documents, please try again."
|
||||
},
|
||||
"BULK_SYNC": {
|
||||
"SUCCESS_MESSAGE_ONE": "Refresh queued for 1 document",
|
||||
"SUCCESS_MESSAGE": "Refresh queued for {count} documents",
|
||||
"ZERO_MESSAGE": "No documents marked for refresh.",
|
||||
"ERROR_MESSAGE": "There was an error queuing the refresh, please try again."
|
||||
},
|
||||
"SYNC": {
|
||||
"QUEUED_MESSAGE": "Refresh queued. We'll update the document shortly.",
|
||||
"ERROR_MESSAGE": "Could not queue refresh, please try again."
|
||||
},
|
||||
"FILTERS": {
|
||||
"SOURCE": {
|
||||
"ALL": "All sources",
|
||||
"WEB": "Web pages",
|
||||
"PDF": "PDFs"
|
||||
},
|
||||
"STATUS": {
|
||||
"ANY": "Any status",
|
||||
"UPDATED": "Updated",
|
||||
"NEEDS_UPDATE": "Needs update",
|
||||
"UPDATING": "Updating",
|
||||
"FAILED": "Failed"
|
||||
},
|
||||
"SORT": {
|
||||
"RECENTLY_UPDATED": "Recently updated",
|
||||
"RECENTLY_CREATED": "Recently created"
|
||||
},
|
||||
"SEARCH_PLACEHOLDER": "Search..."
|
||||
},
|
||||
"SYNC_STATUS": {
|
||||
"SYNCED": "last updated {time}",
|
||||
"SYNCING": "updating...",
|
||||
"STALE_SYNC": "update stalled",
|
||||
"FAILED": "Failed to sync",
|
||||
"NEVER_SYNCED": "not updated yet"
|
||||
},
|
||||
"SYNC_ERRORS": {
|
||||
"NOT_FOUND": "Page not found",
|
||||
"ACCESS_DENIED": "Access denied",
|
||||
"TIMEOUT": "Page took too long to respond",
|
||||
"CONTENT_EMPTY": "Page returned empty content",
|
||||
"FETCH_FAILED": "Could not fetch page",
|
||||
"SYNC_ERROR": "Unexpected error",
|
||||
"DEFAULT": "Sync error"
|
||||
},
|
||||
"RELATED_RESPONSES": {
|
||||
"TITLE": "Related FAQs",
|
||||
"DESCRIPTION": "These FAQs are generated directly from the document."
|
||||
@@ -793,11 +839,15 @@
|
||||
|
||||
"OPTIONS": {
|
||||
"VIEW_RELATED_RESPONSES": "View Related Responses",
|
||||
"SYNC_NOW": "Refresh now",
|
||||
"RETRY_SYNC": "Retry refresh",
|
||||
"DELETE_DOCUMENT": "Delete Document"
|
||||
},
|
||||
"EMPTY_STATE": {
|
||||
"TITLE": "No documents available",
|
||||
"SUBTITLE": "Documents are used by your assistant to generate FAQs. You can import documents to provide context for your assistant.",
|
||||
"FILTERED_TITLE": "No matching documents",
|
||||
"FILTERED_SUBTITLE": "Try changing the source, status, or search term.",
|
||||
"FEATURE_SPOTLIGHT": {
|
||||
"TITLE": "Captain Document",
|
||||
"NOTE": "A document in Captain serves as a knowledge resource for the assistant. By connecting your help center or guides, Captain can analyze the content and provide accurate responses for customer inquiries."
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, nextTick } from 'vue';
|
||||
import { computed, onUnmounted, ref, nextTick, watch } from 'vue';
|
||||
import { useTimeoutPoll } from '@vueuse/core';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { usePolicy } from 'dashboard/composables/usePolicy';
|
||||
|
||||
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
|
||||
import DocumentCard from 'dashboard/components-next/captain/assistant/DocumentCard.vue';
|
||||
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
|
||||
import BulkDeleteDialog from 'dashboard/components-next/captain/pageComponents/BulkDeleteDialog.vue';
|
||||
import DocumentFilter from 'dashboard/components-next/captain/assistant/DocumentFilter.vue';
|
||||
import DocumentBulkActions from 'dashboard/components-next/captain/assistant/DocumentBulkActions.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';
|
||||
@@ -18,6 +20,7 @@ import CreateDocumentDialog from 'dashboard/components-next/captain/pageComponen
|
||||
import DocumentPageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/DocumentPageEmptyState.vue';
|
||||
import FeatureSpotlightPopover from 'dashboard/components-next/feature-spotlight/FeatureSpotlightPopover.vue';
|
||||
import LimitBanner from 'dashboard/components-next/captain/pageComponents/document/LimitBanner.vue';
|
||||
import CaptainDocumentAPI from 'dashboard/api/captain/document';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const route = useRoute();
|
||||
@@ -25,6 +28,9 @@ const store = useStore();
|
||||
const { t } = useI18n();
|
||||
const { checkPermissions } = usePolicy();
|
||||
|
||||
const SYNC_POLL_INTERVAL_MS = 5000;
|
||||
const SYNC_POLL_MAX_DURATION_MS = 15 * 60 * 1000;
|
||||
|
||||
const { isOnChatwootCloud } = useAccount();
|
||||
const uiFlags = useMapGetter('captainDocuments/getUIFlags');
|
||||
const documents = useMapGetter('captainDocuments/getRecords');
|
||||
@@ -36,7 +42,6 @@ const canManageDocuments = computed(() => checkPermissions(['administrator']));
|
||||
|
||||
const selectedDocument = ref(null);
|
||||
const deleteDocumentDialog = ref(null);
|
||||
const bulkDeleteDialog = ref(null);
|
||||
const bulkSelectedIds = ref(new Set());
|
||||
const hoveredCard = ref(null);
|
||||
|
||||
@@ -66,6 +71,152 @@ const handleCreateDialogClose = () => {
|
||||
showCreateDialog.value = false;
|
||||
};
|
||||
|
||||
const documentFilter = ref(null);
|
||||
const syncIntervalHours = ref(null);
|
||||
|
||||
const currentAssistantId = () =>
|
||||
Number.isFinite(selectedAssistantId.value) ? selectedAssistantId.value : null;
|
||||
|
||||
const buildDocumentFilterParams = (page = 1) => {
|
||||
const filterParams = documentFilter.value?.buildParams(page) ?? {
|
||||
page,
|
||||
sort: 'recently_updated',
|
||||
};
|
||||
const assistantId = currentAssistantId();
|
||||
if (assistantId) filterParams.assistantId = assistantId;
|
||||
return filterParams;
|
||||
};
|
||||
|
||||
let documentsRequestId = 0;
|
||||
let fetchingListRequestId = null;
|
||||
|
||||
const isCurrentDocumentRequest = (requestId, filterParams) =>
|
||||
requestId === documentsRequestId &&
|
||||
(filterParams.assistantId || null) === currentAssistantId();
|
||||
|
||||
const pruneSelectionToDocuments = nextDocuments => {
|
||||
if (!bulkSelectedIds.value.size) return;
|
||||
|
||||
const visibleDocumentIds = new Set(nextDocuments.map(doc => doc.id));
|
||||
const selectedIds = new Set(
|
||||
[...bulkSelectedIds.value].filter(id => visibleDocumentIds.has(id))
|
||||
);
|
||||
|
||||
if (selectedIds.size !== bulkSelectedIds.value.size) {
|
||||
bulkSelectedIds.value = selectedIds;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchDocuments = async (page = 1, { showLoader = true } = {}) => {
|
||||
documentsRequestId += 1;
|
||||
const requestId = documentsRequestId;
|
||||
const filterParams = buildDocumentFilterParams(page);
|
||||
|
||||
if (showLoader) {
|
||||
fetchingListRequestId = requestId;
|
||||
store.dispatch('captainDocuments/setFetchingList', true);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await CaptainDocumentAPI.get(filterParams);
|
||||
|
||||
if (!isCurrentDocumentRequest(requestId, filterParams)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const { payload, meta } = response.data;
|
||||
store.dispatch('captainDocuments/setRecords', { records: payload, meta });
|
||||
pruneSelectionToDocuments(payload);
|
||||
syncIntervalHours.value = Number(meta?.sync_interval_hours) || null;
|
||||
return payload;
|
||||
} catch (error) {
|
||||
if (isCurrentDocumentRequest(requestId, filterParams)) {
|
||||
throw error;
|
||||
}
|
||||
return [];
|
||||
} finally {
|
||||
if (showLoader && fetchingListRequestId === requestId) {
|
||||
fetchingListRequestId = null;
|
||||
store.dispatch('captainDocuments/setFetchingList', false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const refreshDocumentsPage = (
|
||||
page = documentsMeta.value?.page || 1,
|
||||
{ showLoader = false } = {}
|
||||
) => {
|
||||
return fetchDocuments(page, { showLoader }).catch(() => {});
|
||||
};
|
||||
|
||||
const onFiltersChanged = () => {
|
||||
bulkSelectedIds.value = new Set();
|
||||
fetchDocuments(1);
|
||||
};
|
||||
|
||||
const syncPollStartedAt = ref(null);
|
||||
|
||||
const hasDocumentsSyncing = computed(() =>
|
||||
(documents.value || []).some(doc => doc.sync_in_progress)
|
||||
);
|
||||
|
||||
const hasSyncingDocuments = computed(() => hasDocumentsSyncing.value);
|
||||
|
||||
const isWithinSyncPollWindow = () =>
|
||||
syncPollStartedAt.value &&
|
||||
Date.now() - syncPollStartedAt.value < SYNC_POLL_MAX_DURATION_MS;
|
||||
|
||||
const shouldContinueSyncPolling = () =>
|
||||
hasSyncingDocuments.value && isWithinSyncPollWindow();
|
||||
|
||||
let syncPollingControls;
|
||||
|
||||
function stopSyncPolling() {
|
||||
syncPollingControls.pause();
|
||||
syncPollStartedAt.value = null;
|
||||
}
|
||||
|
||||
async function pollSyncDocuments() {
|
||||
try {
|
||||
await refreshDocumentsPage();
|
||||
} catch (error) {
|
||||
// Keep the existing polling decision based on the last known sync state.
|
||||
}
|
||||
|
||||
if (!shouldContinueSyncPolling()) {
|
||||
stopSyncPolling();
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleSyncPoll({ extendWindow = false } = {}) {
|
||||
if (extendWindow || !syncPollStartedAt.value) {
|
||||
syncPollStartedAt.value = Date.now();
|
||||
}
|
||||
|
||||
if (syncPollingControls.isActive.value) return;
|
||||
syncPollingControls.resume();
|
||||
}
|
||||
|
||||
syncPollingControls = useTimeoutPoll(pollSyncDocuments, SYNC_POLL_INTERVAL_MS, {
|
||||
immediate: false,
|
||||
});
|
||||
|
||||
watch(hasSyncingDocuments, isSyncing => {
|
||||
if (isSyncing) {
|
||||
scheduleSyncPoll();
|
||||
}
|
||||
});
|
||||
|
||||
const handleSync = async id => {
|
||||
try {
|
||||
await store.dispatch('captainDocuments/sync', id);
|
||||
useAlert(t('CAPTAIN.DOCUMENTS.SYNC.QUEUED_MESSAGE'));
|
||||
scheduleSyncPoll({ extendWindow: true });
|
||||
} catch (error) {
|
||||
useAlert(t('CAPTAIN.DOCUMENTS.SYNC.ERROR_MESSAGE'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleAction = ({ action, id }) => {
|
||||
selectedDocument.value = documents.value.find(
|
||||
captainDocument => id === captainDocument.id
|
||||
@@ -76,19 +227,12 @@ const handleAction = ({ action, id }) => {
|
||||
handleDelete();
|
||||
} else if (action === 'viewRelatedQuestions') {
|
||||
handleShowRelatedDocument();
|
||||
} else if (action === 'sync') {
|
||||
handleSync(id);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const fetchDocuments = (page = 1) => {
|
||||
const filterParams = { page };
|
||||
|
||||
if (selectedAssistantId.value) {
|
||||
filterParams.assistantId = selectedAssistantId.value;
|
||||
}
|
||||
store.dispatch('captainDocuments/get', filterParams);
|
||||
};
|
||||
|
||||
const onPageChange = page => {
|
||||
const hadSelection = bulkSelectedIds.value.size > 0;
|
||||
fetchDocuments(page);
|
||||
@@ -101,31 +245,14 @@ const onPageChange = page => {
|
||||
const onDeleteSuccess = () => {
|
||||
if (documents.value?.length === 0 && documentsMeta.value?.page > 1) {
|
||||
onPageChange(documentsMeta.value.page - 1);
|
||||
} else {
|
||||
refreshDocumentsPage();
|
||||
}
|
||||
};
|
||||
|
||||
const buildSelectedCountLabel = computed(() => {
|
||||
const count = documents.value?.length || 0;
|
||||
const isAllSelected = bulkSelectedIds.value.size === count && count > 0;
|
||||
return isAllSelected
|
||||
? t('CAPTAIN.DOCUMENTS.UNSELECT_ALL', { count })
|
||||
: t('CAPTAIN.DOCUMENTS.SELECT_ALL', { count });
|
||||
});
|
||||
|
||||
const selectedCountLabel = computed(() => {
|
||||
return t('CAPTAIN.DOCUMENTS.SELECTED', {
|
||||
count: bulkSelectedIds.value.size,
|
||||
});
|
||||
});
|
||||
|
||||
const hasBulkSelection = computed(() => bulkSelectedIds.value.size > 0);
|
||||
|
||||
const shouldShowSelectionControl = docId => {
|
||||
return (
|
||||
canManageDocuments.value &&
|
||||
(hoveredCard.value === docId || hasBulkSelection.value)
|
||||
);
|
||||
};
|
||||
const shouldShowSelectionControl = docId =>
|
||||
canManageDocuments.value &&
|
||||
(hoveredCard.value === docId || bulkSelectedIds.value.size > 0);
|
||||
|
||||
const handleCardHover = (isHovered, id) => {
|
||||
hoveredCard.value = isHovered ? id : null;
|
||||
@@ -152,12 +279,30 @@ const fetchDocumentsAfterBulkAction = () => {
|
||||
bulkSelectedIds.value = new Set();
|
||||
};
|
||||
|
||||
const onBulkDeleteSuccess = () => {
|
||||
fetchDocumentsAfterBulkAction();
|
||||
const onCreateSuccess = () => {
|
||||
refreshDocumentsPage(1);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchDocuments();
|
||||
const hasActiveDocumentFilters = computed(
|
||||
() => documentFilter.value?.hasActiveFilters ?? false
|
||||
);
|
||||
|
||||
watch(
|
||||
selectedAssistantId,
|
||||
async () => {
|
||||
documentFilter.value?.reset();
|
||||
bulkSelectedIds.value = new Set();
|
||||
syncIntervalHours.value = null;
|
||||
stopSyncPolling();
|
||||
await fetchDocuments(1);
|
||||
if (hasSyncingDocuments.value) scheduleSyncPoll();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
stopSyncPolling();
|
||||
documentsRequestId += 1;
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -170,27 +315,27 @@ onMounted(() => {
|
||||
:current-page="documentsMeta.page"
|
||||
:show-pagination-footer="!isFetching && !!documents.length"
|
||||
:is-fetching="isFetching"
|
||||
:is-empty="!documents.length"
|
||||
:show-know-more="false"
|
||||
:is-empty="!documents.length && !hasActiveDocumentFilters"
|
||||
:feature-flag="FEATURE_FLAGS.CAPTAIN"
|
||||
@update:current-page="onPageChange"
|
||||
@click="handleCreateDocument"
|
||||
>
|
||||
<template #subHeader>
|
||||
<Policy :permissions="['administrator']">
|
||||
<BulkSelectBar
|
||||
v-model="bulkSelectedIds"
|
||||
:all-items="documents"
|
||||
:select-all-label="buildSelectedCountLabel"
|
||||
:selected-count-label="selectedCountLabel"
|
||||
:delete-label="$t('CAPTAIN.DOCUMENTS.BULK_DELETE_BUTTON')"
|
||||
class="w-fit"
|
||||
:class="{ 'mb-2': bulkSelectedIds.size > 0 }"
|
||||
@bulk-delete="bulkDeleteDialog.dialogRef.open()"
|
||||
<DocumentBulkActions
|
||||
v-model:selected-ids="bulkSelectedIds"
|
||||
:documents="documents"
|
||||
@bulk-sync-queued="scheduleSyncPoll({ extendWindow: true })"
|
||||
@bulk-delete-succeeded="fetchDocumentsAfterBulkAction"
|
||||
/>
|
||||
</Policy>
|
||||
<DocumentFilter
|
||||
v-show="!bulkSelectedIds.size"
|
||||
ref="documentFilter"
|
||||
class="mb-2"
|
||||
@change="onFiltersChanged"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #knowMore>
|
||||
<FeatureSpotlightPopover
|
||||
:button-label="$t('CAPTAIN.HEADER_KNOW_MORE')"
|
||||
@@ -214,15 +359,34 @@ onMounted(() => {
|
||||
<template #body>
|
||||
<LimitBanner class="mb-5" />
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div
|
||||
v-if="!documents.length && hasActiveDocumentFilters"
|
||||
class="flex flex-col items-center justify-center min-h-80 gap-2 text-center"
|
||||
>
|
||||
<span class="text-base font-medium text-n-slate-12">
|
||||
{{ $t('CAPTAIN.DOCUMENTS.EMPTY_STATE.FILTERED_TITLE') }}
|
||||
</span>
|
||||
<span class="max-w-md text-sm text-n-slate-11">
|
||||
{{ $t('CAPTAIN.DOCUMENTS.EMPTY_STATE.FILTERED_SUBTITLE') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-4">
|
||||
<DocumentCard
|
||||
v-for="doc in documents"
|
||||
:id="doc.id"
|
||||
:key="doc.id"
|
||||
:name="doc.name || doc.external_link"
|
||||
:external-link="doc.external_link"
|
||||
:pdf-document="doc.pdf_document"
|
||||
:assistant="doc.assistant"
|
||||
:created-at="doc.created_at"
|
||||
:status="doc.status"
|
||||
:sync-status="doc.sync_status"
|
||||
:last-synced-at="doc.last_synced_at"
|
||||
:last-sync-error-code="doc.last_sync_error_code"
|
||||
:sync-in-progress="doc.sync_in_progress"
|
||||
:sync-stale-after-hours="syncIntervalHours"
|
||||
:is-selected="canManageDocuments && bulkSelectedIds.has(doc.id)"
|
||||
:selectable="canManageDocuments"
|
||||
:show-selection-control="shouldShowSelectionControl(doc.id)"
|
||||
@@ -244,6 +408,7 @@ onMounted(() => {
|
||||
v-if="showCreateDialog"
|
||||
ref="createDocumentDialog"
|
||||
:assistant-id="selectedAssistantId"
|
||||
@create-success="onCreateSuccess"
|
||||
@close="handleCreateDialogClose"
|
||||
/>
|
||||
<DeleteDialog
|
||||
@@ -253,12 +418,5 @@ onMounted(() => {
|
||||
type="Documents"
|
||||
@delete-success="onDeleteSuccess"
|
||||
/>
|
||||
<BulkDeleteDialog
|
||||
v-if="bulkSelectedIds"
|
||||
ref="bulkDeleteDialog"
|
||||
:bulk-ids="bulkSelectedIds"
|
||||
type="AssistantDocument"
|
||||
@delete-success="onBulkDeleteSuccess"
|
||||
/>
|
||||
</PageLayout>
|
||||
</template>
|
||||
|
||||
@@ -61,5 +61,18 @@ export default createStore({
|
||||
});
|
||||
return response;
|
||||
},
|
||||
|
||||
handleBulkSync: async function handleBulkSync({ dispatch }, { ids }) {
|
||||
const response = await dispatch('processBulkAction', {
|
||||
type: 'AssistantDocument',
|
||||
actionType: 'sync',
|
||||
ids,
|
||||
});
|
||||
|
||||
await dispatch('captainDocuments/markSyncing', response.ids || [], {
|
||||
root: true,
|
||||
});
|
||||
return response;
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,15 +1,55 @@
|
||||
import CaptainDocumentAPI from 'dashboard/api/captain/document';
|
||||
import { throwErrorMessage } from 'dashboard/store/utils/api';
|
||||
import { createStore } from '../storeFactory';
|
||||
|
||||
const SYNCING_STATE = 'syncing';
|
||||
|
||||
const markRecordsSyncing = (records, ids) => {
|
||||
const idSet = new Set(ids);
|
||||
return records.map(record =>
|
||||
idSet.has(record.id)
|
||||
? {
|
||||
...record,
|
||||
sync_status: SYNCING_STATE,
|
||||
sync_in_progress: true,
|
||||
last_sync_attempted_at: Math.floor(Date.now() / 1000),
|
||||
last_sync_error_code: null,
|
||||
}
|
||||
: record
|
||||
);
|
||||
};
|
||||
|
||||
export default createStore({
|
||||
name: 'CaptainDocument',
|
||||
API: CaptainDocumentAPI,
|
||||
getters: {
|
||||
getRecords: state => state.records,
|
||||
},
|
||||
actions: mutations => ({
|
||||
setFetchingList({ commit }, isFetching) {
|
||||
commit(mutations.SET_UI_FLAG, { fetchingList: isFetching });
|
||||
},
|
||||
setRecords({ commit }, { records, meta }) {
|
||||
commit(mutations.SET, records);
|
||||
commit(mutations.SET_META, meta);
|
||||
},
|
||||
removeBulkRecords({ commit, getters }, ids) {
|
||||
const records = getters.getRecords.filter(
|
||||
record => !ids.includes(record.id)
|
||||
);
|
||||
commit(mutations.SET, records);
|
||||
},
|
||||
markSyncing({ commit, getters }, ids) {
|
||||
commit(mutations.SET, markRecordsSyncing(getters.getRecords, ids));
|
||||
},
|
||||
async sync({ dispatch }, id) {
|
||||
try {
|
||||
await CaptainDocumentAPI.sync(id);
|
||||
dispatch('markSyncing', [id]);
|
||||
return id;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Constants for document processing
|
||||
const PDF_PREFIX = 'PDF:';
|
||||
const TIMESTAMP_PATTERN = /_\d{14}(?=\.pdf$)/; // Format: _YYYYMMDDHHMMSS before .pdf extension
|
||||
const URL_DISPLAY_PREFIX_PATTERN = /^https?:\/\/(www\.)?/i;
|
||||
|
||||
/**
|
||||
* Checks if a document is a PDF based on its external link
|
||||
@@ -16,10 +17,26 @@ export const isPdfDocument = externalLink => {
|
||||
return externalLink.startsWith(PDF_PREFIX);
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a link is safe to bind to an href attribute (http/https only).
|
||||
* Guards against schemes like `javascript:` that would execute on click.
|
||||
* @param {string} externalLink - The external link string
|
||||
* @returns {boolean} True if the link uses http or https
|
||||
*/
|
||||
export const isSafeHttpLink = externalLink => {
|
||||
if (!externalLink) return false;
|
||||
try {
|
||||
const { protocol } = new URL(externalLink);
|
||||
return protocol === 'http:' || protocol === 'https:';
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats the display link for documents
|
||||
* For PDF documents: removes 'PDF:' prefix and timestamp suffix
|
||||
* For regular URLs: returns as-is
|
||||
* For regular URLs: strips http(s):// and www. for a denser list view
|
||||
*
|
||||
* @param {string} externalLink - The external link string
|
||||
* @returns {string} Formatted display link
|
||||
@@ -34,5 +51,28 @@ export const formatDocumentLink = externalLink => {
|
||||
return fullName.replace(TIMESTAMP_PATTERN, '');
|
||||
}
|
||||
|
||||
return externalLink;
|
||||
return externalLink.replace(URL_DISPLAY_PREFIX_PATTERN, '');
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the path of a URL for compact display in document lists. This avoids
|
||||
* repeating the domain while preserving enough context to distinguish pages.
|
||||
* Falls back to the bare hostname for root URLs and formatDocumentLink for
|
||||
* malformed URLs and PDFs.
|
||||
*/
|
||||
export const getDocumentDisplayPath = externalLink => {
|
||||
if (!externalLink) return '';
|
||||
if (isPdfDocument(externalLink)) return formatDocumentLink(externalLink);
|
||||
try {
|
||||
const { pathname, hostname } = new URL(externalLink);
|
||||
const path = pathname.replace(/^\/+/, '');
|
||||
if (!path) return hostname.replace(/^www\./i, '');
|
||||
try {
|
||||
return decodeURIComponent(path);
|
||||
} catch (e) {
|
||||
return path;
|
||||
}
|
||||
} catch (e) {
|
||||
return formatDocumentLink(externalLink);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
isPdfDocument,
|
||||
isSafeHttpLink,
|
||||
formatDocumentLink,
|
||||
} from 'shared/helpers/documentHelper';
|
||||
|
||||
@@ -31,6 +32,35 @@ describe('documentHelper', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('#isSafeHttpLink', () => {
|
||||
it('returns true for http and https URLs', () => {
|
||||
expect(isSafeHttpLink('http://example.com')).toBe(true);
|
||||
expect(isSafeHttpLink('https://example.com/path?q=1#x')).toBe(true);
|
||||
expect(isSafeHttpLink('HTTPS://EXAMPLE.COM')).toBe(true);
|
||||
});
|
||||
|
||||
/* eslint-disable no-script-url */
|
||||
it('returns false for javascript: and other dangerous schemes', () => {
|
||||
expect(isSafeHttpLink('javascript:alert(1)')).toBe(false);
|
||||
expect(isSafeHttpLink('JavaScript:alert(1)')).toBe(false);
|
||||
expect(isSafeHttpLink('data:text/html,<script>alert(1)</script>')).toBe(
|
||||
false
|
||||
);
|
||||
expect(isSafeHttpLink('vbscript:msgbox(1)')).toBe(false);
|
||||
expect(isSafeHttpLink('file:///etc/passwd')).toBe(false);
|
||||
expect(isSafeHttpLink('ftp://files.example.com/doc.pdf')).toBe(false);
|
||||
});
|
||||
/* eslint-enable no-script-url */
|
||||
|
||||
it('returns false for invalid or empty values', () => {
|
||||
expect(isSafeHttpLink('')).toBe(false);
|
||||
expect(isSafeHttpLink(null)).toBe(false);
|
||||
expect(isSafeHttpLink(undefined)).toBe(false);
|
||||
expect(isSafeHttpLink('not a url')).toBe(false);
|
||||
expect(isSafeHttpLink('//example.com')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#formatDocumentLink', () => {
|
||||
describe('PDF documents', () => {
|
||||
it('removes PDF: prefix from PDF documents', () => {
|
||||
@@ -78,32 +108,30 @@ describe('documentHelper', () => {
|
||||
});
|
||||
|
||||
describe('Regular URLs', () => {
|
||||
it('returns regular URLs unchanged', () => {
|
||||
expect(formatDocumentLink('https://example.com')).toBe(
|
||||
'https://example.com'
|
||||
);
|
||||
it('removes http(s) and www prefixes for compact display', () => {
|
||||
expect(formatDocumentLink('https://example.com')).toBe('example.com');
|
||||
expect(formatDocumentLink('http://docs.example.com/api')).toBe(
|
||||
'http://docs.example.com/api'
|
||||
'docs.example.com/api'
|
||||
);
|
||||
expect(formatDocumentLink('https://github.com/user/repo')).toBe(
|
||||
'https://github.com/user/repo'
|
||||
expect(formatDocumentLink('https://www.github.com/user/repo')).toBe(
|
||||
'github.com/user/repo'
|
||||
);
|
||||
});
|
||||
|
||||
it('handles URLs with query parameters', () => {
|
||||
expect(formatDocumentLink('https://example.com?param=value')).toBe(
|
||||
'https://example.com?param=value'
|
||||
'example.com?param=value'
|
||||
);
|
||||
expect(
|
||||
formatDocumentLink(
|
||||
'https://api.example.com/docs?version=v1&format=json'
|
||||
)
|
||||
).toBe('https://api.example.com/docs?version=v1&format=json');
|
||||
).toBe('api.example.com/docs?version=v1&format=json');
|
||||
});
|
||||
|
||||
it('handles URLs with fragments', () => {
|
||||
expect(formatDocumentLink('https://example.com/docs#section1')).toBe(
|
||||
'https://example.com/docs#section1'
|
||||
'example.com/docs#section1'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user