mirror of
https://github.com/elastic/kibana.git
synced 2025-06-27 18:51:07 -04:00
[Security Assistant] Add the option to delete bulk conversations for the AI Settings page (#223136)
## Summary This PR adds a new api for deleting all conversations without providing conversation ids. It takes excluded ids to skip the conversations that are not going to be deleted. example: Deleting all conversations except the latest one. <img width="2558" alt="Screenshot 2025-06-19 at 10 28 19" src="https://github.com/user-attachments/assets/eef4c9ef-1415-47b4-ad84-957bfd7f6874" /> ``` delete `/app/management/kibana/securityAiAssistantManagement` {"excludedIds":["7X-Dh5cBzHjHjpq0iVDH"]} ``` To test: (Test env: https://p.elstc.co/paste/UjwhcpLK#-Bi4EAfwWwrJNg0kCHz4mZVDy8k16HtNlf9FdJgUM7K) 1. Add some conversations from dev tools: <details> <summary><i>mock conversations:</i></summary> ``` POST .kibana-elastic-ai-assistant-conversations-default/_bulk { "create": {}} { "@timestamp": "2025-06-10T08:00:00Z", "title": "Example Conversation 3", "api_config": { "action_type_id": ".inference", "connector_id": "elastic-llm" }, "messages": [ { "@timestamp": "2025-06-10T08:20:00Z", "content": "Tell me a joke.", "is_error": false, "role": "user", "metadata": { "content_references": {} } }, { "@timestamp": "2025-06-10T08:20:01Z", "content": "Why did the chicken cross the road? To get to the other side!", "is_error": false, "role": "assistant", "metadata": { "content_references": {} } } ], "summary": { "@timestamp": "2025-06-10T08:20:01Z", "confidence": "low", "content": "User asked for a joke, assistant provided one.", "public": true }, "users": [ { "name": "elastic", "id": "u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0" } ], "replacements": { "uuid": "repl-003", "value": "value-003" } } { "create": {}} { "@timestamp": "2025-06-10T08:00:00Z", "title": "Example Conversation 4", "api_config": { "action_type_id": ".inference", "connector_id": "elastic-llm" }, "messages": [ { "@timestamp": "2025-06-10T08:30:00Z", "content": "What is the capital of France?", "is_error": false, "role": "user", "metadata": { "content_references": {} } }, { "@timestamp": "2025-06-10T08:30:01Z", "content": "The capital of France is Paris.", "is_error": false, "role": "assistant", "metadata": { "content_references": {} } } ], "summary": { "@timestamp": "2025-06-10T08:30:01Z", "confidence": "high", "content": "User asked about the capital of France, assistant answered correctly.", "public": true }, "users": [ { "name": "elastic", "id": "u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0" } ], "replacements": { "uuid": "repl-004", "value": "value-004" } } { "create": {}} { "@timestamp": "2025-06-10T08:00:00Z", "title": "Example Conversation 5", "api_config": { "action_type_id": ".inference", "connector_id": "elastic-llm" }, "messages": [ { "@timestamp": "2025-06-10T08:40:00Z", "content": "How do I reset my password?", "is_error": false, "role": "user", "metadata": { "content_references": {} } }, { "@timestamp": "2025-06-10T08:40:01Z", "content": "To reset your password, go to settings and click 'Reset Password'.", "is_error": false, "role": "assistant", "metadata": { "content_references": {} } } ], "summary": { "@timestamp": "2025-06-10T08:40:01Z", "confidence": "medium", "content": "User asked how to reset password, assistant provided instructions.", "public": true }, "users": [ { "name": "elastic", "id": "u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0" } ], "replacements": { "uuid": "repl-005", "value": "value-005" } } { "create": {}} { "@timestamp": "2025-06-10T08:00:00Z", "title": "Example Conversation 6", "api_config": { "action_type_id": ".inference", "connector_id": "elastic-llm" }, "messages": [ { "@timestamp": "2025-06-10T08:50:00Z", "content": "What is the meaning of life?", "is_error": false, "role": "user", "metadata": { "content_references": {} } }, { "@timestamp": "2025-06-10T08:50:01Z", "content": "The meaning of life is subjective and varies for each individual.", "is_error": false, "role": "assistant", "metadata": { "content_references": {} } } ], "summary": { "@timestamp": "2025-06-10T08:50:01Z", "confidence": "low", "content": "User asked philosophical question, assistant provided general answer.", "public": true }, "users": [ { "name": "elastic", "id": "u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0" } ], "replacements": { "uuid": "repl-006", "value": "value-006" } } { "create": {}} { "@timestamp": "2025-06-10T08:00:00Z", "title": "Example Conversation 7", "api_config": { "action_type_id": ".inference", "connector_id": "elastic-llm" }, "messages": [ { "@timestamp": "2025-06-10T09:00:00Z", "content": "Can you help me with my homework?", "is_error": false, "role": "user", "metadata": { "content_references": {} } }, { "@timestamp": "2025-06-10T09:00:01Z", "content": "Sure! What subject is your homework in?", "is_error": false, "role": "assistant", "metadata": { "content_references": {} } } ], "summary": { "@timestamp": "2025-06-10T09:00:01Z", "confidence": "high", "content": "User asked for homework help, assistant offered to assist.", "public": true }, "users": [ { "name": "elastic", "id": "u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0" } ], "replacements": { "uuid": "repl-007", "value": "value-007" } } { "create": {}} { "@timestamp": "2025-06-10T08:00:00Z", "title": "Example Conversation 8", "api_config": { "action_type_id": ".inference", "connector_id": "elastic-llm" }, "messages": [ { "@timestamp": "2025-06-10T09:10:00Z", "content": "What is the best programming language?", "is_error": false, "role": "user", "metadata": { "content_references": {} } }, { "@timestamp": "2025-06-10T09:10:01Z", "content": "It depends on your needs, but Python is a great choice for beginners.", "is_error": false, "role": "assistant", "metadata": { "content_references": {} } } ], "summary": { "@timestamp": "2025-06-10T09:10:01Z", "confidence": "medium", "content": "User asked about programming languages, assistant provided recommendation.", "public": true }, "users": [ { "name": "elastic", "id": "u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0" } ], "replacements": { "uuid": "repl-008", "value": "value-008" } } { "create": {}} { "@timestamp": "2025-06-10T08:00:00Z", "title": "Example Conversation 9", "api_config": { "action_type_id": ".inference", "connector_id": "elastic-llm" }, "messages": [ { "@timestamp": "2025-06-10T09:20:00Z", "content": "How do I improve my writing skills?", "is_error": false, "role": "user", "metadata": { "content_references": {} } }, { "@timestamp": "2025-06-10T09:20:01Z", "content": "Practice regularly, read widely, and seek feedback.", "is_error": false, "role": "assistant", "metadata": { "content_references": {} } } ], "summary": { "@timestamp": "2025-06-10T09:20:01Z", "confidence": "high", "content": "User asked about writing skills, assistant provided tips.", "public": true }, "users": [ { "name": "elastic", "id": "u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0" } ], "replacements": { "uuid": "repl-009", "value": "value-009" } } { "create": {}} { "@timestamp": "2025-06-10T08:00:00Z", "title": "Example Conversation 10", "api_config": { "action_type_id": ".inference", "connector_id": "elastic-llm" }, "messages": [ { "@timestamp": "2025-06-10T09:30:00Z", "content": "What is the best way to learn a new language?", "is_error": false, "role": "user", "metadata": { "content_references": {} } }, { "@timestamp": "2025-06-10T09:30:01Z", "content": "Immersion, practice speaking, and using language learning apps are effective.", "is_error": false, "role": "assistant", "metadata": { "content_references": {} } } ], "summary": { "@timestamp": "2025-06-10T09:30:01Z", "confidence": "medium", "content": "User asked about language learning, assistant provided methods.", "public": true }, "users": [ { "name": "elastic", "id": "u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0" } ], "replacements": { "uuid": "repl-010", "value": "value-010" } } { "create": {}} { "@timestamp": "2025-06-10T08:00:00Z", "title": "Example Conversation 11", "api_config": { "action_type_id": ".inference", "connector_id": "elastic-llm" }, "messages": [ { "@timestamp": "2025-06-10T09:40:00Z", "content": "What are some tips for public speaking?", "is_error": false, "role": "user", "metadata": { "content_references": {} } }, { "@timestamp": "2025-06-10T09:40:01Z", "content": "Practice, know your material, and engage with your audience.", "is_error": false, "role": "assistant", "metadata": { "content_references": {} } } ], "summary": { "@timestamp": "2025-06-10T09:40:01Z", "confidence": "high", "content": "User asked about public speaking, assistant provided tips.", "public": true }, "users": [ { "name": "elastic", "id": "u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0" } ], "replacements": { "uuid": "repl-011", "value": "value-011" } } { "create": {}} { "@timestamp": "2025-06-10T08:00:00Z", "title": "Example Conversation 12", "api_config": { "action_type_id": ".inference", "connector_id": "elastic-llm" }, "messages": [ { "@timestamp": "2025-06-10T09:50:00Z", "content": "How can I improve my time management skills?", "is_error": false, "role": "user", "metadata": { "content_references": {} } }, { "@timestamp": "2025-06-10T09:50:01Z", "content": "Prioritize tasks, set deadlines, and use tools like calendars.", "is_error": false, "role": "assistant", "metadata": { "content_references": {} } } ], "summary": { "@timestamp": "2025-06-10T09:50:01Z", "confidence": "medium", "content": "User asked about time management, assistant provided strategies.", "public": true }, "users": [ { "name": "elastic", "id": "u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0" } ], "replacements": { "uuid": "repl-012", "value": "value-012" } } { "create": {}} { "@timestamp": "2025-06-10T08:00:00Z", "title": "Example Conversation 13", "api_config": { "action_type_id": ".inference", "connector_id": "elastic-llm" }, "messages": [ { "@timestamp": "2025-06-10T10:00:00Z", "content": "What are some effective study techniques?", "is_error": false, "role": "user", "metadata": { "content_references": {} } }, { "@timestamp": "2025-06-10T10:00:01Z", "content": "Active recall, spaced repetition, and summarization are effective.", "is_error": false, "role": "assistant", "metadata": { "content_references": {} } } ], "summary": { "@timestamp": "2025-06-10T10:00:01Z", "confidence": "high", "content": "User asked about study techniques, assistant provided methods.", "public": true }, "users": [ { "name": "elastic", "id": "u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0" } ], "replacements": { "uuid": "repl-013", "value": "value-013" } } { "create": {}} { "@timestamp": "2025-06-10T08:00:00Z", "title": "Example Conversation 14", "api_config": { "action_type_id": ".inference", "connector_id": "elastic-llm" }, "messages": [ { "@timestamp": "2025-06-10T10:10:00Z", "content": "How can I enhance my critical thinking skills?", "is_error": false, "role": "user", "metadata": { "content_references": {} } }, { "@timestamp": "2025-06-10T10:10:01Z", "content": "Engage in debates, analyze arguments, and reflect on your reasoning.", "is_error": false, "role": "assistant", "metadata": { "content_references": {} } } ], "summary": { "@timestamp": "2025-06-10T10:10:01Z", "confidence": "medium", "content": "User asked about critical thinking, assistant provided tips.", "public": true }, "users": [ { "name": "elastic", "id": "u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0" } ], "replacements": { "uuid": "repl-014", "value": "value-014" } } ``` </details> 2. Visit `/app/management/kibana/securityAiAssistantManagement` and try deleting conversations. If it's a delete all request, the request should look like this: ``` delete `/app/management/kibana/securityAiAssistantManagement` ``` <img width="1281" alt="Screenshot 2025-06-16 at 12 59 01" src="https://github.com/user-attachments/assets/9f9562f3-b5d8-4c3b-9418-8550ce24a6b0" /> ### Checklist Check the PR satisfies following conditions. Reviewers should verify this PR satisfies this list as well. - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md) - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
c019b59b41
commit
fde7604f47
34 changed files with 1857 additions and 67 deletions
|
@ -42235,6 +42235,63 @@ paths:
|
|||
tags:
|
||||
- Security AI Assistant API
|
||||
/api/security_ai_assistant/current_user/conversations:
|
||||
delete:
|
||||
description: This endpoint allows users to permanently delete all conversations.
|
||||
operationId: DeleteAllConversations
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
excludedIds:
|
||||
description: Optional list of conversation IDs to delete.
|
||||
example:
|
||||
- abc123
|
||||
- def456
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
required: false
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
example:
|
||||
success: true
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
failures:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
success:
|
||||
example: true
|
||||
type: boolean
|
||||
totalDeleted:
|
||||
example: 10
|
||||
type: number
|
||||
description: Indicates a successful call. The conversations were deleted successfully.
|
||||
'400':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
error:
|
||||
example: Bad Request
|
||||
type: string
|
||||
message:
|
||||
example: Invalid conversation ID
|
||||
type: string
|
||||
statusCode:
|
||||
example: 400
|
||||
type: number
|
||||
description: Generic Error. This response indicates an issue with the request.
|
||||
summary: Delete conversations
|
||||
tags:
|
||||
- Security AI Assistant API
|
||||
post:
|
||||
description: Create a new Security AI Assistant conversation. This endpoint allows the user to initiate a conversation with the Security AI Assistant by providing the required parameters.
|
||||
operationId: CreateConversation
|
||||
|
|
|
@ -45205,6 +45205,63 @@ paths:
|
|||
tags:
|
||||
- Security AI Assistant API
|
||||
/api/security_ai_assistant/current_user/conversations:
|
||||
delete:
|
||||
description: This endpoint allows users to permanently delete all conversations.
|
||||
operationId: DeleteAllConversations
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
excludedIds:
|
||||
description: Optional list of conversation IDs to delete.
|
||||
example:
|
||||
- abc123
|
||||
- def456
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
required: false
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
example:
|
||||
success: true
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
failures:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
success:
|
||||
example: true
|
||||
type: boolean
|
||||
totalDeleted:
|
||||
example: 10
|
||||
type: number
|
||||
description: Indicates a successful call. The conversations were deleted successfully.
|
||||
'400':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
error:
|
||||
example: Bad Request
|
||||
type: string
|
||||
message:
|
||||
example: Invalid conversation ID
|
||||
type: string
|
||||
statusCode:
|
||||
example: 400
|
||||
type: number
|
||||
description: Generic Error. This response indicates an issue with the request.
|
||||
summary: Delete conversations
|
||||
tags:
|
||||
- Security AI Assistant API
|
||||
post:
|
||||
description: Create a new Security AI Assistant conversation. This endpoint allows the user to initiate a conversation with the Security AI Assistant by providing the required parameters.
|
||||
operationId: CreateConversation
|
||||
|
|
|
@ -329,6 +329,66 @@ paths:
|
|||
- Security AI Assistant API
|
||||
- Chat Complete API
|
||||
/api/security_ai_assistant/current_user/conversations:
|
||||
delete:
|
||||
description: This endpoint allows users to permanently delete all conversations.
|
||||
operationId: DeleteAllConversations
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
excludedIds:
|
||||
description: Optional list of conversation IDs to delete.
|
||||
example:
|
||||
- abc123
|
||||
- def456
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
required: false
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
example:
|
||||
success: true
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
failures:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
success:
|
||||
example: true
|
||||
type: boolean
|
||||
totalDeleted:
|
||||
example: 10
|
||||
type: number
|
||||
description: >-
|
||||
Indicates a successful call. The conversations were deleted
|
||||
successfully.
|
||||
'400':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
error:
|
||||
example: Bad Request
|
||||
type: string
|
||||
message:
|
||||
example: Invalid conversation ID
|
||||
type: string
|
||||
statusCode:
|
||||
example: 400
|
||||
type: number
|
||||
description: Generic Error. This response indicates an issue with the request.
|
||||
summary: Delete conversations
|
||||
tags:
|
||||
- Security AI Assistant API
|
||||
- Conversation API
|
||||
post:
|
||||
description: >-
|
||||
Create a new Security AI Assistant conversation. This endpoint allows
|
||||
|
|
|
@ -329,6 +329,66 @@ paths:
|
|||
- Security AI Assistant API
|
||||
- Chat Complete API
|
||||
/api/security_ai_assistant/current_user/conversations:
|
||||
delete:
|
||||
description: This endpoint allows users to permanently delete all conversations.
|
||||
operationId: DeleteAllConversations
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
excludedIds:
|
||||
description: Optional list of conversation IDs to delete.
|
||||
example:
|
||||
- abc123
|
||||
- def456
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
required: false
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
example:
|
||||
success: true
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
failures:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
success:
|
||||
example: true
|
||||
type: boolean
|
||||
totalDeleted:
|
||||
example: 10
|
||||
type: number
|
||||
description: >-
|
||||
Indicates a successful call. The conversations were deleted
|
||||
successfully.
|
||||
'400':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
error:
|
||||
example: Bad Request
|
||||
type: string
|
||||
message:
|
||||
example: Invalid conversation ID
|
||||
type: string
|
||||
statusCode:
|
||||
example: 400
|
||||
type: number
|
||||
description: Generic Error. This response indicates an issue with the request.
|
||||
summary: Delete conversations
|
||||
tags:
|
||||
- Security AI Assistant API
|
||||
- Conversation API
|
||||
post:
|
||||
description: >-
|
||||
Create a new Security AI Assistant conversation. This endpoint allows
|
||||
|
|
|
@ -30,6 +30,24 @@ export type CreateConversationRequestBodyInput = z.input<typeof CreateConversati
|
|||
export type CreateConversationResponse = z.infer<typeof CreateConversationResponse>;
|
||||
export const CreateConversationResponse = ConversationResponse;
|
||||
|
||||
export type DeleteAllConversationsRequestBody = z.infer<typeof DeleteAllConversationsRequestBody>;
|
||||
export const DeleteAllConversationsRequestBody = z.object({
|
||||
/**
|
||||
* Optional list of conversation IDs to delete.
|
||||
*/
|
||||
excludedIds: z.array(z.string()).optional(),
|
||||
});
|
||||
export type DeleteAllConversationsRequestBodyInput = z.input<
|
||||
typeof DeleteAllConversationsRequestBody
|
||||
>;
|
||||
|
||||
export type DeleteAllConversationsResponse = z.infer<typeof DeleteAllConversationsResponse>;
|
||||
export const DeleteAllConversationsResponse = z.object({
|
||||
success: z.boolean().optional(),
|
||||
totalDeleted: z.number().optional(),
|
||||
failures: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export type DeleteConversationRequestParams = z.infer<typeof DeleteConversationRequestParams>;
|
||||
export const DeleteConversationRequestParams = z.object({
|
||||
/**
|
||||
|
|
|
@ -71,7 +71,63 @@ paths:
|
|||
message:
|
||||
type: string
|
||||
example: "Missing required parameter: title"
|
||||
|
||||
delete:
|
||||
x-codegen-enabled: true
|
||||
x-labels: [ess, serverless]
|
||||
operationId: DeleteAllConversations
|
||||
description: This endpoint allows users to permanently delete all conversations.
|
||||
summary: Delete conversations
|
||||
tags:
|
||||
- Conversation API
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
excludedIds:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: Optional list of conversation IDs to delete.
|
||||
example: ["abc123", "def456"]
|
||||
responses:
|
||||
200:
|
||||
description: Indicates a successful call. The conversations were deleted successfully.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
example: true
|
||||
totalDeleted:
|
||||
type: number
|
||||
example: 10
|
||||
failures:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
example:
|
||||
success: true
|
||||
400:
|
||||
description: Generic Error. This response indicates an issue with the request.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
statusCode:
|
||||
type: number
|
||||
example: 400
|
||||
error:
|
||||
type: string
|
||||
example: "Bad Request"
|
||||
message:
|
||||
type: string
|
||||
example: "Invalid conversation ID"
|
||||
/api/security_ai_assistant/current_user/conversations/{id}:
|
||||
get:
|
||||
x-codegen-enabled: true
|
||||
|
|
|
@ -0,0 +1,57 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import { httpServiceMock } from '@kbn/core-http-browser-mocks';
|
||||
import {
|
||||
API_VERSIONS,
|
||||
ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL,
|
||||
} from '@kbn/elastic-assistant-common';
|
||||
import { deleteAllConversations } from './delete_all_conversations';
|
||||
import { IToasts } from '@kbn/core/public';
|
||||
|
||||
const mockAddError = jest.fn();
|
||||
const toasts = {
|
||||
addError: mockAddError,
|
||||
} as unknown as IToasts;
|
||||
|
||||
describe('deleteAllConversations', () => {
|
||||
let httpMock: ReturnType<typeof httpServiceMock.createSetupContract>;
|
||||
|
||||
beforeEach(() => {
|
||||
httpMock = httpServiceMock.createSetupContract();
|
||||
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should send a POST request with the correct parameters and receive a successful response', async () => {
|
||||
await deleteAllConversations({ http: httpMock, toasts });
|
||||
|
||||
expect(httpMock.fetch).toHaveBeenCalledWith(ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL, {
|
||||
method: 'DELETE',
|
||||
version: API_VERSIONS.public.v1,
|
||||
body: JSON.stringify({
|
||||
excludedIds: [],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle cases where result.failure exists', async () => {
|
||||
httpMock.fetch.mockResolvedValue({
|
||||
success: false,
|
||||
failures: [{ message: 'Error updating conversations for conversation Conversation 1' }],
|
||||
});
|
||||
|
||||
await deleteAllConversations({ http: httpMock, toasts });
|
||||
expect(mockAddError.mock.calls[0][0]).toEqual(new Error('Failed to delete all conversations'));
|
||||
});
|
||||
|
||||
it('should handle error', async () => {
|
||||
httpMock.fetch.mockRejectedValue(new Error('Error'));
|
||||
|
||||
await deleteAllConversations({ http: httpMock, toasts });
|
||||
expect(mockAddError.mock.calls[0][0]).toEqual(new Error('Error'));
|
||||
});
|
||||
});
|
|
@ -0,0 +1,57 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { HttpSetup, IToasts } from '@kbn/core/public';
|
||||
import { API_VERSIONS, DeleteAllConversationsResponse } from '@kbn/elastic-assistant-common';
|
||||
import { ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL } from '@kbn/elastic-assistant-common/constants';
|
||||
|
||||
export const deleteAllConversations = async ({
|
||||
http,
|
||||
signal,
|
||||
toasts,
|
||||
excludedIds = [],
|
||||
}: {
|
||||
http: HttpSetup;
|
||||
toasts?: IToasts;
|
||||
signal?: AbortSignal | undefined;
|
||||
excludedIds?: string[];
|
||||
}) => {
|
||||
try {
|
||||
const result = await http.fetch<DeleteAllConversationsResponse>(
|
||||
ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL,
|
||||
{
|
||||
method: 'DELETE',
|
||||
signal,
|
||||
version: API_VERSIONS.public.v1,
|
||||
body: JSON.stringify({ excludedIds }),
|
||||
}
|
||||
);
|
||||
if (result?.failures) {
|
||||
const error = new Error('Failed to delete all conversations');
|
||||
toasts?.addError(error, {
|
||||
title: i18n.translate('xpack.elasticAssistant.conversations.deleteAllError', {
|
||||
defaultMessage: 'Failed to delete all conversations',
|
||||
}),
|
||||
toastMessage: result.failures.join(','),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
toasts?.addError(error.body && error.body.message ? new Error(error.body.message) : error, {
|
||||
title: i18n.translate('xpack.elasticAssistant.conversations.deleteAllConversationsError', {
|
||||
defaultMessage: 'Error deleting conversations {error}',
|
||||
values: {
|
||||
error: error.message
|
||||
? Array.isArray(error.message)
|
||||
? error.message.join(',')
|
||||
: error.message
|
||||
: error,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
};
|
|
@ -8,3 +8,4 @@
|
|||
export * from './conversations';
|
||||
export * from './bulk_update_actions_conversations';
|
||||
export * from './use_fetch_current_user_conversations';
|
||||
export * from './delete_all_conversations';
|
||||
|
|
|
@ -45,7 +45,7 @@ export const ElasticLlmCallout = ({ showEISCallout }: { showEISCallout: boolean
|
|||
}, [showEISCallout, tourCompleted]);
|
||||
|
||||
if (!showCallOut) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
@ -21,7 +21,7 @@ import { css } from '@emotion/react';
|
|||
import { PromptTypeEnum } from '@kbn/elastic-assistant-common';
|
||||
import { useConversationsUpdater } from '../../settings/use_settings_updater/use_conversations_updater';
|
||||
import { Conversation } from '../../../assistant_context/types';
|
||||
import { ConversationTableItem, useConversationsTable } from './use_conversations_table';
|
||||
import { useConversationsTable } from './use_conversations_table';
|
||||
import { ConversationStreamingSwitch } from '../conversation_settings/conversation_streaming_switch';
|
||||
import { AIConnector } from '../../../connectorland/connector_selector';
|
||||
import * as i18n from './translations';
|
||||
|
@ -38,11 +38,16 @@ import {
|
|||
useSessionPagination,
|
||||
} from '../../common/components/assistant_settings_management/pagination/use_session_pagination';
|
||||
import { AssistantSettingsBottomBar } from '../../settings/assistant_settings_bottom_bar';
|
||||
import { Toolbar } from './tool_bar_component';
|
||||
import { ConversationTableItem } from './types';
|
||||
import { useConversationSelection } from './use_conversation_selection';
|
||||
|
||||
interface Props {
|
||||
connectors: AIConnector[] | undefined;
|
||||
defaultConnector?: AIConnector;
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
const ConversationSettingsManagementComponent: React.FC<Props> = ({
|
||||
connectors,
|
||||
defaultConnector,
|
||||
|
@ -58,6 +63,26 @@ const ConversationSettingsManagementComponent: React.FC<Props> = ({
|
|||
|
||||
const { data: allPrompts, refetch: refetchPrompts } = useFetchPrompts();
|
||||
const [totalItemCount, setTotalItemCount] = useState(5);
|
||||
|
||||
const {
|
||||
selectionState: {
|
||||
isDeleteAll,
|
||||
isExcludedMode,
|
||||
deletedConversations,
|
||||
totalSelectedConversations,
|
||||
excludedIds,
|
||||
},
|
||||
selectionActions: {
|
||||
handleUnselectAll,
|
||||
handleSelectAll,
|
||||
handlePageUnchecked,
|
||||
handlePageChecked,
|
||||
handleRowUnChecked,
|
||||
handleRowChecked,
|
||||
setDeletedConversations,
|
||||
},
|
||||
} = useConversationSelection();
|
||||
|
||||
const { onTableChange, pagination, sorting } = useSessionPagination<Conversation, false>({
|
||||
nameSpace,
|
||||
storageKey: CONVERSATION_TABLE_SESSION_STORAGE_KEY,
|
||||
|
@ -66,6 +91,11 @@ const ConversationSettingsManagementComponent: React.FC<Props> = ({
|
|||
totalItemCount,
|
||||
});
|
||||
|
||||
const deletedConversationsIds = useMemo(
|
||||
() => deletedConversations.map((item) => item.id),
|
||||
[deletedConversations]
|
||||
);
|
||||
|
||||
const allSystemPrompts = useMemo(
|
||||
() => allPrompts.data.filter((p) => p.promptType === PromptTypeEnum.system),
|
||||
[allPrompts.data]
|
||||
|
@ -97,6 +127,7 @@ const ConversationSettingsManagementComponent: React.FC<Props> = ({
|
|||
const {
|
||||
assistantStreamingEnabled,
|
||||
conversationsSettingsBulkActions,
|
||||
onConversationsBulkDeleted,
|
||||
onConversationDeleted,
|
||||
resetConversationsSettings,
|
||||
saveConversationsSettings,
|
||||
|
@ -109,19 +140,32 @@ const ConversationSettingsManagementComponent: React.FC<Props> = ({
|
|||
|
||||
const handleSave = useCallback(
|
||||
async (param?: { callback?: () => void }) => {
|
||||
const isSuccess = await saveConversationsSettings();
|
||||
const { callback } = param ?? {};
|
||||
const saveConversationsSettingsParams =
|
||||
isDeleteAll || excludedIds.length > 0
|
||||
? { isDeleteAll: true, excludedIds }
|
||||
: { isDeleteAll: false };
|
||||
const isSuccess = await saveConversationsSettings(saveConversationsSettingsParams);
|
||||
if (isSuccess) {
|
||||
toasts?.addSuccess({
|
||||
iconType: 'check',
|
||||
title: SETTINGS_UPDATED_TOAST_TITLE,
|
||||
});
|
||||
setHasPendingChanges(false);
|
||||
param?.callback?.();
|
||||
handleUnselectAll();
|
||||
callback?.();
|
||||
} else {
|
||||
resetConversationsSettings();
|
||||
}
|
||||
},
|
||||
[resetConversationsSettings, saveConversationsSettings, toasts]
|
||||
[
|
||||
excludedIds,
|
||||
handleUnselectAll,
|
||||
isDeleteAll,
|
||||
resetConversationsSettings,
|
||||
saveConversationsSettings,
|
||||
toasts,
|
||||
]
|
||||
);
|
||||
|
||||
const setAssistantStreamingEnabled = useCallback(
|
||||
|
@ -150,7 +194,6 @@ const ConversationSettingsManagementComponent: React.FC<Props> = ({
|
|||
openFlyout: openEditFlyout,
|
||||
closeFlyout: closeEditFlyout,
|
||||
} = useFlyoutModalVisibility();
|
||||
const [deletedConversation, setDeletedConversation] = useState<ConversationTableItem | null>();
|
||||
|
||||
const {
|
||||
isFlyoutOpen: deleteConfirmModalVisibility,
|
||||
|
@ -168,15 +211,22 @@ const ConversationSettingsManagementComponent: React.FC<Props> = ({
|
|||
|
||||
const onDeleteActionClicked = useCallback(
|
||||
(rowItem: ConversationTableItem) => {
|
||||
setDeletedConversation(rowItem);
|
||||
setDeletedConversations([rowItem]);
|
||||
onConversationDeleted(rowItem.id);
|
||||
|
||||
closeEditFlyout();
|
||||
openConfirmModal();
|
||||
},
|
||||
[closeEditFlyout, onConversationDeleted, openConfirmModal]
|
||||
[closeEditFlyout, onConversationDeleted, openConfirmModal, setDeletedConversations]
|
||||
);
|
||||
|
||||
const onBulkDeleteActionClicked = useCallback(() => {
|
||||
onConversationsBulkDeleted(deletedConversationsIds);
|
||||
|
||||
closeEditFlyout();
|
||||
openConfirmModal();
|
||||
}, [closeEditFlyout, deletedConversationsIds, onConversationsBulkDeleted, openConfirmModal]);
|
||||
|
||||
const onDeleteConfirmed = useCallback(() => {
|
||||
if (Object.keys(conversationsSettingsBulkActions).length === 0) {
|
||||
return;
|
||||
|
@ -193,10 +243,10 @@ const ConversationSettingsManagementComponent: React.FC<Props> = ({
|
|||
]);
|
||||
|
||||
const onDeleteCancelled = useCallback(() => {
|
||||
setDeletedConversation(null);
|
||||
handleUnselectAll();
|
||||
closeConfirmModal();
|
||||
onCancelClick();
|
||||
}, [closeConfirmModal, onCancelClick]);
|
||||
}, [closeConfirmModal, handleUnselectAll, onCancelClick]);
|
||||
|
||||
const { getConversationsList, getColumns } = useConversationsTable();
|
||||
|
||||
|
@ -222,21 +272,48 @@ const ConversationSettingsManagementComponent: React.FC<Props> = ({
|
|||
const columns = useMemo(
|
||||
() =>
|
||||
getColumns({
|
||||
isDeleteEnabled: () => true,
|
||||
isEditEnabled: () => true,
|
||||
conversationOptions,
|
||||
deletedConversationsIds,
|
||||
excludedIds,
|
||||
handlePageChecked,
|
||||
handlePageUnchecked,
|
||||
handleRowChecked,
|
||||
handleRowUnChecked,
|
||||
isDeleteEnabled: () => !isDeleteAll && deletedConversations.length === 0,
|
||||
isEditEnabled: () => !isDeleteAll && deletedConversations.length === 0,
|
||||
isExcludedMode,
|
||||
onDeleteActionClicked,
|
||||
onEditActionClicked,
|
||||
totalItemCount,
|
||||
}),
|
||||
[getColumns, onDeleteActionClicked, onEditActionClicked]
|
||||
[
|
||||
conversationOptions,
|
||||
deletedConversations.length,
|
||||
deletedConversationsIds,
|
||||
excludedIds,
|
||||
getColumns,
|
||||
handlePageChecked,
|
||||
handlePageUnchecked,
|
||||
handleRowChecked,
|
||||
handleRowUnChecked,
|
||||
isDeleteAll,
|
||||
isExcludedMode,
|
||||
onDeleteActionClicked,
|
||||
onEditActionClicked,
|
||||
totalItemCount,
|
||||
]
|
||||
);
|
||||
|
||||
const confirmationTitle = useMemo(
|
||||
() =>
|
||||
deletedConversation?.title
|
||||
? i18n.DELETE_CONVERSATION_CONFIRMATION_TITLE(deletedConversation?.title)
|
||||
: i18n.DELETE_CONVERSATION_CONFIRMATION_DEFAULT_TITLE,
|
||||
[deletedConversation?.title]
|
||||
);
|
||||
const confirmationTitle = useMemo(() => {
|
||||
if (!deletedConversations) {
|
||||
return;
|
||||
}
|
||||
return deletedConversations.length === 1
|
||||
? deletedConversations[0]?.title
|
||||
? i18n.DELETE_CONVERSATION_CONFIRMATION_TITLE(deletedConversations[0]?.title)
|
||||
: i18n.DELETE_CONVERSATION_CONFIRMATION_DEFAULT_TITLE
|
||||
: i18n.DELETE_MULTIPLE_CONVERSATIONS_CONFIRMATION_TITLE(totalSelectedConversations);
|
||||
}, [deletedConversations, totalSelectedConversations]);
|
||||
|
||||
if (!conversationsLoaded) {
|
||||
return null;
|
||||
|
@ -260,12 +337,21 @@ const ConversationSettingsManagementComponent: React.FC<Props> = ({
|
|||
<EuiSpacer size="xs" />
|
||||
<EuiText size="m">{i18n.CONVERSATIONS_LIST_DESCRIPTION}</EuiText>
|
||||
<EuiSpacer size="s" />
|
||||
<Toolbar
|
||||
onConversationsBulkDeleted={onBulkDeleteActionClicked}
|
||||
handleSelectAll={handleSelectAll}
|
||||
handleUnselectAll={handleUnselectAll}
|
||||
totalConversations={totalItemCount}
|
||||
totalSelected={totalSelectedConversations}
|
||||
isDeleteAll={isDeleteAll}
|
||||
/>
|
||||
<EuiBasicTable
|
||||
items={conversationOptions}
|
||||
columns={columns}
|
||||
pagination={pagination}
|
||||
sorting={sorting}
|
||||
onChange={onTableChange}
|
||||
itemId="id"
|
||||
/>
|
||||
</EuiPanel>
|
||||
{editFlyoutVisible && (
|
||||
|
@ -301,21 +387,21 @@ const ConversationSettingsManagementComponent: React.FC<Props> = ({
|
|||
)}
|
||||
</Flyout>
|
||||
)}
|
||||
{deleteConfirmModalVisibility && deletedConversation?.title && (
|
||||
<EuiConfirmModal
|
||||
aria-labelledby={confirmationTitle}
|
||||
title={confirmationTitle}
|
||||
titleProps={{ id: deletedConversation?.id ?? undefined }}
|
||||
onCancel={onDeleteCancelled}
|
||||
onConfirm={onDeleteConfirmed}
|
||||
cancelButtonText={CANCEL}
|
||||
confirmButtonText={DELETE}
|
||||
buttonColor="danger"
|
||||
defaultFocusedButton="confirm"
|
||||
>
|
||||
<p />
|
||||
</EuiConfirmModal>
|
||||
)}
|
||||
{deleteConfirmModalVisibility &&
|
||||
(isDeleteAll || deletedConversations?.length > 0 || excludedIds?.length > 0) && (
|
||||
<EuiConfirmModal
|
||||
aria-labelledby={confirmationTitle}
|
||||
title={confirmationTitle}
|
||||
onCancel={onDeleteCancelled}
|
||||
onConfirm={onDeleteConfirmed}
|
||||
cancelButtonText={CANCEL}
|
||||
confirmButtonText={DELETE}
|
||||
buttonColor="danger"
|
||||
defaultFocusedButton="confirm"
|
||||
>
|
||||
<p />
|
||||
</EuiConfirmModal>
|
||||
)}
|
||||
<AssistantSettingsBottomBar
|
||||
hasPendingChanges={hasPendingChanges}
|
||||
onCancelClick={onCancelClick}
|
||||
|
|
|
@ -0,0 +1,245 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { InputCheckbox, PageSelectionCheckbox } from './table_selection_checkbox';
|
||||
import { ConversationTableItem } from './types';
|
||||
|
||||
describe('PageSelectionCheckbox', () => {
|
||||
it('should render null when conversationOptionsIds is empty', () => {
|
||||
const { container } = render(
|
||||
<PageSelectionCheckbox
|
||||
conversationOptions={[]}
|
||||
deletedConversationsIds={[]}
|
||||
excludedIds={[]}
|
||||
handlePageChecked={jest.fn()}
|
||||
handlePageUnchecked={jest.fn()}
|
||||
isExcludedMode={false}
|
||||
totalItemCount={0}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('page selection checkbox be checked isExcludedMode is true, and excludedIds does not includes conversationOptionsIds', () => {
|
||||
const conversationOptions: ConversationTableItem[] = [
|
||||
{ id: 'conversation1', title: 'Conversation 1' } as ConversationTableItem,
|
||||
];
|
||||
const deletedConversationsIds: string[] = [];
|
||||
const excludedIds: string[] = ['conversation2'];
|
||||
const handlePageChecked = jest.fn();
|
||||
const handlePageUnchecked = jest.fn();
|
||||
const isExcludedMode = true;
|
||||
const totalItemCount = 2;
|
||||
|
||||
const { getByTestId } = render(
|
||||
<PageSelectionCheckbox
|
||||
conversationOptions={conversationOptions}
|
||||
deletedConversationsIds={deletedConversationsIds}
|
||||
excludedIds={excludedIds}
|
||||
handlePageChecked={handlePageChecked}
|
||||
handlePageUnchecked={handlePageUnchecked}
|
||||
isExcludedMode={isExcludedMode}
|
||||
totalItemCount={totalItemCount}
|
||||
/>
|
||||
);
|
||||
const checkbox = getByTestId('conversationPageSelect');
|
||||
expect(checkbox).toBeInTheDocument();
|
||||
expect(checkbox).toBeChecked();
|
||||
});
|
||||
|
||||
it('page selection checkbox should be unchecked when isExcludedMode is true, and not any conversationOptionsId are included in excludedIds', () => {
|
||||
const conversationOptions: ConversationTableItem[] = [
|
||||
{ id: 'conversation1', title: 'Conversation 1' } as ConversationTableItem,
|
||||
];
|
||||
const deletedConversationsIds: string[] = ['conversation2'];
|
||||
const excludedIds: string[] = ['conversation1'];
|
||||
const handlePageChecked = jest.fn();
|
||||
const handlePageUnchecked = jest.fn();
|
||||
const isExcludedMode = true;
|
||||
const totalItemCount = 2;
|
||||
const { getByTestId } = render(
|
||||
<PageSelectionCheckbox
|
||||
conversationOptions={conversationOptions}
|
||||
deletedConversationsIds={deletedConversationsIds}
|
||||
excludedIds={excludedIds}
|
||||
handlePageChecked={handlePageChecked}
|
||||
handlePageUnchecked={handlePageUnchecked}
|
||||
isExcludedMode={isExcludedMode}
|
||||
totalItemCount={totalItemCount}
|
||||
/>
|
||||
);
|
||||
const checkbox = getByTestId('conversationPageSelect');
|
||||
expect(checkbox).toBeInTheDocument();
|
||||
expect(checkbox).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('page selection checkbox should be checked when isExcludedMode is false and every conversationOptionsIds is included in deletedConversationsIds', () => {
|
||||
const conversationOptions: ConversationTableItem[] = [
|
||||
{ id: 'conversation1', title: 'Conversation 1' } as ConversationTableItem,
|
||||
{ id: 'conversation2', title: 'Conversation 2' } as ConversationTableItem,
|
||||
];
|
||||
const deletedConversationsIds: string[] = ['conversation1', 'conversation2'];
|
||||
const excludedIds: string[] = [];
|
||||
const handlePageChecked = jest.fn();
|
||||
const handlePageUnchecked = jest.fn();
|
||||
const isExcludedMode = false;
|
||||
const totalItemCount = 2;
|
||||
const { getByTestId } = render(
|
||||
<PageSelectionCheckbox
|
||||
conversationOptions={conversationOptions}
|
||||
deletedConversationsIds={deletedConversationsIds}
|
||||
excludedIds={excludedIds}
|
||||
handlePageChecked={handlePageChecked}
|
||||
handlePageUnchecked={handlePageUnchecked}
|
||||
isExcludedMode={isExcludedMode}
|
||||
totalItemCount={totalItemCount}
|
||||
/>
|
||||
);
|
||||
const checkbox = getByTestId('conversationPageSelect');
|
||||
expect(checkbox).toBeInTheDocument();
|
||||
expect(checkbox).toBeChecked();
|
||||
});
|
||||
|
||||
it('page selection checkbox should be unchecked when isExcludedMode is false and not all conversationOptionsIds are included in deletedConversationsIds', () => {
|
||||
const conversationOptions: ConversationTableItem[] = [
|
||||
{ id: 'conversation1', title: 'Conversation 1' } as ConversationTableItem,
|
||||
{ id: 'conversation2', title: 'Conversation 2' } as ConversationTableItem,
|
||||
];
|
||||
const deletedConversationsIds: string[] = ['conversation1'];
|
||||
const excludedIds: string[] = [];
|
||||
const handlePageChecked = jest.fn();
|
||||
const handlePageUnchecked = jest.fn();
|
||||
const isExcludedMode = false;
|
||||
const totalItemCount = 2;
|
||||
const { getByTestId } = render(
|
||||
<PageSelectionCheckbox
|
||||
conversationOptions={conversationOptions}
|
||||
deletedConversationsIds={deletedConversationsIds}
|
||||
excludedIds={excludedIds}
|
||||
handlePageChecked={handlePageChecked}
|
||||
handlePageUnchecked={handlePageUnchecked}
|
||||
isExcludedMode={isExcludedMode}
|
||||
totalItemCount={totalItemCount}
|
||||
/>
|
||||
);
|
||||
const checkbox = getByTestId('conversationPageSelect');
|
||||
expect(checkbox).toBeInTheDocument();
|
||||
expect(checkbox).not.toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
describe('InputCheckbox', () => {
|
||||
it('input checkbox should be checked when isExcludedMode is true, and conversationOptionsId is not included in excludedIds', () => {
|
||||
const conversation: ConversationTableItem = {
|
||||
id: 'conversation1',
|
||||
title: 'Conversation 1',
|
||||
} as ConversationTableItem;
|
||||
const deletedConversationsIds: string[] = ['conversation2'];
|
||||
const excludedIds: string[] = ['conversation2'];
|
||||
const handleRowChecked = jest.fn();
|
||||
const handleRowUnChecked = jest.fn();
|
||||
const isExcludedMode = true;
|
||||
const totalItemCount = 1;
|
||||
const { getByTestId } = render(
|
||||
<InputCheckbox
|
||||
conversation={conversation}
|
||||
deletedConversationsIds={deletedConversationsIds}
|
||||
excludedIds={excludedIds}
|
||||
isExcludedMode={isExcludedMode}
|
||||
handleRowChecked={handleRowChecked}
|
||||
handleRowUnChecked={handleRowUnChecked}
|
||||
totalItemCount={totalItemCount}
|
||||
/>
|
||||
);
|
||||
const checkbox = getByTestId(`conversationSelect-${conversation.id}`);
|
||||
expect(checkbox).toBeInTheDocument();
|
||||
expect(checkbox).toBeChecked();
|
||||
});
|
||||
|
||||
it('input checkbox should be unchecked when isExcludedMode is true, and conversationOptionsId is included in excludedIds', () => {
|
||||
const conversation: ConversationTableItem = {
|
||||
id: 'conversation1',
|
||||
title: 'Conversation 1',
|
||||
} as ConversationTableItem;
|
||||
const deletedConversationsIds: string[] = ['conversation2'];
|
||||
const excludedIds: string[] = ['conversation1'];
|
||||
const handleRowChecked = jest.fn();
|
||||
const handleRowUnChecked = jest.fn();
|
||||
const isExcludedMode = true;
|
||||
const totalItemCount = 1;
|
||||
const { getByTestId } = render(
|
||||
<InputCheckbox
|
||||
conversation={conversation}
|
||||
deletedConversationsIds={deletedConversationsIds}
|
||||
excludedIds={excludedIds}
|
||||
isExcludedMode={isExcludedMode}
|
||||
handleRowChecked={handleRowChecked}
|
||||
handleRowUnChecked={handleRowUnChecked}
|
||||
totalItemCount={totalItemCount}
|
||||
/>
|
||||
);
|
||||
const checkbox = getByTestId(`conversationSelect-${conversation.id}`);
|
||||
expect(checkbox).toBeInTheDocument();
|
||||
expect(checkbox).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('input checkbox should be checked when isExcludedMode is false, and conversationOptionsId is included in deletedConversationsIds', () => {
|
||||
const conversation: ConversationTableItem = {
|
||||
id: 'conversation1',
|
||||
title: 'Conversation 1',
|
||||
} as ConversationTableItem;
|
||||
const deletedConversationsIds: string[] = ['conversation1'];
|
||||
const excludedIds: string[] = [];
|
||||
const handleRowChecked = jest.fn();
|
||||
const handleRowUnChecked = jest.fn();
|
||||
const isExcludedMode = false;
|
||||
const totalItemCount = 1;
|
||||
const { getByTestId } = render(
|
||||
<InputCheckbox
|
||||
conversation={conversation}
|
||||
deletedConversationsIds={deletedConversationsIds}
|
||||
excludedIds={excludedIds}
|
||||
isExcludedMode={isExcludedMode}
|
||||
handleRowChecked={handleRowChecked}
|
||||
handleRowUnChecked={handleRowUnChecked}
|
||||
totalItemCount={totalItemCount}
|
||||
/>
|
||||
);
|
||||
const checkbox = getByTestId(`conversationSelect-${conversation.id}`);
|
||||
expect(checkbox).toBeInTheDocument();
|
||||
expect(checkbox).toBeChecked();
|
||||
});
|
||||
|
||||
it('input checkbox should be unchecked when isExcludedMode is false, and conversationOptionsId is not included in deletedConversationsIds', () => {
|
||||
const conversation: ConversationTableItem = {
|
||||
id: 'conversation1',
|
||||
title: 'Conversation 1',
|
||||
} as ConversationTableItem;
|
||||
const deletedConversationsIds: string[] = [];
|
||||
const excludedIds: string[] = [];
|
||||
const handleRowChecked = jest.fn();
|
||||
const handleRowUnChecked = jest.fn();
|
||||
const isExcludedMode = false;
|
||||
const totalItemCount = 1;
|
||||
const { getByTestId } = render(
|
||||
<InputCheckbox
|
||||
conversation={conversation}
|
||||
deletedConversationsIds={deletedConversationsIds}
|
||||
excludedIds={excludedIds}
|
||||
isExcludedMode={isExcludedMode}
|
||||
handleRowChecked={handleRowChecked}
|
||||
handleRowUnChecked={handleRowUnChecked}
|
||||
totalItemCount={totalItemCount}
|
||||
/>
|
||||
);
|
||||
const checkbox = getByTestId(`conversationSelect-${conversation.id}`);
|
||||
expect(checkbox).toBeInTheDocument();
|
||||
expect(checkbox).not.toBeChecked();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,119 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { EuiCheckbox } from '@elastic/eui';
|
||||
import {
|
||||
ConversationTableItem,
|
||||
HandlePageChecked,
|
||||
HandlePageUnchecked,
|
||||
HandleRowChecked,
|
||||
HandleRowUnChecked,
|
||||
} from './types';
|
||||
|
||||
export const PageSelectionCheckbox = ({
|
||||
conversationOptions,
|
||||
deletedConversationsIds,
|
||||
excludedIds,
|
||||
handlePageChecked,
|
||||
handlePageUnchecked,
|
||||
isExcludedMode,
|
||||
totalItemCount,
|
||||
}: {
|
||||
conversationOptions: ConversationTableItem[];
|
||||
deletedConversationsIds: string[];
|
||||
excludedIds: string[];
|
||||
handlePageChecked: HandlePageChecked;
|
||||
handlePageUnchecked: HandlePageUnchecked;
|
||||
isExcludedMode: boolean;
|
||||
totalItemCount: number;
|
||||
}) => {
|
||||
const conversationOptionsIds = useMemo(
|
||||
() => conversationOptions.map((item) => item.id),
|
||||
[conversationOptions]
|
||||
);
|
||||
const [pageSelectionChecked, setPageSelectionChecked] = useState(
|
||||
(!isExcludedMode &&
|
||||
conversationOptionsIds.every((id) => deletedConversationsIds.includes(id))) ||
|
||||
(isExcludedMode && !excludedIds.some((id) => conversationOptionsIds.includes(id)))
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setPageSelectionChecked(
|
||||
(!isExcludedMode &&
|
||||
conversationOptionsIds.every((id) => deletedConversationsIds.includes(id))) ||
|
||||
(isExcludedMode && !excludedIds.some((id) => conversationOptionsIds.includes(id)))
|
||||
);
|
||||
}, [deletedConversationsIds, conversationOptionsIds, excludedIds, isExcludedMode]);
|
||||
|
||||
if (conversationOptionsIds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<EuiCheckbox
|
||||
data-test-subj={`conversationPageSelect`}
|
||||
id={`conversationPageSelect`}
|
||||
checked={pageSelectionChecked}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setPageSelectionChecked(true);
|
||||
handlePageChecked({ conversationOptions, totalItemCount });
|
||||
} else {
|
||||
setPageSelectionChecked(false);
|
||||
handlePageUnchecked({ conversationOptionsIds, totalItemCount });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const InputCheckbox = ({
|
||||
conversation,
|
||||
deletedConversationsIds,
|
||||
excludedIds,
|
||||
isExcludedMode,
|
||||
handleRowChecked,
|
||||
handleRowUnChecked,
|
||||
totalItemCount,
|
||||
}: {
|
||||
conversation: ConversationTableItem;
|
||||
deletedConversationsIds: string[];
|
||||
excludedIds: string[];
|
||||
isExcludedMode: boolean;
|
||||
handleRowChecked: HandleRowChecked;
|
||||
handleRowUnChecked: HandleRowUnChecked;
|
||||
totalItemCount: number;
|
||||
}) => {
|
||||
const [checked, setChecked] = useState(
|
||||
(!isExcludedMode && deletedConversationsIds.includes(conversation.id)) ||
|
||||
(isExcludedMode && !excludedIds.includes(conversation.id))
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setChecked(
|
||||
(!isExcludedMode && deletedConversationsIds.includes(conversation.id)) ||
|
||||
(isExcludedMode && !excludedIds.includes(conversation.id))
|
||||
);
|
||||
}, [deletedConversationsIds, conversation.id, excludedIds, isExcludedMode]);
|
||||
|
||||
return (
|
||||
<EuiCheckbox
|
||||
data-test-subj={`conversationSelect-${conversation.id}`}
|
||||
id={`conversationSelect-${conversation.id}`}
|
||||
checked={checked}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setChecked(true);
|
||||
handleRowChecked({ selectedItem: conversation, totalItemCount });
|
||||
} else {
|
||||
setChecked(false);
|
||||
handleRowUnChecked({ selectedItem: conversation, totalItemCount });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,92 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui';
|
||||
import React, { useCallback } from 'react';
|
||||
import * as i18n from './translations';
|
||||
export interface Props {
|
||||
onConversationsBulkDeleted: () => void;
|
||||
handleSelectAll: (totalItemCount: number) => void;
|
||||
handleUnselectAll: () => void;
|
||||
totalConversations: number;
|
||||
totalSelected: number;
|
||||
isDeleteAll: boolean;
|
||||
}
|
||||
|
||||
const ToolbarComponent: React.FC<Props> = ({
|
||||
onConversationsBulkDeleted,
|
||||
handleSelectAll,
|
||||
handleUnselectAll,
|
||||
totalConversations,
|
||||
totalSelected,
|
||||
isDeleteAll,
|
||||
}) => {
|
||||
const isAnySelected = totalSelected > 0 || isDeleteAll;
|
||||
const onSelectAllClicked = useCallback(() => {
|
||||
handleSelectAll(totalConversations);
|
||||
}, [handleSelectAll, totalConversations]);
|
||||
|
||||
const onDeleteClicked = useCallback(
|
||||
(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
onConversationsBulkDeleted();
|
||||
},
|
||||
[onConversationsBulkDeleted]
|
||||
);
|
||||
|
||||
if (totalConversations === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<EuiFlexGroup alignItems="center" data-test-subj="toolbar" gutterSize="none">
|
||||
<EuiFlexItem grow={false}>
|
||||
{!isDeleteAll && (
|
||||
<EuiButtonEmpty
|
||||
data-test-subj="selectAllConversations"
|
||||
iconType="pagesSelect"
|
||||
onClick={onSelectAllClicked}
|
||||
size="xs"
|
||||
>
|
||||
{i18n.SELECT_ALL_CONVERSATIONS(totalConversations)}
|
||||
</EuiButtonEmpty>
|
||||
)}
|
||||
</EuiFlexItem>
|
||||
|
||||
{isAnySelected && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty
|
||||
data-test-subj="unselectAllConversations"
|
||||
onClick={handleUnselectAll}
|
||||
size="xs"
|
||||
>
|
||||
{i18n.UNSELECT_ALL_CONVERSATIONS(totalConversations)}
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
|
||||
{isAnySelected && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiText color="subdued" data-test-subj="selectedFields" size="xs">
|
||||
{i18n.SELECTED_CONVERSATIONS(isDeleteAll ? totalConversations : totalSelected)}
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
{isAnySelected && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty size="xs" onClick={onDeleteClicked}>
|
||||
{i18n.DELETE_SELECTED_CONVERSATIONS}
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
};
|
||||
|
||||
ToolbarComponent.displayName = 'ToolbarComponent';
|
||||
|
||||
export const Toolbar = React.memo(ToolbarComponent);
|
|
@ -75,3 +75,39 @@ export const DELETE_CONVERSATION_CONFIRMATION_TITLE = (conversationTitle: string
|
|||
values: { conversationTitle },
|
||||
defaultMessage: 'Delete "{conversationTitle}"?',
|
||||
});
|
||||
|
||||
export const DELETE_MULTIPLE_CONVERSATIONS_CONFIRMATION_TITLE = (count: number) =>
|
||||
i18n.translate(
|
||||
'xpack.elasticAssistant.assistant.conversationSettings.deleteConfirmation.multipleTitle',
|
||||
{
|
||||
values: { count },
|
||||
defaultMessage: 'Delete {count} conversations?',
|
||||
}
|
||||
);
|
||||
|
||||
export const SELECTED_CONVERSATIONS = (selected: number) =>
|
||||
i18n.translate('xpack.elasticAssistant.assistant.conversationSettings.selectedConversations', {
|
||||
values: { selected },
|
||||
defaultMessage: 'Selected {selected} conversation{selected, plural, one {} other {s}}',
|
||||
});
|
||||
|
||||
export const SELECT_ALL_CONVERSATIONS = (conversations: number) =>
|
||||
i18n.translate('xpack.elasticAssistant.assistant.conversationSettings.selectAllConversations', {
|
||||
values: { conversations },
|
||||
defaultMessage:
|
||||
'Select all {conversations} conversation{conversations, plural, one {} other {s}}',
|
||||
});
|
||||
|
||||
export const UNSELECT_ALL_CONVERSATIONS = (conversations: number) =>
|
||||
i18n.translate('xpack.elasticAssistant.assistant.conversationSettings.unselectAllConversations', {
|
||||
values: { conversations },
|
||||
defaultMessage:
|
||||
'Unselect all {conversations} conversation{conversations, plural, one {} other {s}}',
|
||||
});
|
||||
|
||||
export const DELETE_SELECTED_CONVERSATIONS = i18n.translate(
|
||||
'xpack.elasticAssistant.assistant.conversationSettings.deleteSelectedConversations',
|
||||
{
|
||||
defaultMessage: 'Delete',
|
||||
}
|
||||
);
|
||||
|
|
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { Conversation } from '../../../assistant_context/types';
|
||||
|
||||
export type ConversationTableItem = Conversation & {
|
||||
connectorTypeTitle?: string | null;
|
||||
systemPromptTitle?: string | null;
|
||||
};
|
||||
|
||||
export type HandlePageChecked = (params: {
|
||||
conversationOptions: ConversationTableItem[];
|
||||
totalItemCount: number;
|
||||
}) => void;
|
||||
|
||||
export type HandlePageUnchecked = (params: {
|
||||
conversationOptionsIds: string[];
|
||||
totalItemCount: number;
|
||||
}) => void;
|
||||
|
||||
export type HandleRowChecked = (params: {
|
||||
selectedItem: ConversationTableItem;
|
||||
totalItemCount: number;
|
||||
}) => void;
|
||||
|
||||
export type HandleRowUnChecked = (params: {
|
||||
selectedItem: ConversationTableItem;
|
||||
totalItemCount: number;
|
||||
}) => void;
|
|
@ -0,0 +1,145 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { useConversationSelection } from './use_conversation_selection';
|
||||
import { ConversationTableItem } from './types';
|
||||
|
||||
describe('useConversationSelection', () => {
|
||||
it('should initialize with default values', () => {
|
||||
const { result } = renderHook(() => useConversationSelection());
|
||||
expect(result.current.selectionState.isDeleteAll).toBe(false);
|
||||
expect(result.current.selectionState.isExcludedMode).toBe(false);
|
||||
expect(result.current.selectionState.deletedConversations).toEqual([]);
|
||||
expect(result.current.selectionState.totalSelectedConversations).toBe(0);
|
||||
expect(result.current.selectionState.excludedIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle unselect all', () => {
|
||||
const { result } = renderHook(() => useConversationSelection());
|
||||
act(() => {
|
||||
result.current.selectionActions.handleUnselectAll();
|
||||
});
|
||||
expect(result.current.selectionState.isDeleteAll).toBe(false);
|
||||
expect(result.current.selectionState.isExcludedMode).toBe(false);
|
||||
expect(result.current.selectionState.deletedConversations).toEqual([]);
|
||||
expect(result.current.selectionState.totalSelectedConversations).toBe(0);
|
||||
expect(result.current.selectionState.excludedIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle select all', () => {
|
||||
const totalItemCount = 5;
|
||||
const { result } = renderHook(() => useConversationSelection());
|
||||
act(() => {
|
||||
result.current.selectionActions.handleSelectAll(totalItemCount);
|
||||
});
|
||||
expect(result.current.selectionState.isDeleteAll).toBe(true);
|
||||
expect(result.current.selectionState.isExcludedMode).toBe(true);
|
||||
expect(result.current.selectionState.totalSelectedConversations).toBe(totalItemCount);
|
||||
});
|
||||
|
||||
it('should handle selecting all conversations on the current page', () => {
|
||||
const { result } = renderHook(() => useConversationSelection());
|
||||
const conversationOptions = [
|
||||
{ id: '1', title: 'Conversation 1' },
|
||||
{ id: '2', title: 'Conversation 2' },
|
||||
] as ConversationTableItem[];
|
||||
|
||||
act(() => {
|
||||
result.current.selectionActions.handleRowChecked({
|
||||
selectedItem: conversationOptions[0],
|
||||
totalItemCount: 2,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.selectionState.deletedConversations).toEqual([conversationOptions[0]]);
|
||||
expect(result.current.selectionState.totalSelectedConversations).toBe(1);
|
||||
expect(result.current.selectionState.isDeleteAll).toBe(false);
|
||||
|
||||
act(() => {
|
||||
result.current.selectionActions.handlePageChecked({
|
||||
conversationOptions,
|
||||
totalItemCount: 2,
|
||||
});
|
||||
});
|
||||
expect(result.current.selectionState.deletedConversations).toEqual(conversationOptions);
|
||||
expect(result.current.selectionState.totalSelectedConversations).toBe(2);
|
||||
expect(result.current.selectionState.isDeleteAll).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle page unselected', () => {
|
||||
const { result } = renderHook(() => useConversationSelection());
|
||||
const conversationOptions = [
|
||||
{ id: '1', title: 'Conversation 1' },
|
||||
{ id: '2', title: 'Conversation 2' },
|
||||
] as ConversationTableItem[];
|
||||
const conversationOptionsIds = conversationOptions.map((item) => item.id);
|
||||
act(() => {
|
||||
result.current.selectionActions.handlePageChecked({
|
||||
conversationOptions,
|
||||
totalItemCount: 2,
|
||||
});
|
||||
});
|
||||
expect(result.current.selectionState.excludedIds).toEqual([]);
|
||||
expect(result.current.selectionState.totalSelectedConversations).toBe(2);
|
||||
expect(result.current.selectionState.deletedConversations).toEqual(conversationOptions);
|
||||
expect(result.current.selectionState.isDeleteAll).toBe(true);
|
||||
expect(result.current.selectionState.isExcludedMode).toBe(true);
|
||||
|
||||
act(() => {
|
||||
result.current.selectionActions.handlePageUnchecked({
|
||||
conversationOptionsIds,
|
||||
totalItemCount: 2,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.selectionState.excludedIds).toEqual(['1', '2']);
|
||||
expect(result.current.selectionState.totalSelectedConversations).toBe(0);
|
||||
expect(result.current.selectionState.deletedConversations).toEqual([]);
|
||||
expect(result.current.selectionState.isDeleteAll).toBe(false);
|
||||
expect(result.current.selectionState.isExcludedMode).toBe(true);
|
||||
});
|
||||
it('should handle row checked', () => {
|
||||
const { result } = renderHook(() => useConversationSelection());
|
||||
const conversation = { id: '1', title: 'Conversation 1' } as ConversationTableItem;
|
||||
act(() => {
|
||||
result.current.selectionActions.handleRowChecked({
|
||||
selectedItem: conversation,
|
||||
totalItemCount: 1,
|
||||
});
|
||||
});
|
||||
expect(result.current.selectionState.deletedConversations).toEqual([conversation]);
|
||||
expect(result.current.selectionState.totalSelectedConversations).toBe(1);
|
||||
expect(result.current.selectionState.isDeleteAll).toBe(true);
|
||||
expect(result.current.selectionState.isExcludedMode).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle row unchecked', () => {
|
||||
const { result } = renderHook(() => useConversationSelection());
|
||||
const conversation = { id: '1', title: 'Conversation 1' } as ConversationTableItem;
|
||||
act(() => {
|
||||
result.current.selectionActions.handleRowChecked({
|
||||
selectedItem: conversation,
|
||||
totalItemCount: 1,
|
||||
});
|
||||
});
|
||||
expect(result.current.selectionState.deletedConversations).toEqual([conversation]);
|
||||
expect(result.current.selectionState.totalSelectedConversations).toBe(1);
|
||||
expect(result.current.selectionState.isDeleteAll).toBe(true);
|
||||
expect(result.current.selectionState.isExcludedMode).toBe(true);
|
||||
act(() => {
|
||||
result.current.selectionActions.handleRowUnChecked({
|
||||
selectedItem: conversation,
|
||||
totalItemCount: 1,
|
||||
});
|
||||
});
|
||||
expect(result.current.selectionState.deletedConversations).toEqual([]);
|
||||
expect(result.current.selectionState.totalSelectedConversations).toBe(0);
|
||||
expect(result.current.selectionState.isDeleteAll).toBe(false);
|
||||
expect(result.current.selectionState.isExcludedMode).toBe(true);
|
||||
expect(result.current.selectionState.excludedIds).toEqual(['1']);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,164 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
import { ConversationTableItem } from './types';
|
||||
|
||||
const EMPTY_CONVERSATIONS_ARRAY: ConversationTableItem[] = [];
|
||||
const EMPTY_CONVERSATIONS_IDS_ARRAY: string[] = [];
|
||||
|
||||
export const useConversationSelection = () => {
|
||||
const [isDeleteAll, setIsDeleteAll] = useState(false);
|
||||
const [isExcludedMode, setIsExcludedMode] = useState(false);
|
||||
const [deletedConversations, setDeletedConversations] = useState(EMPTY_CONVERSATIONS_ARRAY);
|
||||
const [totalSelectedConversations, setTotalSelectedConversations] = useState(0);
|
||||
const [excludedIds, setExcludedIds] = useState<string[]>(EMPTY_CONVERSATIONS_IDS_ARRAY);
|
||||
|
||||
const handleUnselectAll = useCallback(() => {
|
||||
setIsDeleteAll(false);
|
||||
setIsExcludedMode(false);
|
||||
setDeletedConversations([]);
|
||||
setTotalSelectedConversations(0);
|
||||
setExcludedIds([]);
|
||||
}, []);
|
||||
|
||||
const handleSelectAll = useCallback((totalItemCount: number) => {
|
||||
setIsDeleteAll(true);
|
||||
setIsExcludedMode(true);
|
||||
setTotalSelectedConversations(totalItemCount);
|
||||
setExcludedIds([]);
|
||||
}, []);
|
||||
|
||||
const handlePageChecked = useCallback(
|
||||
({
|
||||
conversationOptions,
|
||||
totalItemCount,
|
||||
}: {
|
||||
conversationOptions: ConversationTableItem[];
|
||||
totalItemCount: number;
|
||||
}) => {
|
||||
const conversationOptionsIds = conversationOptions.map((item) => item.id);
|
||||
const deletedConversationsIds = deletedConversations.map((item) => item.id);
|
||||
if (isExcludedMode) {
|
||||
const newExcludedIds = excludedIds.filter((item) => !conversationOptionsIds.includes(item));
|
||||
setExcludedIds(newExcludedIds);
|
||||
setTotalSelectedConversations(
|
||||
(prev) => prev + conversationOptionsIds.filter((id) => excludedIds.includes(id)).length
|
||||
);
|
||||
} else {
|
||||
const newDeletedConversations = conversationOptions.reduce(
|
||||
(acc, curr) => {
|
||||
if (!deletedConversationsIds.includes(curr.id)) {
|
||||
acc.push(curr);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[...deletedConversations]
|
||||
);
|
||||
setDeletedConversations(newDeletedConversations);
|
||||
setTotalSelectedConversations(
|
||||
(prev) =>
|
||||
prev +
|
||||
conversationOptionsIds.filter((id) => !deletedConversationsIds.includes(id)).length
|
||||
);
|
||||
if (newDeletedConversations.length === totalItemCount) {
|
||||
setIsDeleteAll(true);
|
||||
setIsExcludedMode(true);
|
||||
}
|
||||
}
|
||||
},
|
||||
[deletedConversations, excludedIds, isExcludedMode]
|
||||
);
|
||||
|
||||
const handlePageUnchecked = useCallback(
|
||||
({
|
||||
conversationOptionsIds,
|
||||
totalItemCount,
|
||||
}: {
|
||||
conversationOptionsIds: string[];
|
||||
totalItemCount: number;
|
||||
}) => {
|
||||
if (isExcludedMode) {
|
||||
setExcludedIds((prev) => [...prev, ...conversationOptionsIds]);
|
||||
}
|
||||
setDeletedConversations(
|
||||
deletedConversations.filter((item) => !conversationOptionsIds.includes(item.id))
|
||||
);
|
||||
setTotalSelectedConversations(
|
||||
(prev) => (prev || totalItemCount) - conversationOptionsIds.length
|
||||
);
|
||||
|
||||
setIsDeleteAll(false);
|
||||
},
|
||||
[deletedConversations, isExcludedMode]
|
||||
);
|
||||
|
||||
const handleRowChecked = useCallback(
|
||||
({
|
||||
selectedItem,
|
||||
totalItemCount,
|
||||
}: {
|
||||
selectedItem: ConversationTableItem;
|
||||
totalItemCount: number;
|
||||
}) => {
|
||||
if (isExcludedMode) {
|
||||
const newExcludedIds = excludedIds.filter((item) => item !== selectedItem.id);
|
||||
setExcludedIds(newExcludedIds);
|
||||
} else {
|
||||
const newDeletedConversations = [...deletedConversations, selectedItem];
|
||||
setDeletedConversations(newDeletedConversations);
|
||||
if (newDeletedConversations.length === totalItemCount) {
|
||||
setIsDeleteAll(true);
|
||||
setIsExcludedMode(true);
|
||||
}
|
||||
}
|
||||
setTotalSelectedConversations((prev) => prev + 1);
|
||||
},
|
||||
[deletedConversations, excludedIds, isExcludedMode]
|
||||
);
|
||||
|
||||
const handleRowUnChecked = useCallback(
|
||||
({
|
||||
selectedItem,
|
||||
totalItemCount,
|
||||
}: {
|
||||
selectedItem: ConversationTableItem;
|
||||
totalItemCount: number;
|
||||
}) => {
|
||||
if (isExcludedMode) {
|
||||
setExcludedIds((prev) => [...prev, selectedItem.id]);
|
||||
}
|
||||
setDeletedConversations((prev) => prev.filter((item) => item.id !== selectedItem.id));
|
||||
setIsDeleteAll(false);
|
||||
setTotalSelectedConversations((prev) => (prev || totalItemCount) - 1);
|
||||
},
|
||||
[isExcludedMode]
|
||||
);
|
||||
|
||||
return {
|
||||
selectionState: {
|
||||
isDeleteAll,
|
||||
isExcludedMode,
|
||||
deletedConversations,
|
||||
totalSelectedConversations,
|
||||
excludedIds,
|
||||
},
|
||||
selectionActions: {
|
||||
handleUnselectAll,
|
||||
handleSelectAll,
|
||||
handlePageUnchecked,
|
||||
handlePageChecked,
|
||||
handleRowUnChecked,
|
||||
handleRowChecked,
|
||||
setDeletedConversations,
|
||||
setExcludedIds,
|
||||
setIsDeleteAll,
|
||||
setIsExcludedMode,
|
||||
setTotalSelectedConversations,
|
||||
},
|
||||
};
|
||||
};
|
|
@ -6,15 +6,12 @@
|
|||
*/
|
||||
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import {
|
||||
useConversationsTable,
|
||||
GetConversationsListParams,
|
||||
ConversationTableItem,
|
||||
} from './use_conversations_table';
|
||||
import { useConversationsTable, GetConversationsListParams } from './use_conversations_table';
|
||||
import { alertConvo, welcomeConvo, customConvo } from '../../../mock/conversation';
|
||||
import { mockActionTypes, mockConnectors } from '../../../mock/connectors';
|
||||
import { mockSystemPrompts } from '../../../mock/system_prompt';
|
||||
import { ActionTypeRegistryContract } from '@kbn/triggers-actions-ui-plugin/public';
|
||||
import { ConversationTableItem } from './types';
|
||||
|
||||
const mockActionTypeRegistry: ActionTypeRegistryContract = {
|
||||
has: jest
|
||||
|
@ -35,19 +32,29 @@ describe('useConversationsTable', () => {
|
|||
it('should return columns', () => {
|
||||
const { result } = renderHook(() => useConversationsTable());
|
||||
const columns = result.current.getColumns({
|
||||
conversationOptions: [],
|
||||
deletedConversationsIds: [],
|
||||
excludedIds: [],
|
||||
handlePageChecked: jest.fn(),
|
||||
handlePageUnchecked: jest.fn(),
|
||||
handleRowChecked: jest.fn(),
|
||||
handleRowUnChecked: jest.fn(),
|
||||
isDeleteEnabled: jest.fn(),
|
||||
isEditEnabled: jest.fn(),
|
||||
isExcludedMode: false,
|
||||
onDeleteActionClicked: jest.fn(),
|
||||
onEditActionClicked: jest.fn(),
|
||||
totalItemCount: 0,
|
||||
});
|
||||
|
||||
expect(columns).toHaveLength(5);
|
||||
expect(columns).toHaveLength(6);
|
||||
|
||||
expect(columns[0].name).toBe('Title');
|
||||
expect(columns[1].name).toBe('System prompt');
|
||||
expect(columns[2].name).toBe('Connector');
|
||||
expect(columns[3].name).toBe('Date updated');
|
||||
expect(columns[4].name).toBe('Actions');
|
||||
// column 0 is the checkbox column
|
||||
expect(columns[1].name).toBe('Title');
|
||||
expect(columns[2].name).toBe('System prompt');
|
||||
expect(columns[3].name).toBe('Connector');
|
||||
expect(columns[4].name).toBe('Date updated');
|
||||
expect(columns[5].name).toBe('Actions');
|
||||
});
|
||||
|
||||
it('should return a list of conversations', () => {
|
||||
|
|
|
@ -18,6 +18,14 @@ import { getConnectorTypeTitle } from '../../../connectorland/helpers';
|
|||
import { getConversationApiConfig } from '../../use_conversation/helpers';
|
||||
import * as i18n from './translations';
|
||||
import { useInlineActions } from '../../common/components/assistant_settings_management/inline_actions';
|
||||
import { InputCheckbox, PageSelectionCheckbox } from './table_selection_checkbox';
|
||||
import {
|
||||
ConversationTableItem,
|
||||
HandlePageChecked,
|
||||
HandlePageUnchecked,
|
||||
HandleRowChecked,
|
||||
HandleRowUnChecked,
|
||||
} from './types';
|
||||
|
||||
const emptyConversations = {};
|
||||
|
||||
|
@ -29,26 +37,68 @@ export interface GetConversationsListParams {
|
|||
defaultConnector?: AIConnector;
|
||||
}
|
||||
|
||||
export type ConversationTableItem = Conversation & {
|
||||
connectorTypeTitle?: string | null;
|
||||
systemPromptTitle?: string | null;
|
||||
};
|
||||
interface GetColumnsParams {
|
||||
conversationOptions: ConversationTableItem[];
|
||||
deletedConversationsIds: string[];
|
||||
excludedIds: string[];
|
||||
handlePageChecked: HandlePageChecked;
|
||||
handlePageUnchecked: HandlePageUnchecked;
|
||||
handleRowChecked: HandleRowChecked;
|
||||
handleRowUnChecked: HandleRowUnChecked;
|
||||
isDeleteEnabled: (conversation: ConversationTableItem) => boolean;
|
||||
isEditEnabled: (conversation: ConversationTableItem) => boolean;
|
||||
isExcludedMode: boolean;
|
||||
onDeleteActionClicked: (conversation: ConversationTableItem) => void;
|
||||
onEditActionClicked: (conversation: ConversationTableItem) => void;
|
||||
totalItemCount: number;
|
||||
}
|
||||
|
||||
export const useConversationsTable = () => {
|
||||
const getActions = useInlineActions<ConversationTableItem>();
|
||||
const getColumns = useCallback(
|
||||
({
|
||||
conversationOptions,
|
||||
deletedConversationsIds,
|
||||
excludedIds,
|
||||
handlePageChecked,
|
||||
handlePageUnchecked,
|
||||
handleRowChecked,
|
||||
handleRowUnChecked,
|
||||
isDeleteEnabled,
|
||||
isEditEnabled,
|
||||
isExcludedMode,
|
||||
onDeleteActionClicked,
|
||||
onEditActionClicked,
|
||||
}: {
|
||||
isDeleteEnabled: (conversation: ConversationTableItem) => boolean;
|
||||
isEditEnabled: (conversation: ConversationTableItem) => boolean;
|
||||
onDeleteActionClicked: (conversation: ConversationTableItem) => void;
|
||||
onEditActionClicked: (conversation: ConversationTableItem) => void;
|
||||
}): Array<EuiBasicTableColumn<ConversationTableItem>> => {
|
||||
totalItemCount,
|
||||
}: GetColumnsParams): Array<EuiBasicTableColumn<ConversationTableItem>> => {
|
||||
return [
|
||||
{
|
||||
field: '',
|
||||
name: (
|
||||
<PageSelectionCheckbox
|
||||
conversationOptions={conversationOptions}
|
||||
deletedConversationsIds={deletedConversationsIds}
|
||||
excludedIds={excludedIds}
|
||||
isExcludedMode={isExcludedMode}
|
||||
handlePageChecked={handlePageChecked}
|
||||
handlePageUnchecked={handlePageUnchecked}
|
||||
totalItemCount={totalItemCount}
|
||||
/>
|
||||
),
|
||||
render: (conversation: ConversationTableItem) => (
|
||||
<InputCheckbox
|
||||
conversation={conversation}
|
||||
deletedConversationsIds={deletedConversationsIds}
|
||||
excludedIds={excludedIds}
|
||||
isExcludedMode={isExcludedMode}
|
||||
handleRowChecked={handleRowChecked}
|
||||
handleRowUnChecked={handleRowUnChecked}
|
||||
totalItemCount={totalItemCount}
|
||||
/>
|
||||
),
|
||||
width: '70px',
|
||||
sortable: false,
|
||||
},
|
||||
{
|
||||
name: i18n.CONVERSATIONS_TABLE_COLUMN_TITLE,
|
||||
render: (conversation: ConversationTableItem) => (
|
||||
|
|
|
@ -113,7 +113,7 @@ const SystemPromptSettingsManagementComponent = ({ connectors, defaultConnector
|
|||
async (param?: { callback?: () => void }) => {
|
||||
const { success, conversationUpdates } = await saveSystemPromptSettings();
|
||||
if (success) {
|
||||
await saveConversationsSettings(conversationUpdates);
|
||||
await saveConversationsSettings({ bulkActions: conversationUpdates });
|
||||
await refetchPrompts();
|
||||
await refetchSystemPromptConversations();
|
||||
toasts?.addSuccess({
|
||||
|
|
|
@ -153,7 +153,9 @@ describe('AssistantSettings', () => {
|
|||
expect(onSave).toHaveBeenCalled();
|
||||
expect(mockSystemUpdater.saveSystemPromptSettings).toHaveBeenCalled();
|
||||
expect(mockConversationsUpdater.saveConversationsSettings).toHaveBeenCalledWith({
|
||||
updates: [],
|
||||
bulkActions: {
|
||||
updates: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -142,7 +142,7 @@ export const AssistantSettings: React.FC<Props> = React.memo(
|
|||
const { success: systemPromptSuccess, conversationUpdates } =
|
||||
await saveSystemPromptSettings();
|
||||
if (systemPromptSuccess) {
|
||||
saveResult = await saveConversationsSettings(conversationUpdates);
|
||||
saveResult = await saveConversationsSettings({ bulkActions: conversationUpdates });
|
||||
} else {
|
||||
saveResult = false;
|
||||
}
|
||||
|
|
|
@ -10,9 +10,13 @@ import { useConversationsUpdater } from './use_conversations_updater';
|
|||
import { useAssistantContext } from '../../../assistant_context';
|
||||
import { bulkUpdateConversations } from '../../api/conversations/bulk_update_actions_conversations';
|
||||
import { Conversation } from '../../../..';
|
||||
import { deleteAllConversations } from '../../api/conversations/delete_all_conversations';
|
||||
|
||||
jest.mock('../../../assistant_context');
|
||||
jest.mock('../../api/conversations/bulk_update_actions_conversations');
|
||||
jest.mock('../../api/conversations/delete_all_conversations', () => ({
|
||||
deleteAllConversations: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockConversations: Record<string, Conversation> = {
|
||||
'03a2ef3c-3aec-4f13-8f18-bb31b47b2df1': {
|
||||
|
@ -200,4 +204,38 @@ describe('useConversationsUpdater', () => {
|
|||
expect(result.current.conversationSettings).toEqual(mockConversations);
|
||||
expect(result.current.conversationsSettingsBulkActions).toEqual({});
|
||||
});
|
||||
|
||||
it('should call deleteAllConversations when isDeleteAll is true', async () => {
|
||||
const { result } = renderHook(() => useConversationsUpdater(mockConversations, true));
|
||||
|
||||
act(() => {
|
||||
result.current.saveConversationsSettings({
|
||||
isDeleteAll: true,
|
||||
excludedIds: [],
|
||||
});
|
||||
});
|
||||
|
||||
expect(deleteAllConversations as jest.Mock).toHaveBeenCalledWith({
|
||||
excludedIds: [],
|
||||
http: mockAssistantContext.http,
|
||||
toasts: mockAssistantContext.toasts,
|
||||
});
|
||||
});
|
||||
|
||||
it('should call deleteAllConversations when excludedIds is not empty', async () => {
|
||||
const { result } = renderHook(() => useConversationsUpdater(mockConversations, true));
|
||||
|
||||
act(() => {
|
||||
result.current.saveConversationsSettings({
|
||||
isDeleteAll: true,
|
||||
excludedIds: ['1'],
|
||||
});
|
||||
});
|
||||
|
||||
expect(deleteAllConversations as jest.Mock).toHaveBeenCalledWith({
|
||||
excludedIds: ['1'],
|
||||
http: mockAssistantContext.http,
|
||||
toasts: mockAssistantContext.toasts,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -12,19 +12,28 @@ import {
|
|||
ConversationsBulkActions,
|
||||
bulkUpdateConversations,
|
||||
} from '../../api/conversations/bulk_update_actions_conversations';
|
||||
import { deleteAllConversations } from '../../api/conversations/delete_all_conversations';
|
||||
|
||||
export type SaveConversationsSettingsParams =
|
||||
| {
|
||||
isDeleteAll?: boolean;
|
||||
bulkActions?: ConversationsBulkActions;
|
||||
excludedIds?: string[];
|
||||
}
|
||||
| undefined;
|
||||
interface UseConversationsUpdater {
|
||||
assistantStreamingEnabled: boolean;
|
||||
conversationSettings: Record<string, Conversation>;
|
||||
conversationsSettingsBulkActions: ConversationsBulkActions;
|
||||
onConversationDeleted: (cId: string) => void;
|
||||
onConversationsBulkDeleted: (cIds: string[]) => void;
|
||||
resetConversationsSettings: () => void;
|
||||
setConversationSettings: React.Dispatch<React.SetStateAction<Record<string, Conversation>>>;
|
||||
setConversationsSettingsBulkActions: React.Dispatch<
|
||||
React.SetStateAction<ConversationsBulkActions>
|
||||
>;
|
||||
setUpdatedAssistantStreamingEnabled: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
saveConversationsSettings: (bulkActions?: ConversationsBulkActions) => Promise<boolean>;
|
||||
saveConversationsSettings: (params?: SaveConversationsSettingsParams) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export const useConversationsUpdater = (
|
||||
|
@ -55,6 +64,37 @@ export const useConversationsUpdater = (
|
|||
setUpdatedAssistantStreamingEnabled(assistantStreamingEnabled);
|
||||
}, [assistantStreamingEnabled, conversations]);
|
||||
|
||||
const onConversationsBulkDeleted = useCallback(
|
||||
(cIds: string[]) => {
|
||||
let updatedConversationSettings: Record<string, Conversation> = {};
|
||||
const deletedConversations = new Set(conversationsSettingsBulkActions.delete?.ids ?? []);
|
||||
Object.values(conversations).forEach((current) => {
|
||||
const isConversationExist = cIds.includes(current.id);
|
||||
if (isConversationExist) {
|
||||
if (!deletedConversations.has(current.id)) {
|
||||
deletedConversations.add(current.id);
|
||||
} else {
|
||||
updatedConversationSettings = { ...updatedConversationSettings, current };
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setConversationSettings(updatedConversationSettings);
|
||||
setConversationsSettingsBulkActions({
|
||||
...conversationsSettingsBulkActions,
|
||||
delete: {
|
||||
ids: Array.from(deletedConversations),
|
||||
},
|
||||
});
|
||||
},
|
||||
[
|
||||
conversations,
|
||||
conversationsSettingsBulkActions,
|
||||
setConversationSettings,
|
||||
setConversationsSettingsBulkActions,
|
||||
]
|
||||
);
|
||||
|
||||
const onConversationDeleted = useCallback(
|
||||
(cId: string) => {
|
||||
const conversationId = Object.values(conversations).find((c) => c.id === cId)?.id;
|
||||
|
@ -85,14 +125,18 @@ export const useConversationsUpdater = (
|
|||
* Save all pending settings
|
||||
*/
|
||||
const saveConversationsSettings = useCallback(
|
||||
async (bulkActions?: ConversationsBulkActions): Promise<boolean> => {
|
||||
async (params?: SaveConversationsSettingsParams): Promise<boolean> => {
|
||||
const { isDeleteAll, bulkActions, excludedIds = [] } = params ?? {};
|
||||
// had trouble with conversationsSettingsBulkActions not updating fast enough
|
||||
// from the setConversationsSettingsBulkActions in saveSystemPromptSettings
|
||||
const bulkUpdates = bulkActions ?? conversationsSettingsBulkActions;
|
||||
const hasBulkConversations = bulkUpdates.create || bulkUpdates.update || bulkUpdates.delete;
|
||||
const bulkResult = hasBulkConversations
|
||||
? await bulkUpdateConversations(http, bulkUpdates, toasts)
|
||||
: undefined;
|
||||
const bulkResult =
|
||||
isDeleteAll || excludedIds?.length > 0
|
||||
? await deleteAllConversations({ http, toasts, excludedIds })
|
||||
: hasBulkConversations
|
||||
? await bulkUpdateConversations(http, bulkUpdates, toasts)
|
||||
: undefined;
|
||||
const didUpdateAssistantStreamingEnabled =
|
||||
assistantStreamingEnabled !== updatedAssistantStreamingEnabled;
|
||||
|
||||
|
@ -107,9 +151,9 @@ export const useConversationsUpdater = (
|
|||
return bulkResult?.success ?? didUpdateAssistantStreamingEnabled ?? false;
|
||||
},
|
||||
[
|
||||
conversationsSettingsBulkActions,
|
||||
http,
|
||||
toasts,
|
||||
conversationsSettingsBulkActions,
|
||||
assistantStreamingEnabled,
|
||||
updatedAssistantStreamingEnabled,
|
||||
setAssistantStreamingEnabled,
|
||||
|
@ -129,6 +173,7 @@ export const useConversationsUpdater = (
|
|||
conversationSettings,
|
||||
conversationsSettingsBulkActions,
|
||||
onConversationDeleted,
|
||||
onConversationsBulkDeleted,
|
||||
resetConversationsSettings,
|
||||
saveConversationsSettings,
|
||||
setUpdatedAssistantStreamingEnabled,
|
||||
|
|
|
@ -12,6 +12,7 @@ import {
|
|||
ConversationCreateProps,
|
||||
ConversationResponse,
|
||||
ConversationUpdateProps,
|
||||
DeleteAllConversationsRequestBody,
|
||||
} from '@kbn/elastic-assistant-common';
|
||||
import {
|
||||
CreateMessageSchema,
|
||||
|
@ -88,6 +89,10 @@ export const getCreateConversationSchemaMock = (
|
|||
...rest,
|
||||
});
|
||||
|
||||
export const getDeleteAllConversationsSchemaMock = (): DeleteAllConversationsRequestBody => ({
|
||||
excludedIds: ['conversation-1'],
|
||||
});
|
||||
|
||||
export const getUpdateConversationSchemaMock = (
|
||||
conversationId = 'conversation-1'
|
||||
): ConversationUpdateProps => ({
|
||||
|
|
|
@ -30,6 +30,7 @@ const createConversationsDataClientMock = () => {
|
|||
appendConversationMessages: jest.fn(),
|
||||
createConversation: jest.fn(),
|
||||
deleteConversation: jest.fn(),
|
||||
deleteAllConversations: jest.fn(),
|
||||
getConversation: jest.fn(),
|
||||
updateConversation: jest.fn(),
|
||||
getReader: jest.fn(),
|
||||
|
|
|
@ -53,6 +53,7 @@ import {
|
|||
import {
|
||||
getAppendConversationMessagesSchemaMock,
|
||||
getCreateConversationSchemaMock,
|
||||
getDeleteAllConversationsSchemaMock,
|
||||
getUpdateConversationSchemaMock,
|
||||
} from './conversations_schema.mock';
|
||||
import { getCreateKnowledgeBaseEntrySchemaMock } from './knowledge_base_entry_schema.mock';
|
||||
|
@ -186,6 +187,13 @@ export const getDeleteConversationRequest = (id: string = '04128c15-0d1b-4716-a4
|
|||
params: { id },
|
||||
});
|
||||
|
||||
export const getDeleteAllConversationsRequest = () =>
|
||||
requestMock.create({
|
||||
method: 'delete',
|
||||
path: ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL,
|
||||
body: getDeleteAllConversationsSchemaMock(),
|
||||
});
|
||||
|
||||
export const getCreateConversationRequest = () =>
|
||||
requestMock.create({
|
||||
method: 'post',
|
||||
|
|
|
@ -0,0 +1,54 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { elasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks';
|
||||
import { loggingSystemMock } from '@kbn/core-logging-server-mocks';
|
||||
import { DeleteAllConversationsParams, deleteAllConversations } from './delete_all_conversations';
|
||||
|
||||
export const getDeleteAllConversationsOptionsMock = (): DeleteAllConversationsParams => ({
|
||||
esClient: elasticsearchClientMock.createScopedClusterClient().asCurrentUser,
|
||||
conversationIndex: '.kibana-elastic-ai-assistant-conversations',
|
||||
logger: loggingSystemMock.createLogger(),
|
||||
excludedIds: ['test'],
|
||||
});
|
||||
|
||||
describe('deleteAllConversations', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('Delete all conversations', async () => {
|
||||
const mockResponse = { deleted: 1 };
|
||||
const options = getDeleteAllConversationsOptionsMock();
|
||||
options.esClient.deleteByQuery = jest.fn().mockResolvedValue(mockResponse);
|
||||
|
||||
const deletedConversations = await deleteAllConversations(options);
|
||||
expect(deletedConversations).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
test('throw error if no conversation was deleted', async () => {
|
||||
const mockResponse = { deleted: 0 };
|
||||
const options = getDeleteAllConversationsOptionsMock();
|
||||
options.esClient.deleteByQuery = jest.fn().mockResolvedValue(mockResponse);
|
||||
|
||||
await expect(deleteAllConversations(options)).rejects.toThrow(
|
||||
'No conversations have been deleted.'
|
||||
);
|
||||
});
|
||||
|
||||
test('handles error from deleteByQuery', async () => {
|
||||
const mockError = new Error('Test Error');
|
||||
const options = getDeleteAllConversationsOptionsMock();
|
||||
options.esClient.deleteByQuery = jest.fn().mockRejectedValue(mockError);
|
||||
|
||||
await expect(deleteAllConversations(options)).rejects.toThrow(mockError);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,51 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { DeleteByQueryResponse } from '@elastic/elasticsearch/lib/api/types';
|
||||
import { ElasticsearchClient, Logger } from '@kbn/core/server';
|
||||
|
||||
export interface DeleteAllConversationsParams {
|
||||
esClient: ElasticsearchClient;
|
||||
conversationIndex: string;
|
||||
logger: Logger;
|
||||
excludedIds?: string[];
|
||||
}
|
||||
export const deleteAllConversations = async ({
|
||||
esClient,
|
||||
conversationIndex,
|
||||
logger,
|
||||
excludedIds = [],
|
||||
}: DeleteAllConversationsParams): Promise<DeleteByQueryResponse | undefined> => {
|
||||
try {
|
||||
const response = await esClient.deleteByQuery({
|
||||
query: {
|
||||
bool: {
|
||||
must: {
|
||||
match_all: {},
|
||||
},
|
||||
must_not: {
|
||||
ids: {
|
||||
values: excludedIds,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
conflicts: 'proceed',
|
||||
index: conversationIndex,
|
||||
refresh: true,
|
||||
});
|
||||
|
||||
if (!response.deleted && response.deleted === 0) {
|
||||
logger.error('No conversations have been deleted.');
|
||||
throw Error('No conversations have been deleted.');
|
||||
}
|
||||
return response;
|
||||
} catch (err) {
|
||||
logger.error(`Error deleting all conversations: ${err}`);
|
||||
throw err;
|
||||
}
|
||||
};
|
|
@ -12,12 +12,14 @@ import {
|
|||
ConversationUpdateProps,
|
||||
Message,
|
||||
} from '@kbn/elastic-assistant-common';
|
||||
import { DeleteByQueryResponse } from '@elastic/elasticsearch/lib/api/types';
|
||||
import { createConversation } from './create_conversation';
|
||||
import { updateConversation } from './update_conversation';
|
||||
import { getConversation } from './get_conversation';
|
||||
import { deleteConversation } from './delete_conversation';
|
||||
import { appendConversationMessages } from './append_conversation_messages';
|
||||
import { AIAssistantDataClient, AIAssistantDataClientParams } from '..';
|
||||
import { deleteAllConversations } from './delete_all_conversations';
|
||||
|
||||
/**
|
||||
* Params for when creating ConversationDataClient in Request Context Factory. Useful if needing to modify
|
||||
|
@ -148,7 +150,7 @@ export class AIAssistantConversationsDataClient extends AIAssistantDataClient {
|
|||
* @param options.id The id of the conversation to delete
|
||||
* @returns The conversation deleted if found, otherwise null
|
||||
*/
|
||||
public deleteConversation = async (id: string) => {
|
||||
public deleteConversation = async (id: string): Promise<number | undefined> => {
|
||||
const esClient = await this.options.elasticsearchClientPromise;
|
||||
return deleteConversation({
|
||||
esClient,
|
||||
|
@ -157,4 +159,21 @@ export class AIAssistantConversationsDataClient extends AIAssistantDataClient {
|
|||
logger: this.options.logger,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Deletes all conversations in the index.
|
||||
* @param options.excludedIds An array of ids to exclude from deletion.
|
||||
* @returns The number of conversations deleted
|
||||
*/
|
||||
public deleteAllConversations = async (options?: {
|
||||
excludedIds?: string[];
|
||||
}): Promise<DeleteByQueryResponse | undefined> => {
|
||||
const esClient = await this.options.elasticsearchClientPromise;
|
||||
return deleteAllConversations({
|
||||
esClient,
|
||||
conversationIndex: this.indexTemplateAndPattern.alias,
|
||||
logger: this.options.logger,
|
||||
excludedIds: options?.excludedIds,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
|
|
@ -55,6 +55,7 @@ import { findAttackDiscoverySchedulesRoute } from './attack_discovery/schedules/
|
|||
import { disableAttackDiscoverySchedulesRoute } from './attack_discovery/schedules/disable';
|
||||
import { enableAttackDiscoverySchedulesRoute } from './attack_discovery/schedules/enable';
|
||||
import type { ConfigSchema } from '../config_schema';
|
||||
import { deleteAllConversationsRoute } from './user_conversations/delete_all_route';
|
||||
|
||||
export const registerRoutes = (
|
||||
router: ElasticAssistantPluginRouter,
|
||||
|
@ -74,6 +75,7 @@ export const registerRoutes = (
|
|||
readConversationRoute(router);
|
||||
updateConversationRoute(router);
|
||||
deleteConversationRoute(router);
|
||||
deleteAllConversationsRoute(router);
|
||||
appendConversationMessageRoute(router);
|
||||
|
||||
// User Conversations bulk CRUD
|
||||
|
|
|
@ -0,0 +1,88 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { requestContextMock } from '../../__mocks__/request_context';
|
||||
import { serverMock } from '../../__mocks__/server';
|
||||
import { getDeleteAllConversationsRequest } from '../../__mocks__/request';
|
||||
import { authenticatedUser } from '../../__mocks__/user';
|
||||
import { deleteAllConversationsRoute } from './delete_all_route';
|
||||
|
||||
describe('Delete all conversations route', () => {
|
||||
let server: ReturnType<typeof serverMock.create>;
|
||||
let { clients, context } = requestContextMock.createTools();
|
||||
const mockUser1 = authenticatedUser;
|
||||
|
||||
beforeEach(() => {
|
||||
server = serverMock.create();
|
||||
({ clients, context } = requestContextMock.createTools());
|
||||
|
||||
clients.elasticAssistant.getAIAssistantConversationsDataClient.deleteAllConversations.mockResolvedValue(
|
||||
{
|
||||
total: 1,
|
||||
}
|
||||
);
|
||||
context.elasticAssistant.getCurrentUser.mockResolvedValue(mockUser1);
|
||||
deleteAllConversationsRoute(server.router);
|
||||
});
|
||||
|
||||
describe('status codes with getAIAssistantConversationsDataClient', () => {
|
||||
test('returns 200 when deleting all conversations', async () => {
|
||||
const response = await server.inject(
|
||||
getDeleteAllConversationsRequest(),
|
||||
requestContextMock.convertContext(context)
|
||||
);
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
});
|
||||
|
||||
test('returns failure if exists', async () => {
|
||||
clients.elasticAssistant.getAIAssistantConversationsDataClient.deleteAllConversations.mockResolvedValue(
|
||||
{
|
||||
total: 0,
|
||||
failures: [
|
||||
{
|
||||
id: 'error-id',
|
||||
index: 'test-index',
|
||||
status: 400,
|
||||
cause: {
|
||||
reason: 'Test error',
|
||||
type: 'Error',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
);
|
||||
|
||||
const response = await server.inject(
|
||||
getDeleteAllConversationsRequest(),
|
||||
requestContextMock.convertContext(context)
|
||||
);
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual({
|
||||
success: false,
|
||||
totalDeleted: 0,
|
||||
failures: ['Test error'],
|
||||
});
|
||||
});
|
||||
|
||||
test('catches error if deletion throws error', async () => {
|
||||
clients.elasticAssistant.getAIAssistantConversationsDataClient.deleteAllConversations.mockRejectedValue(
|
||||
new Error('Test error')
|
||||
);
|
||||
const response = await server.inject(
|
||||
getDeleteAllConversationsRequest(),
|
||||
requestContextMock.convertContext(context)
|
||||
);
|
||||
expect(response.status).toEqual(500);
|
||||
expect(response.body).toEqual({
|
||||
message: 'Test error',
|
||||
status_code: 500,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,77 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { transformError } from '@kbn/securitysolution-es-utils';
|
||||
import {
|
||||
ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL,
|
||||
API_VERSIONS,
|
||||
DeleteAllConversationsRequestBody,
|
||||
} from '@kbn/elastic-assistant-common';
|
||||
import { buildRouteValidationWithZod } from '@kbn/elastic-assistant-common/impl/schemas/common';
|
||||
import { ElasticAssistantPluginRouter } from '../../types';
|
||||
import { buildResponse } from '../utils';
|
||||
import { performChecks } from '../helpers';
|
||||
|
||||
export const deleteAllConversationsRoute = (router: ElasticAssistantPluginRouter) => {
|
||||
router.versioned
|
||||
.delete({
|
||||
access: 'public',
|
||||
path: ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL,
|
||||
security: {
|
||||
authz: {
|
||||
requiredPrivileges: ['elasticAssistant'],
|
||||
},
|
||||
},
|
||||
})
|
||||
.addVersion(
|
||||
{
|
||||
version: API_VERSIONS.public.v1,
|
||||
validate: {
|
||||
request: {
|
||||
body: buildRouteValidationWithZod(DeleteAllConversationsRequestBody),
|
||||
},
|
||||
},
|
||||
},
|
||||
async (context, request, response) => {
|
||||
const assistantResponse = buildResponse(response);
|
||||
try {
|
||||
const ctx = await context.resolve(['core', 'elasticAssistant', 'licensing']);
|
||||
const checkResponse = await performChecks({
|
||||
context: ctx,
|
||||
request,
|
||||
response,
|
||||
});
|
||||
if (!checkResponse.isSuccess) {
|
||||
return checkResponse.response;
|
||||
}
|
||||
const dataClient = await ctx.elasticAssistant.getAIAssistantConversationsDataClient();
|
||||
|
||||
const result = await dataClient?.deleteAllConversations({
|
||||
excludedIds: request.body?.excludedIds,
|
||||
});
|
||||
|
||||
const hasFailures = result?.failures && result.failures.length > 0;
|
||||
|
||||
return response.ok({
|
||||
body: {
|
||||
success: !hasFailures,
|
||||
totalDeleted: result?.total,
|
||||
failures: hasFailures
|
||||
? result.failures?.map((failure) => failure.cause.reason)
|
||||
: null,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
const error = transformError(err);
|
||||
return assistantResponse.error({
|
||||
body: error.message,
|
||||
statusCode: error.statusCode,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
Loading…
Add table
Add a link
Reference in a new issue