mirror of
https://github.com/lingble/chatwoot.git
synced 2025-11-20 13:05:16 +00:00
feat: allow searching captain responses [CW-5631] (#12463)
This commit is contained in:
@@ -6,11 +6,11 @@ class CaptainResponses extends ApiClient {
|
|||||||
super('captain/assistant_responses', { accountScoped: true });
|
super('captain/assistant_responses', { accountScoped: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
get({ page = 1, searchKey, assistantId, documentId, status } = {}) {
|
get({ page = 1, search, assistantId, documentId, status } = {}) {
|
||||||
return axios.get(this.url, {
|
return axios.get(this.url, {
|
||||||
params: {
|
params: {
|
||||||
page,
|
page,
|
||||||
searchKey,
|
search,
|
||||||
assistant_id: assistantId,
|
assistant_id: assistantId,
|
||||||
document_id: documentId,
|
document_id: documentId,
|
||||||
status,
|
status,
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ const props = defineProps({
|
|||||||
placeholder: { type: String, default: '' },
|
placeholder: { type: String, default: '' },
|
||||||
label: { type: String, default: '' },
|
label: { type: String, default: '' },
|
||||||
id: { type: String, default: '' },
|
id: { type: String, default: '' },
|
||||||
|
size: {
|
||||||
|
type: String,
|
||||||
|
default: 'md',
|
||||||
|
validator: value => ['sm', 'md'].includes(value),
|
||||||
|
},
|
||||||
message: { type: String, default: '' },
|
message: { type: String, default: '' },
|
||||||
disabled: { type: Boolean, default: false },
|
disabled: { type: Boolean, default: false },
|
||||||
messageType: {
|
messageType: {
|
||||||
@@ -69,6 +74,17 @@ const handleFocus = event => {
|
|||||||
isFocused.value = true;
|
isFocused.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sizeClass = computed(() => {
|
||||||
|
switch (props.size) {
|
||||||
|
case 'sm':
|
||||||
|
return 'h-8 !px-3 !py-2';
|
||||||
|
case 'md':
|
||||||
|
return 'h-10 !px-3 !py-2.5';
|
||||||
|
default:
|
||||||
|
return 'h-10 !px-3 !py-2.5';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const handleBlur = event => {
|
const handleBlur = event => {
|
||||||
emit('blur', event);
|
emit('blur', event);
|
||||||
isFocused.value = false;
|
isFocused.value = false;
|
||||||
@@ -105,6 +121,7 @@ onMounted(() => {
|
|||||||
:class="[
|
:class="[
|
||||||
customInputClass,
|
customInputClass,
|
||||||
inputOutlineClass,
|
inputOutlineClass,
|
||||||
|
sizeClass,
|
||||||
{
|
{
|
||||||
error: messageType === 'error',
|
error: messageType === 'error',
|
||||||
focus: isFocused,
|
focus: isFocused,
|
||||||
@@ -119,7 +136,7 @@ onMounted(() => {
|
|||||||
? max
|
? max
|
||||||
: undefined
|
: undefined
|
||||||
"
|
"
|
||||||
class="block w-full reset-base text-sm h-10 !px-3 !py-2.5 !mb-0 outline outline-1 border-none border-0 outline-offset-[-1px] rounded-lg bg-n-alpha-black2 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 disabled:cursor-not-allowed disabled:opacity-50 text-n-slate-12 transition-all duration-500 ease-in-out [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
class="block w-full reset-base text-sm !mb-0 outline outline-1 border-none border-0 outline-offset-[-1px] rounded-lg bg-n-alpha-black2 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 disabled:cursor-not-allowed disabled:opacity-50 text-n-slate-12 transition-all duration-500 ease-in-out [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||||
@input="handleInput"
|
@input="handleInput"
|
||||||
@focus="handleFocus"
|
@focus="handleFocus"
|
||||||
@blur="handleBlur"
|
@blur="handleBlur"
|
||||||
|
|||||||
@@ -759,6 +759,7 @@
|
|||||||
"SELECTED": "{count} selected",
|
"SELECTED": "{count} selected",
|
||||||
"SELECT_ALL": "Select all ({count})",
|
"SELECT_ALL": "Select all ({count})",
|
||||||
"UNSELECT_ALL": "Unselect all ({count})",
|
"UNSELECT_ALL": "Unselect all ({count})",
|
||||||
|
"SEARCH_PLACEHOLDER": "Search FAQs...",
|
||||||
"BULK_APPROVE_BUTTON": "Approve",
|
"BULK_APPROVE_BUTTON": "Approve",
|
||||||
"BULK_DELETE_BUTTON": "Delete",
|
"BULK_DELETE_BUTTON": "Delete",
|
||||||
"BULK_APPROVE": {
|
"BULK_APPROVE": {
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ import { useI18n } from 'vue-i18n';
|
|||||||
import { OnClickOutside } from '@vueuse/components';
|
import { OnClickOutside } from '@vueuse/components';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||||
|
import { debounce } from '@chatwoot/utils';
|
||||||
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||||
|
import Input from 'dashboard/components-next/input/Input.vue';
|
||||||
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
|
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
|
||||||
import BulkDeleteDialog from 'dashboard/components-next/captain/pageComponents/BulkDeleteDialog.vue';
|
import BulkDeleteDialog from 'dashboard/components-next/captain/pageComponents/BulkDeleteDialog.vue';
|
||||||
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
|
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
|
||||||
@@ -36,6 +38,7 @@ const bulkDeleteDialog = ref(null);
|
|||||||
const selectedStatus = ref('all');
|
const selectedStatus = ref('all');
|
||||||
const selectedAssistant = ref('all');
|
const selectedAssistant = ref('all');
|
||||||
const dialogType = ref('');
|
const dialogType = ref('');
|
||||||
|
const searchQuery = ref('');
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const createDialog = ref(null);
|
const createDialog = ref(null);
|
||||||
@@ -138,6 +141,9 @@ const fetchResponses = (page = 1) => {
|
|||||||
if (selectedAssistant.value !== 'all') {
|
if (selectedAssistant.value !== 'all') {
|
||||||
filterParams.assistantId = selectedAssistant.value;
|
filterParams.assistantId = selectedAssistant.value;
|
||||||
}
|
}
|
||||||
|
if (searchQuery.value) {
|
||||||
|
filterParams.search = searchQuery.value;
|
||||||
|
}
|
||||||
store.dispatch('captainResponses/get', filterParams);
|
store.dispatch('captainResponses/get', filterParams);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -250,6 +256,10 @@ const handleAssistantFilterChange = assistant => {
|
|||||||
fetchResponses();
|
fetchResponses();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const debouncedSearch = debounce(async () => {
|
||||||
|
fetchResponses();
|
||||||
|
}, 500);
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
store.dispatch('captainAssistants/get');
|
store.dispatch('captainAssistants/get');
|
||||||
fetchResponses();
|
fetchResponses();
|
||||||
@@ -292,34 +302,47 @@ onMounted(() => {
|
|||||||
<template #controls>
|
<template #controls>
|
||||||
<div
|
<div
|
||||||
v-if="shouldShowDropdown"
|
v-if="shouldShowDropdown"
|
||||||
class="mb-4 -mt-3 flex justify-between items-center w-fit py-1"
|
class="mb-4 -mt-3 flex justify-between items-center py-1"
|
||||||
:class="{
|
:class="{
|
||||||
'ltr:pl-3 rtl:pr-3 ltr:pr-1 rtl:pl-1 rounded-lg outline outline-1 outline-n-weak bg-n-solid-3':
|
'ltr:pl-3 rtl:pr-3 ltr:pr-1 rtl:pl-1 rounded-lg outline outline-1 outline-n-weak bg-n-solid-3 w-fit':
|
||||||
bulkSelectionState.hasSelected,
|
bulkSelectionState.hasSelected,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<div v-if="!bulkSelectionState.hasSelected" class="flex gap-3">
|
<div
|
||||||
<OnClickOutside @trigger="isStatusFilterOpen = false">
|
v-if="!bulkSelectionState.hasSelected"
|
||||||
<Button
|
class="flex gap-3 justify-between w-full items-center"
|
||||||
:label="selectedStatusLabel"
|
>
|
||||||
icon="i-lucide-chevron-down"
|
<div class="flex gap-3">
|
||||||
size="sm"
|
<OnClickOutside @trigger="isStatusFilterOpen = false">
|
||||||
color="slate"
|
<Button
|
||||||
trailing-icon
|
:label="selectedStatusLabel"
|
||||||
class="max-w-48"
|
icon="i-lucide-chevron-down"
|
||||||
@click="isStatusFilterOpen = !isStatusFilterOpen"
|
size="sm"
|
||||||
/>
|
color="slate"
|
||||||
|
trailing-icon
|
||||||
|
class="max-w-48"
|
||||||
|
@click="isStatusFilterOpen = !isStatusFilterOpen"
|
||||||
|
/>
|
||||||
|
|
||||||
<DropdownMenu
|
<DropdownMenu
|
||||||
v-if="isStatusFilterOpen"
|
v-if="isStatusFilterOpen"
|
||||||
:menu-items="statusOptions"
|
:menu-items="statusOptions"
|
||||||
class="mt-2"
|
class="mt-2"
|
||||||
@action="handleStatusFilterChange"
|
@action="handleStatusFilterChange"
|
||||||
|
/>
|
||||||
|
</OnClickOutside>
|
||||||
|
<AssistantSelector
|
||||||
|
:assistant-id="selectedAssistant"
|
||||||
|
@update="handleAssistantFilterChange"
|
||||||
/>
|
/>
|
||||||
</OnClickOutside>
|
</div>
|
||||||
<AssistantSelector
|
<Input
|
||||||
:assistant-id="selectedAssistant"
|
v-model="searchQuery"
|
||||||
@update="handleAssistantFilterChange"
|
:placeholder="$t('CAPTAIN.RESPONSES.SEARCH_PLACEHOLDER')"
|
||||||
|
class="w-64"
|
||||||
|
size="sm"
|
||||||
|
autofocus
|
||||||
|
@input="debouncedSearch"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -10,21 +10,9 @@ class Api::V1::Accounts::Captain::AssistantResponsesController < Api::V1::Accoun
|
|||||||
RESULTS_PER_PAGE = 25
|
RESULTS_PER_PAGE = 25
|
||||||
|
|
||||||
def index
|
def index
|
||||||
base_query = @responses
|
filtered_query = apply_filters(@responses)
|
||||||
base_query = base_query.where(assistant_id: permitted_params[:assistant_id]) if permitted_params[:assistant_id].present?
|
@responses_count = filtered_query.count
|
||||||
|
@responses = filtered_query.page(@current_page).per(RESULTS_PER_PAGE)
|
||||||
if permitted_params[:document_id].present?
|
|
||||||
base_query = base_query.where(
|
|
||||||
documentable_id: permitted_params[:document_id],
|
|
||||||
documentable_type: 'Captain::Document'
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
base_query = base_query.where(status: permitted_params[:status]) if permitted_params[:status].present?
|
|
||||||
|
|
||||||
@responses_count = base_query.count
|
|
||||||
|
|
||||||
@responses = base_query.page(@current_page).per(RESULTS_PER_PAGE)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def show; end
|
def show; end
|
||||||
@@ -46,6 +34,29 @@ class Api::V1::Accounts::Captain::AssistantResponsesController < Api::V1::Accoun
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def apply_filters(base_query)
|
||||||
|
base_query = base_query.where(assistant_id: permitted_params[:assistant_id]) if permitted_params[:assistant_id].present?
|
||||||
|
|
||||||
|
if permitted_params[:document_id].present?
|
||||||
|
base_query = base_query.where(
|
||||||
|
documentable_id: permitted_params[:document_id],
|
||||||
|
documentable_type: 'Captain::Document'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
base_query = base_query.where(status: permitted_params[:status]) if permitted_params[:status].present?
|
||||||
|
|
||||||
|
if permitted_params[:search].present?
|
||||||
|
search_term = "%#{permitted_params[:search]}%"
|
||||||
|
base_query = base_query.where(
|
||||||
|
'question ILIKE :search OR answer ILIKE :search',
|
||||||
|
search: search_term
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
base_query
|
||||||
|
end
|
||||||
|
|
||||||
def set_assistant
|
def set_assistant
|
||||||
@assistant = Current.account.captain_assistants.find_by(id: params[:assistant_id])
|
@assistant = Current.account.captain_assistants.find_by(id: params[:assistant_id])
|
||||||
end
|
end
|
||||||
@@ -63,7 +74,7 @@ class Api::V1::Accounts::Captain::AssistantResponsesController < Api::V1::Accoun
|
|||||||
end
|
end
|
||||||
|
|
||||||
def permitted_params
|
def permitted_params
|
||||||
params.permit(:id, :assistant_id, :page, :document_id, :account_id, :status)
|
params.permit(:id, :assistant_id, :page, :document_id, :account_id, :status, :search)
|
||||||
end
|
end
|
||||||
|
|
||||||
def response_params
|
def response_params
|
||||||
|
|||||||
@@ -90,6 +90,53 @@ RSpec.describe 'Api::V1::Accounts::Captain::AssistantResponses', type: :request
|
|||||||
expect(json_response[:payload][0][:documentable][:id]).to eq(document.id)
|
expect(json_response[:payload][0][:documentable][:id]).to eq(document.id)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context 'when searching' do
|
||||||
|
before do
|
||||||
|
create(:captain_assistant_response,
|
||||||
|
account: account,
|
||||||
|
assistant: assistant,
|
||||||
|
question: 'How to reset password?',
|
||||||
|
answer: 'Click forgot password')
|
||||||
|
create(:captain_assistant_response,
|
||||||
|
account: account,
|
||||||
|
assistant: assistant,
|
||||||
|
question: 'How to change email?',
|
||||||
|
answer: 'Go to settings')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'finds responses by question text' do
|
||||||
|
get "/api/v1/accounts/#{account.id}/captain/assistant_responses",
|
||||||
|
params: { search: 'password' },
|
||||||
|
headers: agent.create_new_auth_token,
|
||||||
|
as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(json_response[:payload].length).to eq(1)
|
||||||
|
expect(json_response[:payload][0][:question]).to include('password')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'finds responses by answer text' do
|
||||||
|
get "/api/v1/accounts/#{account.id}/captain/assistant_responses",
|
||||||
|
params: { search: 'settings' },
|
||||||
|
headers: agent.create_new_auth_token,
|
||||||
|
as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(json_response[:payload].length).to eq(1)
|
||||||
|
expect(json_response[:payload][0][:answer]).to include('settings')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns empty when no matches' do
|
||||||
|
get "/api/v1/accounts/#{account.id}/captain/assistant_responses",
|
||||||
|
params: { search: 'nonexistent' },
|
||||||
|
headers: agent.create_new_auth_token,
|
||||||
|
as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(json_response[:payload].length).to eq(0)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe 'GET /api/v1/accounts/:account_id/captain/assistant_responses/:id' do
|
describe 'GET /api/v1/accounts/:account_id/captain/assistant_responses/:id' do
|
||||||
|
|||||||
Reference in New Issue
Block a user