## Description
Implements comprehensive search functionality with advanced filtering
capabilities for Chatwoot (Linear: CW-5956).
This PR adds:
1. **Time-based filtering** for contacts and conversations (SQL-based
search)
2. **Advanced message search** with multiple filters
(OpenSearch/Elasticsearch-based)
- **`from` filter**: Filter messages by sender (format: `contact:42` or
`agent:5`)
- **`inbox_id` filter**: Filter messages by specific inbox
- **Time range filters**: Filter messages using `since` and `until`
parameters (Unix timestamps in seconds)
- **90-day limit enforcement**: Automatically limits searches to the
last 90 days to prevent performance issues
The implementation extends the existing `Enterprise::SearchService`
module for advanced features and adds time filtering to the base
`SearchService` for SQL-based searches.
## API Documentation
### Base URL
All search endpoints follow this pattern:
```
GET /api/v1/accounts/{account_id}/search/{resource}
```
### Authentication
All requests require authentication headers:
```
api_access_token: YOUR_ACCESS_TOKEN
```
---
## 1. Search All Resources
**Endpoint:** `GET /api/v1/accounts/{account_id}/search`
Returns results from all searchable resources (contacts, conversations,
messages, articles).
### Parameters
| Parameter | Type | Description | Required |
|-----------|------|-------------|----------|
| `q` | string | Search query | Yes |
| `page` | integer | Page number (15 items per page) | No |
| `since` | integer | Unix timestamp (contacts/conversations only) | No
|
| `until` | integer | Unix timestamp (contacts/conversations only) | No
|
### Example Request
```bash
curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search?q=customer" \
-H "api_access_token: YOUR_ACCESS_TOKEN"
```
### Example Response
```json
{
"payload": {
"contacts": [...],
"conversations": [...],
"messages": [...],
"articles": [...]
}
}
```
---
## 2. Search Contacts
**Endpoint:** `GET /api/v1/accounts/{account_id}/search/contacts`
Search contacts by name, email, phone number, or identifier with
optional time filtering.
### Parameters
| Parameter | Type | Description | Required |
|-----------|------|-------------|----------|
| `q` | string | Search query | Yes |
| `page` | integer | Page number (15 items per page) | No |
| `since` | integer | Unix timestamp - filter by last_activity_at | No |
| `until` | integer | Unix timestamp - filter by last_activity_at | No |
### Example Requests
**Basic search:**
```bash
curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/contacts?q=john" \
-H "api_access_token: YOUR_ACCESS_TOKEN"
```
**Search contacts active in the last 7 days:**
```bash
SINCE=$(date -v-7d +%s)
curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/contacts?q=john&since=${SINCE}" \
-H "api_access_token: YOUR_ACCESS_TOKEN"
```
**Search contacts active between 30 and 7 days ago:**
```bash
SINCE=$(date -v-30d +%s)
UNTIL=$(date -v-7d +%s)
curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/contacts?q=john&since=${SINCE}&until=${UNTIL}" \
-H "api_access_token: YOUR_ACCESS_TOKEN"
```
### Example Response
```json
{
"payload": {
"contacts": [
{
"id": 42,
"email": "john@example.com",
"name": "John Doe",
"phone_number": "+1234567890",
"identifier": "user_123",
"additional_attributes": {},
"created_at": 1701234567
}
]
}
}
```
---
## 3. Search Conversations
**Endpoint:** `GET /api/v1/accounts/{account_id}/search/conversations`
Search conversations by display ID, contact name, email, phone number,
or identifier with optional time filtering.
### Parameters
| Parameter | Type | Description | Required |
|-----------|------|-------------|----------|
| `q` | string | Search query | Yes |
| `page` | integer | Page number (15 items per page) | No |
| `since` | integer | Unix timestamp - filter by last_activity_at | No |
| `until` | integer | Unix timestamp - filter by last_activity_at | No |
### Example Requests
**Basic search:**
```bash
curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/conversations?q=billing" \
-H "api_access_token: YOUR_ACCESS_TOKEN"
```
**Search conversations active in the last 24 hours:**
```bash
SINCE=$(date -v-1d +%s)
curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/conversations?q=billing&since=${SINCE}" \
-H "api_access_token: YOUR_ACCESS_TOKEN"
```
**Search conversations from last month:**
```bash
SINCE=$(date -v-30d +%s)
UNTIL=$(date +%s)
curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/conversations?q=billing&since=${SINCE}&until=${UNTIL}" \
-H "api_access_token: YOUR_ACCESS_TOKEN"
```
### Example Response
```json
{
"payload": {
"conversations": [
{
"id": 123,
"display_id": 45,
"inbox_id": 1,
"status": "open",
"messages": [...],
"meta": {...}
}
]
}
}
```
---
## 4. Search Messages (Advanced)
**Endpoint:** `GET /api/v1/accounts/{account_id}/search/messages`
Advanced message search with multiple filters powered by
OpenSearch/Elasticsearch.
### Prerequisites
- OpenSearch/Elasticsearch must be running (`OPENSEARCH_URL` env var
configured)
- Account must have `advanced_search` feature flag enabled
- Messages must be indexed in OpenSearch
### Parameters
| Parameter | Type | Description | Required |
|-----------|------|-------------|----------|
| `q` | string | Search query | Yes |
| `page` | integer | Page number (15 items per page) | No |
| `from` | string | Filter by sender: `contact:{id}` or `agent:{id}` |
No |
| `inbox_id` | integer | Filter by specific inbox ID | No |
| `since` | integer | Unix timestamp - searches from this time (max 90
days ago) | No |
| `until` | integer | Unix timestamp - searches until this time | No |
### Important Notes
- **90-Day Limit**: If `since` is not provided, searches default to the
last 90 days
- If `since` exceeds 90 days, returns `422` error: "Search is limited to
the last 90 days"
- All time filters use message `created_at` timestamp
### Example Requests
**Basic message search:**
```bash
curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/messages?q=refund" \
-H "api_access_token: YOUR_ACCESS_TOKEN"
```
**Search messages from a specific contact:**
```bash
curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/messages?q=refund&from=contact:42" \
-H "api_access_token: YOUR_ACCESS_TOKEN"
```
**Search messages from a specific agent:**
```bash
curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/messages?q=refund&from=agent:5" \
-H "api_access_token: YOUR_ACCESS_TOKEN"
```
**Search messages in a specific inbox:**
```bash
curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/messages?q=refund&inbox_id=3" \
-H "api_access_token: YOUR_ACCESS_TOKEN"
```
**Search messages from the last 7 days:**
```bash
SINCE=$(date -v-7d +%s)
curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/messages?q=refund&since=${SINCE}" \
-H "api_access_token: YOUR_ACCESS_TOKEN"
```
**Search messages between specific dates:**
```bash
SINCE=$(date -v-30d +%s)
UNTIL=$(date -v-7d +%s)
curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/messages?q=refund&since=${SINCE}&until=${UNTIL}" \
-H "api_access_token: YOUR_ACCESS_TOKEN"
```
**Combine all filters:**
```bash
SINCE=$(date -v-14d +%s)
curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/messages?q=refund&from=contact:42&inbox_id=3&since=${SINCE}" \
-H "api_access_token: YOUR_ACCESS_TOKEN"
```
**Attempt to search beyond 90 days (returns error):**
```bash
SINCE=$(date -v-120d +%s)
curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/messages?q=refund&since=${SINCE}" \
-H "api_access_token: YOUR_ACCESS_TOKEN"
```
### Example Response (Success)
```json
{
"payload": {
"messages": [
{
"id": 789,
"content": "I need a refund for my purchase",
"message_type": "incoming",
"created_at": 1701234567,
"conversation_id": 123,
"inbox_id": 3,
"sender": {
"id": 42,
"type": "contact"
}
}
]
}
}
```
### Example Response (90-day limit exceeded)
```json
{
"error": "Search is limited to the last 90 days"
}
```
**Status Code:** `422 Unprocessable Entity`
---
## 5. Search Articles
**Endpoint:** `GET /api/v1/accounts/{account_id}/search/articles`
Search help center articles by title or content.
### Parameters
| Parameter | Type | Description | Required |
|-----------|------|-------------|----------|
| `q` | string | Search query | Yes |
| `page` | integer | Page number (15 items per page) | No |
### Example Request
```bash
curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/articles?q=installation" \
-H "api_access_token: YOUR_ACCESS_TOKEN"
```
### Example Response
```json
{
"payload": {
"articles": [
{
"id": 456,
"title": "Installation Guide",
"slug": "installation-guide",
"portal_slug": "help",
"account_id": 1,
"category_name": "Getting Started",
"status": "published",
"updated_at": 1701234567
}
]
}
}
```
---
## Technical Implementation
### SQL-Based Search (Contacts, Conversations, Articles)
- Uses PostgreSQL `ILIKE` queries by default
- Optional GIN index support via `search_with_gin` feature flag for
better performance
- Time filtering uses `last_activity_at` for contacts/conversations
- Returns paginated results (15 per page)
### Advanced Search (Messages)
- Powered by OpenSearch/Elasticsearch via Searchkick gem
- Requires `OPENSEARCH_URL` environment variable
- Requires `advanced_search` account feature flag
- Enforces 90-day lookback limit via
`Limits::MESSAGE_SEARCH_TIME_RANGE_LIMIT_DAYS`
- Validates inbox access permissions before filtering
- Returns paginated results (15 per page)
---
## Type of change
- [x] New feature (non-breaking change which adds functionality)
- [x] Enhancement (improves existing functionality)
---
## How Has This Been Tested?
### Unit Tests
- **Contact Search Tests**: 3 new test cases for time filtering
(`since`, `until`, combined)
- **Conversation Search Tests**: 3 new test cases for time filtering
- **Message Search Tests**: 10+ test cases covering:
- Individual filters (`from`, `inbox_id`, time range)
- Combined filters
- Permission validation for inbox access
- Feature flag checks
- 90-day limit enforcement
- Error handling for exceeded time limits
### Test Commands
```bash
# Run all search controller tests
bundle exec rspec spec/controllers/api/v1/accounts/search_controller_spec.rb
# Run search service tests (includes enterprise specs)
bundle exec rspec spec/services/search_service_spec.rb
```
### Manual Testing Setup
A rake task is provided to create 50,000 test messages across multiple
inboxes:
```bash
# 1. Create test data
bundle exec rake search:setup_test_data
# 2. Start OpenSearch
mise elasticsearch-start
# 3. Reindex messages
rails runner "Message.search_index.import Message.all"
# 4. Enable feature flag
rails runner "Account.first.enable_features('advanced_search')"
# 5. Test via API or Rails console
```
---
## 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
- [x] I have made corresponding changes to the documentation (this PR
description)
- [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
- [ ] Any dependent changes have been merged and published in downstream
modules
---
## Additional Notes
### Requirements
- **OpenSearch/Elasticsearch**: Required for advanced message search
- Set `OPENSEARCH_URL` environment variable
- Example: `export OPENSEARCH_URL=http://localhost:9200`
- **Feature Flags**:
- `advanced_search`: Account-level flag for message advanced search
- `search_with_gin` (optional): Account-level flag for GIN-based SQL
search
### Performance Considerations
- 90-day limit prevents expensive long-range queries on large datasets
- GIN indexes recommended for high-volume search on SQL-based resources
- OpenSearch/Elasticsearch provides faster full-text search for messages
### Breaking Changes
- None. All new parameters are optional and backward compatible.
### Frontend Integration
- Frontend PR tracking advanced search UI will consume these endpoints
- Time range pickers should convert JavaScript `Date` to Unix timestamps
(seconds)
- Date conversion: `Math.floor(date.getTime() / 1000)`
### Error Handling
- Invalid `from` parameter format is silently ignored (filter not
applied)
- Time range exceeding 90 days returns `422` with error message
- Missing `q` parameter returns `422` (existing behavior)
- Unauthorized inbox access is filtered out (no error, just excluded
from results)
---------
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Pranav <pranav@chatwoot.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
523 lines
16 KiB
Vue
523 lines
16 KiB
Vue
<script setup>
|
|
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
|
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
|
|
import { useRouter, useRoute } from 'vue-router';
|
|
import { useTrack } from 'dashboard/composables';
|
|
import { useI18n } from 'vue-i18n';
|
|
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
|
|
import { generateURLParams, parseURLParams } from '../helpers/searchHelper';
|
|
import {
|
|
ROLES,
|
|
CONVERSATION_PERMISSIONS,
|
|
CONTACT_PERMISSIONS,
|
|
PORTAL_PERMISSIONS,
|
|
} from 'dashboard/constants/permissions.js';
|
|
import { usePolicy } from 'dashboard/composables/usePolicy';
|
|
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
|
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
|
|
|
|
import Policy from 'dashboard/components/policy.vue';
|
|
import NextButton from 'dashboard/components-next/button/Button.vue';
|
|
import SearchHeader from './SearchHeader.vue';
|
|
import SearchTabs from './SearchTabs.vue';
|
|
import SearchResultConversationsList from './SearchResultConversationsList.vue';
|
|
import SearchResultMessagesList from './SearchResultMessagesList.vue';
|
|
import SearchResultContactsList from './SearchResultContactsList.vue';
|
|
import SearchResultArticlesList from './SearchResultArticlesList.vue';
|
|
|
|
const router = useRouter();
|
|
const route = useRoute();
|
|
const store = useStore();
|
|
const { t } = useI18n();
|
|
|
|
const PER_PAGE = 15; // Results per page
|
|
const selectedTab = ref(route.params.tab || 'all');
|
|
const query = ref(route.query.q || '');
|
|
const pages = ref({
|
|
contacts: 1,
|
|
conversations: 1,
|
|
messages: 1,
|
|
articles: 1,
|
|
});
|
|
|
|
const contactRecords = useMapGetter('conversationSearch/getContactRecords');
|
|
const conversationRecords = useMapGetter(
|
|
'conversationSearch/getConversationRecords'
|
|
);
|
|
const messageRecords = useMapGetter('conversationSearch/getMessageRecords');
|
|
const articleRecords = useMapGetter('conversationSearch/getArticleRecords');
|
|
const uiFlags = useMapGetter('conversationSearch/getUIFlags');
|
|
|
|
const addTypeToRecords = (records, type) =>
|
|
records.value.map(item => ({ ...useCamelCase(item, { deep: true }), type }));
|
|
|
|
const mappedContacts = computed(() =>
|
|
addTypeToRecords(contactRecords, 'contact')
|
|
);
|
|
const mappedConversations = computed(() =>
|
|
addTypeToRecords(conversationRecords, 'conversation')
|
|
);
|
|
const mappedMessages = computed(() =>
|
|
addTypeToRecords(messageRecords, 'message')
|
|
);
|
|
const mappedArticles = computed(() =>
|
|
addTypeToRecords(articleRecords, 'article')
|
|
);
|
|
|
|
const isSelectedTabAll = computed(() => selectedTab.value === 'all');
|
|
|
|
const searchResultSectionClass = computed(() => ({
|
|
'mt-4': isSelectedTabAll.value,
|
|
'mt-0.5': !isSelectedTabAll.value,
|
|
}));
|
|
|
|
const sliceRecordsIfAllTab = items =>
|
|
isSelectedTabAll.value ? items.value.slice(0, 5) : items.value;
|
|
|
|
const contacts = computed(() => sliceRecordsIfAllTab(mappedContacts));
|
|
const conversations = computed(() => sliceRecordsIfAllTab(mappedConversations));
|
|
const messages = computed(() => sliceRecordsIfAllTab(mappedMessages));
|
|
const articles = computed(() => sliceRecordsIfAllTab(mappedArticles));
|
|
|
|
const filterByTab = tab =>
|
|
computed(() => selectedTab.value === tab || isSelectedTabAll.value);
|
|
|
|
const filterContacts = filterByTab('contacts');
|
|
const filterConversations = filterByTab('conversations');
|
|
const filterMessages = filterByTab('messages');
|
|
const filterArticles = filterByTab('articles');
|
|
|
|
const { shouldShow, isFeatureFlagEnabled } = usePolicy();
|
|
|
|
const TABS_CONFIG = {
|
|
all: {
|
|
permissions: [
|
|
CONTACT_PERMISSIONS,
|
|
...ROLES,
|
|
...CONVERSATION_PERMISSIONS,
|
|
PORTAL_PERMISSIONS,
|
|
],
|
|
count: () => null, // No count for all tab
|
|
},
|
|
contacts: {
|
|
permissions: [...ROLES, CONTACT_PERMISSIONS],
|
|
count: () => mappedContacts.value.length,
|
|
},
|
|
conversations: {
|
|
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
|
|
count: () => mappedConversations.value.length,
|
|
},
|
|
messages: {
|
|
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
|
|
count: () => mappedMessages.value.length,
|
|
},
|
|
articles: {
|
|
permissions: [...ROLES, PORTAL_PERMISSIONS],
|
|
featureFlag: FEATURE_FLAGS.HELP_CENTER,
|
|
count: () => mappedArticles.value.length,
|
|
},
|
|
};
|
|
|
|
const tabs = computed(() => {
|
|
return Object.entries(TABS_CONFIG)
|
|
.map(([key, config]) => ({
|
|
key,
|
|
name: t(`SEARCH.TABS.${key.toUpperCase()}`),
|
|
count: config.count(),
|
|
showBadge: key !== 'all',
|
|
permissions: config.permissions,
|
|
featureFlag: config.featureFlag,
|
|
}))
|
|
.filter(config => {
|
|
// why the double check, glad you asked.
|
|
// Some features are marked as premium features, that means
|
|
// the feature will be visible, but a Paywall will be shown instead
|
|
// this works for pages and routes, but fails for UI elements like search here
|
|
// so we explicitly check if the feature is enabled
|
|
return (
|
|
shouldShow(config.featureFlag, config.permissions, null) &&
|
|
isFeatureFlagEnabled(config.featureFlag)
|
|
);
|
|
});
|
|
});
|
|
|
|
const totalSearchResultsCount = computed(() => {
|
|
const permissionCounts = [
|
|
{
|
|
permissions: [...ROLES, CONTACT_PERMISSIONS],
|
|
count: () => contacts.value.length,
|
|
},
|
|
{
|
|
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
|
|
count: () => conversations.value.length + messages.value.length,
|
|
},
|
|
{
|
|
permissions: [...ROLES, PORTAL_PERMISSIONS],
|
|
featureFlag: FEATURE_FLAGS.HELP_CENTER,
|
|
count: () => articles.value.length,
|
|
},
|
|
];
|
|
|
|
return permissionCounts
|
|
.filter(config => {
|
|
// why the double check, glad you asked.
|
|
// Some features are marked as premium features, that means
|
|
// the feature will be visible, but a Paywall will be shown instead
|
|
// this works for pages and routes, but fails for UI elements like search here
|
|
// so we explicitly check if the feature is enabled
|
|
return (
|
|
shouldShow(config.featureFlag, config.permissions, null) &&
|
|
isFeatureFlagEnabled(config.featureFlag)
|
|
);
|
|
})
|
|
.map(config => {
|
|
return config.count();
|
|
})
|
|
.reduce((sum, count) => sum + count, 0);
|
|
});
|
|
|
|
const activeTabIndex = computed(() => {
|
|
const index = tabs.value.findIndex(tab => tab.key === selectedTab.value);
|
|
return index >= 0 ? index : 0;
|
|
});
|
|
|
|
const isFetchingAny = computed(() => {
|
|
const { contact, message, conversation, article, isFetching } = uiFlags.value;
|
|
return (
|
|
isFetching ||
|
|
contact.isFetching ||
|
|
message.isFetching ||
|
|
conversation.isFetching ||
|
|
article.isFetching
|
|
);
|
|
});
|
|
|
|
const showEmptySearchResults = computed(
|
|
() =>
|
|
totalSearchResultsCount.value === 0 &&
|
|
uiFlags.value.isSearchCompleted &&
|
|
isSelectedTabAll.value &&
|
|
!isFetchingAny.value &&
|
|
query.value
|
|
);
|
|
|
|
const showResultsSection = computed(
|
|
() =>
|
|
(uiFlags.value.isSearchCompleted && totalSearchResultsCount.value !== 0) ||
|
|
isFetchingAny.value ||
|
|
(!isSelectedTabAll.value && query.value && !isFetchingAny.value)
|
|
);
|
|
|
|
const showLoadMore = computed(() => {
|
|
if (!query.value || isFetchingAny.value || selectedTab.value === 'all')
|
|
return false;
|
|
|
|
const records = {
|
|
contacts: mappedContacts.value,
|
|
conversations: mappedConversations.value,
|
|
messages: mappedMessages.value,
|
|
articles: mappedArticles.value,
|
|
}[selectedTab.value];
|
|
|
|
return (
|
|
records?.length > 0 &&
|
|
records.length === pages.value[selectedTab.value] * PER_PAGE
|
|
);
|
|
});
|
|
|
|
const showViewMore = computed(() => ({
|
|
// Hide view more button if the number of records is less than 5
|
|
contacts: mappedContacts.value?.length > 5 && isSelectedTabAll.value,
|
|
conversations:
|
|
mappedConversations.value?.length > 5 && isSelectedTabAll.value,
|
|
messages: mappedMessages.value?.length > 5 && isSelectedTabAll.value,
|
|
articles: mappedArticles.value?.length > 5 && isSelectedTabAll.value,
|
|
}));
|
|
|
|
const filters = ref({
|
|
from: null,
|
|
in: null,
|
|
dateRange: { type: null, from: null, to: null },
|
|
});
|
|
|
|
const clearSearchResult = () => {
|
|
pages.value = { contacts: 1, conversations: 1, messages: 1, articles: 1 };
|
|
store.dispatch('conversationSearch/clearSearchResults');
|
|
};
|
|
|
|
const buildSearchPayload = (basePayload = {}, searchType = 'message') => {
|
|
const payload = { ...basePayload };
|
|
|
|
// Only include filters if advanced search is enabled
|
|
if (isFeatureFlagEnabled(FEATURE_FLAGS.ADVANCED_SEARCH)) {
|
|
// Date filters apply to all search types
|
|
if (filters.value.dateRange.from) {
|
|
payload.since = filters.value.dateRange.from;
|
|
}
|
|
if (filters.value.dateRange.to) {
|
|
payload.until = filters.value.dateRange.to;
|
|
}
|
|
|
|
// Only messages support 'from' and 'inboxId' filters
|
|
if (searchType === 'message') {
|
|
if (filters.value.from) payload.from = filters.value.from;
|
|
if (filters.value.in) payload.inboxId = filters.value.in;
|
|
}
|
|
}
|
|
|
|
return payload;
|
|
};
|
|
|
|
const updateURL = () => {
|
|
const params = {
|
|
accountId: route.params.accountId,
|
|
...(selectedTab.value !== 'all' && { tab: selectedTab.value }),
|
|
};
|
|
|
|
const queryParams = {
|
|
...(query.value?.trim() && { q: query.value.trim() }),
|
|
...generateURLParams(
|
|
filters.value,
|
|
isFeatureFlagEnabled(FEATURE_FLAGS.ADVANCED_SEARCH)
|
|
),
|
|
};
|
|
|
|
router.replace({ name: 'search', params, query: queryParams });
|
|
};
|
|
|
|
const onSearch = q => {
|
|
query.value = q;
|
|
clearSearchResult();
|
|
updateURL();
|
|
if (!q) return;
|
|
useTrack(CONVERSATION_EVENTS.SEARCH_CONVERSATION);
|
|
|
|
const searchPayload = buildSearchPayload({ q, page: 1 });
|
|
store.dispatch('conversationSearch/fullSearch', searchPayload);
|
|
};
|
|
|
|
const onFilterChange = () => {
|
|
onSearch(query.value);
|
|
};
|
|
|
|
const onBack = () => {
|
|
if (window.history.length > 2) {
|
|
router.go(-1);
|
|
} else {
|
|
router.push({ name: 'home' });
|
|
}
|
|
clearSearchResult();
|
|
};
|
|
|
|
const loadMore = () => {
|
|
const SEARCH_ACTIONS = {
|
|
contacts: 'conversationSearch/contactSearch',
|
|
conversations: 'conversationSearch/conversationSearch',
|
|
messages: 'conversationSearch/messageSearch',
|
|
articles: 'conversationSearch/articleSearch',
|
|
};
|
|
|
|
if (uiFlags.value.isFetching || selectedTab.value === 'all') return;
|
|
|
|
const tab = selectedTab.value;
|
|
pages.value[tab] += 1;
|
|
|
|
const payload = buildSearchPayload(
|
|
{ q: query.value, page: pages.value[tab] },
|
|
tab
|
|
);
|
|
|
|
store.dispatch(SEARCH_ACTIONS[tab], payload);
|
|
};
|
|
|
|
const onTabChange = tab => {
|
|
selectedTab.value = tab;
|
|
updateURL();
|
|
};
|
|
|
|
onMounted(() => {
|
|
store.dispatch('conversationSearch/clearSearchResults');
|
|
store.dispatch('agents/get');
|
|
|
|
const parsedFilters = parseURLParams(
|
|
route.query,
|
|
isFeatureFlagEnabled(FEATURE_FLAGS.ADVANCED_SEARCH)
|
|
);
|
|
filters.value = parsedFilters;
|
|
|
|
// Auto-execute search if query parameter exists
|
|
if (route.query.q) {
|
|
onSearch(route.query.q);
|
|
}
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
query.value = '';
|
|
store.dispatch('conversationSearch/clearSearchResults');
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<div class="flex flex-col w-full h-full bg-n-background">
|
|
<div class="flex w-full p-4">
|
|
<NextButton
|
|
:label="t('GENERAL_SETTINGS.BACK')"
|
|
icon="i-lucide-chevron-left"
|
|
faded
|
|
primary
|
|
sm
|
|
@click="onBack"
|
|
/>
|
|
</div>
|
|
<section class="flex flex-col flex-grow w-full h-full overflow-hidden">
|
|
<div class="w-full max-w-5xl mx-auto z-[60]">
|
|
<div class="flex flex-col w-full px-4">
|
|
<SearchHeader
|
|
v-model:filters="filters"
|
|
:initial-query="query"
|
|
@search="onSearch"
|
|
@filter-change="onFilterChange"
|
|
/>
|
|
<SearchTabs
|
|
v-if="query"
|
|
:tabs="tabs"
|
|
:selected-tab="activeTabIndex"
|
|
@tab-change="onTabChange"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div class="flex-grow w-full h-full overflow-y-auto">
|
|
<div class="w-full max-w-5xl mx-auto px-4 pb-6">
|
|
<div v-if="showResultsSection">
|
|
<Policy
|
|
:permissions="[...ROLES, CONTACT_PERMISSIONS]"
|
|
class="flex flex-col justify-center"
|
|
>
|
|
<SearchResultContactsList
|
|
v-if="filterContacts"
|
|
:is-fetching="uiFlags.contact.isFetching"
|
|
:contacts="contacts"
|
|
:query="query"
|
|
:show-title="isSelectedTabAll"
|
|
class="mt-0.5"
|
|
/>
|
|
<NextButton
|
|
v-if="showViewMore.contacts"
|
|
:label="t(`SEARCH.VIEW_MORE`)"
|
|
icon="i-lucide-eye"
|
|
slate
|
|
sm
|
|
outline
|
|
@click="selectedTab = 'contacts'"
|
|
/>
|
|
</Policy>
|
|
|
|
<Policy
|
|
:permissions="[...ROLES, ...CONVERSATION_PERMISSIONS]"
|
|
class="flex flex-col justify-center"
|
|
>
|
|
<SearchResultMessagesList
|
|
v-if="filterMessages"
|
|
:is-fetching="uiFlags.message.isFetching"
|
|
:messages="messages"
|
|
:query="query"
|
|
:show-title="isSelectedTabAll"
|
|
:class="searchResultSectionClass"
|
|
/>
|
|
<NextButton
|
|
v-if="showViewMore.messages"
|
|
:label="t(`SEARCH.VIEW_MORE`)"
|
|
icon="i-lucide-eye"
|
|
slate
|
|
sm
|
|
outline
|
|
@click="selectedTab = 'messages'"
|
|
/>
|
|
</Policy>
|
|
|
|
<Policy
|
|
:permissions="[...ROLES, ...CONVERSATION_PERMISSIONS]"
|
|
class="flex flex-col justify-center"
|
|
>
|
|
<SearchResultConversationsList
|
|
v-if="filterConversations"
|
|
:is-fetching="uiFlags.conversation.isFetching"
|
|
:conversations="conversations"
|
|
:query="query"
|
|
:show-title="isSelectedTabAll"
|
|
:class="searchResultSectionClass"
|
|
/>
|
|
<NextButton
|
|
v-if="showViewMore.conversations"
|
|
:label="t(`SEARCH.VIEW_MORE`)"
|
|
icon="i-lucide-eye"
|
|
slate
|
|
sm
|
|
outline
|
|
@click="selectedTab = 'conversations'"
|
|
/>
|
|
</Policy>
|
|
|
|
<Policy
|
|
v-if="isFeatureFlagEnabled(FEATURE_FLAGS.HELP_CENTER)"
|
|
:permissions="[...ROLES, PORTAL_PERMISSIONS]"
|
|
:feature-flag="FEATURE_FLAGS.HELP_CENTER"
|
|
class="flex flex-col justify-center"
|
|
>
|
|
<SearchResultArticlesList
|
|
v-if="filterArticles"
|
|
:is-fetching="uiFlags.article.isFetching"
|
|
:articles="articles"
|
|
:query="query"
|
|
:show-title="isSelectedTabAll"
|
|
:class="searchResultSectionClass"
|
|
/>
|
|
<NextButton
|
|
v-if="showViewMore.articles"
|
|
:label="t(`SEARCH.VIEW_MORE`)"
|
|
icon="i-lucide-eye"
|
|
slate
|
|
sm
|
|
outline
|
|
@click="selectedTab = 'articles'"
|
|
/>
|
|
</Policy>
|
|
|
|
<div v-if="showLoadMore" class="flex justify-center mt-3 mb-6">
|
|
<NextButton
|
|
v-if="!isSelectedTabAll"
|
|
:label="t(`SEARCH.LOAD_MORE`)"
|
|
icon="i-lucide-cloud-download"
|
|
slate
|
|
sm
|
|
faded
|
|
@click="loadMore"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div
|
|
v-else-if="showEmptySearchResults"
|
|
class="flex flex-col items-center justify-center px-4 py-6 mt-8 rounded-md"
|
|
>
|
|
<fluent-icon icon="info" size="16px" class="text-n-slate-11" />
|
|
<p class="m-2 text-center text-n-slate-11">
|
|
{{ t('SEARCH.EMPTY_STATE_FULL', { query }) }}
|
|
</p>
|
|
</div>
|
|
<div
|
|
v-else-if="!query"
|
|
class="flex flex-col items-center justify-center px-4 py-6 mt-8 text-center rounded-md"
|
|
>
|
|
<p class="text-center margin-bottom-0">
|
|
<fluent-icon icon="search" size="24px" class="text-n-slate-11" />
|
|
</p>
|
|
<p class="m-2 text-center text-n-slate-11">
|
|
{{ t('SEARCH.EMPTY_STATE_DEFAULT') }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</template>
|