From 1fd1b9e0e08cb1b76657633e8141e3a71c2a697d Mon Sep 17 00:00:00 2001 From: Michael Olorunnisola Date: Sun, 15 Mar 2026 08:39:41 -0400 Subject: [PATCH 01/22] [Cases] tasks: add feature flag xpack.cases.tasks.enabled Adds config schema entry tasks.enabled (default: false) to gate all task management code behind a feature flag. Updates the mock config and plugin test fixture to include the new required property. --- x-pack/platform/plugins/shared/cases/server/config.ts | 3 +++ x-pack/platform/plugins/shared/cases/server/mocks.ts | 3 +++ x-pack/platform/plugins/shared/cases/server/plugin.test.ts | 1 + 3 files changed, 7 insertions(+) diff --git a/x-pack/platform/plugins/shared/cases/server/config.ts b/x-pack/platform/plugins/shared/cases/server/config.ts index c143c6d6f6c16..4b30fd82c6b39 100644 --- a/x-pack/platform/plugins/shared/cases/server/config.ts +++ b/x-pack/platform/plugins/shared/cases/server/config.ts @@ -63,6 +63,9 @@ export const ConfigSchema = schema.object({ templates: schema.object({ enabled: schema.boolean({ defaultValue: false }), }), + tasks: schema.object({ + enabled: schema.boolean({ defaultValue: false }), + }), enabled: schema.boolean({ defaultValue: true }), }); diff --git a/x-pack/platform/plugins/shared/cases/server/mocks.ts b/x-pack/platform/plugins/shared/cases/server/mocks.ts index a4cdd45a86a17..5c2042357d34c 100644 --- a/x-pack/platform/plugins/shared/cases/server/mocks.ts +++ b/x-pack/platform/plugins/shared/cases/server/mocks.ts @@ -803,6 +803,9 @@ export const mockCasesContract = (): CasesServerStart => ({ attachments: { enabled: true, }, + tasks: { + enabled: false, + }, }, }); diff --git a/x-pack/platform/plugins/shared/cases/server/plugin.test.ts b/x-pack/platform/plugins/shared/cases/server/plugin.test.ts index aaebbafa2b3d1..e43af84ad10eb 100644 --- a/x-pack/platform/plugins/shared/cases/server/plugin.test.ts +++ b/x-pack/platform/plugins/shared/cases/server/plugin.test.ts @@ -34,6 +34,7 @@ function getConfig(overrides: Partial = {}): ConfigType { analytics: { index: { enabled: true } }, templates: { enabled: true }, attachments: { enabled: true }, + tasks: { enabled: false }, ...overrides, }; } From 770ad8e644081a5b1c4e8e76a41e2b44997f934d Mon Sep 17 00:00:00 2001 From: Michael Olorunnisola Date: Sun, 15 Mar 2026 08:39:49 -0400 Subject: [PATCH 02/22] [Cases] tasks: add CaseTask and CaseTaskTemplate domain types in common/ Adds io-ts runtime schemas and derived TypeScript types for: - CaseTask (status, priority, assignees, due_date, sort_order, custom_fields, task_summary, hierarchy via parent_task_id) - CaseTaskSummary (denormalized counts + next_due_date for case SO) - CaseTaskTemplate (scope, relative_due_days, nested subtasks) Also adds CASE_TASK_SAVED_OBJECT / CASE_TASK_TEMPLATE_SAVED_OBJECT constants and all task/task_template route URL constants. --- .../shared/cases/common/constants/index.ts | 22 +++++ .../cases/common/types/domain/task/index.ts | 8 ++ .../cases/common/types/domain/task/v1.ts | 87 +++++++++++++++++++ .../types/domain/task_template/index.ts | 8 ++ .../common/types/domain/task_template/v1.ts | 56 ++++++++++++ 5 files changed, 181 insertions(+) create mode 100644 x-pack/platform/plugins/shared/cases/common/types/domain/task/index.ts create mode 100644 x-pack/platform/plugins/shared/cases/common/types/domain/task/v1.ts create mode 100644 x-pack/platform/plugins/shared/cases/common/types/domain/task_template/index.ts create mode 100644 x-pack/platform/plugins/shared/cases/common/types/domain/task_template/v1.ts diff --git a/x-pack/platform/plugins/shared/cases/common/constants/index.ts b/x-pack/platform/plugins/shared/cases/common/constants/index.ts index ec4d91fc35398..2bda55ebf9d74 100644 --- a/x-pack/platform/plugins/shared/cases/common/constants/index.ts +++ b/x-pack/platform/plugins/shared/cases/common/constants/index.ts @@ -35,6 +35,8 @@ export const CASE_CONFIGURE_SAVED_OBJECT = 'cases-configure' as const; export const CASE_RULES_SAVED_OBJECT = 'cases-rules' as const; export const CASE_ID_INCREMENTER_SAVED_OBJECT = 'cases-incrementing-id' as const; export const CASE_TEMPLATE_SAVED_OBJECT = 'cases-templates' as const; +export const CASE_TASK_SAVED_OBJECT = 'cases-tasks' as const; +export const CASE_TASK_TEMPLATE_SAVED_OBJECT = 'cases-task-templates' as const; /** * If more values are added here please also add them here: x-pack/test/cases_api_integration/common/plugins @@ -108,6 +110,26 @@ export const INTERNAL_CASE_GET_CASES_BY_ATTACHMENT_URL = `${CASES_INTERNAL_URL}/case/attachments/_find_containing_all` as const; export const INTERNAL_BULK_CREATE_CASE_OBSERVABLES_URL = `${CASES_INTERNAL_URL}/{case_id}/observables/_bulk_create`; +/** + * Task routes + */ +export const CASES_TASKS_URL = `${CASES_URL}/tasks` as const; +export const CASE_TASKS_URL = `${CASE_DETAILS_URL}/tasks` as const; +export const CASE_TASK_DETAILS_URL = `${CASE_TASKS_URL}/{task_id}` as const; +export const CASE_TASKS_BULK_CREATE_URL = `${CASE_TASKS_URL}/_bulk_create` as const; +export const CASE_TASKS_BULK_UPDATE_URL = `${CASE_TASKS_URL}/_bulk_update` as const; +export const CASE_TASKS_BULK_DELETE_URL = `${CASE_TASKS_URL}/_bulk_delete` as const; +export const CASE_TASKS_REORDER_URL = `${CASE_TASKS_URL}/_reorder` as const; +export const CASE_TASKS_APPLY_TEMPLATE_URL = `${CASE_TASKS_URL}/_apply_template` as const; +export const CASES_TASKS_MY_URL = `${CASES_TASKS_URL}/_my` as const; +export const CASES_TASKS_FIND_URL = `${CASES_TASKS_URL}/_find` as const; + +/** + * Task template routes + */ +export const CASES_TASK_TEMPLATES_URL = `${CASES_URL}/task_templates` as const; +export const CASE_TASK_TEMPLATE_DETAILS_URL = `${CASES_TASK_TEMPLATES_URL}/{template_id}` as const; + export const INTERNAL_TEMPLATES_URL = `${CASES_INTERNAL_URL}/templates` as const; export const INTERNAL_TEMPLATE_DETAILS_URL = `${INTERNAL_TEMPLATES_URL}/{template_id}` as const; export const INTERNAL_BULK_DELETE_TEMPLATES_URL = `${INTERNAL_TEMPLATES_URL}/_bulk_delete` as const; diff --git a/x-pack/platform/plugins/shared/cases/common/types/domain/task/index.ts b/x-pack/platform/plugins/shared/cases/common/types/domain/task/index.ts new file mode 100644 index 0000000000000..25300c97a6d2e --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/common/types/domain/task/index.ts @@ -0,0 +1,8 @@ +/* + * 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. + */ + +export * from './v1'; diff --git a/x-pack/platform/plugins/shared/cases/common/types/domain/task/v1.ts b/x-pack/platform/plugins/shared/cases/common/types/domain/task/v1.ts new file mode 100644 index 0000000000000..f69fae71766be --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/common/types/domain/task/v1.ts @@ -0,0 +1,87 @@ +/* + * 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 * as rt from 'io-ts'; +import { UserRt } from '../user/v1'; + +export const CaseTaskStatusRt = rt.keyof({ + open: null, + in_progress: null, + completed: null, + cancelled: null, +}); + +export const CaseTaskPriorityRt = rt.keyof({ + low: null, + medium: null, + high: null, + critical: null, +}); + +export const CaseTaskCustomFieldRt = rt.strict({ + key: rt.string, + type: rt.keyof({ text: null, toggle: null, number: null, date: null }), + value: rt.union([rt.string, rt.boolean, rt.number, rt.null]), +}); + +export const CaseTaskAssigneeRt = rt.strict({ + uid: rt.string, +}); + +export const CaseTaskAttributesRt = rt.intersection([ + rt.strict({ + title: rt.string, + description: rt.string, + case_id: rt.string, + parent_task_id: rt.union([rt.string, rt.null]), + status: CaseTaskStatusRt, + priority: CaseTaskPriorityRt, + assignees: rt.array(CaseTaskAssigneeRt), + due_date: rt.union([rt.string, rt.null]), + started_at: rt.union([rt.string, rt.null]), + completed_at: rt.union([rt.string, rt.null]), + sort_order: rt.number, + template_id: rt.union([rt.string, rt.null]), + custom_fields: rt.array(CaseTaskCustomFieldRt), + required_role: rt.union([rt.string, rt.null]), + owner_team: rt.union([rt.string, rt.null]), + owner: rt.string, + created_at: rt.string, + created_by: UserRt, + updated_at: rt.union([rt.string, rt.null]), + updated_by: rt.union([UserRt, rt.null]), + }), + rt.exact(rt.partial({})), +]); + +export const CaseTaskRt = rt.intersection([ + CaseTaskAttributesRt, + rt.strict({ + id: rt.string, + version: rt.string, + }), +]); + +export const CaseTasksRt = rt.array(CaseTaskRt); + +export const CaseTaskSummaryRt = rt.strict({ + total: rt.number, + open: rt.number, + in_progress: rt.number, + completed: rt.number, + cancelled: rt.number, + next_due_date: rt.union([rt.string, rt.null]), +}); + +export type CaseTaskStatus = rt.TypeOf; +export type CaseTaskPriority = rt.TypeOf; +export type CaseTaskAssignee = rt.TypeOf; +export type CaseTaskCustomField = rt.TypeOf; +export type CaseTaskAttributes = rt.TypeOf; +export type CaseTask = rt.TypeOf; +export type CaseTasks = rt.TypeOf; +export type CaseTaskSummary = rt.TypeOf; diff --git a/x-pack/platform/plugins/shared/cases/common/types/domain/task_template/index.ts b/x-pack/platform/plugins/shared/cases/common/types/domain/task_template/index.ts new file mode 100644 index 0000000000000..25300c97a6d2e --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/common/types/domain/task_template/index.ts @@ -0,0 +1,8 @@ +/* + * 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. + */ + +export * from './v1'; diff --git a/x-pack/platform/plugins/shared/cases/common/types/domain/task_template/v1.ts b/x-pack/platform/plugins/shared/cases/common/types/domain/task_template/v1.ts new file mode 100644 index 0000000000000..dacb02b4088bd --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/common/types/domain/task_template/v1.ts @@ -0,0 +1,56 @@ +/* + * 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 * as rt from 'io-ts'; +import { UserRt } from '../user/v1'; +import { CaseTaskPriorityRt } from '../task/v1'; + +export const CaseTaskTemplateSubtaskRt = rt.strict({ + title: rt.string, + description: rt.string, + priority: CaseTaskPriorityRt, + relative_due_days: rt.union([rt.number, rt.null]), + sort_order: rt.number, +}); + +export const CaseTaskTemplateTaskRt = rt.strict({ + title: rt.string, + description: rt.string, + priority: CaseTaskPriorityRt, + relative_due_days: rt.union([rt.number, rt.null]), + sort_order: rt.number, + subtasks: rt.array(CaseTaskTemplateSubtaskRt), +}); + +export const CaseTaskTemplateAttributesRt = rt.strict({ + name: rt.string, + description: rt.string, + scope: rt.keyof({ global: null, space: null }), + tags: rt.array(rt.string), + tasks: rt.array(CaseTaskTemplateTaskRt), + owner: rt.string, + created_at: rt.string, + created_by: UserRt, + updated_at: rt.union([rt.string, rt.null]), + updated_by: rt.union([UserRt, rt.null]), +}); + +export const CaseTaskTemplateRt = rt.intersection([ + CaseTaskTemplateAttributesRt, + rt.strict({ + id: rt.string, + version: rt.string, + }), +]); + +export const CaseTaskTemplatesRt = rt.array(CaseTaskTemplateRt); + +export type CaseTaskTemplateSubtask = rt.TypeOf; +export type CaseTaskTemplateTask = rt.TypeOf; +export type CaseTaskTemplateAttributes = rt.TypeOf; +export type CaseTaskTemplate = rt.TypeOf; +export type CaseTaskTemplates = rt.TypeOf; From 0b0565dfccda8a325cd6e051573d64d537f9c929 Mon Sep 17 00:00:00 2001 From: Michael Olorunnisola Date: Sun, 15 Mar 2026 08:39:59 -0400 Subject: [PATCH 03/22] [Cases] tasks: add task-related user action types Extends UserActionTypes with: create_task, update_task, delete_task, apply_task_template. Adds io-ts payload schemas for each in user_action/task/v1.ts and wires them into the main UserAction union. Server-side BuilderFactory and BuilderParameters are updated to handle the new types (using NoopUserActionBuilder until PR 4 adds real renderers). Public-side registers them as UNSUPPORTED_ACTION_TYPES so the activity timeline skips them gracefully until PR 7 adds UI renders. --- .../types/domain/user_action/action/v1.ts | 4 + .../types/domain/user_action/task/v1.ts | 85 +++++++++++++++++++ .../common/types/domain/user_action/v1.ts | 14 +++ .../components/user_actions/constants.ts | 8 +- .../components/user_actions/translations.ts | 8 ++ .../user_actions/user_actions_aria_labels.tsx | 4 + .../services/user_actions/builder_factory.ts | 4 + .../server/services/user_actions/types.ts | 18 ++++ 8 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 x-pack/platform/plugins/shared/cases/common/types/domain/user_action/task/v1.ts diff --git a/x-pack/platform/plugins/shared/cases/common/types/domain/user_action/action/v1.ts b/x-pack/platform/plugins/shared/cases/common/types/domain/user_action/action/v1.ts index cbff90a3b79f6..5b189b27f0b4c 100644 --- a/x-pack/platform/plugins/shared/cases/common/types/domain/user_action/action/v1.ts +++ b/x-pack/platform/plugins/shared/cases/common/types/domain/user_action/action/v1.ts @@ -27,6 +27,10 @@ export const UserActionTypes = { category: 'category', customFields: 'customFields', observables: 'observables', + create_task: 'create_task', + update_task: 'update_task', + delete_task: 'delete_task', + apply_task_template: 'apply_task_template', } as const; type UserActionActionTypeKeys = keyof typeof UserActionTypes; diff --git a/x-pack/platform/plugins/shared/cases/common/types/domain/user_action/task/v1.ts b/x-pack/platform/plugins/shared/cases/common/types/domain/user_action/task/v1.ts new file mode 100644 index 0000000000000..08ef0cfd3e5c2 --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/common/types/domain/user_action/task/v1.ts @@ -0,0 +1,85 @@ +/* + * 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 * as rt from 'io-ts'; +import { UserActionTypes } from '../action/v1'; + +// --- create_task --- + +const CreateTaskPayloadTaskRt = rt.strict({ + id: rt.string, + title: rt.string, + status: rt.string, + priority: rt.string, + assignees: rt.array(rt.strict({ uid: rt.string })), +}); + +export const CreateTaskUserActionPayloadRt = rt.strict({ + task: CreateTaskPayloadTaskRt, +}); + +export const CreateTaskUserActionRt = rt.strict({ + type: rt.literal(UserActionTypes.create_task), + payload: CreateTaskUserActionPayloadRt, +}); + +// --- update_task --- + +export const UpdateTaskChangedFieldRt = rt.strict({ + field: rt.string, + old_value: rt.unknown, + new_value: rt.unknown, +}); + +export const UpdateTaskUserActionPayloadRt = rt.strict({ + task_id: rt.string, + task_title: rt.string, + changed_fields: rt.array(UpdateTaskChangedFieldRt), +}); + +export const UpdateTaskUserActionRt = rt.strict({ + type: rt.literal(UserActionTypes.update_task), + payload: UpdateTaskUserActionPayloadRt, +}); + +// --- delete_task --- + +export const DeleteTaskUserActionPayloadRt = rt.strict({ + task_id: rt.string, + task_title: rt.string, + subtasks_deleted: rt.number, +}); + +export const DeleteTaskUserActionRt = rt.strict({ + type: rt.literal(UserActionTypes.delete_task), + payload: DeleteTaskUserActionPayloadRt, +}); + +// --- apply_task_template --- + +export const ApplyTaskTemplateUserActionPayloadRt = rt.strict({ + template_id: rt.string, + template_name: rt.string, + tasks_created: rt.number, +}); + +export const ApplyTaskTemplateUserActionRt = rt.strict({ + type: rt.literal(UserActionTypes.apply_task_template), + payload: ApplyTaskTemplateUserActionPayloadRt, +}); + +export type CreateTaskUserActionPayload = rt.TypeOf; +export type CreateTaskUserAction = rt.TypeOf; +export type UpdateTaskChangedField = rt.TypeOf; +export type UpdateTaskUserActionPayload = rt.TypeOf; +export type UpdateTaskUserAction = rt.TypeOf; +export type DeleteTaskUserActionPayload = rt.TypeOf; +export type DeleteTaskUserAction = rt.TypeOf; +export type ApplyTaskTemplateUserActionPayload = rt.TypeOf< + typeof ApplyTaskTemplateUserActionPayloadRt +>; +export type ApplyTaskTemplateUserAction = rt.TypeOf; diff --git a/x-pack/platform/plugins/shared/cases/common/types/domain/user_action/v1.ts b/x-pack/platform/plugins/shared/cases/common/types/domain/user_action/v1.ts index 10d11cbc9632e..e4bdaf263f1cf 100644 --- a/x-pack/platform/plugins/shared/cases/common/types/domain/user_action/v1.ts +++ b/x-pack/platform/plugins/shared/cases/common/types/domain/user_action/v1.ts @@ -25,6 +25,12 @@ import { TagsUserActionRt } from './tags/v1'; import { TitleUserActionRt } from './title/v1'; import { CustomFieldsUserActionRt } from './custom_fields/v1'; import { ObservablesUserActionRt } from './observables/v1'; +import { + CreateTaskUserActionRt, + UpdateTaskUserActionRt, + DeleteTaskUserActionRt, + ApplyTaskTemplateUserActionRt, +} from './task/v1'; export { UserActionTypes, UserActionActions } from './action/v1'; export { StatusUserActionRt } from './status/v1'; @@ -63,6 +69,10 @@ const BasicUserActionsRt = rt.union([ CategoryUserActionRt, CustomFieldsUserActionRt, ObservablesUserActionRt, + CreateTaskUserActionRt, + UpdateTaskUserActionRt, + DeleteTaskUserActionRt, + ApplyTaskTemplateUserActionRt, ]); const CommonUserActionsWithIdsRt = rt.union([BasicUserActionsRt, CommentUserActionRt]); @@ -158,3 +168,7 @@ export type CreateCaseUserActionWithoutConnectorId = UserActionWithAttributes< >; export type CustomFieldsUserAction = UserAction>; export type ObservablesUserAction = UserAction>; +export type CreateTaskUserAction = UserAction>; +export type UpdateTaskUserAction = UserAction>; +export type DeleteTaskUserAction = UserAction>; +export type ApplyTaskTemplateUserAction = UserAction>; diff --git a/x-pack/platform/plugins/shared/cases/public/components/user_actions/constants.ts b/x-pack/platform/plugins/shared/cases/public/components/user_actions/constants.ts index 79569e7a35744..999ed8e1402fb 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/user_actions/constants.ts +++ b/x-pack/platform/plugins/shared/cases/public/components/user_actions/constants.ts @@ -11,7 +11,13 @@ import type { SupportedUserActionTypes } from './types'; export const DRAFT_COMMENT_STORAGE_ID = 'xpack.cases.commentDraft'; -export const UNSUPPORTED_ACTION_TYPES = ['delete_case'] as const; +export const UNSUPPORTED_ACTION_TYPES = [ + 'delete_case', + 'create_task', + 'update_task', + 'delete_task', + 'apply_task_template', +] as const; export const SUPPORTED_ACTION_TYPES: SupportedUserActionTypes[] = Object.keys( omit(UserActionTypes, UNSUPPORTED_ACTION_TYPES) ) as SupportedUserActionTypes[]; diff --git a/x-pack/platform/plugins/shared/cases/public/components/user_actions/translations.ts b/x-pack/platform/plugins/shared/cases/public/components/user_actions/translations.ts index e4e45d531b262..5d6702cfbdc56 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/user_actions/translations.ts +++ b/x-pack/platform/plugins/shared/cases/public/components/user_actions/translations.ts @@ -155,6 +155,14 @@ export const OBSERVABLES = i18n.translate('xpack.cases.caseView.userActions.obse defaultMessage: 'Observables', }); +export const TASK = i18n.translate('xpack.cases.caseView.userActions.task', { + defaultMessage: 'Task', +}); + +export const TASK_TEMPLATE = i18n.translate('xpack.cases.caseView.userActions.taskTemplate', { + defaultMessage: 'Task Template', +}); + export const USER_ACTION_EDITED = (type: string) => i18n.translate('xpack.cases.caseView.userActions.edited', { values: { type }, diff --git a/x-pack/platform/plugins/shared/cases/public/components/user_actions/user_actions_aria_labels.tsx b/x-pack/platform/plugins/shared/cases/public/components/user_actions/user_actions_aria_labels.tsx index c889b76b31d38..ece1b8fdd3c57 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/user_actions/user_actions_aria_labels.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/user_actions/user_actions_aria_labels.tsx @@ -25,6 +25,10 @@ export const getUserActionAriaLabel = (type: keyof typeof UserActionTypes) => { category: i18n.CATEGORY, customFields: i18n.CUSTOM_FIELDS, observables: i18n.OBSERVABLES, + create_task: i18n.TASK, + update_task: i18n.TASK, + delete_task: i18n.TASK, + apply_task_template: i18n.TASK_TEMPLATE, }; switch (type) { diff --git a/x-pack/platform/plugins/shared/cases/server/services/user_actions/builder_factory.ts b/x-pack/platform/plugins/shared/cases/server/services/user_actions/builder_factory.ts index fd9645244dfff..ac98f632d5aa7 100644 --- a/x-pack/platform/plugins/shared/cases/server/services/user_actions/builder_factory.ts +++ b/x-pack/platform/plugins/shared/cases/server/services/user_actions/builder_factory.ts @@ -41,6 +41,10 @@ const builderMap = { delete_case: NoopUserActionBuilder, customFields: CustomFieldsUserActionBuilder, observables: ObservablesUserActionBuilder, + create_task: NoopUserActionBuilder, + update_task: NoopUserActionBuilder, + delete_task: NoopUserActionBuilder, + apply_task_template: NoopUserActionBuilder, }; export class BuilderFactory { diff --git a/x-pack/platform/plugins/shared/cases/server/services/user_actions/types.ts b/x-pack/platform/plugins/shared/cases/server/services/user_actions/types.ts index 0dd02c52f4da2..985f959ff94aa 100644 --- a/x-pack/platform/plugins/shared/cases/server/services/user_actions/types.ts +++ b/x-pack/platform/plugins/shared/cases/server/services/user_actions/types.ts @@ -40,6 +40,12 @@ import type { UserActionFindRequest, } from '../../../common/types/api'; import type { ObservablesActionType } from '../../../common/types/domain/user_action/observables/v1'; +import type { + CreateTaskUserActionPayload, + UpdateTaskUserActionPayload, + DeleteTaskUserActionPayload, + ApplyTaskTemplateUserActionPayload, +} from '../../../common/types/domain/user_action/task/v1'; export interface BuilderParameters { title: { @@ -103,6 +109,18 @@ export interface BuilderParameters { }; }; }; + create_task: { + parameters: { payload: CreateTaskUserActionPayload }; + }; + update_task: { + parameters: { payload: UpdateTaskUserActionPayload }; + }; + delete_task: { + parameters: { payload: DeleteTaskUserActionPayload }; + }; + apply_task_template: { + parameters: { payload: ApplyTaskTemplateUserActionPayload }; + }; } export interface CreateUserAction { From 7572354b9f9b26051c630e5b6c06c49d474ebfcb Mon Sep 17 00:00:00 2001 From: Michael Olorunnisola Date: Sun, 15 Mar 2026 08:40:07 -0400 Subject: [PATCH 04/22] [Cases] tasks: register case-task and case-task-template SavedObject types Both types use hidden: true, namespaceType: 'multiple-isolated', and ALERTING_CASES_SAVED_OBJECT_INDEX matching existing Cases SO patterns. case-task mappings index: case_id, parent_task_id, status, priority, due_date, assignees.uid, owner, sort_order, created_at. A parent-case reference is stored in the SO references array via buildCaseTaskCaseReference(). Both types are gated behind config.tasks.enabled and registered at modelVersion 1 to establish the migration version chain. --- .../cases/server/saved_object_types/index.ts | 6 ++ .../task_templates/index.ts | 56 +++++++++++ .../server/saved_object_types/tasks/index.ts | 98 +++++++++++++++++++ 3 files changed, 160 insertions(+) create mode 100644 x-pack/platform/plugins/shared/cases/server/saved_object_types/task_templates/index.ts create mode 100644 x-pack/platform/plugins/shared/cases/server/saved_object_types/tasks/index.ts diff --git a/x-pack/platform/plugins/shared/cases/server/saved_object_types/index.ts b/x-pack/platform/plugins/shared/cases/server/saved_object_types/index.ts index 30ba490af1a82..fcc099d7babee 100644 --- a/x-pack/platform/plugins/shared/cases/server/saved_object_types/index.ts +++ b/x-pack/platform/plugins/shared/cases/server/saved_object_types/index.ts @@ -18,6 +18,8 @@ import { caseIdIncrementerSavedObjectType } from './id_incrementer'; import { createCaseAttachmentSavedObjectType } from './attachments'; import type { PersistableStateAttachmentTypeRegistry } from '../attachment_framework/persistable_state_registry'; import { caseTemplateSavedObjectType } from './templates'; +import { caseTaskSavedObjectType } from './tasks'; +import { caseTaskTemplateSavedObjectType } from './task_templates'; import type { ConfigType } from '../config'; interface RegisterSavedObjectsArgs { @@ -63,4 +65,8 @@ export const registerSavedObjects = ({ if (config.attachments?.enabled) { core.savedObjects.registerType(createCaseAttachmentSavedObjectType()); } + if (config.tasks?.enabled) { + core.savedObjects.registerType(caseTaskSavedObjectType); + core.savedObjects.registerType(caseTaskTemplateSavedObjectType); + } }; diff --git a/x-pack/platform/plugins/shared/cases/server/saved_object_types/task_templates/index.ts b/x-pack/platform/plugins/shared/cases/server/saved_object_types/task_templates/index.ts new file mode 100644 index 0000000000000..42f1ad638341a --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/server/saved_object_types/task_templates/index.ts @@ -0,0 +1,56 @@ +/* + * 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 type { SavedObjectsType } from '@kbn/core/server'; +import { ALERTING_CASES_SAVED_OBJECT_INDEX } from '@kbn/core-saved-objects-server'; +import { CASE_TASK_TEMPLATE_SAVED_OBJECT } from '../../../common/constants'; + +export const caseTaskTemplateSavedObjectType: SavedObjectsType = { + name: CASE_TASK_TEMPLATE_SAVED_OBJECT, + indexPattern: ALERTING_CASES_SAVED_OBJECT_INDEX, + hidden: true, + namespaceType: 'multiple-isolated', + convertToMultiNamespaceTypeVersion: '8.0.0', + mappings: { + dynamic: false, + properties: { + name: { type: 'keyword' }, + description: { type: 'text' }, + scope: { type: 'keyword' }, + tags: { type: 'keyword' }, + owner: { type: 'keyword' }, + created_at: { type: 'date' }, + created_by: { + properties: { + username: { type: 'keyword' }, + full_name: { type: 'keyword' }, + email: { type: 'keyword' }, + profile_uid: { type: 'keyword' }, + }, + }, + updated_at: { type: 'date' }, + updated_by: { + properties: { + username: { type: 'keyword' }, + full_name: { type: 'keyword' }, + email: { type: 'keyword' }, + profile_uid: { type: 'keyword' }, + }, + }, + // tasks array is stored but not indexed (dynamic: false) + }, + }, + modelVersions: { + 1: { + changes: [], + }, + }, + management: { + importableAndExportable: false, + visibleInManagement: false, + }, +}; diff --git a/x-pack/platform/plugins/shared/cases/server/saved_object_types/tasks/index.ts b/x-pack/platform/plugins/shared/cases/server/saved_object_types/tasks/index.ts new file mode 100644 index 0000000000000..80c589589b9fd --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/server/saved_object_types/tasks/index.ts @@ -0,0 +1,98 @@ +/* + * 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 type { SavedObjectsType } from '@kbn/core/server'; +import { ALERTING_CASES_SAVED_OBJECT_INDEX } from '@kbn/core-saved-objects-server'; +import { CASE_TASK_SAVED_OBJECT, CASE_SAVED_OBJECT } from '../../../common/constants'; + +export const caseTaskSavedObjectType: SavedObjectsType = { + name: CASE_TASK_SAVED_OBJECT, + indexPattern: ALERTING_CASES_SAVED_OBJECT_INDEX, + hidden: true, + namespaceType: 'multiple-isolated', + convertToMultiNamespaceTypeVersion: '8.0.0', + mappings: { + dynamic: false, + properties: { + // Identity + title: { type: 'text' }, + description: { type: 'text' }, + + // Hierarchy + case_id: { type: 'keyword' }, + parent_task_id: { type: 'keyword' }, + + // State + status: { type: 'keyword' }, + priority: { type: 'keyword' }, + + // Assignment + assignees: { + properties: { + uid: { type: 'keyword' }, + }, + }, + + // Scheduling + due_date: { type: 'date' }, + started_at: { type: 'date' }, + completed_at: { type: 'date' }, + + // Ordering + sort_order: { type: 'integer' }, + + // Template origin + template_id: { type: 'keyword' }, + + // RBAC future-proofing (stored, not indexed — dynamic: false covers them) + + // Metadata + owner: { type: 'keyword' }, + created_at: { type: 'date' }, + created_by: { + properties: { + username: { type: 'keyword' }, + full_name: { type: 'keyword' }, + email: { type: 'keyword' }, + profile_uid: { type: 'keyword' }, + }, + }, + updated_at: { type: 'date' }, + updated_by: { + properties: { + username: { type: 'keyword' }, + full_name: { type: 'keyword' }, + email: { type: 'keyword' }, + profile_uid: { type: 'keyword' }, + }, + }, + }, + }, + modelVersions: { + 1: { + changes: [], + }, + }, + management: { + importableAndExportable: false, + visibleInManagement: false, + }, +}; + +/** + * The name used for the reference to the parent case in the SO references array. + */ +export const CASE_TASK_PARENT_CASE_REF_NAME = 'parentCase' as const; + +/** + * Build the references array entry linking a task to its parent case. + */ +export const buildCaseTaskCaseReference = (caseId: string) => ({ + type: CASE_SAVED_OBJECT, + name: CASE_TASK_PARENT_CASE_REF_NAME, + id: caseId, +}); From b8dc288bedeab1f0625a14e36563ac4316e0f15f Mon Sep 17 00:00:00 2001 From: Michael Olorunnisola Date: Sun, 15 Mar 2026 08:40:14 -0400 Subject: [PATCH 05/22] [Cases] tasks: add task_summary field to case SO Adds a denormalized task_summary object to the cases SavedObject via modelVersion 10. The field stores per-status task counts and the earliest uncompleted due date, avoiding N+1 queries on case list views. The field is optional/nullable so existing cases are unaffected. CasePersistedAttributes is updated to reflect the new field. --- .../shared/cases/server/common/types/case.ts | 8 ++++ .../server/saved_object_types/cases/cases.ts | 13 +++++++ .../cases/model_versions/index.ts | 1 + .../cases/model_versions/model_version_10.ts | 39 +++++++++++++++++++ .../saved_object_types/cases/schemas/index.ts | 1 + .../cases/schemas/latest.ts | 2 +- .../saved_object_types/cases/schemas/v10.ts | 24 ++++++++++++ 7 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 x-pack/platform/plugins/shared/cases/server/saved_object_types/cases/model_versions/model_version_10.ts create mode 100644 x-pack/platform/plugins/shared/cases/server/saved_object_types/cases/schemas/v10.ts diff --git a/x-pack/platform/plugins/shared/cases/server/common/types/case.ts b/x-pack/platform/plugins/shared/cases/server/common/types/case.ts index 1a831cc3be0fd..a5b517918a93f 100644 --- a/x-pack/platform/plugins/shared/cases/server/common/types/case.ts +++ b/x-pack/platform/plugins/shared/cases/server/common/types/case.ts @@ -62,6 +62,14 @@ export interface CasePersistedAttributes { version: number; } | null; extended_fields?: Record | null; + task_summary?: { + total: number; + open: number; + in_progress: number; + completed: number; + cancelled: number; + next_due_date: string | null; + } | null; } type CasePersistedCustomFields = Array<{ diff --git a/x-pack/platform/plugins/shared/cases/server/saved_object_types/cases/cases.ts b/x-pack/platform/plugins/shared/cases/server/saved_object_types/cases/cases.ts index a18ccc7f94477..1cad4ad616f1d 100644 --- a/x-pack/platform/plugins/shared/cases/server/saved_object_types/cases/cases.ts +++ b/x-pack/platform/plugins/shared/cases/server/saved_object_types/cases/cases.ts @@ -27,6 +27,7 @@ import { modelVersion7, modelVersion8, modelVersion9, + modelVersion10, } from './model_versions'; import { handleImport } from '../import_export/import'; @@ -286,6 +287,17 @@ export const createCaseSavedObjectType = ( [CASE_EXTENDED_FIELDS]: { type: 'flattened', }, + task_summary: { + type: 'object', + properties: { + total: { type: 'integer' }, + open: { type: 'integer' }, + in_progress: { type: 'integer' }, + completed: { type: 'integer' }, + cancelled: { type: 'integer' }, + next_due_date: { type: 'date' }, + }, + }, }, }, migrations: caseMigrations, @@ -299,6 +311,7 @@ export const createCaseSavedObjectType = ( 7: modelVersion7, 8: modelVersion8, 9: modelVersion9, + 10: modelVersion10, }, management: { importableAndExportable: true, diff --git a/x-pack/platform/plugins/shared/cases/server/saved_object_types/cases/model_versions/index.ts b/x-pack/platform/plugins/shared/cases/server/saved_object_types/cases/model_versions/index.ts index 9d6cf6dc5f25c..f8931cb16306e 100644 --- a/x-pack/platform/plugins/shared/cases/server/saved_object_types/cases/model_versions/index.ts +++ b/x-pack/platform/plugins/shared/cases/server/saved_object_types/cases/model_versions/index.ts @@ -14,3 +14,4 @@ export { modelVersion6 } from './model_version_6'; export { modelVersion7 } from './model_version_7'; export { modelVersion8 } from './model_version_8'; export { modelVersion9 } from './model_version_9'; +export { modelVersion10 } from './model_version_10'; diff --git a/x-pack/platform/plugins/shared/cases/server/saved_object_types/cases/model_versions/model_version_10.ts b/x-pack/platform/plugins/shared/cases/server/saved_object_types/cases/model_versions/model_version_10.ts new file mode 100644 index 0000000000000..e3e1e0f248e01 --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/server/saved_object_types/cases/model_versions/model_version_10.ts @@ -0,0 +1,39 @@ +/* + * 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 type { SavedObjectsModelVersion } from '@kbn/core-saved-objects-server'; +import { casesSchemaV10 } from '../schemas'; + +/** + * Adds the task_summary field to the cases SO. + * This field is a denormalized summary of task counts per status and the + * next due date, maintained by the CaseTaskService on every task mutation. + * Default value for existing cases is null (no tasks). + */ +export const modelVersion10: SavedObjectsModelVersion = { + changes: [ + { + type: 'mappings_addition', + addedMappings: { + task_summary: { + type: 'object', + properties: { + total: { type: 'integer' }, + open: { type: 'integer' }, + in_progress: { type: 'integer' }, + completed: { type: 'integer' }, + cancelled: { type: 'integer' }, + next_due_date: { type: 'date' }, + }, + }, + }, + }, + ], + schemas: { + forwardCompatibility: casesSchemaV10.extends({}, { unknowns: 'ignore' }), + }, +}; diff --git a/x-pack/platform/plugins/shared/cases/server/saved_object_types/cases/schemas/index.ts b/x-pack/platform/plugins/shared/cases/server/saved_object_types/cases/schemas/index.ts index 6cd6bf9f8f8e2..9c3c298ddabd6 100644 --- a/x-pack/platform/plugins/shared/cases/server/saved_object_types/cases/schemas/index.ts +++ b/x-pack/platform/plugins/shared/cases/server/saved_object_types/cases/schemas/index.ts @@ -16,3 +16,4 @@ export { casesSchema as casesSchemaV6 } from './v6'; export { casesSchema as casesSchemaV7 } from './v7'; export { casesSchema as casesSchemaV8 } from './v8'; export { casesSchema as casesSchemaV9 } from './v9'; +export { casesSchema as casesSchemaV10 } from './v10'; diff --git a/x-pack/platform/plugins/shared/cases/server/saved_object_types/cases/schemas/latest.ts b/x-pack/platform/plugins/shared/cases/server/saved_object_types/cases/schemas/latest.ts index 16643bc87f0b7..ad6b9f37c1061 100644 --- a/x-pack/platform/plugins/shared/cases/server/saved_object_types/cases/schemas/latest.ts +++ b/x-pack/platform/plugins/shared/cases/server/saved_object_types/cases/schemas/latest.ts @@ -5,4 +5,4 @@ * 2.0. */ -export * from './v9'; +export * from './v10'; diff --git a/x-pack/platform/plugins/shared/cases/server/saved_object_types/cases/schemas/v10.ts b/x-pack/platform/plugins/shared/cases/server/saved_object_types/cases/schemas/v10.ts new file mode 100644 index 0000000000000..87c8f5d1e8463 --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/server/saved_object_types/cases/schemas/v10.ts @@ -0,0 +1,24 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { casesSchema as casesSchemaV9 } from './v9'; + +export const casesSchema = casesSchemaV9.extends({ + task_summary: schema.maybe( + schema.nullable( + schema.object({ + total: schema.number(), + open: schema.number(), + in_progress: schema.number(), + completed: schema.number(), + cancelled: schema.number(), + next_due_date: schema.maybe(schema.nullable(schema.string())), + }) + ) + ), +}); From 4691192ac335b98461951302be6a1c21bc76a8a3 Mon Sep 17 00:00:00 2001 From: Michael Olorunnisola Date: Sun, 15 Mar 2026 09:04:17 -0400 Subject: [PATCH 06/22] =?UTF-8?q?[Cases]=20PR=202:=20Add=20CaseTaskService?= =?UTF-8?q?=20=E2=80=94=20core=20CRUD,=20cascade=20delete,=20sort=20order,?= =?UTF-8?q?=20task=20summary=20sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the CaseTaskService class covering: - createTask / bulkCreateTasks with gap-based sort_order (1000 gaps) - getTask / getTasksByCase / findTasks / getMyTasks with KQL filters - updateTask with status-transition timestamps (started_at, completed_at) - bulkUpdateTasks / bulkUpdateTasks - deleteTask / bulkDeleteTasks with BFS cascade descent deletion - reorderTasks with 1000-gap sort_order recomputation - validateDepth (max 2 levels deep) and syncTaskSummary (denormalized on case SO) Co-Authored-By: Claude Sonnet 4.6 --- .../shared/cases/server/services/index.ts | 1 + .../cases/server/services/tasks/index.test.ts | 527 +++++++++++++ .../cases/server/services/tasks/index.ts | 698 ++++++++++++++++++ .../cases/server/services/tasks/sort_order.ts | 77 ++ .../server/services/tasks/task_summary.ts | 129 ++++ .../cases/server/services/tasks/types.ts | 98 +++ 6 files changed, 1530 insertions(+) create mode 100644 x-pack/platform/plugins/shared/cases/server/services/tasks/index.test.ts create mode 100644 x-pack/platform/plugins/shared/cases/server/services/tasks/index.ts create mode 100644 x-pack/platform/plugins/shared/cases/server/services/tasks/sort_order.ts create mode 100644 x-pack/platform/plugins/shared/cases/server/services/tasks/task_summary.ts create mode 100644 x-pack/platform/plugins/shared/cases/server/services/tasks/types.ts diff --git a/x-pack/platform/plugins/shared/cases/server/services/index.ts b/x-pack/platform/plugins/shared/cases/server/services/index.ts index 1d130526bbccf..2b5254d7461c2 100644 --- a/x-pack/platform/plugins/shared/cases/server/services/index.ts +++ b/x-pack/platform/plugins/shared/cases/server/services/index.ts @@ -15,6 +15,7 @@ export { AlertService } from './alerts'; export { AttachmentService } from './attachments'; export { UserProfileService } from './user_profiles'; export { TemplatesService } from './templates'; +export { CaseTaskService } from './tasks'; export interface ClientArgs { unsecuredSavedObjectsClient: SavedObjectsClientContract; diff --git a/x-pack/platform/plugins/shared/cases/server/services/tasks/index.test.ts b/x-pack/platform/plugins/shared/cases/server/services/tasks/index.test.ts new file mode 100644 index 0000000000000..c262b63346ccd --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/server/services/tasks/index.test.ts @@ -0,0 +1,527 @@ +/* + * 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 { loggingSystemMock, savedObjectsClientMock } from '@kbn/core/server/mocks'; +import { CaseTaskService } from './index'; +import type { CaseTaskAttributes } from '../../../common/types/domain/task/v1'; +import { CASE_TASK_SAVED_OBJECT, CASE_SAVED_OBJECT } from '../../../common/constants'; + +const mockUser = { + username: 'elastic', + full_name: 'Elastic User', + email: 'elastic@example.com', + profile_uid: 'uid-1', +}; + +const makeTaskSO = (overrides: Partial & { id?: string } = {}) => { + const { id = 'task-1', ...attrs } = overrides; + return { + id, + score: 1, + type: CASE_TASK_SAVED_OBJECT, + references: [], + version: 'WzEsMV0=', + attributes: { + title: 'Test task', + description: '', + case_id: 'case-1', + parent_task_id: null, + status: 'open' as const, + priority: 'medium' as const, + assignees: [], + due_date: null, + started_at: null, + completed_at: null, + sort_order: 1000, + template_id: null, + custom_fields: [], + required_role: null, + owner_team: null, + owner: 'securitySolution', + created_at: '2024-01-01T00:00:00.000Z', + created_by: mockUser, + updated_at: null, + updated_by: null, + ...attrs, + }, + }; +}; + +describe('CaseTaskService', () => { + let soClient: ReturnType; + let logger: ReturnType; + let service: CaseTaskService; + + beforeEach(() => { + soClient = savedObjectsClientMock.create(); + logger = loggingSystemMock.createLogger(); + service = new CaseTaskService({ + log: logger, + unsecuredSavedObjectsClient: soClient, + }); + + // Default: find returns empty (for sort_order and descendant queries) + soClient.find.mockResolvedValue({ + saved_objects: [], + total: 0, + per_page: 1, + page: 1, + }); + // Default: case update for task_summary succeeds + soClient.get.mockImplementation(async (type, id) => { + if (type === CASE_SAVED_OBJECT) { + return { id, type, attributes: {}, references: [], version: 'Wzk5LDFd' }; + } + return makeTaskSO({ id }); + }); + soClient.update.mockResolvedValue({ id: 'case-1', type: CASE_SAVED_OBJECT, attributes: {}, references: [] }); + }); + + // ---- createTask ----------------------------------------------------------- + + describe('createTask', () => { + it('creates a task SO with correct attributes', async () => { + soClient.create.mockResolvedValue(makeTaskSO()); + + const result = await service.createTask({ + caseId: 'case-1', + title: 'My task', + owner: 'securitySolution', + user: mockUser, + }); + + expect(soClient.create).toHaveBeenCalledWith( + CASE_TASK_SAVED_OBJECT, + expect.objectContaining({ + title: 'My task', + case_id: 'case-1', + status: 'open', + priority: 'medium', + parent_task_id: null, + owner: 'securitySolution', + }), + expect.objectContaining({ + references: [expect.objectContaining({ id: 'case-1', type: CASE_SAVED_OBJECT })], + }) + ); + + expect(result.id).toBe('task-1'); + expect(result.title).toBe('Test task'); + }); + + it('auto-sets started_at when status is in_progress', async () => { + soClient.create.mockResolvedValue(makeTaskSO({ status: 'in_progress', started_at: '2024-01-01T00:00:00.000Z' })); + + await service.createTask({ + caseId: 'case-1', + title: 'Task', + status: 'in_progress', + owner: 'securitySolution', + user: mockUser, + }); + + const createdAttrs = soClient.create.mock.calls[0][1] as CaseTaskAttributes; + expect(createdAttrs.started_at).not.toBeNull(); + }); + + it('auto-sets completed_at when status is completed', async () => { + soClient.create.mockResolvedValue(makeTaskSO({ status: 'completed', completed_at: '2024-01-01T00:00:00.000Z' })); + + await service.createTask({ + caseId: 'case-1', + title: 'Task', + status: 'completed', + owner: 'securitySolution', + user: mockUser, + }); + + const createdAttrs = soClient.create.mock.calls[0][1] as CaseTaskAttributes; + expect(createdAttrs.completed_at).not.toBeNull(); + }); + + it('calls syncTaskSummary after create', async () => { + soClient.create.mockResolvedValue(makeTaskSO()); + + await service.createTask({ + caseId: 'case-1', + title: 'Task', + owner: 'securitySolution', + user: mockUser, + }); + + // syncTaskSummary issues a find + get + update on the case SO + expect(soClient.find).toHaveBeenCalled(); + expect(soClient.update).toHaveBeenCalledWith( + CASE_SAVED_OBJECT, + 'case-1', + expect.objectContaining({ task_summary: expect.any(Object) }), + expect.any(Object) + ); + }); + + it('uses sort_order gap = 1000 for first task', async () => { + soClient.create.mockResolvedValue(makeTaskSO({ sort_order: 1000 })); + + await service.createTask({ + caseId: 'case-1', + title: 'Task', + owner: 'securitySolution', + user: mockUser, + }); + + const createdAttrs = soClient.create.mock.calls[0][1] as CaseTaskAttributes; + expect(createdAttrs.sort_order).toBe(1000); + }); + + it('uses max(existing) + 1000 for subsequent tasks', async () => { + soClient.find.mockResolvedValueOnce({ + saved_objects: [makeTaskSO({ sort_order: 3000 })], + total: 1, + per_page: 1, + page: 1, + }); + soClient.create.mockResolvedValue(makeTaskSO({ sort_order: 4000 })); + + await service.createTask({ + caseId: 'case-1', + title: 'Task', + owner: 'securitySolution', + user: mockUser, + }); + + const createdAttrs = soClient.create.mock.calls[0][1] as CaseTaskAttributes; + expect(createdAttrs.sort_order).toBe(4000); + }); + + it('rejects subtask that would exceed depth 2', async () => { + // parent chain: task-3 → task-2 → task-1 (root) + soClient.get.mockImplementation(async (type, id) => { + if (type === CASE_TASK_SAVED_OBJECT) { + if (id === 'task-3') return makeTaskSO({ id: 'task-3', parent_task_id: 'task-2' }); + if (id === 'task-2') return makeTaskSO({ id: 'task-2', parent_task_id: 'task-1' }); + if (id === 'task-1') return makeTaskSO({ id: 'task-1', parent_task_id: null }); + } + return { id, type: CASE_SAVED_OBJECT, attributes: {}, references: [], version: 'v1' }; + }); + + await expect( + service.createTask({ + caseId: 'case-1', + title: 'Too deep', + parent_task_id: 'task-3', + owner: 'securitySolution', + user: mockUser, + }) + ).rejects.toThrow('Maximum subtask depth'); + }); + }); + + // ---- getTask -------------------------------------------------------------- + + describe('getTask', () => { + it('fetches a single task by id', async () => { + soClient.get.mockResolvedValue(makeTaskSO({ id: 'task-42' })); + + const result = await service.getTask('task-42'); + expect(result.id).toBe('task-42'); + expect(soClient.get).toHaveBeenCalledWith(CASE_TASK_SAVED_OBJECT, 'task-42'); + }); + + it('throws a CaseError when SO client throws', async () => { + soClient.get.mockRejectedValue(new Error('not found')); + await expect(service.getTask('bad-id')).rejects.toThrow(); + }); + }); + + // ---- findTasks ------------------------------------------------------------ + + describe('findTasks', () => { + it('passes correct sort/page params', async () => { + soClient.find.mockResolvedValue({ saved_objects: [], total: 0, per_page: 50, page: 1 }); + + await service.findTasks({ sort_field: 'due_date', sort_order: 'desc', page: 2, per_page: 25 }); + + expect(soClient.find).toHaveBeenCalledWith( + expect.objectContaining({ + type: CASE_TASK_SAVED_OBJECT, + sortField: 'due_date', + sortOrder: 'desc', + page: 2, + perPage: 25, + }) + ); + }); + + it('caps per_page at 200', async () => { + soClient.find.mockResolvedValue({ saved_objects: [], total: 0, per_page: 200, page: 1 }); + + await service.findTasks({ per_page: 9999 }); + + expect(soClient.find).toHaveBeenCalledWith( + expect.objectContaining({ perPage: 200 }) + ); + }); + }); + + // ---- updateTask ----------------------------------------------------------- + + describe('updateTask', () => { + it('sets started_at on open→in_progress transition', async () => { + soClient.get.mockImplementation(async (type, id) => { + if (type === CASE_TASK_SAVED_OBJECT) + return makeTaskSO({ id, status: 'open', started_at: null }); + return { id, type: CASE_SAVED_OBJECT, attributes: {}, references: [], version: 'v1' }; + }); + soClient.update.mockResolvedValue({ + id: 'task-1', + type: CASE_TASK_SAVED_OBJECT, + attributes: {}, + references: [], + }); + + await service.updateTask({ + taskId: 'task-1', + status: 'in_progress', + user: mockUser, + version: 'WzEsMV0=', + }); + + const [, , updatedAttrs] = soClient.update.mock.calls[0]; + expect((updatedAttrs as CaseTaskAttributes).started_at).not.toBeNull(); + }); + + it('sets completed_at on →completed transition', async () => { + soClient.get.mockImplementation(async (type, id) => { + if (type === CASE_TASK_SAVED_OBJECT) + return makeTaskSO({ id, status: 'in_progress', completed_at: null }); + return { id, type: CASE_SAVED_OBJECT, attributes: {}, references: [], version: 'v1' }; + }); + soClient.update.mockResolvedValue({ + id: 'task-1', + type: CASE_TASK_SAVED_OBJECT, + attributes: {}, + references: [], + }); + + await service.updateTask({ + taskId: 'task-1', + status: 'completed', + user: mockUser, + version: 'WzEsMV0=', + }); + + const [, , updatedAttrs] = soClient.update.mock.calls[0]; + expect((updatedAttrs as CaseTaskAttributes).completed_at).not.toBeNull(); + }); + + it('does not overwrite started_at if already set', async () => { + const existingStartedAt = '2024-01-01T00:00:00.000Z'; + soClient.get.mockImplementation(async (type, id) => { + if (type === CASE_TASK_SAVED_OBJECT) + return makeTaskSO({ id, status: 'in_progress', started_at: existingStartedAt }); + return { id, type: CASE_SAVED_OBJECT, attributes: {}, references: [], version: 'v1' }; + }); + soClient.update.mockResolvedValue({ + id: 'task-1', + type: CASE_TASK_SAVED_OBJECT, + attributes: {}, + references: [], + }); + + await service.updateTask({ + taskId: 'task-1', + status: 'in_progress', + user: mockUser, + version: 'WzEsMV0=', + }); + + const [, , updatedAttrs] = soClient.update.mock.calls[0]; + expect((updatedAttrs as CaseTaskAttributes).started_at).toBeUndefined(); + }); + }); + + // ---- deleteTask ----------------------------------------------------------- + + describe('deleteTask', () => { + it('deletes the task and all descendants', async () => { + // task-1 has child task-2, task-2 has child task-3 + soClient.get.mockImplementation(async (type, id) => { + if (type === CASE_TASK_SAVED_OBJECT) + return makeTaskSO({ id, case_id: 'case-1' }); + return { id, type: CASE_SAVED_OBJECT, attributes: {}, references: [], version: 'v1' }; + }); + + soClient.find.mockImplementation(async (options) => { + const f = JSON.stringify((options as { filter?: unknown }).filter ?? ''); + if (f.includes('task-1')) + return { saved_objects: [makeTaskSO({ id: 'task-2', parent_task_id: 'task-1' })], total: 1, per_page: 1000, page: 1 }; + if (f.includes('task-2')) + return { saved_objects: [makeTaskSO({ id: 'task-3', parent_task_id: 'task-2' })], total: 1, per_page: 1000, page: 1 }; + return { saved_objects: [], total: 0, per_page: 1000, page: 1 }; + }); + + soClient.bulkDelete = jest.fn().mockResolvedValue({}); + + await service.deleteTask('task-1'); + + expect(soClient.bulkDelete).toHaveBeenCalledWith( + expect.arrayContaining([ + { type: CASE_TASK_SAVED_OBJECT, id: 'task-1' }, + { type: CASE_TASK_SAVED_OBJECT, id: 'task-2' }, + { type: CASE_TASK_SAVED_OBJECT, id: 'task-3' }, + ]) + ); + }); + + it('syncs task_summary after delete', async () => { + soClient.get.mockImplementation(async (type, id) => { + if (type === CASE_TASK_SAVED_OBJECT) + return makeTaskSO({ id, case_id: 'case-1' }); + return { id, type: CASE_SAVED_OBJECT, attributes: {}, references: [], version: 'v1' }; + }); + soClient.bulkDelete = jest.fn().mockResolvedValue({}); + + await service.deleteTask('task-1'); + + expect(soClient.update).toHaveBeenCalledWith( + CASE_SAVED_OBJECT, + 'case-1', + expect.objectContaining({ task_summary: expect.any(Object) }), + expect.any(Object) + ); + }); + }); + + // ---- reorderTasks --------------------------------------------------------- + + describe('reorderTasks', () => { + it('bulk updates sort_orders with 1000-gap spacing', async () => { + soClient.bulkUpdate = jest.fn().mockResolvedValue({ saved_objects: [] }); + + await service.reorderTasks({ + caseId: 'case-1', + parentTaskId: null, + orderedTaskIds: ['task-3', 'task-1', 'task-2'], + }); + + expect(soClient.bulkUpdate).toHaveBeenCalledWith( + [ + { type: CASE_TASK_SAVED_OBJECT, id: 'task-3', attributes: { sort_order: 1000 } }, + { type: CASE_TASK_SAVED_OBJECT, id: 'task-1', attributes: { sort_order: 2000 } }, + { type: CASE_TASK_SAVED_OBJECT, id: 'task-2', attributes: { sort_order: 3000 } }, + ], + expect.any(Object) + ); + }); + }); + + // ---- task_summary accuracy ------------------------------------------------ + + describe('syncTaskSummary', () => { + it('correctly counts tasks by status', async () => { + // sort_order find fires first (returns empty → sort_order = 1000) + soClient.find.mockResolvedValueOnce({ saved_objects: [], total: 0, per_page: 1, page: 1 }); + // syncTaskSummary find fires second (returns tasks by status) + soClient.find.mockResolvedValueOnce({ + saved_objects: [ + makeTaskSO({ status: 'open' }), + makeTaskSO({ id: 'task-2', status: 'open' }), + makeTaskSO({ id: 'task-3', status: 'in_progress' }), + makeTaskSO({ id: 'task-4', status: 'completed' }), + makeTaskSO({ id: 'task-5', status: 'cancelled' }), + ], + total: 5, + per_page: 10000, + page: 1, + }); + + soClient.create.mockResolvedValue(makeTaskSO()); + + await service.createTask({ + caseId: 'case-1', + title: 'Task', + owner: 'securitySolution', + user: mockUser, + }); + + const updateCall = soClient.update.mock.calls.find( + ([type]) => type === CASE_SAVED_OBJECT + ); + const summary = (updateCall![2] as { task_summary: unknown }).task_summary as { + open: number; + in_progress: number; + completed: number; + cancelled: number; + total: number; + }; + + expect(summary.open).toBe(2); + expect(summary.in_progress).toBe(1); + expect(summary.completed).toBe(1); + expect(summary.cancelled).toBe(1); + expect(summary.total).toBe(5); + }); + + it('computes next_due_date as the earliest uncompleted due date', async () => { + // sort_order find fires first (returns empty → sort_order = 1000) + soClient.find.mockResolvedValueOnce({ saved_objects: [], total: 0, per_page: 1, page: 1 }); + // syncTaskSummary find fires second + soClient.find.mockResolvedValueOnce({ + saved_objects: [ + makeTaskSO({ status: 'open', due_date: '2024-03-10T00:00:00.000Z' }), + makeTaskSO({ id: 't2', status: 'open', due_date: '2024-01-05T00:00:00.000Z' }), + makeTaskSO({ id: 't3', status: 'completed', due_date: '2024-01-01T00:00:00.000Z' }), + ], + total: 3, + per_page: 10000, + page: 1, + }); + + soClient.create.mockResolvedValue(makeTaskSO()); + + await service.createTask({ + caseId: 'case-1', + title: 'Task', + owner: 'securitySolution', + user: mockUser, + }); + + const updateCall = soClient.update.mock.calls.find(([type]) => type === CASE_SAVED_OBJECT); + const summary = (updateCall![2] as { task_summary: { next_due_date: string | null } }) + .task_summary; + + // completed task's due date should be excluded; earliest open = Jan 5 + expect(summary.next_due_date).toBe('2024-01-05T00:00:00.000Z'); + }); + }); + + // ---- bulkCreateTasks ------------------------------------------------------ + + describe('bulkCreateTasks', () => { + it('uses bulkCreate in a single call', async () => { + soClient.bulkCreate = jest.fn().mockResolvedValue({ + saved_objects: [makeTaskSO(), makeTaskSO({ id: 'task-2' })], + }); + + await service.bulkCreateTasks({ + tasks: [ + { caseId: 'case-1', title: 'T1', owner: 'securitySolution', user: mockUser }, + { caseId: 'case-1', title: 'T2', owner: 'securitySolution', user: mockUser }, + ], + }); + + expect(soClient.bulkCreate).toHaveBeenCalledTimes(1); + expect(soClient.bulkCreate).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ type: CASE_TASK_SAVED_OBJECT, attributes: expect.objectContaining({ title: 'T1' }) }), + expect.objectContaining({ type: CASE_TASK_SAVED_OBJECT, attributes: expect.objectContaining({ title: 'T2' }) }), + ]), + expect.any(Object) + ); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/cases/server/services/tasks/index.ts b/x-pack/platform/plugins/shared/cases/server/services/tasks/index.ts new file mode 100644 index 0000000000000..118ba8bbfaacc --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/server/services/tasks/index.ts @@ -0,0 +1,698 @@ +/* + * 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 type { Logger, SavedObjectsClientContract, SavedObject } from '@kbn/core/server'; +import { nodeBuilder } from '@kbn/es-query'; +import { CASE_TASK_SAVED_OBJECT } from '../../../common/constants'; +import type { + CaseTask, + CaseTaskAttributes, +} from '../../../common/types/domain/task/v1'; +import { createCaseError } from '../../common/error'; +import { buildCaseTaskCaseReference } from '../../saved_object_types/tasks'; +import { syncTaskSummary } from './task_summary'; +import { getNextSortOrder, computeReorderedSortOrders } from './sort_order'; +import type { + CreateTaskArgs, + BulkCreateTasksArgs, + UpdateTaskArgs, + BulkUpdateTasksArgs, + FindTasksArgs, + MyTasksArgs, + ReorderTasksArgs, + TaskFilterArgs, +} from './types'; + +/** Maximum nesting depth: task → subtask → sub-subtask = 2 levels below root */ +const MAX_SUBTASK_DEPTH = 2; + +export class CaseTaskService { + private readonly log: Logger; + private readonly unsecuredSavedObjectsClient: SavedObjectsClientContract; + + constructor({ + log, + unsecuredSavedObjectsClient, + }: { + log: Logger; + unsecuredSavedObjectsClient: SavedObjectsClientContract; + }) { + this.log = log; + this.unsecuredSavedObjectsClient = unsecuredSavedObjectsClient; + } + + // --------------------------------------------------------------------------- + // Create + // --------------------------------------------------------------------------- + + public async createTask(args: CreateTaskArgs): Promise { + const { + caseId, + title, + description = '', + status = 'open', + priority = 'medium', + assignees = [], + due_date = null, + parent_task_id = null, + custom_fields = [], + template_id = null, + owner, + user, + refresh, + } = args; + + try { + if (parent_task_id) { + await this.validateDepth(parent_task_id); + } + + const sort_order = await getNextSortOrder({ + caseId, + parentTaskId: parent_task_id, + unsecuredSavedObjectsClient: this.unsecuredSavedObjectsClient, + logger: this.log, + }); + + const now = new Date().toISOString(); + + const attributes: CaseTaskAttributes = { + title, + description, + case_id: caseId, + parent_task_id, + status, + priority, + assignees, + due_date, + started_at: status === 'in_progress' ? now : null, + completed_at: status === 'completed' || status === 'cancelled' ? now : null, + sort_order, + template_id, + custom_fields, + required_role: null, + owner_team: null, + owner, + created_at: now, + created_by: user, + updated_at: null, + updated_by: null, + }; + + const so = await this.unsecuredSavedObjectsClient.create( + CASE_TASK_SAVED_OBJECT, + attributes, + { + refresh, + references: [buildCaseTaskCaseReference(caseId)], + } + ); + + await syncTaskSummary({ + caseId, + unsecuredSavedObjectsClient: this.unsecuredSavedObjectsClient, + logger: this.log, + }); + + return this.transformToTask(so); + } catch (error) { + throw createCaseError({ + message: `Failed to create task for case ${caseId}: ${error}`, + error, + logger: this.log, + }); + } + } + + public async bulkCreateTasks(args: BulkCreateTasksArgs): Promise { + const { tasks, refresh } = args; + if (tasks.length === 0) return []; + + const caseId = tasks[0].caseId; + const now = new Date().toISOString(); + + try { + // Validate depth for all subtasks up-front + for (const t of tasks) { + if (t.parent_task_id) { + await this.validateDepth(t.parent_task_id); + } + } + + const objects = await Promise.all( + tasks.map(async (t) => { + const sort_order = await getNextSortOrder({ + caseId: t.caseId, + parentTaskId: t.parent_task_id ?? null, + unsecuredSavedObjectsClient: this.unsecuredSavedObjectsClient, + logger: this.log, + }); + + const attrs: CaseTaskAttributes = { + title: t.title, + description: t.description ?? '', + case_id: t.caseId, + parent_task_id: t.parent_task_id ?? null, + status: t.status ?? 'open', + priority: t.priority ?? 'medium', + assignees: t.assignees ?? [], + due_date: t.due_date ?? null, + started_at: t.status === 'in_progress' ? now : null, + completed_at: + t.status === 'completed' || t.status === 'cancelled' ? now : null, + sort_order, + template_id: t.template_id ?? null, + custom_fields: t.custom_fields ?? [], + required_role: null, + owner_team: null, + owner: t.owner, + created_at: now, + created_by: t.user, + updated_at: null, + updated_by: null, + }; + + return { + type: CASE_TASK_SAVED_OBJECT, + attributes: attrs, + references: [buildCaseTaskCaseReference(t.caseId)], + }; + }) + ); + + const result = await this.unsecuredSavedObjectsClient.bulkCreate( + objects, + { refresh } + ); + + await syncTaskSummary({ + caseId, + unsecuredSavedObjectsClient: this.unsecuredSavedObjectsClient, + logger: this.log, + }); + + return result.saved_objects.map((so) => this.transformToTask(so)); + } catch (error) { + throw createCaseError({ + message: `Failed to bulk create tasks for case ${caseId}: ${error}`, + error, + logger: this.log, + }); + } + } + + // --------------------------------------------------------------------------- + // Read + // --------------------------------------------------------------------------- + + public async getTask(taskId: string): Promise { + try { + const so = await this.unsecuredSavedObjectsClient.get( + CASE_TASK_SAVED_OBJECT, + taskId + ); + return this.transformToTask(so); + } catch (error) { + throw createCaseError({ + message: `Failed to get task ${taskId}: ${error}`, + error, + logger: this.log, + }); + } + } + + public async getTasksByCase(caseId: string, args?: TaskFilterArgs): Promise { + try { + const result = await this.findTasks({ ...args, caseIds: [caseId], per_page: 10000 }); + return result.tasks; + } catch (error) { + throw createCaseError({ + message: `Failed to get tasks for case ${caseId}: ${error}`, + error, + logger: this.log, + }); + } + } + + public async findTasks( + args: FindTasksArgs + ): Promise<{ tasks: CaseTask[]; total: number }> { + const { + caseIds, + owners, + status, + priority, + assignees, + due_date_from, + due_date_to, + parent_task_id, + sort_field = 'sort_order', + sort_order: sortOrder = 'asc', + page = 1, + per_page = 50, + search, + } = args; + + try { + const filters = this.buildFilters({ + caseIds, + owners, + status, + priority, + assignees, + due_date_from, + due_date_to, + parent_task_id, + }); + + const result = await this.unsecuredSavedObjectsClient.find({ + type: CASE_TASK_SAVED_OBJECT, + filter: filters.length > 0 ? nodeBuilder.and(filters) : undefined, + sortField: sort_field, + sortOrder, + page, + perPage: Math.min(per_page, 200), + search: search ?? undefined, + searchFields: search ? ['title', 'description'] : undefined, + }); + + return { + tasks: result.saved_objects.map((so) => this.transformToTask(so)), + total: result.total, + }; + } catch (error) { + throw createCaseError({ + message: `Failed to find tasks: ${error}`, + error, + logger: this.log, + }); + } + } + + public async getMyTasks( + args: MyTasksArgs + ): Promise<{ tasks: CaseTask[]; total: number }> { + const { userProfileUid, ...filterArgs } = args; + return this.findTasks({ + ...filterArgs, + assignees: [userProfileUid, ...(filterArgs.assignees ?? [])], + }); + } + + // --------------------------------------------------------------------------- + // Update + // --------------------------------------------------------------------------- + + public async updateTask(args: UpdateTaskArgs): Promise { + const { taskId, user, version, refresh, ...patch } = args; + + try { + // Validate reparenting depth if parent_task_id is being changed + if (patch.parent_task_id !== undefined && patch.parent_task_id !== null) { + await this.validateDepth(patch.parent_task_id); + } + + const now = new Date().toISOString(); + const existing = await this.getTask(taskId); + + const statusTransitions: Partial = {}; + if (patch.status) { + if (patch.status === 'in_progress' && !existing.started_at) { + statusTransitions.started_at = now; + } + if ( + (patch.status === 'completed' || patch.status === 'cancelled') && + !existing.completed_at + ) { + statusTransitions.completed_at = now; + } + } + + const updatedAttributes: Partial = { + ...patch, + ...statusTransitions, + updated_at: now, + updated_by: user, + }; + + const so = await this.unsecuredSavedObjectsClient.update( + CASE_TASK_SAVED_OBJECT, + taskId, + updatedAttributes, + { version, refresh } + ); + + await syncTaskSummary({ + caseId: existing.case_id, + unsecuredSavedObjectsClient: this.unsecuredSavedObjectsClient, + logger: this.log, + }); + + // Merge the existing SO with the update result since SO update only returns changed fields + return this.transformToTask({ + ...so, + attributes: { ...(existing as CaseTaskAttributes), ...updatedAttributes }, + } as SavedObject); + } catch (error) { + throw createCaseError({ + message: `Failed to update task ${taskId}: ${error}`, + error, + logger: this.log, + }); + } + } + + public async bulkUpdateTasks(args: BulkUpdateTasksArgs): Promise { + const { updates, refresh } = args; + if (updates.length === 0) return []; + + const now = new Date().toISOString(); + + try { + const objects = updates.map(({ taskId, attributes, version }) => ({ + type: CASE_TASK_SAVED_OBJECT, + id: taskId, + attributes: { ...attributes, updated_at: now }, + version, + })); + + const result = await this.unsecuredSavedObjectsClient.bulkUpdate>( + objects, + { refresh } + ); + + // Sync summary once after all updates. Get caseId from first fetched task. + const firstTaskId = updates[0]?.taskId; + if (firstTaskId) { + const firstTask = await this.getTask(firstTaskId); + await syncTaskSummary({ + caseId: firstTask.case_id, + unsecuredSavedObjectsClient: this.unsecuredSavedObjectsClient, + logger: this.log, + }); + } + + // bulkUpdate returns partial SOs; re-fetch to get full objects + return Promise.all(result.saved_objects.map((so) => this.getTask(so.id))); + } catch (error) { + throw createCaseError({ + message: `Failed to bulk update tasks: ${error}`, + error, + logger: this.log, + }); + } + } + + // --------------------------------------------------------------------------- + // Delete + // --------------------------------------------------------------------------- + + public async deleteTask(taskId: string): Promise { + try { + const task = await this.getTask(taskId); + const caseId = task.case_id; + + // Cascade: collect all descendants + const descendants = await this.collectDescendants(taskId); + const allIds = [taskId, ...descendants]; + + await this.unsecuredSavedObjectsClient.bulkDelete( + allIds.map((id) => ({ type: CASE_TASK_SAVED_OBJECT, id })) + ); + + await syncTaskSummary({ + caseId, + unsecuredSavedObjectsClient: this.unsecuredSavedObjectsClient, + logger: this.log, + }); + } catch (error) { + throw createCaseError({ + message: `Failed to delete task ${taskId}: ${error}`, + error, + logger: this.log, + }); + } + } + + public async bulkDeleteTasks(taskIds: string[]): Promise { + if (taskIds.length === 0) return; + + try { + // Collect all descendants for every task being deleted + const allDescendants = await Promise.all(taskIds.map((id) => this.collectDescendants(id))); + const allIds = [...new Set([...taskIds, ...allDescendants.flat()])]; + + // Get caseId before deleting + const firstTask = await this.getTask(taskIds[0]); + const caseId = firstTask.case_id; + + await this.unsecuredSavedObjectsClient.bulkDelete( + allIds.map((id) => ({ type: CASE_TASK_SAVED_OBJECT, id })) + ); + + await syncTaskSummary({ + caseId, + unsecuredSavedObjectsClient: this.unsecuredSavedObjectsClient, + logger: this.log, + }); + } catch (error) { + throw createCaseError({ + message: `Failed to bulk delete tasks: ${error}`, + error, + logger: this.log, + }); + } + } + + // --------------------------------------------------------------------------- + // Reorder + // --------------------------------------------------------------------------- + + public async reorderTasks(args: ReorderTasksArgs): Promise { + const { caseId, orderedTaskIds, refresh } = args; + if (orderedTaskIds.length === 0) return; + + try { + const reordered = computeReorderedSortOrders(orderedTaskIds); + + await this.unsecuredSavedObjectsClient.bulkUpdate>( + reordered.map(({ id, sort_order }) => ({ + type: CASE_TASK_SAVED_OBJECT, + id, + attributes: { sort_order }, + })), + { refresh } + ); + } catch (error) { + throw createCaseError({ + message: `Failed to reorder tasks for case ${caseId}: ${error}`, + error, + logger: this.log, + }); + } + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + /** + * Validates that adding a child to parentTaskId will not exceed MAX_SUBTASK_DEPTH. + * Walks the parent_task_id chain upward. + */ + private async validateDepth(parentTaskId: string): Promise { + let currentId: string | null | undefined = parentTaskId; + let depth = 1; + + // Use loose inequality to stop on both null and undefined parent_task_id + while (currentId != null) { + if (depth > MAX_SUBTASK_DEPTH) { + throw Object.assign( + new Error( + `Maximum subtask depth of ${MAX_SUBTASK_DEPTH} exceeded. ` + + `Tasks can be nested at most ${MAX_SUBTASK_DEPTH} levels deep.` + ), + { statusCode: 400 } + ); + } + + try { + const parent: SavedObject = + await this.unsecuredSavedObjectsClient.get( + CASE_TASK_SAVED_OBJECT, + currentId as string + ); + currentId = parent.attributes.parent_task_id; + depth++; + } catch { + // Parent not found — chain is broken, stop walking + break; + } + } + } + + /** + * Collects all descendant task IDs for a given task (breadth-first). + * Used for cascade delete. + */ + private async collectDescendants(taskId: string): Promise { + const descendants: string[] = []; + const queue = [taskId]; + + while (queue.length > 0) { + const parentId = queue.shift()!; + const children = await this.unsecuredSavedObjectsClient.find({ + type: CASE_TASK_SAVED_OBJECT, + filter: nodeBuilder.is( + `${CASE_TASK_SAVED_OBJECT}.attributes.parent_task_id`, + parentId + ), + fields: ['parent_task_id'], + page: 1, + perPage: 1000, + }); + + for (const child of children.saved_objects) { + descendants.push(child.id); + queue.push(child.id); + } + } + + return descendants; + } + + /** + * Builds ES KueryNode filters from TaskFilterArgs / FindTasksArgs. + */ + private buildFilters({ + caseIds, + owners, + status, + priority, + assignees, + due_date_from, + due_date_to, + parent_task_id, + }: { + caseIds?: string[]; + owners?: string[]; + status?: string | string[]; + priority?: string | string[]; + assignees?: string[]; + due_date_from?: string; + due_date_to?: string; + parent_task_id?: string | null; + }) { + const filters = []; + + if (caseIds && caseIds.length > 0) { + const caseFilter = + caseIds.length === 1 + ? nodeBuilder.is(`${CASE_TASK_SAVED_OBJECT}.attributes.case_id`, caseIds[0]) + : nodeBuilder.or( + caseIds.map((id) => + nodeBuilder.is(`${CASE_TASK_SAVED_OBJECT}.attributes.case_id`, id) + ) + ); + filters.push(caseFilter); + } + + if (owners && owners.length > 0) { + const ownerFilter = + owners.length === 1 + ? nodeBuilder.is(`${CASE_TASK_SAVED_OBJECT}.attributes.owner`, owners[0]) + : nodeBuilder.or( + owners.map((o) => + nodeBuilder.is(`${CASE_TASK_SAVED_OBJECT}.attributes.owner`, o) + ) + ); + filters.push(ownerFilter); + } + + if (status) { + const statuses = Array.isArray(status) ? status : [status]; + const statusFilter = + statuses.length === 1 + ? nodeBuilder.is(`${CASE_TASK_SAVED_OBJECT}.attributes.status`, statuses[0]) + : nodeBuilder.or( + statuses.map((s) => + nodeBuilder.is(`${CASE_TASK_SAVED_OBJECT}.attributes.status`, s) + ) + ); + filters.push(statusFilter); + } + + if (priority) { + const priorities = Array.isArray(priority) ? priority : [priority]; + const priorityFilter = + priorities.length === 1 + ? nodeBuilder.is(`${CASE_TASK_SAVED_OBJECT}.attributes.priority`, priorities[0]) + : nodeBuilder.or( + priorities.map((p) => + nodeBuilder.is(`${CASE_TASK_SAVED_OBJECT}.attributes.priority`, p) + ) + ); + filters.push(priorityFilter); + } + + if (assignees && assignees.length > 0) { + const assigneeFilter = nodeBuilder.or( + assignees.map((uid) => + nodeBuilder.is(`${CASE_TASK_SAVED_OBJECT}.attributes.assignees.uid`, uid) + ) + ); + filters.push(assigneeFilter); + } + + if (due_date_from) { + filters.push( + nodeBuilder.range( + `${CASE_TASK_SAVED_OBJECT}.attributes.due_date`, + 'gte', + due_date_from + ) + ); + } + + if (due_date_to) { + filters.push( + nodeBuilder.range( + `${CASE_TASK_SAVED_OBJECT}.attributes.due_date`, + 'lte', + due_date_to + ) + ); + } + + if (parent_task_id === null) { + // Root tasks only: parent_task_id is null/missing + filters.push( + nodeBuilder.is(`${CASE_TASK_SAVED_OBJECT}.attributes.parent_task_id`, '__null__') + ); + } else if (parent_task_id !== undefined) { + filters.push( + nodeBuilder.is( + `${CASE_TASK_SAVED_OBJECT}.attributes.parent_task_id`, + parent_task_id + ) + ); + } + + return filters; + } + + /** + * Converts a SavedObject to the CaseTask domain type. + */ + private transformToTask(so: SavedObject): CaseTask { + return { + ...so.attributes, + id: so.id, + version: so.version ?? '', + }; + } +} diff --git a/x-pack/platform/plugins/shared/cases/server/services/tasks/sort_order.ts b/x-pack/platform/plugins/shared/cases/server/services/tasks/sort_order.ts new file mode 100644 index 0000000000000..f0fbfd2a16163 --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/server/services/tasks/sort_order.ts @@ -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 type { SavedObjectsClientContract } from '@kbn/core/server'; +import type { Logger } from '@kbn/core/server'; +import { CASE_TASK_SAVED_OBJECT } from '../../../common/constants'; +import { nodeBuilder } from '@kbn/es-query'; + +export const SORT_ORDER_GAP = 1000; + +/** + * Returns the next sort_order value for a new task within a parent scope + * (caseId + parentTaskId). Uses a gap of SORT_ORDER_GAP to allow future + * insertions without rewriting all existing orders. + */ +export const getNextSortOrder = async ({ + caseId, + parentTaskId, + unsecuredSavedObjectsClient, + logger, +}: { + caseId: string; + parentTaskId: string | null; + unsecuredSavedObjectsClient: SavedObjectsClientContract; + logger: Logger; +}): Promise => { + try { + const filters = [nodeBuilder.is(`${CASE_TASK_SAVED_OBJECT}.attributes.case_id`, caseId)]; + + if (parentTaskId === null) { + filters.push( + nodeBuilder.is(`${CASE_TASK_SAVED_OBJECT}.attributes.parent_task_id`, '__null__') + ); + } else { + filters.push( + nodeBuilder.is(`${CASE_TASK_SAVED_OBJECT}.attributes.parent_task_id`, parentTaskId) + ); + } + + const result = await unsecuredSavedObjectsClient.find<{ sort_order: number }>({ + type: CASE_TASK_SAVED_OBJECT, + filter: nodeBuilder.and(filters), + sortField: 'sort_order', + sortOrder: 'desc', + page: 1, + perPage: 1, + fields: ['sort_order'], + }); + + if (result.total === 0) { + return SORT_ORDER_GAP; + } + + const maxOrder = result.saved_objects[0]?.attributes?.sort_order ?? 0; + return maxOrder + SORT_ORDER_GAP; + } catch (error) { + logger.warn(`Failed to compute next sort_order for case ${caseId}: ${error.message}`); + return SORT_ORDER_GAP; + } +}; + +/** + * Reassigns sort_order values across an ordered set of task IDs with gaps of + * SORT_ORDER_GAP starting from SORT_ORDER_GAP. Used by reorderTasks. + * Returns an array of { id, sort_order } ready for bulkUpdate. + */ +export const computeReorderedSortOrders = ( + orderedTaskIds: string[] +): Array<{ id: string; sort_order: number }> => + orderedTaskIds.map((id, index) => ({ + id, + sort_order: (index + 1) * SORT_ORDER_GAP, + })); diff --git a/x-pack/platform/plugins/shared/cases/server/services/tasks/task_summary.ts b/x-pack/platform/plugins/shared/cases/server/services/tasks/task_summary.ts new file mode 100644 index 0000000000000..3f32a76f22441 --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/server/services/tasks/task_summary.ts @@ -0,0 +1,129 @@ +/* + * 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 type { SavedObjectsClientContract, Logger } from '@kbn/core/server'; +import { nodeBuilder } from '@kbn/es-query'; +import { CASE_TASK_SAVED_OBJECT, CASE_SAVED_OBJECT } from '../../../common/constants'; +import { createCaseError } from '../../common/error'; + +interface TaskStatusCounts { + open: number; + in_progress: number; + completed: number; + cancelled: number; +} + +/** + * Recomputes the task_summary for a case by aggregating task statuses and + * finding the earliest uncompleted due date, then writes it back to the + * case SavedObject. Called after every task mutation. + * + * Uses optimistic concurrency: if the case has been updated concurrently, + * the write is retried once. + */ +export const syncTaskSummary = async ({ + caseId, + unsecuredSavedObjectsClient, + logger, +}: { + caseId: string; + unsecuredSavedObjectsClient: SavedObjectsClientContract; + logger: Logger; +}): Promise => { + try { + const summary = await computeTaskSummary({ caseId, unsecuredSavedObjectsClient }); + + // Fetch the current case to get its version for optimistic concurrency + const caseObj = await unsecuredSavedObjectsClient.get(CASE_SAVED_OBJECT, caseId); + + try { + await unsecuredSavedObjectsClient.update( + CASE_SAVED_OBJECT, + caseId, + { task_summary: summary }, + { version: caseObj.version } + ); + } catch (conflictError) { + // On version conflict, retry once without version check + if (conflictError?.output?.statusCode === 409) { + logger.debug(`task_summary sync conflict on case ${caseId}, retrying`); + await unsecuredSavedObjectsClient.update(CASE_SAVED_OBJECT, caseId, { + task_summary: summary, + }); + } else { + throw conflictError; + } + } + } catch (error) { + throw createCaseError({ + message: `Failed to sync task_summary for case ${caseId}: ${error}`, + error, + logger, + }); + } +}; + +/** + * Loads all tasks for a case and computes the summary object. + */ +const computeTaskSummary = async ({ + caseId, + unsecuredSavedObjectsClient, +}: { + caseId: string; + unsecuredSavedObjectsClient: SavedObjectsClientContract; +}): Promise<{ + total: number; + open: number; + in_progress: number; + completed: number; + cancelled: number; + next_due_date: string | null; +}> => { + // Fetch all tasks for this case in a single query (up to 10k) + const result = await unsecuredSavedObjectsClient.find<{ + status: string; + due_date: string | null; + }>({ + type: CASE_TASK_SAVED_OBJECT, + filter: nodeBuilder.is(`${CASE_TASK_SAVED_OBJECT}.attributes.case_id`, caseId), + fields: ['status', 'due_date'], + page: 1, + perPage: 10000, + }); + + const counts: TaskStatusCounts = { open: 0, in_progress: 0, completed: 0, cancelled: 0 }; + const pendingDueDates: string[] = []; + + for (const task of result.saved_objects) { + const { status, due_date } = task.attributes; + + if (status === 'open') counts.open++; + else if (status === 'in_progress') counts.in_progress++; + else if (status === 'completed') counts.completed++; + else if (status === 'cancelled') counts.cancelled++; + + // Collect due dates for incomplete tasks only + if (due_date && status !== 'completed' && status !== 'cancelled') { + pendingDueDates.push(due_date); + } + } + + const next_due_date = + pendingDueDates.length > 0 + ? pendingDueDates.sort((a, b) => new Date(a).getTime() - new Date(b).getTime())[0] + : null; + + return { + total: result.total, + open: counts.open, + in_progress: counts.in_progress, + completed: counts.completed, + cancelled: counts.cancelled, + next_due_date: next_due_date ?? null, + }; +}; diff --git a/x-pack/platform/plugins/shared/cases/server/services/tasks/types.ts b/x-pack/platform/plugins/shared/cases/server/services/tasks/types.ts new file mode 100644 index 0000000000000..0f2e675428b00 --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/server/services/tasks/types.ts @@ -0,0 +1,98 @@ +/* + * 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 type { + CaseTaskStatus, + CaseTaskPriority, + CaseTaskAssignee, + CaseTaskCustomField, +} from '../../../common/types/domain/task/v1'; +import type { User } from '../../common/types/user'; +import type { IndexRefresh } from '../types'; + +// ---- Create ---------------------------------------------------------------- + +export interface CreateTaskArgs extends IndexRefresh { + caseId: string; + title: string; + description?: string; + status?: CaseTaskStatus; + priority?: CaseTaskPriority; + assignees?: CaseTaskAssignee[]; + due_date?: string | null; + parent_task_id?: string | null; + custom_fields?: CaseTaskCustomField[]; + template_id?: string | null; + owner: string; + user: User; +} + +export interface BulkCreateTasksArgs extends IndexRefresh { + tasks: Array>; +} + +// ---- Update ---------------------------------------------------------------- + +export interface UpdateTaskArgs extends IndexRefresh { + taskId: string; + title?: string; + description?: string; + status?: CaseTaskStatus; + priority?: CaseTaskPriority; + assignees?: CaseTaskAssignee[]; + due_date?: string | null; + parent_task_id?: string | null; + sort_order?: number; + custom_fields?: CaseTaskCustomField[]; + user: User; + version: string; +} + +export interface BulkUpdateTaskArgs extends IndexRefresh { + taskId: string; + attributes: Partial; + version: string; +} + +export interface BulkUpdateTasksArgs extends IndexRefresh { + updates: BulkUpdateTaskArgs[]; +} + +// ---- Find / Filter --------------------------------------------------------- + +export interface TaskFilterArgs { + status?: CaseTaskStatus | CaseTaskStatus[]; + priority?: CaseTaskPriority | CaseTaskPriority[]; + assignees?: string[]; // user profile UIDs + due_date_from?: string; + due_date_to?: string; + parent_task_id?: string | null; // null = root tasks only; undefined = all depths + sort_field?: 'created_at' | 'due_date' | 'priority' | 'sort_order' | 'status'; + sort_order?: 'asc' | 'desc'; + page?: number; + per_page?: number; + search?: string; +} + +export interface FindTasksArgs extends TaskFilterArgs { + caseIds?: string[]; + owners?: string[]; +} + +export interface MyTasksArgs extends TaskFilterArgs { + userProfileUid: string; + caseIds?: string[]; + includeCompleted?: boolean; +} + +// ---- Reorder --------------------------------------------------------------- + +export interface ReorderTasksArgs extends IndexRefresh { + caseId: string; + parentTaskId: string | null; + orderedTaskIds: string[]; +} From 13dfec5368f92111942f9050a99e2784e021bc9b Mon Sep 17 00:00:00 2001 From: Michael Olorunnisola Date: Sun, 15 Mar 2026 21:22:24 -0400 Subject: [PATCH 07/22] =?UTF-8?q?[Cases]=20PR=203:=20Add=20CaseTaskTemplat?= =?UTF-8?q?eService=20=E2=80=94=20CRUD=20+=20applyTemplate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements CaseTaskTemplateService: - createTemplate / getTemplate / findTemplates / updateTemplate / deleteTemplate - Instance-level 60s TTL cache for template definitions (avoids repeated SO lookups) - applyTemplate: resolves template, bulk-creates root tasks, then bulk-creates subtasks with correct parent_task_id references and relative_due_days → due_date computation Co-Authored-By: Claude Sonnet 4.6 --- .../shared/cases/server/services/index.ts | 1 + .../services/task_templates/index.test.ts | 233 ++++++++++++++ .../server/services/task_templates/index.ts | 297 ++++++++++++++++++ .../server/services/task_templates/types.ts | 48 +++ 4 files changed, 579 insertions(+) create mode 100644 x-pack/platform/plugins/shared/cases/server/services/task_templates/index.test.ts create mode 100644 x-pack/platform/plugins/shared/cases/server/services/task_templates/index.ts create mode 100644 x-pack/platform/plugins/shared/cases/server/services/task_templates/types.ts diff --git a/x-pack/platform/plugins/shared/cases/server/services/index.ts b/x-pack/platform/plugins/shared/cases/server/services/index.ts index 2b5254d7461c2..51095596b0070 100644 --- a/x-pack/platform/plugins/shared/cases/server/services/index.ts +++ b/x-pack/platform/plugins/shared/cases/server/services/index.ts @@ -16,6 +16,7 @@ export { AttachmentService } from './attachments'; export { UserProfileService } from './user_profiles'; export { TemplatesService } from './templates'; export { CaseTaskService } from './tasks'; +export { CaseTaskTemplateService } from './task_templates'; export interface ClientArgs { unsecuredSavedObjectsClient: SavedObjectsClientContract; diff --git a/x-pack/platform/plugins/shared/cases/server/services/task_templates/index.test.ts b/x-pack/platform/plugins/shared/cases/server/services/task_templates/index.test.ts new file mode 100644 index 0000000000000..059f14b5a12dc --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/server/services/task_templates/index.test.ts @@ -0,0 +1,233 @@ +/* + * 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 { loggingSystemMock, savedObjectsClientMock } from '@kbn/core/server/mocks'; +import { CaseTaskTemplateService } from './index'; +import type { CaseTaskTemplateAttributes } from '../../../common/types/domain/task_template/v1'; +import { CASE_TASK_TEMPLATE_SAVED_OBJECT, CASE_TASK_SAVED_OBJECT, CASE_SAVED_OBJECT } from '../../../common/constants'; + +const mockUser = { + username: 'elastic', + full_name: 'Elastic User', + email: 'elastic@example.com', + profile_uid: 'uid-1', +}; + +const makeTemplateSO = (overrides: Partial & { id?: string } = {}) => { + const { id = 'tmpl-1', ...attrs } = overrides; + return { + id, + type: CASE_TASK_TEMPLATE_SAVED_OBJECT, + references: [], + version: 'WzEsMV0=', + attributes: { + name: 'My Template', + description: '', + scope: 'space' as const, + tags: [], + tasks: [], + owner: 'securitySolution', + created_at: '2024-01-01T00:00:00.000Z', + created_by: mockUser, + updated_at: null, + updated_by: null, + ...attrs, + }, + }; +}; + +describe('CaseTaskTemplateService', () => { + let soClient: ReturnType; + let logger: ReturnType; + let service: CaseTaskTemplateService; + + beforeEach(() => { + soClient = savedObjectsClientMock.create(); + logger = loggingSystemMock.createLogger(); + service = new CaseTaskTemplateService({ + log: logger, + unsecuredSavedObjectsClient: soClient, + }); + + soClient.find.mockResolvedValue({ saved_objects: [], total: 0, per_page: 50, page: 1 }); + soClient.get.mockImplementation(async (type, id) => { + if (type === CASE_SAVED_OBJECT) + return { id, type: CASE_SAVED_OBJECT, attributes: {}, references: [], version: 'v1' }; + return makeTemplateSO({ id }); + }); + soClient.update.mockResolvedValue({ id: 'tmpl-1', type: CASE_TASK_TEMPLATE_SAVED_OBJECT, attributes: {}, references: [] }); + }); + + describe('createTemplate', () => { + it('creates a template SO with correct attributes', async () => { + soClient.create.mockResolvedValue(makeTemplateSO()); + + const result = await service.createTemplate({ + name: 'My Template', + tasks: [], + owner: 'securitySolution', + user: mockUser, + }); + + expect(soClient.create).toHaveBeenCalledWith( + CASE_TASK_TEMPLATE_SAVED_OBJECT, + expect.objectContaining({ name: 'My Template', scope: 'space', owner: 'securitySolution' }), + expect.any(Object) + ); + expect(result.name).toBe('My Template'); + }); + + it('defaults scope to space', async () => { + soClient.create.mockResolvedValue(makeTemplateSO()); + await service.createTemplate({ name: 'T', tasks: [], owner: 'o', user: mockUser }); + const attrs = soClient.create.mock.calls[0][1] as CaseTaskTemplateAttributes; + expect(attrs.scope).toBe('space'); + }); + }); + + describe('getTemplate', () => { + it('fetches from SO client', async () => { + soClient.get.mockResolvedValue(makeTemplateSO({ id: 'tmpl-42' })); + const result = await service.getTemplate('tmpl-42'); + expect(result.id).toBe('tmpl-42'); + }); + + it('returns cached result on second call', async () => { + soClient.get.mockResolvedValue(makeTemplateSO({ id: 'tmpl-cached' })); + await service.getTemplate('tmpl-cached'); + await service.getTemplate('tmpl-cached'); + expect(soClient.get).toHaveBeenCalledTimes(1); + }); + }); + + describe('deleteTemplate', () => { + it('calls SO delete', async () => { + await service.deleteTemplate('tmpl-1'); + expect(soClient.delete).toHaveBeenCalledWith(CASE_TASK_TEMPLATE_SAVED_OBJECT, 'tmpl-1'); + }); + }); + + describe('applyTemplate', () => { + it('creates root tasks and subtasks with correct parent references', async () => { + soClient.get.mockImplementation(async (type, id) => { + if (type === CASE_TASK_TEMPLATE_SAVED_OBJECT) { + return makeTemplateSO({ + id, + tasks: [ + { + title: 'Root 1', + description: '', + priority: 'medium', + relative_due_days: 3, + sort_order: 1000, + subtasks: [ + { title: 'Sub 1', description: '', priority: 'low', relative_due_days: null, sort_order: 1000 }, + ], + }, + ], + }); + } + return { id, type: CASE_SAVED_OBJECT, attributes: {}, references: [], version: 'v1' }; + }); + + const rootTaskId = 'root-task-1'; + soClient.bulkCreate = jest.fn() + .mockResolvedValueOnce({ + // Root task bulk create + saved_objects: [{ + id: rootTaskId, + type: CASE_TASK_SAVED_OBJECT, + references: [], + version: 'v1', + attributes: { + title: 'Root 1', description: '', case_id: 'case-1', + parent_task_id: null, status: 'open', priority: 'medium', + assignees: [], due_date: null, started_at: null, completed_at: null, + sort_order: 1000, template_id: 'tmpl-1', custom_fields: [], + required_role: null, owner_team: null, owner: 'securitySolution', + created_at: '2024-01-01T00:00:00.000Z', created_by: mockUser, + updated_at: null, updated_by: null, + }, + }], + }) + .mockResolvedValueOnce({ + // Subtask bulk create + saved_objects: [{ + id: 'sub-task-1', + type: CASE_TASK_SAVED_OBJECT, + references: [], + version: 'v1', + attributes: { + title: 'Sub 1', description: '', case_id: 'case-1', + parent_task_id: rootTaskId, status: 'open', priority: 'low', + assignees: [], due_date: null, started_at: null, completed_at: null, + sort_order: 1000, template_id: 'tmpl-1', custom_fields: [], + required_role: null, owner_team: null, owner: 'securitySolution', + created_at: '2024-01-01T00:00:00.000Z', created_by: mockUser, + updated_at: null, updated_by: null, + }, + }], + }); + + const result = await service.applyTemplate({ + templateId: 'tmpl-1', + caseId: 'case-1', + owner: 'securitySolution', + user: mockUser, + due_date_anchor: '2024-01-01T00:00:00.000Z', + }); + + // Should have called bulkCreate twice: once for roots, once for subtasks + expect(soClient.bulkCreate).toHaveBeenCalledTimes(2); + + // The subtask call should reference the root task id + const subtaskCall = soClient.bulkCreate.mock.calls[1][0] as Array<{ attributes: { parent_task_id: string } }>; + expect(subtaskCall[0].attributes.parent_task_id).toBe(rootTaskId); + + expect(result).toHaveLength(2); + }); + + it('computes due_date correctly from relative_due_days', async () => { + soClient.get.mockImplementation(async (type, id) => { + if (type === CASE_TASK_TEMPLATE_SAVED_OBJECT) { + return makeTemplateSO({ + id, + tasks: [{ + title: 'T', description: '', priority: 'medium', + relative_due_days: 7, sort_order: 1000, subtasks: [], + }], + }); + } + return { id, type: CASE_SAVED_OBJECT, attributes: {}, references: [], version: 'v1' }; + }); + + soClient.bulkCreate = jest.fn().mockResolvedValue({ saved_objects: [] }); + + await service.applyTemplate({ + templateId: 'tmpl-1', + caseId: 'case-1', + owner: 'securitySolution', + user: mockUser, + due_date_anchor: '2024-01-01T00:00:00.000Z', + }); + + const rootArgs = soClient.bulkCreate.mock.calls[0][0] as Array<{ attributes: { due_date: string } }>; + // Jan 1 + 7 days = Jan 8 + expect(rootArgs[0].attributes.due_date).toContain('2024-01-08'); + }); + }); + + describe('findTemplates', () => { + it('filters by scope', async () => { + soClient.find.mockResolvedValue({ saved_objects: [], total: 0, per_page: 50, page: 1 }); + await service.findTemplates({ scope: 'global' }); + expect(soClient.find).toHaveBeenCalledWith( + expect.objectContaining({ filter: expect.anything() }) + ); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/cases/server/services/task_templates/index.ts b/x-pack/platform/plugins/shared/cases/server/services/task_templates/index.ts new file mode 100644 index 0000000000000..4289abd9d81bd --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/server/services/task_templates/index.ts @@ -0,0 +1,297 @@ +/* + * 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 type { Logger, SavedObjectsClientContract } from '@kbn/core/server'; +import { nodeBuilder } from '@kbn/es-query'; +import { CASE_TASK_TEMPLATE_SAVED_OBJECT } from '../../../common/constants'; +import type { + CaseTaskTemplate, + CaseTaskTemplateAttributes, +} from '../../../common/types/domain/task_template/v1'; +import type { CaseTask } from '../../../common/types/domain/task/v1'; +import { createCaseError } from '../../common/error'; +import { CaseTaskService } from '../tasks'; +import type { + CreateTemplateArgs, + UpdateTemplateArgs, + FindTemplatesArgs, + ApplyTemplateArgs, +} from './types'; + +const CACHE_TTL_MS = 60_000; + +export class CaseTaskTemplateService { + private readonly log: Logger; + private readonly unsecuredSavedObjectsClient: SavedObjectsClientContract; + private readonly taskService: CaseTaskService; + /** Simple in-process LRU-like cache (60 s TTL) for template definitions. */ + private readonly templateCache = new Map(); + + constructor({ + log, + unsecuredSavedObjectsClient, + }: { + log: Logger; + unsecuredSavedObjectsClient: SavedObjectsClientContract; + }) { + this.log = log; + this.unsecuredSavedObjectsClient = unsecuredSavedObjectsClient; + this.taskService = new CaseTaskService({ log, unsecuredSavedObjectsClient }); + } + + // --------------------------------------------------------------------------- + // CRUD + // --------------------------------------------------------------------------- + + public async createTemplate(args: CreateTemplateArgs): Promise { + const { + name, + description = '', + scope = 'space', + tags = [], + tasks, + owner, + user, + refresh, + } = args; + + try { + const now = new Date().toISOString(); + + const attributes: CaseTaskTemplateAttributes = { + name, + description, + scope, + tags, + tasks, + owner, + created_at: now, + created_by: user, + updated_at: null, + updated_by: null, + }; + + const so = await this.unsecuredSavedObjectsClient.create( + CASE_TASK_TEMPLATE_SAVED_OBJECT, + attributes, + { refresh } + ); + + return this.transformToTemplate(so); + } catch (error) { + throw createCaseError({ + message: `Failed to create task template: ${error}`, + error, + logger: this.log, + }); + } + } + + public async getTemplate(templateId: string): Promise { + const cached = this.templateCache.get(templateId); + if (cached && cached.expiresAt > Date.now()) { + return cached.template; + } + + try { + const so = await this.unsecuredSavedObjectsClient.get( + CASE_TASK_TEMPLATE_SAVED_OBJECT, + templateId + ); + const template = this.transformToTemplate(so); + this.templateCache.set(templateId, { template, expiresAt: Date.now() + CACHE_TTL_MS }); + return template; + } catch (error) { + throw createCaseError({ + message: `Failed to get task template ${templateId}: ${error}`, + error, + logger: this.log, + }); + } + } + + public async findTemplates( + args: FindTemplatesArgs + ): Promise<{ templates: CaseTaskTemplate[]; total: number }> { + const { scope, tags, owners, search, page = 1, per_page = 50 } = args; + + try { + const filters = []; + + if (scope) { + filters.push( + nodeBuilder.is(`${CASE_TASK_TEMPLATE_SAVED_OBJECT}.attributes.scope`, scope) + ); + } + if (owners && owners.length > 0) { + filters.push( + nodeBuilder.or( + owners.map((o) => + nodeBuilder.is(`${CASE_TASK_TEMPLATE_SAVED_OBJECT}.attributes.owner`, o) + ) + ) + ); + } + if (tags && tags.length > 0) { + filters.push( + nodeBuilder.or( + tags.map((t) => + nodeBuilder.is(`${CASE_TASK_TEMPLATE_SAVED_OBJECT}.attributes.tags`, t) + ) + ) + ); + } + + const result = await this.unsecuredSavedObjectsClient.find({ + type: CASE_TASK_TEMPLATE_SAVED_OBJECT, + filter: filters.length > 0 ? nodeBuilder.and(filters) : undefined, + search: search ?? undefined, + searchFields: search ? ['name', 'description'] : undefined, + page, + perPage: Math.min(per_page, 200), + }); + + return { + templates: result.saved_objects.map((so) => this.transformToTemplate(so)), + total: result.total, + }; + } catch (error) { + throw createCaseError({ + message: `Failed to find task templates: ${error}`, + error, + logger: this.log, + }); + } + } + + public async updateTemplate(args: UpdateTemplateArgs): Promise { + const { templateId, version, user, refresh, ...patch } = args; + + try { + const now = new Date().toISOString(); + + const updatedAttributes: Partial = { + ...patch, + updated_at: now, + updated_by: user, + }; + + await this.unsecuredSavedObjectsClient.update( + CASE_TASK_TEMPLATE_SAVED_OBJECT, + templateId, + updatedAttributes, + { version, refresh } + ); + + // Invalidate cache + this.templateCache.delete(templateId); + + return this.getTemplate(templateId); + } catch (error) { + throw createCaseError({ + message: `Failed to update task template ${templateId}: ${error}`, + error, + logger: this.log, + }); + } + } + + public async deleteTemplate(templateId: string): Promise { + try { + await this.unsecuredSavedObjectsClient.delete(CASE_TASK_TEMPLATE_SAVED_OBJECT, templateId); + this.templateCache.delete(templateId); + } catch (error) { + throw createCaseError({ + message: `Failed to delete task template ${templateId}: ${error}`, + error, + logger: this.log, + }); + } + } + + // --------------------------------------------------------------------------- + // Apply + // --------------------------------------------------------------------------- + + public async applyTemplate(args: ApplyTemplateArgs): Promise { + const { templateId, caseId, owner, user, due_date_anchor, refresh } = args; + + try { + const template = await this.getTemplate(templateId); + const anchorDate = due_date_anchor ? new Date(due_date_anchor) : new Date(); + + const computeDueDate = (relativeDays: number | null): string | null => { + if (relativeDays === null) return null; + const d = new Date(anchorDate); + d.setDate(d.getDate() + relativeDays); + return d.toISOString(); + }; + + // Create root tasks in bulk + const rootTaskArgs = template.tasks.map((t) => ({ + caseId, + title: t.title, + description: t.description, + priority: t.priority, + due_date: computeDueDate(t.relative_due_days), + sort_order: t.sort_order, + template_id: templateId, + owner, + user, + })); + + const rootTasks = await this.taskService.bulkCreateTasks({ + tasks: rootTaskArgs, + refresh, + }); + + // Create subtasks in bulk, mapping root task sort_order → created task ID + const rootIdByIndex = new Map(rootTasks.map((t, i) => [i, t.id])); + const subtaskArgs = template.tasks.flatMap((t, i) => + t.subtasks.map((sub) => ({ + caseId, + title: sub.title, + description: sub.description, + priority: sub.priority, + due_date: computeDueDate(sub.relative_due_days), + sort_order: sub.sort_order, + parent_task_id: rootIdByIndex.get(i) ?? null, + template_id: templateId, + owner, + user, + })) + ); + + const subtasks = + subtaskArgs.length > 0 + ? await this.taskService.bulkCreateTasks({ tasks: subtaskArgs, refresh }) + : []; + + return [...rootTasks, ...subtasks]; + } catch (error) { + throw createCaseError({ + message: `Failed to apply template ${templateId} to case ${caseId}: ${error}`, + error, + logger: this.log, + }); + } + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + private transformToTemplate( + so: Awaited> + ): CaseTaskTemplate { + return { + ...(so.attributes as CaseTaskTemplateAttributes), + id: so.id, + version: so.version ?? '', + }; + } +} diff --git a/x-pack/platform/plugins/shared/cases/server/services/task_templates/types.ts b/x-pack/platform/plugins/shared/cases/server/services/task_templates/types.ts new file mode 100644 index 0000000000000..96eaf1245efd8 --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/server/services/task_templates/types.ts @@ -0,0 +1,48 @@ +/* + * 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 type { CaseTaskTemplateTask } from '../../../common/types/domain/task_template/v1'; +import type { User } from '../../common/types/user'; +import type { IndexRefresh } from '../types'; + +export interface CreateTemplateArgs extends IndexRefresh { + name: string; + description?: string; + scope?: 'global' | 'space'; + tags?: string[]; + tasks: CaseTaskTemplateTask[]; + owner: string; + user: User; +} + +export interface UpdateTemplateArgs extends IndexRefresh { + templateId: string; + version: string; + name?: string; + description?: string; + scope?: 'global' | 'space'; + tags?: string[]; + tasks?: CaseTaskTemplateTask[]; + user: User; +} + +export interface FindTemplatesArgs { + scope?: 'global' | 'space'; + tags?: string[]; + owners?: string[]; + search?: string; + page?: number; + per_page?: number; +} + +export interface ApplyTemplateArgs extends IndexRefresh { + templateId: string; + caseId: string; + owner: string; + user: User; + due_date_anchor?: string; // ISO 8601 — defaults to now +} From 2c96d67a6686faaccc0a9c5f5042024bd08fc001 Mon Sep 17 00:00:00 2001 From: Michael Olorunnisola Date: Sun, 15 Mar 2026 21:34:46 -0400 Subject: [PATCH 08/22] =?UTF-8?q?[Cases]=20PR=204:=20Client=20layer=20?= =?UTF-8?q?=E2=80=94=20TasksSubClient=20+=20TaskTemplatesSubClient=20with?= =?UTF-8?q?=20RBAC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds CasesClient sub-clients for the task management system: - TasksSubClient: create, get, find, update, delete, reorder, getMyTasks, applyTemplate - TaskTemplatesSubClient: create, get, find, update, delete - RBAC: ensureAuthorized / getAuthorizationFilter wired to existing CasesSupportedOperations - User actions created for create/update/delete/applyTemplate operations (NoopBuilder until PR 7) - New ReadOperations/WriteOperations enum entries for all task operations - Factory wires CaseTaskService + CaseTaskTemplateService into CasesServices - Mock factory updated for existing tests Co-Authored-By: Claude Sonnet 4.6 --- .../cases/server/authorization/index.ts | 108 +++++++ .../cases/server/authorization/types.ts | 14 + .../shared/cases/server/client/client.ts | 22 ++ .../shared/cases/server/client/factory.ts | 7 + .../shared/cases/server/client/mocks.ts | 37 +++ .../server/client/task_templates/client.ts | 176 ++++++++++ .../cases/server/client/tasks/client.ts | 305 ++++++++++++++++++ .../shared/cases/server/client/types.ts | 4 + .../shared/cases/server/services/mocks.ts | 35 ++ 9 files changed, 708 insertions(+) create mode 100644 x-pack/platform/plugins/shared/cases/server/client/task_templates/client.ts create mode 100644 x-pack/platform/plugins/shared/cases/server/client/tasks/client.ts diff --git a/x-pack/platform/plugins/shared/cases/server/authorization/index.ts b/x-pack/platform/plugins/shared/cases/server/authorization/index.ts index caa697594cc1e..5dfa14bb703d4 100644 --- a/x-pack/platform/plugins/shared/cases/server/authorization/index.ts +++ b/x-pack/platform/plugins/shared/cases/server/authorization/index.ts @@ -13,6 +13,8 @@ import { CASE_CONFIGURE_SAVED_OBJECT, CASE_SAVED_OBJECT, CASE_USER_ACTION_SAVED_OBJECT, + CASE_TASK_SAVED_OBJECT, + CASE_TASK_TEMPLATE_SAVED_OBJECT, } from '../../common/constants'; import type { Verbs, OperationDetails } from './types'; import { ReadOperations, WriteOperations } from './types'; @@ -318,6 +320,110 @@ const AttachmentOperations = { }, }; +const ACCESS_TASK_OPERATION: CasesSupportedOperations = 'getCase'; + +const TaskOperations = { + [ReadOperations.GetTask]: { + ecsType: EVENT_TYPES.access, + name: ACCESS_TASK_OPERATION, + action: 'case_task_get', + verbs: accessVerbs, + docType: 'task', + savedObjectType: CASE_TASK_SAVED_OBJECT, + }, + [ReadOperations.FindTasks]: { + ecsType: EVENT_TYPES.access, + name: ACCESS_TASK_OPERATION, + action: 'case_task_find', + verbs: accessVerbs, + docType: 'tasks', + savedObjectType: CASE_TASK_SAVED_OBJECT, + }, + [WriteOperations.CreateTask]: { + ecsType: EVENT_TYPES.creation, + name: WriteOperations.CreateComment as const, + action: 'case_task_create', + verbs: createVerbs, + docType: 'task', + savedObjectType: CASE_TASK_SAVED_OBJECT, + }, + [WriteOperations.UpdateTask]: { + ecsType: EVENT_TYPES.change, + name: WriteOperations.UpdateCase as const, + action: 'case_task_update', + verbs: updateVerbs, + docType: 'task', + savedObjectType: CASE_TASK_SAVED_OBJECT, + }, + [WriteOperations.DeleteTask]: { + ecsType: EVENT_TYPES.deletion, + name: WriteOperations.DeleteCase as const, + action: 'case_task_delete', + verbs: deleteVerbs, + docType: 'task', + savedObjectType: CASE_TASK_SAVED_OBJECT, + }, + [WriteOperations.ReorderTasks]: { + ecsType: EVENT_TYPES.change, + name: WriteOperations.UpdateCase as const, + action: 'case_task_reorder', + verbs: updateVerbs, + docType: 'tasks', + savedObjectType: CASE_TASK_SAVED_OBJECT, + }, + [WriteOperations.ApplyTaskTemplate]: { + ecsType: EVENT_TYPES.creation, + name: WriteOperations.CreateComment as const, + action: 'case_task_apply_template', + verbs: createVerbs, + docType: 'tasks', + savedObjectType: CASE_TASK_SAVED_OBJECT, + }, +}; + +const TaskTemplateOperations = { + [ReadOperations.GetTaskTemplate]: { + ecsType: EVENT_TYPES.access, + name: ACCESS_TASK_OPERATION, + action: 'case_task_template_get', + verbs: accessVerbs, + docType: 'task template', + savedObjectType: CASE_TASK_TEMPLATE_SAVED_OBJECT, + }, + [ReadOperations.FindTaskTemplates]: { + ecsType: EVENT_TYPES.access, + name: ACCESS_TASK_OPERATION, + action: 'case_task_template_find', + verbs: accessVerbs, + docType: 'task templates', + savedObjectType: CASE_TASK_TEMPLATE_SAVED_OBJECT, + }, + [WriteOperations.CreateTaskTemplate]: { + ecsType: EVENT_TYPES.creation, + name: WriteOperations.CreateConfiguration as const, + action: 'case_task_template_create', + verbs: createVerbs, + docType: 'task template', + savedObjectType: CASE_TASK_TEMPLATE_SAVED_OBJECT, + }, + [WriteOperations.UpdateTaskTemplate]: { + ecsType: EVENT_TYPES.change, + name: WriteOperations.UpdateConfiguration as const, + action: 'case_task_template_update', + verbs: updateVerbs, + docType: 'task template', + savedObjectType: CASE_TASK_TEMPLATE_SAVED_OBJECT, + }, + [WriteOperations.DeleteTaskTemplate]: { + ecsType: EVENT_TYPES.deletion, + name: WriteOperations.DeleteCase as const, + action: 'case_task_template_delete', + verbs: deleteVerbs, + docType: 'task template', + savedObjectType: CASE_TASK_TEMPLATE_SAVED_OBJECT, + }, +}; + /** * Definition of all APIs within the cases backend. */ @@ -325,6 +431,8 @@ export const Operations: Record { }); }; +type TasksSubClientMock = jest.Mocked; + +const createTasksSubClientMock = (): TasksSubClientMock => { + return lazyObject({ + create: jest.fn(), + get: jest.fn(), + find: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + reorder: jest.fn(), + getMyTasks: jest.fn(), + applyTemplate: jest.fn(), + }); +}; + +type TaskTemplatesSubClientMock = jest.Mocked; + +const createTaskTemplatesSubClientMock = (): TaskTemplatesSubClientMock => { + return lazyObject({ + create: jest.fn(), + get: jest.fn(), + find: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }); +}; + type InternalConfigureSubClientMock = jest.Mocked; const createInternalConfigureSubClientMock = (): InternalConfigureSubClientMock => { @@ -167,6 +198,8 @@ export interface CasesClientMock extends CasesClient { attachments: AttachmentsSubClientMock; userActions: UserActionsSubClientMock; templates: TemplatesSubClientMock; + tasks: TasksSubClientMock; + taskTemplates: TaskTemplatesSubClientMock; } export const createCasesClientMock = (): CasesClientMock => { @@ -177,6 +210,8 @@ export const createCasesClientMock = (): CasesClientMock => { configure: createConfigureSubClientMock(), metrics: createMetricsSubClientMock(), templates: createTemplatesSubClientMock(), + tasks: createTasksSubClientMock(), + taskTemplates: createTaskTemplatesSubClientMock(), }); return client as unknown as CasesClientMock; }; @@ -228,6 +263,8 @@ export const createCasesClientMockArgs = () => { licensingService: createLicensingServiceMock(), notificationService: createNotificationServiceMock(), templatesService: createTemplatesServiceMock(), + taskService: createCaseTaskServiceMock(), + taskTemplateService: createCaseTaskTemplateServiceMock(), }, authorization: createAuthorizationMock(), logger: loggingSystemMock.createLogger(), diff --git a/x-pack/platform/plugins/shared/cases/server/client/task_templates/client.ts b/x-pack/platform/plugins/shared/cases/server/client/task_templates/client.ts new file mode 100644 index 0000000000000..75f22df908a57 --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/server/client/task_templates/client.ts @@ -0,0 +1,176 @@ +/* + * 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 Boom from '@hapi/boom'; +import type { + CaseTaskTemplate, + CaseTaskTemplateTask, +} from '../../../common/types/domain/task_template/v1'; +import { createCaseError } from '../../common/error'; +import { Operations, ReadOperations, WriteOperations } from '../../authorization'; +import type { CasesClientArgs } from '../types'; + +export interface CreateTaskTemplateParams { + name: string; + description?: string; + scope?: 'global' | 'space'; + tags?: string[]; + tasks: CaseTaskTemplateTask[]; + owner: string; +} + +export interface UpdateTaskTemplateParams { + templateId: string; + version: string; + name?: string; + description?: string; + scope?: 'global' | 'space'; + tags?: string[]; + tasks?: CaseTaskTemplateTask[]; +} + +export interface FindTaskTemplatesParams { + scope?: 'global' | 'space'; + tags?: string[]; + owners?: string[]; + search?: string; + page?: number; + per_page?: number; +} + +export interface TaskTemplatesSubClient { + create(params: CreateTaskTemplateParams): Promise; + get(templateId: string): Promise; + find(params: FindTaskTemplatesParams): Promise<{ templates: CaseTaskTemplate[]; total: number }>; + update(params: UpdateTaskTemplateParams): Promise; + delete(templateId: string): Promise; +} + +export const createTaskTemplatesSubClient = ( + clientArgs: CasesClientArgs +): TaskTemplatesSubClient => { + const { + services: { taskTemplateService }, + user, + authorization, + logger, + config, + } = clientArgs; + + const assertTasksEnabled = () => { + if (!config.tasks?.enabled) { + throw Boom.notFound('Tasks feature is not enabled'); + } + }; + + const taskTemplatesSubClient: TaskTemplatesSubClient = { + async create(params: CreateTaskTemplateParams): Promise { + assertTasksEnabled(); + try { + await authorization.ensureAuthorized({ + operation: Operations[WriteOperations.CreateTaskTemplate], + entities: [{ owner: params.owner, id: params.owner }], + }); + + return taskTemplateService.createTemplate({ ...params, user }); + } catch (error) { + throw createCaseError({ + message: `Failed to create task template: ${error}`, + error, + logger, + }); + } + }, + + async get(templateId: string): Promise { + assertTasksEnabled(); + try { + const template = await taskTemplateService.getTemplate(templateId); + + await authorization.ensureAuthorized({ + operation: Operations[ReadOperations.GetTaskTemplate], + entities: [{ owner: template.owner, id: templateId }], + }); + + return template; + } catch (error) { + throw createCaseError({ + message: `Failed to get task template ${templateId}: ${error}`, + error, + logger, + }); + } + }, + + async find( + params: FindTaskTemplatesParams + ): Promise<{ templates: CaseTaskTemplate[]; total: number }> { + assertTasksEnabled(); + try { + const { ensureSavedObjectsAreAuthorized } = await authorization.getAuthorizationFilter( + Operations[ReadOperations.FindTaskTemplates] + ); + + const result = await taskTemplateService.findTemplates(params); + + ensureSavedObjectsAreAuthorized( + result.templates.map((t) => ({ owner: t.owner, id: t.id })) + ); + + return result; + } catch (error) { + throw createCaseError({ + message: `Failed to find task templates: ${error}`, + error, + logger, + }); + } + }, + + async update(params: UpdateTaskTemplateParams): Promise { + assertTasksEnabled(); + try { + const existing = await taskTemplateService.getTemplate(params.templateId); + + await authorization.ensureAuthorized({ + operation: Operations[WriteOperations.UpdateTaskTemplate], + entities: [{ owner: existing.owner, id: params.templateId }], + }); + + return taskTemplateService.updateTemplate({ ...params, user }); + } catch (error) { + throw createCaseError({ + message: `Failed to update task template ${params.templateId}: ${error}`, + error, + logger, + }); + } + }, + + async delete(templateId: string): Promise { + assertTasksEnabled(); + try { + const template = await taskTemplateService.getTemplate(templateId); + + await authorization.ensureAuthorized({ + operation: Operations[WriteOperations.DeleteTaskTemplate], + entities: [{ owner: template.owner, id: templateId }], + }); + + return taskTemplateService.deleteTemplate(templateId); + } catch (error) { + throw createCaseError({ + message: `Failed to delete task template ${templateId}: ${error}`, + error, + logger, + }); + } + }, + }; + + return Object.freeze(taskTemplatesSubClient); +}; diff --git a/x-pack/platform/plugins/shared/cases/server/client/tasks/client.ts b/x-pack/platform/plugins/shared/cases/server/client/tasks/client.ts new file mode 100644 index 0000000000000..6948e79e19c49 --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/server/client/tasks/client.ts @@ -0,0 +1,305 @@ +/* + * 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 Boom from '@hapi/boom'; +import type { + CaseTask, + CaseTaskStatus, + CaseTaskPriority, + CaseTaskAssignee, +} from '../../../common/types/domain/task/v1'; +import { UserActionTypes } from '../../../common/types/domain/user_action/action/v1'; +import { createCaseError } from '../../common/error'; +import { Operations, ReadOperations, WriteOperations } from '../../authorization'; +import type { FindTasksArgs, MyTasksArgs, ReorderTasksArgs as ServiceReorderArgs } from '../../services/tasks/types'; +import type { CasesClientArgs } from '../types'; + +export interface CreateTaskParams { + caseId: string; + title: string; + description?: string; + status?: CaseTaskStatus; + priority?: CaseTaskPriority; + assignees?: CaseTaskAssignee[]; + due_date?: string | null; + parent_task_id?: string | null; + owner: string; +} + +export interface UpdateTaskParams { + taskId: string; + version: string; + title?: string; + description?: string; + status?: CaseTaskStatus; + priority?: CaseTaskPriority; + assignees?: CaseTaskAssignee[]; + due_date?: string | null; +} + +export type FindTasksParams = FindTasksArgs; +export type GetMyTasksParams = MyTasksArgs; +export type ReorderTasksParams = ServiceReorderArgs; + +export interface ApplyTemplateParams { + caseId: string; + templateId: string; + owner: string; + due_date_anchor?: string; +} + +export interface TasksSubClient { + create(params: CreateTaskParams): Promise; + get(taskId: string): Promise; + find(params: FindTasksParams): Promise<{ tasks: CaseTask[]; total: number }>; + update(params: UpdateTaskParams): Promise; + delete(taskId: string): Promise; + reorder(params: ReorderTasksParams): Promise; + getMyTasks(params: GetMyTasksParams): Promise<{ tasks: CaseTask[]; total: number }>; + applyTemplate(params: ApplyTemplateParams): Promise; +} + +export const createTasksSubClient = (clientArgs: CasesClientArgs): TasksSubClient => { + const { + services: { taskService, userActionService }, + user, + authorization, + logger, + config, + } = clientArgs; + + const assertTasksEnabled = () => { + if (!config.tasks?.enabled) { + throw Boom.notFound('Tasks feature is not enabled'); + } + }; + + const tasksSubClient: TasksSubClient = { + async create(params: CreateTaskParams): Promise { + assertTasksEnabled(); + try { + await authorization.ensureAuthorized({ + operation: Operations[WriteOperations.CreateTask], + entities: [{ owner: params.owner, id: params.caseId }], + }); + + const task = await taskService.createTask({ + ...params, + user, + }); + + await userActionService.creator.createUserAction({ + userAction: { + type: UserActionTypes.create_task, + caseId: params.caseId, + user, + payload: { + task: { + id: task.id, + title: task.title, + status: task.status, + priority: task.priority, + assignees: task.assignees, + }, + }, + owner: params.owner, + }, + }); + + return task; + } catch (error) { + throw createCaseError({ message: `Failed to create task: ${error}`, error, logger }); + } + }, + + async get(taskId: string): Promise { + assertTasksEnabled(); + try { + const task = await taskService.getTask(taskId); + + await authorization.ensureAuthorized({ + operation: Operations[ReadOperations.GetTask], + entities: [{ owner: task.owner, id: taskId }], + }); + + return task; + } catch (error) { + throw createCaseError({ message: `Failed to get task ${taskId}: ${error}`, error, logger }); + } + }, + + async find(params: FindTasksParams): Promise<{ tasks: CaseTask[]; total: number }> { + assertTasksEnabled(); + try { + const { ensureSavedObjectsAreAuthorized } = await authorization.getAuthorizationFilter( + Operations[ReadOperations.FindTasks] + ); + + const result = await taskService.findTasks(params); + + ensureSavedObjectsAreAuthorized( + result.tasks.map((t) => ({ owner: t.owner, id: t.id })) + ); + + return result; + } catch (error) { + throw createCaseError({ message: `Failed to find tasks: ${error}`, error, logger }); + } + }, + + async update(params: UpdateTaskParams): Promise { + assertTasksEnabled(); + try { + const existing = await taskService.getTask(params.taskId); + + await authorization.ensureAuthorized({ + operation: Operations[WriteOperations.UpdateTask], + entities: [{ owner: existing.owner, id: params.taskId }], + }); + + const updated = await taskService.updateTask({ + ...params, + user, + }); + + await userActionService.creator.createUserAction({ + userAction: { + type: UserActionTypes.update_task, + caseId: existing.case_id, + user, + payload: { + task_id: params.taskId, + task_title: existing.title, + changed_fields: [], + }, + owner: existing.owner, + }, + }); + + return updated; + } catch (error) { + throw createCaseError({ + message: `Failed to update task ${params.taskId}: ${error}`, + error, + logger, + }); + } + }, + + async delete(taskId: string): Promise { + assertTasksEnabled(); + try { + const task = await taskService.getTask(taskId); + + await authorization.ensureAuthorized({ + operation: Operations[WriteOperations.DeleteTask], + entities: [{ owner: task.owner, id: taskId }], + }); + + await taskService.deleteTask(taskId); + + await userActionService.creator.createUserAction({ + userAction: { + type: UserActionTypes.delete_task, + caseId: task.case_id, + user, + payload: { + task_id: taskId, + task_title: task.title, + subtasks_deleted: 0, + }, + owner: task.owner, + }, + }); + } catch (error) { + throw createCaseError({ + message: `Failed to delete task ${taskId}: ${error}`, + error, + logger, + }); + } + }, + + async reorder(params: ReorderTasksParams): Promise { + assertTasksEnabled(); + try { + if (params.orderedTaskIds.length > 0) { + const firstTask = await taskService.getTask(params.orderedTaskIds[0]); + await authorization.ensureAuthorized({ + operation: Operations[WriteOperations.ReorderTasks], + entities: [{ owner: firstTask.owner, id: params.caseId }], + }); + } + + await taskService.reorderTasks(params); + } catch (error) { + throw createCaseError({ message: `Failed to reorder tasks: ${error}`, error, logger }); + } + }, + + async getMyTasks( + params: GetMyTasksParams + ): Promise<{ tasks: CaseTask[]; total: number }> { + assertTasksEnabled(); + try { + const { ensureSavedObjectsAreAuthorized } = await authorization.getAuthorizationFilter( + Operations[ReadOperations.FindTasks] + ); + + const result = await taskService.getMyTasks(params); + + ensureSavedObjectsAreAuthorized( + result.tasks.map((t) => ({ owner: t.owner, id: t.id })) + ); + + return result; + } catch (error) { + throw createCaseError({ message: `Failed to get my tasks: ${error}`, error, logger }); + } + }, + + async applyTemplate(params: ApplyTemplateParams): Promise { + assertTasksEnabled(); + const { taskTemplateService } = clientArgs.services; + try { + await authorization.ensureAuthorized({ + operation: Operations[WriteOperations.ApplyTaskTemplate], + entities: [{ owner: params.owner, id: params.caseId }], + }); + + const tasks = await taskTemplateService.applyTemplate({ + ...params, + user, + }); + + await userActionService.creator.createUserAction({ + userAction: { + type: UserActionTypes.apply_task_template, + caseId: params.caseId, + user, + payload: { + template_id: params.templateId, + template_name: '', + tasks_created: tasks.length, + }, + owner: params.owner, + }, + }); + + return tasks; + } catch (error) { + throw createCaseError({ + message: `Failed to apply task template to case ${params.caseId}: ${error}`, + error, + logger, + }); + } + }, + }; + + return Object.freeze(tasksSubClient); +}; diff --git a/x-pack/platform/plugins/shared/cases/server/client/types.ts b/x-pack/platform/plugins/shared/cases/server/client/types.ts index 9807b8d9a73c0..d708a65a9291c 100644 --- a/x-pack/platform/plugins/shared/cases/server/client/types.ts +++ b/x-pack/platform/plugins/shared/cases/server/client/types.ts @@ -25,6 +25,8 @@ import type { AttachmentService, AlertService, TemplatesService, + CaseTaskService, + CaseTaskTemplateService, } from '../services'; import type { PersistableStateAttachmentTypeRegistry } from '../attachment_framework/persistable_state_registry'; import type { ExternalReferenceAttachmentTypeRegistry } from '../attachment_framework/external_reference_registry'; @@ -44,6 +46,8 @@ export interface CasesServices { licensingService: LicensingService; notificationService: NotificationService; templatesService: TemplatesService; + taskService: CaseTaskService; + taskTemplateService: CaseTaskTemplateService; } /** diff --git a/x-pack/platform/plugins/shared/cases/server/services/mocks.ts b/x-pack/platform/plugins/shared/cases/server/services/mocks.ts index 6592544257ced..8474dc9e8b2eb 100644 --- a/x-pack/platform/plugins/shared/cases/server/services/mocks.ts +++ b/x-pack/platform/plugins/shared/cases/server/services/mocks.ts @@ -15,6 +15,8 @@ import type { ConnectorMappingsService, AttachmentService, TemplatesService, + CaseTaskService, + CaseTaskTemplateService, } from '.'; import type { AttachmentGetter } from './attachments/operations/get'; import type { LicensingService } from './licensing'; @@ -46,6 +48,8 @@ export type AttachmentServiceMock = jest.Mocked; export type NotificationServiceMock = jest.Mocked; export type TemplatesServiceMock = jest.Mocked; +export type CaseTaskServiceMock = jest.Mocked; +export type CaseTaskTemplateServiceMock = jest.Mocked; export const createCaseServiceMock = (): CaseServiceMock => { const service: PublicMethodsOf = lazyObject({ @@ -234,3 +238,34 @@ export const createTemplatesServiceMock = (): TemplatesServiceMock => { // the cast here is required because jest.Mocked tries to include private members and would throw an error return service as unknown as TemplatesServiceMock; }; + +export const createCaseTaskServiceMock = (): CaseTaskServiceMock => { + const service: PublicMethodsOf = lazyObject({ + createTask: jest.fn(), + bulkCreateTasks: jest.fn(), + getTask: jest.fn(), + getTasksByCase: jest.fn(), + findTasks: jest.fn(), + getMyTasks: jest.fn(), + updateTask: jest.fn(), + bulkUpdateTasks: jest.fn(), + deleteTask: jest.fn(), + bulkDeleteTasks: jest.fn(), + reorderTasks: jest.fn(), + }); + + return service as unknown as CaseTaskServiceMock; +}; + +export const createCaseTaskTemplateServiceMock = (): CaseTaskTemplateServiceMock => { + const service: PublicMethodsOf = lazyObject({ + createTemplate: jest.fn(), + getTemplate: jest.fn(), + findTemplates: jest.fn(), + updateTemplate: jest.fn(), + deleteTemplate: jest.fn(), + applyTemplate: jest.fn(), + }); + + return service as unknown as CaseTaskTemplateServiceMock; +}; From 6f754c52b6a766447da6bd670638cdbf255609ba Mon Sep 17 00:00:00 2001 From: Michael Olorunnisola Date: Sun, 15 Mar 2026 21:41:15 -0400 Subject: [PATCH 09/22] [Cases] PR 5: HTTP API routes for tasks and task templates Adds internal HTTP routes for the task management feature, all gated behind config.tasks.enabled feature flag. Task routes (POST/GET/PATCH/DELETE /api/cases/{case_id}/tasks): - post_task_route: create a task for a case - get_task_route: retrieve a task by ID - find_tasks_route: GET /api/cases/tasks/_find with filters - patch_task_route: partial update a task - delete_task_route: delete a task - reorder_tasks_route: PUT _reorder to set sort_order - get_my_tasks_route: GET /api/cases/tasks/_my for current user - apply_template_route: POST _apply_template to bulk-create from template Task template routes (CRUD /api/cases/task_templates): - post_task_template_route - get_task_template_route - find_task_templates_route - patch_task_template_route - delete_task_template_route Both sets are wired in via getTaskRoutes(config) and getTaskTemplateRoutes(config) spread into getInternalRoutes(). Co-Authored-By: Claude Sonnet 4.6 --- .../server/routes/api/get_internal_routes.ts | 4 + .../delete_task_template_route.ts | 48 +++++++ .../find_task_templates_route.ts | 70 ++++++++++ .../task_templates/get_task_template_route.ts | 48 +++++++ .../server/routes/api/task_templates/index.ts | 30 +++++ .../patch_task_template_route.ts | 109 +++++++++++++++ .../post_task_template_route.ts | 103 ++++++++++++++ .../routes/api/tasks/apply_template_route.ts | 64 +++++++++ .../routes/api/tasks/delete_task_route.ts | 49 +++++++ .../routes/api/tasks/find_tasks_route.ts | 127 ++++++++++++++++++ .../routes/api/tasks/get_my_tasks_route.ts | 119 ++++++++++++++++ .../server/routes/api/tasks/get_task_route.ts | 49 +++++++ .../cases/server/routes/api/tasks/index.ts | 36 +++++ .../routes/api/tasks/patch_task_route.ts | 86 ++++++++++++ .../routes/api/tasks/post_task_route.ts | 87 ++++++++++++ .../routes/api/tasks/reorder_tasks_route.ts | 61 +++++++++ 16 files changed, 1090 insertions(+) create mode 100644 x-pack/platform/plugins/shared/cases/server/routes/api/task_templates/delete_task_template_route.ts create mode 100644 x-pack/platform/plugins/shared/cases/server/routes/api/task_templates/find_task_templates_route.ts create mode 100644 x-pack/platform/plugins/shared/cases/server/routes/api/task_templates/get_task_template_route.ts create mode 100644 x-pack/platform/plugins/shared/cases/server/routes/api/task_templates/index.ts create mode 100644 x-pack/platform/plugins/shared/cases/server/routes/api/task_templates/patch_task_template_route.ts create mode 100644 x-pack/platform/plugins/shared/cases/server/routes/api/task_templates/post_task_template_route.ts create mode 100644 x-pack/platform/plugins/shared/cases/server/routes/api/tasks/apply_template_route.ts create mode 100644 x-pack/platform/plugins/shared/cases/server/routes/api/tasks/delete_task_route.ts create mode 100644 x-pack/platform/plugins/shared/cases/server/routes/api/tasks/find_tasks_route.ts create mode 100644 x-pack/platform/plugins/shared/cases/server/routes/api/tasks/get_my_tasks_route.ts create mode 100644 x-pack/platform/plugins/shared/cases/server/routes/api/tasks/get_task_route.ts create mode 100644 x-pack/platform/plugins/shared/cases/server/routes/api/tasks/index.ts create mode 100644 x-pack/platform/plugins/shared/cases/server/routes/api/tasks/patch_task_route.ts create mode 100644 x-pack/platform/plugins/shared/cases/server/routes/api/tasks/post_task_route.ts create mode 100644 x-pack/platform/plugins/shared/cases/server/routes/api/tasks/reorder_tasks_route.ts diff --git a/x-pack/platform/plugins/shared/cases/server/routes/api/get_internal_routes.ts b/x-pack/platform/plugins/shared/cases/server/routes/api/get_internal_routes.ts index 683638dd0a76a..88217a1992ab7 100644 --- a/x-pack/platform/plugins/shared/cases/server/routes/api/get_internal_routes.ts +++ b/x-pack/platform/plugins/shared/cases/server/routes/api/get_internal_routes.ts @@ -29,6 +29,8 @@ import { findUserActionsRoute } from './internal/find_user_actions'; import { findCasesContainingAllDocumentsRoute } from './internal/find_cases_containing_all_documents'; import type { ConfigType } from '../../config'; import { getTemplateRoutes } from './templates'; +import { getTaskRoutes } from './tasks'; +import { getTaskTemplateRoutes } from './task_templates'; export const getInternalRoutes = (userProfileService: UserProfileService, config: ConfigType) => [ @@ -53,4 +55,6 @@ export const getInternalRoutes = (userProfileService: UserProfileService, config findUserActionsRoute, findCasesContainingAllDocumentsRoute, ...getTemplateRoutes(config), + ...getTaskRoutes(config), + ...getTaskTemplateRoutes(config), ] as CaseRoute[]; diff --git a/x-pack/platform/plugins/shared/cases/server/routes/api/task_templates/delete_task_template_route.ts b/x-pack/platform/plugins/shared/cases/server/routes/api/task_templates/delete_task_template_route.ts new file mode 100644 index 0000000000000..3ef6b19c2633a --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/server/routes/api/task_templates/delete_task_template_route.ts @@ -0,0 +1,48 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { CASE_TASK_TEMPLATE_DETAILS_URL } from '../../../../common/constants'; +import { createCaseError } from '../../../common/error'; +import { createCasesRoute } from '../create_cases_route'; +import { DEFAULT_CASES_ROUTE_SECURITY } from '../constants'; + +/** + * DELETE /api/cases/task_templates/{template_id} + * Delete a task template by ID + */ +export const deleteTaskTemplateRoute = createCasesRoute({ + method: 'delete', + path: CASE_TASK_TEMPLATE_DETAILS_URL, + security: DEFAULT_CASES_ROUTE_SECURITY, + routerOptions: { + access: 'internal', + summary: 'Delete a case task template', + }, + params: { + params: schema.object({ + template_id: schema.string(), + }), + }, + handler: async ({ context, request, response }) => { + try { + const caseContext = await context.cases; + const casesClient = await caseContext.getCasesClient(); + + const { template_id: templateId } = request.params; + + await casesClient.taskTemplates.delete(templateId); + + return response.noContent(); + } catch (error) { + throw createCaseError({ + message: `Failed to delete task template: ${error}`, + error, + }); + } + }, +}); diff --git a/x-pack/platform/plugins/shared/cases/server/routes/api/task_templates/find_task_templates_route.ts b/x-pack/platform/plugins/shared/cases/server/routes/api/task_templates/find_task_templates_route.ts new file mode 100644 index 0000000000000..490ab2112ffa3 --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/server/routes/api/task_templates/find_task_templates_route.ts @@ -0,0 +1,70 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { castArray } from 'lodash'; +import { CASES_TASK_TEMPLATES_URL } from '../../../../common/constants'; +import { createCaseError } from '../../../common/error'; +import { createCasesRoute } from '../create_cases_route'; +import { DEFAULT_CASES_ROUTE_SECURITY } from '../constants'; + +/** + * GET /api/cases/task_templates + * Find task templates with optional filters + */ +export const findTaskTemplatesRoute = createCasesRoute({ + method: 'get', + path: CASES_TASK_TEMPLATES_URL, + security: DEFAULT_CASES_ROUTE_SECURITY, + routerOptions: { + access: 'internal', + summary: 'Find case task templates', + }, + params: { + query: schema.object({ + scope: schema.maybe( + schema.oneOf([schema.literal('global'), schema.literal('space')]) + ), + tags: schema.maybe(schema.oneOf([schema.string(), schema.arrayOf(schema.string())])), + owners: schema.maybe(schema.oneOf([schema.string(), schema.arrayOf(schema.string())])), + search: schema.maybe(schema.string()), + page: schema.maybe(schema.number({ min: 1 })), + per_page: schema.maybe(schema.number({ min: 1, max: 100 })), + }), + }, + handler: async ({ context, request, response }) => { + try { + const caseContext = await context.cases; + const casesClient = await caseContext.getCasesClient(); + + const { scope, tags, owners, search, page, per_page } = request.query as { + scope?: 'global' | 'space'; + tags?: string | string[]; + owners?: string | string[]; + search?: string; + page?: number; + per_page?: number; + }; + + const result = await casesClient.taskTemplates.find({ + scope, + tags: tags ? castArray(tags).filter(Boolean) : undefined, + owners: owners ? castArray(owners).filter(Boolean) : undefined, + search, + page, + per_page, + }); + + return response.ok({ body: result }); + } catch (error) { + throw createCaseError({ + message: `Failed to find task templates: ${error}`, + error, + }); + } + }, +}); diff --git a/x-pack/platform/plugins/shared/cases/server/routes/api/task_templates/get_task_template_route.ts b/x-pack/platform/plugins/shared/cases/server/routes/api/task_templates/get_task_template_route.ts new file mode 100644 index 0000000000000..dd5fc64fe7e34 --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/server/routes/api/task_templates/get_task_template_route.ts @@ -0,0 +1,48 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { CASE_TASK_TEMPLATE_DETAILS_URL } from '../../../../common/constants'; +import { createCaseError } from '../../../common/error'; +import { createCasesRoute } from '../create_cases_route'; +import { DEFAULT_CASES_ROUTE_SECURITY } from '../constants'; + +/** + * GET /api/cases/task_templates/{template_id} + * Get a task template by ID + */ +export const getTaskTemplateRoute = createCasesRoute({ + method: 'get', + path: CASE_TASK_TEMPLATE_DETAILS_URL, + security: DEFAULT_CASES_ROUTE_SECURITY, + routerOptions: { + access: 'internal', + summary: 'Get a case task template by ID', + }, + params: { + params: schema.object({ + template_id: schema.string(), + }), + }, + handler: async ({ context, request, response }) => { + try { + const caseContext = await context.cases; + const casesClient = await caseContext.getCasesClient(); + + const { template_id: templateId } = request.params; + + const template = await casesClient.taskTemplates.get(templateId); + + return response.ok({ body: template }); + } catch (error) { + throw createCaseError({ + message: `Failed to get task template: ${error}`, + error, + }); + } + }, +}); diff --git a/x-pack/platform/plugins/shared/cases/server/routes/api/task_templates/index.ts b/x-pack/platform/plugins/shared/cases/server/routes/api/task_templates/index.ts new file mode 100644 index 0000000000000..6516779b8d39e --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/server/routes/api/task_templates/index.ts @@ -0,0 +1,30 @@ +/* + * 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 type { ConfigType } from '../../../config'; +import { postTaskTemplateRoute } from './post_task_template_route'; +import { getTaskTemplateRoute } from './get_task_template_route'; +import { findTaskTemplatesRoute } from './find_task_templates_route'; +import { patchTaskTemplateRoute } from './patch_task_template_route'; +import { deleteTaskTemplateRoute } from './delete_task_template_route'; + +/** + * Register task template routes conditionally, based on feature flag + */ +export const getTaskTemplateRoutes = (config: ConfigType) => { + if (!config.tasks?.enabled) { + return []; + } + + return [ + postTaskTemplateRoute, + getTaskTemplateRoute, + findTaskTemplatesRoute, + patchTaskTemplateRoute, + deleteTaskTemplateRoute, + ]; +}; diff --git a/x-pack/platform/plugins/shared/cases/server/routes/api/task_templates/patch_task_template_route.ts b/x-pack/platform/plugins/shared/cases/server/routes/api/task_templates/patch_task_template_route.ts new file mode 100644 index 0000000000000..1000e5f8ef538 --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/server/routes/api/task_templates/patch_task_template_route.ts @@ -0,0 +1,109 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { CASE_TASK_TEMPLATE_DETAILS_URL } from '../../../../common/constants'; +import { createCaseError } from '../../../common/error'; +import { createCasesRoute } from '../create_cases_route'; +import { DEFAULT_CASES_ROUTE_SECURITY } from '../constants'; + +const subtaskSchema = schema.object({ + title: schema.string({ minLength: 1, maxLength: 160 }), + description: schema.string({ maxLength: 30000, defaultValue: '' }), + priority: schema.oneOf([ + schema.literal('low'), + schema.literal('medium'), + schema.literal('high'), + schema.literal('critical'), + ]), + relative_due_days: schema.nullable(schema.number({ min: 0 })), + sort_order: schema.number({ defaultValue: 0 }), +}); + +const taskSchema = schema.object({ + title: schema.string({ minLength: 1, maxLength: 160 }), + description: schema.string({ maxLength: 30000, defaultValue: '' }), + priority: schema.oneOf([ + schema.literal('low'), + schema.literal('medium'), + schema.literal('high'), + schema.literal('critical'), + ]), + relative_due_days: schema.nullable(schema.number({ min: 0 })), + sort_order: schema.number({ defaultValue: 0 }), + subtasks: schema.arrayOf(subtaskSchema, { defaultValue: [] }), +}); + +/** + * PATCH /api/cases/task_templates/{template_id} + * Partial update of a task template + */ +export const patchTaskTemplateRoute = createCasesRoute({ + method: 'patch', + path: CASE_TASK_TEMPLATE_DETAILS_URL, + security: DEFAULT_CASES_ROUTE_SECURITY, + routerOptions: { + access: 'internal', + summary: 'Update a case task template', + }, + params: { + params: schema.object({ + template_id: schema.string(), + }), + body: schema.object({ + version: schema.string(), + name: schema.maybe(schema.string({ minLength: 1, maxLength: 160 })), + description: schema.maybe(schema.string({ maxLength: 30000 })), + scope: schema.maybe( + schema.oneOf([schema.literal('global'), schema.literal('space')]) + ), + tags: schema.maybe(schema.arrayOf(schema.string({ maxLength: 256 }), { maxSize: 200 })), + tasks: schema.maybe(schema.arrayOf(taskSchema, { minSize: 1 })), + }), + }, + handler: async ({ context, request, response }) => { + try { + const caseContext = await context.cases; + const casesClient = await caseContext.getCasesClient(); + + const { template_id: templateId } = request.params; + const body = request.body as { + version: string; + name?: string; + description?: string; + scope?: 'global' | 'space'; + tags?: string[]; + tasks?: Array<{ + title: string; + description: string; + priority: 'low' | 'medium' | 'high' | 'critical'; + relative_due_days: number | null; + sort_order: number; + subtasks: Array<{ + title: string; + description: string; + priority: 'low' | 'medium' | 'high' | 'critical'; + relative_due_days: number | null; + sort_order: number; + }>; + }>; + }; + + const template = await casesClient.taskTemplates.update({ + templateId, + ...body, + }); + + return response.ok({ body: template }); + } catch (error) { + throw createCaseError({ + message: `Failed to update task template: ${error}`, + error, + }); + } + }, +}); diff --git a/x-pack/platform/plugins/shared/cases/server/routes/api/task_templates/post_task_template_route.ts b/x-pack/platform/plugins/shared/cases/server/routes/api/task_templates/post_task_template_route.ts new file mode 100644 index 0000000000000..bc3117852412c --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/server/routes/api/task_templates/post_task_template_route.ts @@ -0,0 +1,103 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { CASES_TASK_TEMPLATES_URL } from '../../../../common/constants'; +import { createCaseError } from '../../../common/error'; +import { createCasesRoute } from '../create_cases_route'; +import { DEFAULT_CASES_ROUTE_SECURITY } from '../constants'; + +const taskSchema = schema.object({ + title: schema.string({ minLength: 1, maxLength: 160 }), + description: schema.string({ maxLength: 30000, defaultValue: '' }), + priority: schema.oneOf([ + schema.literal('low'), + schema.literal('medium'), + schema.literal('high'), + schema.literal('critical'), + ]), + relative_due_days: schema.nullable(schema.number({ min: 0 })), + sort_order: schema.number({ defaultValue: 0 }), + subtasks: schema.arrayOf( + schema.object({ + title: schema.string({ minLength: 1, maxLength: 160 }), + description: schema.string({ maxLength: 30000, defaultValue: '' }), + priority: schema.oneOf([ + schema.literal('low'), + schema.literal('medium'), + schema.literal('high'), + schema.literal('critical'), + ]), + relative_due_days: schema.nullable(schema.number({ min: 0 })), + sort_order: schema.number({ defaultValue: 0 }), + }), + { defaultValue: [] } + ), +}); + +/** + * POST /api/cases/task_templates + * Create a new task template + */ +export const postTaskTemplateRoute = createCasesRoute({ + method: 'post', + path: CASES_TASK_TEMPLATES_URL, + security: DEFAULT_CASES_ROUTE_SECURITY, + routerOptions: { + access: 'internal', + summary: 'Create a case task template', + }, + params: { + body: schema.object({ + name: schema.string({ minLength: 1, maxLength: 160 }), + description: schema.maybe(schema.string({ maxLength: 30000 })), + scope: schema.maybe( + schema.oneOf([schema.literal('global'), schema.literal('space')]) + ), + tags: schema.maybe(schema.arrayOf(schema.string({ maxLength: 256 }), { maxSize: 200 })), + tasks: schema.arrayOf(taskSchema, { minSize: 1 }), + owner: schema.string(), + }), + }, + handler: async ({ context, request, response }) => { + try { + const caseContext = await context.cases; + const casesClient = await caseContext.getCasesClient(); + + const body = request.body as { + name: string; + description?: string; + scope?: 'global' | 'space'; + tags?: string[]; + tasks: Array<{ + title: string; + description: string; + priority: 'low' | 'medium' | 'high' | 'critical'; + relative_due_days: number | null; + sort_order: number; + subtasks: Array<{ + title: string; + description: string; + priority: 'low' | 'medium' | 'high' | 'critical'; + relative_due_days: number | null; + sort_order: number; + }>; + }>; + owner: string; + }; + + const template = await casesClient.taskTemplates.create(body); + + return response.ok({ body: template }); + } catch (error) { + throw createCaseError({ + message: `Failed to create task template: ${error}`, + error, + }); + } + }, +}); diff --git a/x-pack/platform/plugins/shared/cases/server/routes/api/tasks/apply_template_route.ts b/x-pack/platform/plugins/shared/cases/server/routes/api/tasks/apply_template_route.ts new file mode 100644 index 0000000000000..84c8b242d074b --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/server/routes/api/tasks/apply_template_route.ts @@ -0,0 +1,64 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { CASE_TASKS_APPLY_TEMPLATE_URL } from '../../../../common/constants'; +import { createCaseError } from '../../../common/error'; +import { createCasesRoute } from '../create_cases_route'; +import { DEFAULT_CASES_ROUTE_SECURITY } from '../constants'; + +/** + * POST /api/cases/{case_id}/tasks/_apply_template + * Apply a task template to a case, creating tasks from it + */ +export const applyTemplateRoute = createCasesRoute({ + method: 'post', + path: CASE_TASKS_APPLY_TEMPLATE_URL, + security: DEFAULT_CASES_ROUTE_SECURITY, + routerOptions: { + access: 'internal', + summary: 'Apply a task template to a case', + }, + params: { + params: schema.object({ + case_id: schema.string(), + }), + body: schema.object({ + template_id: schema.string(), + owner: schema.string(), + due_date_anchor: schema.maybe(schema.string()), + }), + }, + handler: async ({ context, request, response }) => { + try { + const caseContext = await context.cases; + const casesClient = await caseContext.getCasesClient(); + + const { case_id: caseId } = request.params; + const { template_id: templateId, owner, due_date_anchor: dueDateAnchor } = + request.body as { + template_id: string; + owner: string; + due_date_anchor?: string; + }; + + const tasks = await casesClient.tasks.applyTemplate({ + caseId, + templateId, + owner, + due_date_anchor: dueDateAnchor, + }); + + return response.ok({ body: { tasks } }); + } catch (error) { + throw createCaseError({ + message: `Failed to apply task template: ${error}`, + error, + }); + } + }, +}); diff --git a/x-pack/platform/plugins/shared/cases/server/routes/api/tasks/delete_task_route.ts b/x-pack/platform/plugins/shared/cases/server/routes/api/tasks/delete_task_route.ts new file mode 100644 index 0000000000000..a66aa9c3fcbec --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/server/routes/api/tasks/delete_task_route.ts @@ -0,0 +1,49 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { CASE_TASK_DETAILS_URL } from '../../../../common/constants'; +import { createCaseError } from '../../../common/error'; +import { createCasesRoute } from '../create_cases_route'; +import { DEFAULT_CASES_ROUTE_SECURITY } from '../constants'; + +/** + * DELETE /api/cases/{case_id}/tasks/{task_id} + * Delete a task by ID + */ +export const deleteTaskRoute = createCasesRoute({ + method: 'delete', + path: CASE_TASK_DETAILS_URL, + security: DEFAULT_CASES_ROUTE_SECURITY, + routerOptions: { + access: 'internal', + summary: 'Delete a case task', + }, + params: { + params: schema.object({ + case_id: schema.string(), + task_id: schema.string(), + }), + }, + handler: async ({ context, request, response }) => { + try { + const caseContext = await context.cases; + const casesClient = await caseContext.getCasesClient(); + + const { task_id: taskId } = request.params; + + await casesClient.tasks.delete(taskId); + + return response.noContent(); + } catch (error) { + throw createCaseError({ + message: `Failed to delete task: ${error}`, + error, + }); + } + }, +}); diff --git a/x-pack/platform/plugins/shared/cases/server/routes/api/tasks/find_tasks_route.ts b/x-pack/platform/plugins/shared/cases/server/routes/api/tasks/find_tasks_route.ts new file mode 100644 index 0000000000000..63d516684eff4 --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/server/routes/api/tasks/find_tasks_route.ts @@ -0,0 +1,127 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { castArray } from 'lodash'; +import { CASES_TASKS_FIND_URL } from '../../../../common/constants'; +import { createCaseError } from '../../../common/error'; +import { createCasesRoute } from '../create_cases_route'; +import { DEFAULT_CASES_ROUTE_SECURITY } from '../constants'; + +/** + * GET /api/cases/tasks/_find + * Find tasks across cases with filters + */ +export const findTasksRoute = createCasesRoute({ + method: 'get', + path: CASES_TASKS_FIND_URL, + security: DEFAULT_CASES_ROUTE_SECURITY, + routerOptions: { + access: 'internal', + summary: 'Find case tasks', + }, + params: { + query: schema.object({ + case_ids: schema.maybe(schema.oneOf([schema.string(), schema.arrayOf(schema.string())])), + owners: schema.maybe(schema.oneOf([schema.string(), schema.arrayOf(schema.string())])), + status: schema.maybe( + schema.oneOf([ + schema.literal('open'), + schema.literal('in_progress'), + schema.literal('completed'), + schema.literal('cancelled'), + ]) + ), + priority: schema.maybe( + schema.oneOf([ + schema.literal('low'), + schema.literal('medium'), + schema.literal('high'), + schema.literal('critical'), + ]) + ), + assignees: schema.maybe(schema.oneOf([schema.string(), schema.arrayOf(schema.string())])), + due_date_from: schema.maybe(schema.string()), + due_date_to: schema.maybe(schema.string()), + parent_task_id: schema.maybe(schema.nullable(schema.string())), + sort_field: schema.maybe( + schema.oneOf([ + schema.literal('created_at'), + schema.literal('due_date'), + schema.literal('priority'), + schema.literal('sort_order'), + schema.literal('status'), + ]) + ), + sort_order: schema.maybe( + schema.oneOf([schema.literal('asc'), schema.literal('desc')]) + ), + page: schema.maybe(schema.number({ min: 1 })), + per_page: schema.maybe(schema.number({ min: 1, max: 100 })), + search: schema.maybe(schema.string()), + }), + }, + handler: async ({ context, request, response }) => { + try { + const caseContext = await context.cases; + const casesClient = await caseContext.getCasesClient(); + + const { + case_ids, + owners, + status, + priority, + assignees, + due_date_from, + due_date_to, + parent_task_id, + sort_field, + sort_order, + page, + per_page, + search, + } = request.query as { + case_ids?: string | string[]; + owners?: string | string[]; + status?: 'open' | 'in_progress' | 'completed' | 'cancelled'; + priority?: 'low' | 'medium' | 'high' | 'critical'; + assignees?: string | string[]; + due_date_from?: string; + due_date_to?: string; + parent_task_id?: string | null; + sort_field?: 'created_at' | 'due_date' | 'priority' | 'sort_order' | 'status'; + sort_order?: 'asc' | 'desc'; + page?: number; + per_page?: number; + search?: string; + }; + + const result = await casesClient.tasks.find({ + caseIds: case_ids ? castArray(case_ids).filter(Boolean) : undefined, + owners: owners ? castArray(owners).filter(Boolean) : undefined, + status, + priority, + assignees: assignees ? castArray(assignees).filter(Boolean) : undefined, + due_date_from, + due_date_to, + parent_task_id, + sort_field, + sort_order, + page, + per_page, + search, + }); + + return response.ok({ body: result }); + } catch (error) { + throw createCaseError({ + message: `Failed to find tasks: ${error}`, + error, + }); + } + }, +}); diff --git a/x-pack/platform/plugins/shared/cases/server/routes/api/tasks/get_my_tasks_route.ts b/x-pack/platform/plugins/shared/cases/server/routes/api/tasks/get_my_tasks_route.ts new file mode 100644 index 0000000000000..20f55930be459 --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/server/routes/api/tasks/get_my_tasks_route.ts @@ -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 { schema } from '@kbn/config-schema'; +import { castArray } from 'lodash'; +import { CASES_TASKS_MY_URL } from '../../../../common/constants'; +import { createCaseError } from '../../../common/error'; +import { createCasesRoute } from '../create_cases_route'; +import { DEFAULT_CASES_ROUTE_SECURITY } from '../constants'; + +/** + * GET /api/cases/tasks/_my + * Get tasks assigned to the current user + */ +export const getMyTasksRoute = createCasesRoute({ + method: 'get', + path: CASES_TASKS_MY_URL, + security: DEFAULT_CASES_ROUTE_SECURITY, + routerOptions: { + access: 'internal', + summary: 'Get tasks assigned to the current user', + }, + params: { + query: schema.object({ + user_profile_uid: schema.string(), + case_ids: schema.maybe(schema.oneOf([schema.string(), schema.arrayOf(schema.string())])), + status: schema.maybe( + schema.oneOf([ + schema.literal('open'), + schema.literal('in_progress'), + schema.literal('completed'), + schema.literal('cancelled'), + ]) + ), + priority: schema.maybe( + schema.oneOf([ + schema.literal('low'), + schema.literal('medium'), + schema.literal('high'), + schema.literal('critical'), + ]) + ), + due_date_from: schema.maybe(schema.string()), + due_date_to: schema.maybe(schema.string()), + include_completed: schema.maybe(schema.boolean()), + sort_field: schema.maybe( + schema.oneOf([ + schema.literal('created_at'), + schema.literal('due_date'), + schema.literal('priority'), + schema.literal('sort_order'), + schema.literal('status'), + ]) + ), + sort_order: schema.maybe( + schema.oneOf([schema.literal('asc'), schema.literal('desc')]) + ), + page: schema.maybe(schema.number({ min: 1 })), + per_page: schema.maybe(schema.number({ min: 1, max: 100 })), + }), + }, + handler: async ({ context, request, response }) => { + try { + const caseContext = await context.cases; + const casesClient = await caseContext.getCasesClient(); + + const { + user_profile_uid: userProfileUid, + case_ids, + status, + priority, + due_date_from, + due_date_to, + include_completed: includeCompleted, + sort_field, + sort_order, + page, + per_page, + } = request.query as { + user_profile_uid: string; + case_ids?: string | string[]; + status?: 'open' | 'in_progress' | 'completed' | 'cancelled'; + priority?: 'low' | 'medium' | 'high' | 'critical'; + due_date_from?: string; + due_date_to?: string; + include_completed?: boolean; + sort_field?: 'created_at' | 'due_date' | 'priority' | 'sort_order' | 'status'; + sort_order?: 'asc' | 'desc'; + page?: number; + per_page?: number; + }; + + const result = await casesClient.tasks.getMyTasks({ + userProfileUid, + caseIds: case_ids ? castArray(case_ids).filter(Boolean) : undefined, + status, + priority, + due_date_from, + due_date_to, + includeCompleted, + sort_field, + sort_order, + page, + per_page, + }); + + return response.ok({ body: result }); + } catch (error) { + throw createCaseError({ + message: `Failed to get my tasks: ${error}`, + error, + }); + } + }, +}); diff --git a/x-pack/platform/plugins/shared/cases/server/routes/api/tasks/get_task_route.ts b/x-pack/platform/plugins/shared/cases/server/routes/api/tasks/get_task_route.ts new file mode 100644 index 0000000000000..8d01a69a2123f --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/server/routes/api/tasks/get_task_route.ts @@ -0,0 +1,49 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { CASE_TASK_DETAILS_URL } from '../../../../common/constants'; +import { createCaseError } from '../../../common/error'; +import { createCasesRoute } from '../create_cases_route'; +import { DEFAULT_CASES_ROUTE_SECURITY } from '../constants'; + +/** + * GET /api/cases/{case_id}/tasks/{task_id} + * Get a task by ID + */ +export const getTaskRoute = createCasesRoute({ + method: 'get', + path: CASE_TASK_DETAILS_URL, + security: DEFAULT_CASES_ROUTE_SECURITY, + routerOptions: { + access: 'internal', + summary: 'Get a case task by ID', + }, + params: { + params: schema.object({ + case_id: schema.string(), + task_id: schema.string(), + }), + }, + handler: async ({ context, request, response }) => { + try { + const caseContext = await context.cases; + const casesClient = await caseContext.getCasesClient(); + + const { task_id: taskId } = request.params; + + const task = await casesClient.tasks.get(taskId); + + return response.ok({ body: task }); + } catch (error) { + throw createCaseError({ + message: `Failed to get task: ${error}`, + error, + }); + } + }, +}); diff --git a/x-pack/platform/plugins/shared/cases/server/routes/api/tasks/index.ts b/x-pack/platform/plugins/shared/cases/server/routes/api/tasks/index.ts new file mode 100644 index 0000000000000..7382ff851e48d --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/server/routes/api/tasks/index.ts @@ -0,0 +1,36 @@ +/* + * 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 type { ConfigType } from '../../../config'; +import { postTaskRoute } from './post_task_route'; +import { getTaskRoute } from './get_task_route'; +import { findTasksRoute } from './find_tasks_route'; +import { patchTaskRoute } from './patch_task_route'; +import { deleteTaskRoute } from './delete_task_route'; +import { reorderTasksRoute } from './reorder_tasks_route'; +import { getMyTasksRoute } from './get_my_tasks_route'; +import { applyTemplateRoute } from './apply_template_route'; + +/** + * Register task routes conditionally, based on feature flag + */ +export const getTaskRoutes = (config: ConfigType) => { + if (!config.tasks?.enabled) { + return []; + } + + return [ + postTaskRoute, + getTaskRoute, + findTasksRoute, + patchTaskRoute, + deleteTaskRoute, + reorderTasksRoute, + getMyTasksRoute, + applyTemplateRoute, + ]; +}; diff --git a/x-pack/platform/plugins/shared/cases/server/routes/api/tasks/patch_task_route.ts b/x-pack/platform/plugins/shared/cases/server/routes/api/tasks/patch_task_route.ts new file mode 100644 index 0000000000000..964fe04983e4a --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/server/routes/api/tasks/patch_task_route.ts @@ -0,0 +1,86 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { CASE_TASK_DETAILS_URL } from '../../../../common/constants'; +import { createCaseError } from '../../../common/error'; +import { createCasesRoute } from '../create_cases_route'; +import { DEFAULT_CASES_ROUTE_SECURITY } from '../constants'; + +/** + * PATCH /api/cases/{case_id}/tasks/{task_id} + * Update a task (partial update) + */ +export const patchTaskRoute = createCasesRoute({ + method: 'patch', + path: CASE_TASK_DETAILS_URL, + security: DEFAULT_CASES_ROUTE_SECURITY, + routerOptions: { + access: 'internal', + summary: 'Update a case task', + }, + params: { + params: schema.object({ + case_id: schema.string(), + task_id: schema.string(), + }), + body: schema.object({ + version: schema.string(), + title: schema.maybe(schema.string({ minLength: 1, maxLength: 160 })), + description: schema.maybe(schema.string({ maxLength: 30000 })), + status: schema.maybe( + schema.oneOf([ + schema.literal('open'), + schema.literal('in_progress'), + schema.literal('completed'), + schema.literal('cancelled'), + ]) + ), + priority: schema.maybe( + schema.oneOf([ + schema.literal('low'), + schema.literal('medium'), + schema.literal('high'), + schema.literal('critical'), + ]) + ), + assignees: schema.maybe( + schema.arrayOf(schema.object({ uid: schema.string() }), { maxSize: 10 }) + ), + due_date: schema.maybe(schema.nullable(schema.string())), + }), + }, + handler: async ({ context, request, response }) => { + try { + const caseContext = await context.cases; + const casesClient = await caseContext.getCasesClient(); + + const { task_id: taskId } = request.params; + const body = request.body as { + version: string; + title?: string; + description?: string; + status?: 'open' | 'in_progress' | 'completed' | 'cancelled'; + priority?: 'low' | 'medium' | 'high' | 'critical'; + assignees?: Array<{ uid: string }>; + due_date?: string | null; + }; + + const task = await casesClient.tasks.update({ + taskId, + ...body, + }); + + return response.ok({ body: task }); + } catch (error) { + throw createCaseError({ + message: `Failed to update task: ${error}`, + error, + }); + } + }, +}); diff --git a/x-pack/platform/plugins/shared/cases/server/routes/api/tasks/post_task_route.ts b/x-pack/platform/plugins/shared/cases/server/routes/api/tasks/post_task_route.ts new file mode 100644 index 0000000000000..5d23015387740 --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/server/routes/api/tasks/post_task_route.ts @@ -0,0 +1,87 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { CASE_TASKS_URL } from '../../../../common/constants'; +import { createCaseError } from '../../../common/error'; +import { createCasesRoute } from '../create_cases_route'; +import { DEFAULT_CASES_ROUTE_SECURITY } from '../constants'; + +/** + * POST /api/cases/{case_id}/tasks + * Create a new task for a case + */ +export const postTaskRoute = createCasesRoute({ + method: 'post', + path: CASE_TASKS_URL, + security: DEFAULT_CASES_ROUTE_SECURITY, + routerOptions: { + access: 'internal', + summary: 'Create a case task', + }, + params: { + params: schema.object({ + case_id: schema.string(), + }), + body: schema.object({ + title: schema.string({ minLength: 1, maxLength: 160 }), + description: schema.maybe(schema.string({ maxLength: 30000 })), + status: schema.maybe( + schema.oneOf([ + schema.literal('open'), + schema.literal('in_progress'), + schema.literal('completed'), + schema.literal('cancelled'), + ]) + ), + priority: schema.maybe( + schema.oneOf([ + schema.literal('low'), + schema.literal('medium'), + schema.literal('high'), + schema.literal('critical'), + ]) + ), + assignees: schema.maybe( + schema.arrayOf(schema.object({ uid: schema.string() }), { maxSize: 10 }) + ), + due_date: schema.maybe(schema.nullable(schema.string())), + parent_task_id: schema.maybe(schema.nullable(schema.string())), + owner: schema.string(), + }), + }, + handler: async ({ context, request, response }) => { + try { + const caseContext = await context.cases; + const casesClient = await caseContext.getCasesClient(); + + const { case_id: caseId } = request.params; + const body = request.body as { + title: string; + description?: string; + status?: 'open' | 'in_progress' | 'completed' | 'cancelled'; + priority?: 'low' | 'medium' | 'high' | 'critical'; + assignees?: Array<{ uid: string }>; + due_date?: string | null; + parent_task_id?: string | null; + owner: string; + }; + + const task = await casesClient.tasks.create({ + caseId, + ...body, + }); + + return response.ok({ body: task }); + } catch (error) { + throw createCaseError({ + message: `Failed to create task: ${error}`, + error, + }); + } + }, +}); diff --git a/x-pack/platform/plugins/shared/cases/server/routes/api/tasks/reorder_tasks_route.ts b/x-pack/platform/plugins/shared/cases/server/routes/api/tasks/reorder_tasks_route.ts new file mode 100644 index 0000000000000..ae95e76aacd94 --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/server/routes/api/tasks/reorder_tasks_route.ts @@ -0,0 +1,61 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { CASE_TASKS_REORDER_URL } from '../../../../common/constants'; +import { createCaseError } from '../../../common/error'; +import { createCasesRoute } from '../create_cases_route'; +import { DEFAULT_CASES_ROUTE_SECURITY } from '../constants'; + +/** + * PUT /api/cases/{case_id}/tasks/_reorder + * Reorder tasks within a case (or within a parent task's subtask list) + */ +export const reorderTasksRoute = createCasesRoute({ + method: 'put', + path: CASE_TASKS_REORDER_URL, + security: DEFAULT_CASES_ROUTE_SECURITY, + routerOptions: { + access: 'internal', + summary: 'Reorder case tasks', + }, + params: { + params: schema.object({ + case_id: schema.string(), + }), + body: schema.object({ + ordered_task_ids: schema.arrayOf(schema.string(), { minSize: 1 }), + parent_task_id: schema.maybe(schema.nullable(schema.string())), + }), + }, + handler: async ({ context, request, response }) => { + try { + const caseContext = await context.cases; + const casesClient = await caseContext.getCasesClient(); + + const { case_id: caseId } = request.params; + const { ordered_task_ids: orderedTaskIds, parent_task_id: parentTaskId } = + request.body as { + ordered_task_ids: string[]; + parent_task_id?: string | null; + }; + + await casesClient.tasks.reorder({ + caseId, + orderedTaskIds, + parentTaskId: parentTaskId ?? null, + }); + + return response.noContent(); + } catch (error) { + throw createCaseError({ + message: `Failed to reorder tasks: ${error}`, + error, + }); + } + }, +}); From 43a62be6d6321f185efe62a5e5f9c64d248ea778 Mon Sep 17 00:00:00 2001 From: Michael Olorunnisola Date: Sun, 15 Mar 2026 21:54:32 -0400 Subject: [PATCH 10/22] =?UTF-8?q?[Cases]=20PR=206:=20UI=20layer=20?= =?UTF-8?q?=E2=80=94=20Tasks=20tab=20in=20case=20view?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Tasks tab to the case view page with task list/CRUD UI: - `CASE_VIEW_PAGE_TABS.TASKS = 'tasks'` added to the enum - URL helpers for task endpoints added to `common/api/helpers.ts` - Task API functions added to `containers/api.ts`: getCaseTasks, createTask, updateTask, deleteTask, reorderTasks, applyTaskTemplate - Query/mutation hooks: useGetTasks, useCreateTask, useUpdateTask, useDeleteTask - React components in `components/tasks/`: - `TasksTable` — table with status badge cycling, delete action, empty state - `CaseViewTasks` — tab-level wrapper that fetches and renders tasks - `TASKS_TAB` translation string added to case_view/translations.ts - `CaseViewTabs` now includes a Tasks tab - `CaseViewPage` renders `` when Tasks tab is active Co-Authored-By: Claude Sonnet 4.6 --- .../shared/cases/common/api/helpers.ts | 20 ++ .../plugins/shared/cases/common/types.ts | 1 + .../components/case_view/case_view_page.tsx | 4 + .../components/case_view/case_view_tabs.tsx | 6 +- .../components/case_view/translations.ts | 4 + .../components/tasks/case_view_tasks.tsx | 33 +++ .../public/components/tasks/tasks_table.tsx | 193 ++++++++++++++++++ .../public/components/tasks/translations.ts | 88 ++++++++ .../shared/cases/public/containers/api.ts | 129 ++++++++++++ .../cases/public/containers/constants.ts | 7 + .../cases/public/containers/translations.ts | 8 + .../public/containers/use_create_task.tsx | 35 ++++ .../public/containers/use_delete_task.tsx | 34 +++ .../cases/public/containers/use_get_tasks.tsx | 24 +++ .../public/containers/use_update_task.tsx | 35 ++++ 15 files changed, 620 insertions(+), 1 deletion(-) create mode 100644 x-pack/platform/plugins/shared/cases/public/components/tasks/case_view_tasks.tsx create mode 100644 x-pack/platform/plugins/shared/cases/public/components/tasks/tasks_table.tsx create mode 100644 x-pack/platform/plugins/shared/cases/public/components/tasks/translations.ts create mode 100644 x-pack/platform/plugins/shared/cases/public/containers/use_create_task.tsx create mode 100644 x-pack/platform/plugins/shared/cases/public/containers/use_delete_task.tsx create mode 100644 x-pack/platform/plugins/shared/cases/public/containers/use_get_tasks.tsx create mode 100644 x-pack/platform/plugins/shared/cases/public/containers/use_update_task.tsx diff --git a/x-pack/platform/plugins/shared/cases/common/api/helpers.ts b/x-pack/platform/plugins/shared/cases/common/api/helpers.ts index f459bdac4ace1..d51254198dffd 100644 --- a/x-pack/platform/plugins/shared/cases/common/api/helpers.ts +++ b/x-pack/platform/plugins/shared/cases/common/api/helpers.ts @@ -26,6 +26,10 @@ import { INTERNAL_CASE_SIMILAR_CASES_URL, INTERNAL_CASE_OBSERVABLES_DELETE_URL, INTERNAL_BULK_CREATE_CASE_OBSERVABLES_URL, + CASE_TASKS_URL, + CASE_TASK_DETAILS_URL, + CASE_TASKS_REORDER_URL, + CASE_TASKS_APPLY_TEMPLATE_URL, } from '../constants'; export const getCaseDetailsUrl = (id: string): string => { @@ -116,3 +120,19 @@ export const getBulkCreateObservablesUrl = (id: string): string => { export const getCaseSimilarCasesUrl = (caseId: string) => { return INTERNAL_CASE_SIMILAR_CASES_URL.replace('{case_id}', caseId); }; + +export const getCaseTasksUrl = (caseId: string): string => { + return CASE_TASKS_URL.replace('{case_id}', caseId); +}; + +export const getCaseTaskDetailsUrl = (caseId: string, taskId: string): string => { + return CASE_TASK_DETAILS_URL.replace('{case_id}', caseId).replace('{task_id}', taskId); +}; + +export const getCaseTasksReorderUrl = (caseId: string): string => { + return CASE_TASKS_REORDER_URL.replace('{case_id}', caseId); +}; + +export const getCaseTasksApplyTemplateUrl = (caseId: string): string => { + return CASE_TASKS_APPLY_TEMPLATE_URL.replace('{case_id}', caseId); +}; diff --git a/x-pack/platform/plugins/shared/cases/common/types.ts b/x-pack/platform/plugins/shared/cases/common/types.ts index 0e913f2914785..d8ada904dee26 100644 --- a/x-pack/platform/plugins/shared/cases/common/types.ts +++ b/x-pack/platform/plugins/shared/cases/common/types.ts @@ -29,4 +29,5 @@ export enum CASE_VIEW_PAGE_TABS { OBSERVABLES = 'observables', SIMILAR_CASES = 'similar_cases', ATTACHMENTS = 'attachments', + TASKS = 'tasks', } diff --git a/x-pack/platform/plugins/shared/cases/public/components/case_view/case_view_page.tsx b/x-pack/platform/plugins/shared/cases/public/components/case_view/case_view_page.tsx index dbf647c4c7fac..45c96521d4373 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/case_view/case_view_page.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/case_view/case_view_page.tsx @@ -23,6 +23,7 @@ import type { CaseViewPageProps } from './types'; import { useRefreshCaseViewPage } from './use_on_refresh_case_view_page'; import { useOnUpdateField } from './use_on_update_field'; import { CaseViewSimilarCases } from './components/case_view_similar_cases'; +import { CaseViewTasks } from '../tasks/case_view_tasks'; import { CaseViewEvents } from './components/case_view_events'; import { CaseViewAttachments } from './components/case_view_attachments'; import { filterCaseAttachmentsBySearchTerm } from './components/helpers'; @@ -186,6 +187,9 @@ export const CaseViewPage = React.memo( {activeTabId === CASE_VIEW_PAGE_TABS.SIMILAR_CASES && ( )} + {activeTabId === CASE_VIEW_PAGE_TABS.TASKS && ( + + )} ); diff --git a/x-pack/platform/plugins/shared/cases/public/components/case_view/case_view_tabs.tsx b/x-pack/platform/plugins/shared/cases/public/components/case_view/case_view_tabs.tsx index eff787d571c96..e9c0f5bc05605 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/case_view/case_view_tabs.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/case_view/case_view_tabs.tsx @@ -10,7 +10,7 @@ import { EuiTab, EuiTabs, useEuiTheme } from '@elastic/eui'; import { CASE_VIEW_PAGE_TABS } from '../../../common/types'; import { useCaseViewNavigation } from '../../common/navigation'; -import { ACTIVITY_TAB, ATTACHMENTS_TAB, SIMILAR_CASES_TAB } from './translations'; +import { ACTIVITY_TAB, ATTACHMENTS_TAB, SIMILAR_CASES_TAB, TASKS_TAB } from './translations'; import { type CaseUI } from '../../../common'; import type { CaseViewTab } from './use_case_attachment_tabs'; import { @@ -86,6 +86,10 @@ export const CaseViewTabs = React.memo(({ caseData, activeTab /> ), }, + { + id: CASE_VIEW_PAGE_TABS.TASKS, + name: TASKS_TAB, + }, ], [activeTab, euiTheme, isAttachmentsTabActive, similarCasesData?.total, totalAttachments] ); diff --git a/x-pack/platform/plugins/shared/cases/public/components/case_view/translations.ts b/x-pack/platform/plugins/shared/cases/public/components/case_view/translations.ts index 7b2fb5e723022..c98f2e4f3fbce 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/case_view/translations.ts +++ b/x-pack/platform/plugins/shared/cases/public/components/case_view/translations.ts @@ -210,6 +210,10 @@ export const SIMILAR_CASES_TAB = i18n.translate('xpack.cases.caseView.tabs.simil defaultMessage: 'Similar cases', }); +export const TASKS_TAB = i18n.translate('xpack.cases.caseView.tabs.tasks', { + defaultMessage: 'Tasks', +}); + export const ALERTS_EMPTY_DESCRIPTION = i18n.translate( 'xpack.cases.caseView.tabs.alerts.emptyDescription', { diff --git a/x-pack/platform/plugins/shared/cases/public/components/tasks/case_view_tasks.tsx b/x-pack/platform/plugins/shared/cases/public/components/tasks/case_view_tasks.tsx new file mode 100644 index 0000000000000..03e8f927668d3 --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/public/components/tasks/case_view_tasks.tsx @@ -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 React from 'react'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { useGetTasks } from '../../containers/use_get_tasks'; +import { TasksTable } from './tasks_table'; + +interface CaseViewTasksProps { + caseId: string; +} + +export const CaseViewTasks = React.memo(({ caseId }) => { + const { data, isLoading } = useGetTasks(caseId); + + return ( + + + + + + ); +}); + +CaseViewTasks.displayName = 'CaseViewTasks'; diff --git a/x-pack/platform/plugins/shared/cases/public/components/tasks/tasks_table.tsx b/x-pack/platform/plugins/shared/cases/public/components/tasks/tasks_table.tsx new file mode 100644 index 0000000000000..499338a2c2ec5 --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/public/components/tasks/tasks_table.tsx @@ -0,0 +1,193 @@ +/* + * 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, { useCallback } from 'react'; +import { + EuiBasicTable, + EuiEmptyPrompt, + EuiSkeletonText, + EuiBadge, + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiButton, +} from '@elastic/eui'; +import type { EuiBasicTableColumn } from '@elastic/eui'; +import type { CaseTask } from '../../../common/types/domain/task/v1'; +import { useDeleteTask } from '../../containers/use_delete_task'; +import { useUpdateTask } from '../../containers/use_update_task'; +import * as i18n from './translations'; + +const STATUS_LABELS: Record = { + open: i18n.STATUS_OPEN, + in_progress: i18n.STATUS_IN_PROGRESS, + completed: i18n.STATUS_COMPLETED, + cancelled: i18n.STATUS_CANCELLED, +}; + +const STATUS_COLORS: Record = { + open: 'default', + in_progress: 'primary', + completed: 'success', + cancelled: 'default', +}; + +const PRIORITY_LABELS: Record = { + low: i18n.PRIORITY_LOW, + medium: i18n.PRIORITY_MEDIUM, + high: i18n.PRIORITY_HIGH, + critical: i18n.PRIORITY_CRITICAL, +}; + +interface TasksTableProps { + caseId: string; + tasks: CaseTask[]; + isLoading: boolean; + onAddTask?: () => void; +} + +export const TasksTable = React.memo( + ({ caseId, tasks, isLoading, onAddTask }) => { + const { mutate: deleteTask } = useDeleteTask(caseId); + const { mutate: updateTask } = useUpdateTask(caseId); + + const handleStatusChange = useCallback( + (task: CaseTask, newStatus: CaseTask['status']) => { + updateTask({ taskId: task.id, request: { version: task.version, status: newStatus } }); + }, + [updateTask] + ); + + const handleDelete = useCallback( + (taskId: string) => { + deleteTask(taskId); + }, + [deleteTask] + ); + + const columns: Array> = [ + { + name: i18n.TASK_TITLE, + field: 'title', + 'data-test-subj': 'cases-tasks-table-title', + truncateText: true, + }, + { + name: i18n.TASK_STATUS, + field: 'status', + 'data-test-subj': 'cases-tasks-table-status', + width: '120px', + render: (status: CaseTask['status'], task: CaseTask) => ( + { + const nextStatus = status === 'open' ? 'in_progress' : status === 'in_progress' ? 'completed' : 'open'; + handleStatusChange(task, nextStatus as CaseTask['status']); + }} + onClickAriaLabel={`Change status from ${status}`} + data-test-subj={`cases-tasks-status-${status}`} + > + {STATUS_LABELS[status]} + + ), + }, + { + name: i18n.TASK_PRIORITY, + field: 'priority', + 'data-test-subj': 'cases-tasks-table-priority', + width: '100px', + render: (priority: CaseTask['priority']) => PRIORITY_LABELS[priority], + }, + { + name: i18n.TASK_DUE_DATE, + field: 'due_date', + 'data-test-subj': 'cases-tasks-table-due-date', + width: '150px', + render: (dueDate: string | null) => + dueDate ? new Date(dueDate).toLocaleDateString() : '—', + }, + { + name: i18n.TASK_ACTIONS, + field: 'actions', + 'data-test-subj': 'cases-tasks-table-actions', + width: '80px', + actions: [ + { + name: i18n.DELETE_TASK, + render: (task: CaseTask) => ( + handleDelete(task.id)} + /> + ), + }, + ], + }, + ]; + + if (isLoading) { + return ; + } + + if (tasks.length === 0) { + return ( + {i18n.NO_TASKS}} + body={

{i18n.NO_TASKS_DESCRIPTION}

} + data-test-subj="cases-tasks-table-empty" + titleSize="xs" + actions={ + onAddTask ? ( + + {i18n.ADD_TASK} + + ) : null + } + /> + ); + } + + return ( + + + + + {onAddTask && ( + + {i18n.ADD_TASK} + + )} + + + + + + + + ); + } +); + +TasksTable.displayName = 'TasksTable'; diff --git a/x-pack/platform/plugins/shared/cases/public/components/tasks/translations.ts b/x-pack/platform/plugins/shared/cases/public/components/tasks/translations.ts new file mode 100644 index 0000000000000..0df1303a8c24b --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/public/components/tasks/translations.ts @@ -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 { i18n } from '@kbn/i18n'; + +export const TASKS_TAB_TITLE = i18n.translate('xpack.cases.caseView.tabs.tasks', { + defaultMessage: 'Tasks', +}); + +export const ADD_TASK = i18n.translate('xpack.cases.tasks.addTask', { + defaultMessage: 'Add task', +}); + +export const NO_TASKS = i18n.translate('xpack.cases.tasks.noTasks', { + defaultMessage: 'No tasks', +}); + +export const NO_TASKS_DESCRIPTION = i18n.translate('xpack.cases.tasks.noTasksDescription', { + defaultMessage: 'Add a task to track work items for this case.', +}); + +export const TASK_TITLE = i18n.translate('xpack.cases.tasks.columns.title', { + defaultMessage: 'Title', +}); + +export const TASK_STATUS = i18n.translate('xpack.cases.tasks.columns.status', { + defaultMessage: 'Status', +}); + +export const TASK_PRIORITY = i18n.translate('xpack.cases.tasks.columns.priority', { + defaultMessage: 'Priority', +}); + +export const TASK_ASSIGNEES = i18n.translate('xpack.cases.tasks.columns.assignees', { + defaultMessage: 'Assignees', +}); + +export const TASK_DUE_DATE = i18n.translate('xpack.cases.tasks.columns.dueDate', { + defaultMessage: 'Due date', +}); + +export const TASK_ACTIONS = i18n.translate('xpack.cases.tasks.columns.actions', { + defaultMessage: 'Actions', +}); + +export const DELETE_TASK = i18n.translate('xpack.cases.tasks.deleteTask', { + defaultMessage: 'Delete task', +}); + +export const CONFIRM_DELETE_TASK = i18n.translate('xpack.cases.tasks.confirmDelete', { + defaultMessage: 'Are you sure you want to delete this task?', +}); + +export const STATUS_OPEN = i18n.translate('xpack.cases.tasks.status.open', { + defaultMessage: 'Open', +}); + +export const STATUS_IN_PROGRESS = i18n.translate('xpack.cases.tasks.status.inProgress', { + defaultMessage: 'In progress', +}); + +export const STATUS_COMPLETED = i18n.translate('xpack.cases.tasks.status.completed', { + defaultMessage: 'Completed', +}); + +export const STATUS_CANCELLED = i18n.translate('xpack.cases.tasks.status.cancelled', { + defaultMessage: 'Cancelled', +}); + +export const PRIORITY_LOW = i18n.translate('xpack.cases.tasks.priority.low', { + defaultMessage: 'Low', +}); + +export const PRIORITY_MEDIUM = i18n.translate('xpack.cases.tasks.priority.medium', { + defaultMessage: 'Medium', +}); + +export const PRIORITY_HIGH = i18n.translate('xpack.cases.tasks.priority.high', { + defaultMessage: 'High', +}); + +export const PRIORITY_CRITICAL = i18n.translate('xpack.cases.tasks.priority.critical', { + defaultMessage: 'Critical', +}); diff --git a/x-pack/platform/plugins/shared/cases/public/containers/api.ts b/x-pack/platform/plugins/shared/cases/public/containers/api.ts index ebb035ccf0b00..ee6eff3a378d9 100644 --- a/x-pack/platform/plugins/shared/cases/public/containers/api.ts +++ b/x-pack/platform/plugins/shared/cases/public/containers/api.ts @@ -73,6 +73,13 @@ import { CASES_INTERNAL_URL, INTERNAL_CASE_GET_CASES_BY_ATTACHMENT_URL, } from '../../common/constants'; +import type { CaseTask } from '../../common/types/domain/task/v1'; +import { + getCaseTasksUrl, + getCaseTaskDetailsUrl, + getCaseTasksReorderUrl, + getCaseTasksApplyTemplateUrl, +} from '../../common/api'; import { getAllConnectorTypesUrl } from '../../common/utils/connectors_api'; import { KibanaServices } from '../common/lib/kibana'; @@ -700,3 +707,125 @@ export const getSimilarCases = async ({ return convertSimilarCasesToCamel(decodeCasesSimilarResponse(response)); }; + +// ---- Tasks ----------------------------------------------------------------- + +export interface CreateTaskRequest { + title: string; + description?: string; + status?: 'open' | 'in_progress' | 'completed' | 'cancelled'; + priority?: 'low' | 'medium' | 'high' | 'critical'; + assignees?: Array<{ uid: string }>; + due_date?: string | null; + parent_task_id?: string | null; + owner: string; +} + +export interface UpdateTaskRequest { + version: string; + title?: string; + description?: string; + status?: 'open' | 'in_progress' | 'completed' | 'cancelled'; + priority?: 'low' | 'medium' | 'high' | 'critical'; + assignees?: Array<{ uid: string }>; + due_date?: string | null; +} + +export interface FindTasksRequest { + status?: string; + priority?: string; + sort_field?: string; + sort_order?: 'asc' | 'desc'; + page?: number; + per_page?: number; +} + +export const getCaseTasks = async ( + caseId: string, + params?: FindTasksRequest, + signal?: AbortSignal +): Promise<{ tasks: CaseTask[]; total: number }> => { + const query = params + ? Object.fromEntries(Object.entries(params).filter(([, v]) => v !== undefined)) + : {}; + const response = await KibanaServices.get().http.fetch<{ tasks: CaseTask[]; total: number }>( + getCaseTasksUrl(caseId), + { + method: 'GET', + query, + signal, + } + ); + return response; +}; + +export const createTask = async ( + caseId: string, + request: CreateTaskRequest, + signal?: AbortSignal +): Promise => { + const response = await KibanaServices.get().http.fetch(getCaseTasksUrl(caseId), { + method: 'POST', + body: JSON.stringify(request), + signal, + }); + return response; +}; + +export const updateTask = async ( + caseId: string, + taskId: string, + request: UpdateTaskRequest, + signal?: AbortSignal +): Promise => { + const response = await KibanaServices.get().http.fetch( + getCaseTaskDetailsUrl(caseId, taskId), + { + method: 'PATCH', + body: JSON.stringify(request), + signal, + } + ); + return response; +}; + +export const deleteTask = async ( + caseId: string, + taskId: string, + signal?: AbortSignal +): Promise => { + await KibanaServices.get().http.fetch(getCaseTaskDetailsUrl(caseId, taskId), { + method: 'DELETE', + signal, + }); +}; + +export const reorderTasks = async ( + caseId: string, + orderedTaskIds: string[], + parentTaskId: string | null, + signal?: AbortSignal +): Promise => { + await KibanaServices.get().http.fetch(getCaseTasksReorderUrl(caseId), { + method: 'PUT', + body: JSON.stringify({ ordered_task_ids: orderedTaskIds, parent_task_id: parentTaskId }), + signal, + }); +}; + +export const applyTaskTemplate = async ( + caseId: string, + templateId: string, + owner: string, + signal?: AbortSignal +): Promise<{ tasks: CaseTask[] }> => { + const response = await KibanaServices.get().http.fetch<{ tasks: CaseTask[] }>( + getCaseTasksApplyTemplateUrl(caseId), + { + method: 'POST', + body: JSON.stringify({ template_id: templateId, owner }), + signal, + } + ); + return response; +}; diff --git a/x-pack/platform/plugins/shared/cases/public/containers/constants.ts b/x-pack/platform/plugins/shared/cases/public/containers/constants.ts index 8a6f72cc446bd..30ddbd809d6be 100644 --- a/x-pack/platform/plugins/shared/cases/public/containers/constants.ts +++ b/x-pack/platform/plugins/shared/cases/public/containers/constants.ts @@ -14,6 +14,9 @@ export const DEFAULT_TABLE_LIMIT = 10; export const casesQueriesKeys = { all: ['cases'] as const, + tasks: ['tasks'] as const, + tasksList: (caseId: string, params?: unknown) => + [...casesQueriesKeys.tasks, 'list', caseId, params] as const, users: ['users'] as const, connectors: ['connectors'] as const, alerts: ['alerts'] as const, @@ -84,6 +87,10 @@ export const casesMutationsKeys = { exportTemplate: ['export-template'] as const, bulkDeleteTemplates: ['bulk-delete-templates'] as const, bulkExportTemplates: ['bulk-export-templates'] as const, + createTask: ['create-task'] as const, + updateTask: ['update-task'] as const, + deleteTask: ['delete-task'] as const, + reorderTasks: ['reorder-tasks'] as const, }; export const inferenceKeys = { diff --git a/x-pack/platform/plugins/shared/cases/public/containers/translations.ts b/x-pack/platform/plugins/shared/cases/public/containers/translations.ts index 3455b570273b0..7bad32b4f58d1 100644 --- a/x-pack/platform/plugins/shared/cases/public/containers/translations.ts +++ b/x-pack/platform/plugins/shared/cases/public/containers/translations.ts @@ -100,3 +100,11 @@ export const OBSERVABLE_MAX_REACHED = (maxObservables: number) => defaultMessage: "You've reached the maximum number of observables {maxObservables} that can be added to a case. Some observables were not added.", }); + +export const TASK_CREATED = i18n.translate('xpack.cases.caseView.tasks.created', { + defaultMessage: 'Task created', +}); + +export const TASK_DELETED = i18n.translate('xpack.cases.caseView.tasks.deleted', { + defaultMessage: 'Task deleted', +}); diff --git a/x-pack/platform/plugins/shared/cases/public/containers/use_create_task.tsx b/x-pack/platform/plugins/shared/cases/public/containers/use_create_task.tsx new file mode 100644 index 0000000000000..8a5639ef19d4f --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/public/containers/use_create_task.tsx @@ -0,0 +1,35 @@ +/* + * 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 { useMutation, useQueryClient } from '@kbn/react-query'; +import type { CreateTaskRequest } from './api'; +import { createTask } from './api'; +import type { ServerError } from '../types'; +import { useCasesToast } from '../common/use_cases_toast'; +import { casesMutationsKeys, casesQueriesKeys } from './constants'; +import * as i18n from './translations'; + +export const useCreateTask = (caseId: string) => { + const { showErrorToast, showSuccessToast } = useCasesToast(); + const queryClient = useQueryClient(); + + return useMutation( + (request: CreateTaskRequest) => createTask(caseId, request), + { + mutationKey: casesMutationsKeys.createTask, + onError: (error: ServerError) => { + showErrorToast(error, { title: i18n.ERROR_TITLE }); + }, + onSuccess: () => { + queryClient.invalidateQueries(casesQueriesKeys.tasksList(caseId)); + showSuccessToast(i18n.TASK_CREATED); + }, + } + ); +}; + +export type UseCreateTask = ReturnType; diff --git a/x-pack/platform/plugins/shared/cases/public/containers/use_delete_task.tsx b/x-pack/platform/plugins/shared/cases/public/containers/use_delete_task.tsx new file mode 100644 index 0000000000000..1b04b49758e95 --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/public/containers/use_delete_task.tsx @@ -0,0 +1,34 @@ +/* + * 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 { useMutation, useQueryClient } from '@kbn/react-query'; +import { deleteTask } from './api'; +import type { ServerError } from '../types'; +import { useCasesToast } from '../common/use_cases_toast'; +import { casesMutationsKeys, casesQueriesKeys } from './constants'; +import * as i18n from './translations'; + +export const useDeleteTask = (caseId: string) => { + const { showErrorToast, showSuccessToast } = useCasesToast(); + const queryClient = useQueryClient(); + + return useMutation( + (taskId: string) => deleteTask(caseId, taskId), + { + mutationKey: casesMutationsKeys.deleteTask, + onError: (error: ServerError) => { + showErrorToast(error, { title: i18n.ERROR_TITLE }); + }, + onSuccess: () => { + queryClient.invalidateQueries(casesQueriesKeys.tasksList(caseId)); + showSuccessToast(i18n.TASK_DELETED); + }, + } + ); +}; + +export type UseDeleteTask = ReturnType; diff --git a/x-pack/platform/plugins/shared/cases/public/containers/use_get_tasks.tsx b/x-pack/platform/plugins/shared/cases/public/containers/use_get_tasks.tsx new file mode 100644 index 0000000000000..ded1474a01e82 --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/public/containers/use_get_tasks.tsx @@ -0,0 +1,24 @@ +/* + * 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 { useQuery } from '@kbn/react-query'; +import { getCaseTasks } from './api'; +import { casesQueriesKeys } from './constants'; +import type { FindTasksRequest } from './api'; + +export const useGetTasks = (caseId: string, params?: FindTasksRequest) => { + return useQuery( + casesQueriesKeys.tasksList(caseId, params), + ({ signal }) => getCaseTasks(caseId, params, signal), + { + enabled: Boolean(caseId), + keepPreviousData: true, + } + ); +}; + +export type UseGetTasks = ReturnType; diff --git a/x-pack/platform/plugins/shared/cases/public/containers/use_update_task.tsx b/x-pack/platform/plugins/shared/cases/public/containers/use_update_task.tsx new file mode 100644 index 0000000000000..9b5f7ec595000 --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/public/containers/use_update_task.tsx @@ -0,0 +1,35 @@ +/* + * 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 { useMutation, useQueryClient } from '@kbn/react-query'; +import type { UpdateTaskRequest } from './api'; +import { updateTask } from './api'; +import type { ServerError } from '../types'; +import { useCasesToast } from '../common/use_cases_toast'; +import { casesMutationsKeys, casesQueriesKeys } from './constants'; +import * as i18n from './translations'; + +export const useUpdateTask = (caseId: string) => { + const { showErrorToast } = useCasesToast(); + const queryClient = useQueryClient(); + + return useMutation( + ({ taskId, request }: { taskId: string; request: UpdateTaskRequest }) => + updateTask(caseId, taskId, request), + { + mutationKey: casesMutationsKeys.updateTask, + onError: (error: ServerError) => { + showErrorToast(error, { title: i18n.ERROR_TITLE }); + }, + onSuccess: () => { + queryClient.invalidateQueries(casesQueriesKeys.tasksList(caseId)); + }, + } + ); +}; + +export type UseUpdateTask = ReturnType; From 2cefb5b7e94320fbf2357a3e5608db62fe4c7c89 Mon Sep 17 00:00:00 2001 From: Michael Olorunnisola Date: Sun, 15 Mar 2026 21:57:58 -0400 Subject: [PATCH 11/22] [Cases] PR 7: Task user action builders for activity timeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promotes task-related user action types from UNSUPPORTED to the activity timeline by adding proper UserActionBuilder implementations. - Removes create_task, update_task, delete_task, apply_task_template from UNSUPPORTED_ACTION_TYPES (now only delete_case remains there) - Creates tasks.tsx with four UserActionBuilder factories: - createCreateTaskUserActionBuilder — "created task 'X'" - createUpdateTaskUserActionBuilder — "updated task 'X'" - createDeleteTaskUserActionBuilder — "deleted task 'X'" - createApplyTaskTemplateUserActionBuilder — "applied a task template and created N task(s)" - Registers all four builders in builderMap (builder.tsx) - Adds CREATED_TASK, UPDATED_TASK, DELETED_TASK, APPLIED_TASK_TEMPLATE translation functions to translations.ts Co-Authored-By: Claude Sonnet 4.6 --- .../components/user_actions/builder.tsx | 10 ++ .../components/user_actions/constants.ts | 8 +- .../public/components/user_actions/tasks.tsx | 127 ++++++++++++++++++ .../components/user_actions/translations.ts | 24 ++++ 4 files changed, 162 insertions(+), 7 deletions(-) create mode 100644 x-pack/platform/plugins/shared/cases/public/components/user_actions/tasks.tsx diff --git a/x-pack/platform/plugins/shared/cases/public/components/user_actions/builder.tsx b/x-pack/platform/plugins/shared/cases/public/components/user_actions/builder.tsx index 197f0f52e315a..eeed744b8b23e 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/user_actions/builder.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/user_actions/builder.tsx @@ -20,6 +20,12 @@ import type { UserActionBuilderMap } from './types'; import { createCategoryUserActionBuilder } from './category'; import { createCustomFieldsUserActionBuilder } from './custom_fields/custom_fields'; import { createObservablesUserActionBuilder } from './observables'; +import { + createCreateTaskUserActionBuilder, + createUpdateTaskUserActionBuilder, + createDeleteTaskUserActionBuilder, + createApplyTaskTemplateUserActionBuilder, +} from './tasks'; export const builderMap: UserActionBuilderMap = { create_case: createCaseUserActionBuilder, @@ -36,4 +42,8 @@ export const builderMap: UserActionBuilderMap = { category: createCategoryUserActionBuilder, customFields: createCustomFieldsUserActionBuilder, observables: createObservablesUserActionBuilder, + create_task: createCreateTaskUserActionBuilder, + update_task: createUpdateTaskUserActionBuilder, + delete_task: createDeleteTaskUserActionBuilder, + apply_task_template: createApplyTaskTemplateUserActionBuilder, }; diff --git a/x-pack/platform/plugins/shared/cases/public/components/user_actions/constants.ts b/x-pack/platform/plugins/shared/cases/public/components/user_actions/constants.ts index 999ed8e1402fb..79569e7a35744 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/user_actions/constants.ts +++ b/x-pack/platform/plugins/shared/cases/public/components/user_actions/constants.ts @@ -11,13 +11,7 @@ import type { SupportedUserActionTypes } from './types'; export const DRAFT_COMMENT_STORAGE_ID = 'xpack.cases.commentDraft'; -export const UNSUPPORTED_ACTION_TYPES = [ - 'delete_case', - 'create_task', - 'update_task', - 'delete_task', - 'apply_task_template', -] as const; +export const UNSUPPORTED_ACTION_TYPES = ['delete_case'] as const; export const SUPPORTED_ACTION_TYPES: SupportedUserActionTypes[] = Object.keys( omit(UserActionTypes, UNSUPPORTED_ACTION_TYPES) ) as SupportedUserActionTypes[]; diff --git a/x-pack/platform/plugins/shared/cases/public/components/user_actions/tasks.tsx b/x-pack/platform/plugins/shared/cases/public/components/user_actions/tasks.tsx new file mode 100644 index 0000000000000..5b31745d585ed --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/public/components/user_actions/tasks.tsx @@ -0,0 +1,127 @@ +/* + * 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 { EuiText } from '@elastic/eui'; +import type { SnakeToCamelCase } from '../../../common/types'; +import type { + CreateTaskUserAction, + UpdateTaskUserAction, + DeleteTaskUserAction, + ApplyTaskTemplateUserAction, +} from '../../../common/types/domain'; +import type { UserActionBuilder } from './types'; +import { createCommonUpdateUserActionBuilder } from './common'; +import * as i18n from './translations'; + +export const createCreateTaskUserActionBuilder: UserActionBuilder = ({ + userAction, + userProfiles, + handleOutlineComment, +}) => ({ + build: () => { + const action = userAction as SnakeToCamelCase; + const taskTitle = action?.payload?.task?.title ?? ''; + + const label = ( + + {i18n.CREATED_TASK(taskTitle)} + + ); + + const commonBuilder = createCommonUpdateUserActionBuilder({ + userProfiles, + userAction, + handleOutlineComment, + label, + icon: 'checkInCircleFilled', + }); + + return commonBuilder.build(); + }, +}); + +export const createUpdateTaskUserActionBuilder: UserActionBuilder = ({ + userAction, + userProfiles, + handleOutlineComment, +}) => ({ + build: () => { + const action = userAction as SnakeToCamelCase; + const taskTitle = action?.payload?.taskTitle ?? ''; + + const label = ( + + {i18n.UPDATED_TASK(taskTitle)} + + ); + + const commonBuilder = createCommonUpdateUserActionBuilder({ + userProfiles, + userAction, + handleOutlineComment, + label, + icon: 'dot', + }); + + return commonBuilder.build(); + }, +}); + +export const createDeleteTaskUserActionBuilder: UserActionBuilder = ({ + userAction, + userProfiles, + handleOutlineComment, +}) => ({ + build: () => { + const action = userAction as SnakeToCamelCase; + const taskTitle = action?.payload?.taskTitle ?? ''; + + const label = ( + + {i18n.DELETED_TASK(taskTitle)} + + ); + + const commonBuilder = createCommonUpdateUserActionBuilder({ + userProfiles, + userAction, + handleOutlineComment, + label, + icon: 'minusInCircle', + }); + + return commonBuilder.build(); + }, +}); + +export const createApplyTaskTemplateUserActionBuilder: UserActionBuilder = ({ + userAction, + userProfiles, + handleOutlineComment, +}) => ({ + build: () => { + const action = userAction as SnakeToCamelCase; + const tasksCreated = action?.payload?.tasksCreated ?? 0; + + const label = ( + + {i18n.APPLIED_TASK_TEMPLATE(tasksCreated)} + + ); + + const commonBuilder = createCommonUpdateUserActionBuilder({ + userProfiles, + userAction, + handleOutlineComment, + label, + icon: 'checkInCircleFilled', + }); + + return commonBuilder.build(); + }, +}); diff --git a/x-pack/platform/plugins/shared/cases/public/components/user_actions/translations.ts b/x-pack/platform/plugins/shared/cases/public/components/user_actions/translations.ts index 5d6702cfbdc56..40c4fe6f9e1f0 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/user_actions/translations.ts +++ b/x-pack/platform/plugins/shared/cases/public/components/user_actions/translations.ts @@ -163,6 +163,30 @@ export const TASK_TEMPLATE = i18n.translate('xpack.cases.caseView.userActions.ta defaultMessage: 'Task Template', }); +export const CREATED_TASK = (taskTitle: string) => + i18n.translate('xpack.cases.caseView.userActions.createdTask', { + values: { taskTitle }, + defaultMessage: 'created task "{taskTitle}"', + }); + +export const UPDATED_TASK = (taskTitle: string) => + i18n.translate('xpack.cases.caseView.userActions.updatedTask', { + values: { taskTitle }, + defaultMessage: 'updated task "{taskTitle}"', + }); + +export const DELETED_TASK = (taskTitle: string) => + i18n.translate('xpack.cases.caseView.userActions.deletedTask', { + values: { taskTitle }, + defaultMessage: 'deleted task "{taskTitle}"', + }); + +export const APPLIED_TASK_TEMPLATE = (tasksCreated: number) => + i18n.translate('xpack.cases.caseView.userActions.appliedTaskTemplate', { + values: { tasksCreated }, + defaultMessage: 'applied a task template and created {tasksCreated} task(s)', + }); + export const USER_ACTION_EDITED = (type: string) => i18n.translate('xpack.cases.caseView.userActions.edited', { values: { type }, From 1a3651de51d1c075d0bc4a5bced19ab451c919b6 Mon Sep 17 00:00:00 2001 From: Michael Olorunnisola Date: Sun, 15 Mar 2026 21:58:52 -0400 Subject: [PATCH 12/22] [Cases] Fix: set importableAndExportable: true on task SO types The SavedObjects management API requires importableAndExportable to be true when visibleInManagement is specified. Updated both cases-tasks and cases-task-templates SO type definitions. Co-Authored-By: Claude Sonnet 4.6 --- .../cases/server/saved_object_types/task_templates/index.ts | 2 +- .../shared/cases/server/saved_object_types/tasks/index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/platform/plugins/shared/cases/server/saved_object_types/task_templates/index.ts b/x-pack/platform/plugins/shared/cases/server/saved_object_types/task_templates/index.ts index 42f1ad638341a..cb6aba3d8e684 100644 --- a/x-pack/platform/plugins/shared/cases/server/saved_object_types/task_templates/index.ts +++ b/x-pack/platform/plugins/shared/cases/server/saved_object_types/task_templates/index.ts @@ -50,7 +50,7 @@ export const caseTaskTemplateSavedObjectType: SavedObjectsType = { }, }, management: { - importableAndExportable: false, + importableAndExportable: true, visibleInManagement: false, }, }; diff --git a/x-pack/platform/plugins/shared/cases/server/saved_object_types/tasks/index.ts b/x-pack/platform/plugins/shared/cases/server/saved_object_types/tasks/index.ts index 80c589589b9fd..7cdd649dbdc55 100644 --- a/x-pack/platform/plugins/shared/cases/server/saved_object_types/tasks/index.ts +++ b/x-pack/platform/plugins/shared/cases/server/saved_object_types/tasks/index.ts @@ -78,7 +78,7 @@ export const caseTaskSavedObjectType: SavedObjectsType = { }, }, management: { - importableAndExportable: false, + importableAndExportable: true, visibleInManagement: false, }, }; From b90d1f18d69b336d12eca6a6505dbbc5cb6cbcb1 Mon Sep 17 00:00:00 2001 From: Michael Olorunnisola Date: Sun, 15 Mar 2026 22:02:38 -0400 Subject: [PATCH 13/22] [Cases] PR 8: Add task telemetry collection Adds tasks and task templates to the Cases telemetry schema: - New CasesTelemetry.tasks field tracking total task count by status and template count - New getTasksTelemetryData() query in server/telemetry/queries/tasks.ts - Updated schema.ts and collect_telemetry_data.ts to include tasks Co-Authored-By: Claude Sonnet 4.6 --- .../telemetry/collect_telemetry_data.ts | 4 ++ .../cases/server/telemetry/queries/tasks.ts | 60 +++++++++++++++++++ .../shared/cases/server/telemetry/schema.ts | 14 +++++ .../shared/cases/server/telemetry/types.ts | 14 +++++ 4 files changed, 92 insertions(+) create mode 100644 x-pack/platform/plugins/shared/cases/server/telemetry/queries/tasks.ts diff --git a/x-pack/platform/plugins/shared/cases/server/telemetry/collect_telemetry_data.ts b/x-pack/platform/plugins/shared/cases/server/telemetry/collect_telemetry_data.ts index 1bcf599f014fb..03fb36efe4769 100644 --- a/x-pack/platform/plugins/shared/cases/server/telemetry/collect_telemetry_data.ts +++ b/x-pack/platform/plugins/shared/cases/server/telemetry/collect_telemetry_data.ts @@ -13,6 +13,7 @@ import { getConfigurationTelemetryData } from './queries/configuration'; import { getConnectorsTelemetryData } from './queries/connectors'; import { getPushedTelemetryData } from './queries/push'; import { getUserActionsTelemetryData } from './queries/user_actions'; +import { getTasksTelemetryData } from './queries/tasks'; import type { CasesTelemetry, CollectTelemetryDataParams } from './types'; export const collectTelemetryData = async ({ @@ -29,6 +30,7 @@ export const collectTelemetryData = async ({ pushes, configuration, casesSystemAction, + tasks, ] = await Promise.all([ getCasesTelemetryData({ savedObjectsClient, logger }), getUserActionsTelemetryData({ savedObjectsClient, logger }), @@ -38,6 +40,7 @@ export const collectTelemetryData = async ({ getPushedTelemetryData({ savedObjectsClient, logger }), getConfigurationTelemetryData({ savedObjectsClient, logger }), getCasesSystemActionData({ savedObjectsClient, logger }), + getTasksTelemetryData({ savedObjectsClient, logger }), ]); return { @@ -49,6 +52,7 @@ export const collectTelemetryData = async ({ pushes, configuration, casesSystemAction, + tasks, }; } catch (err) { logger.debug('Failed collecting Cases telemetry data'); diff --git a/x-pack/platform/plugins/shared/cases/server/telemetry/queries/tasks.ts b/x-pack/platform/plugins/shared/cases/server/telemetry/queries/tasks.ts new file mode 100644 index 0000000000000..c9fdc1396b76c --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/server/telemetry/queries/tasks.ts @@ -0,0 +1,60 @@ +/* + * 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 { CASE_TASK_SAVED_OBJECT, CASE_TASK_TEMPLATE_SAVED_OBJECT } from '../../../common/constants'; +import type { CasesTelemetry, CollectTelemetryDataParams } from '../types'; +import type { Buckets } from '../types'; + +interface TaskStatusAggregation { + byStatus: Buckets; +} + +export const getTasksTelemetryData = async ({ + savedObjectsClient, +}: CollectTelemetryDataParams): Promise => { + const [taskRes, templateRes] = await Promise.all([ + savedObjectsClient.find({ + page: 0, + perPage: 0, + type: CASE_TASK_SAVED_OBJECT, + namespaces: ['*'], + aggs: { + byStatus: { + terms: { + field: `${CASE_TASK_SAVED_OBJECT}.attributes.status`, + size: 10, + }, + }, + }, + }), + savedObjectsClient.find({ + page: 0, + perPage: 0, + type: CASE_TASK_TEMPLATE_SAVED_OBJECT, + namespaces: ['*'], + }), + ]); + + const buckets = taskRes.aggregations?.byStatus?.buckets ?? []; + const findCount = (key: string) => + buckets.find((b) => b.key === key)?.doc_count ?? 0; + + return { + all: { + total: taskRes.total, + byStatus: { + open: findCount('open'), + inProgress: findCount('inProgress'), + completed: findCount('completed'), + cancelled: findCount('cancelled'), + }, + }, + templates: { + total: templateRes.total, + }, + }; +}; diff --git a/x-pack/platform/plugins/shared/cases/server/telemetry/schema.ts b/x-pack/platform/plugins/shared/cases/server/telemetry/schema.ts index 37fc6967f136d..8b5354fa2553b 100644 --- a/x-pack/platform/plugins/shared/cases/server/telemetry/schema.ts +++ b/x-pack/platform/plugins/shared/cases/server/telemetry/schema.ts @@ -202,4 +202,18 @@ export const casesSchema: CasesTelemetrySchema = { totalCasesCreated: long, totalRules: long, }, + tasks: { + all: { + total: long, + byStatus: { + open: long, + inProgress: long, + completed: long, + cancelled: long, + }, + }, + templates: { + total: long, + }, + }, }; diff --git a/x-pack/platform/plugins/shared/cases/server/telemetry/types.ts b/x-pack/platform/plugins/shared/cases/server/telemetry/types.ts index 40fa4e84c83f6..282be3e301219 100644 --- a/x-pack/platform/plugins/shared/cases/server/telemetry/types.ts +++ b/x-pack/platform/plugins/shared/cases/server/telemetry/types.ts @@ -280,6 +280,20 @@ export interface CasesTelemetry { totalCasesCreated: number; totalRules: number; }; + tasks: { + all: { + total: number; + byStatus: { + open: number; + inProgress: number; + completed: number; + cancelled: number; + }; + }; + templates: { + total: number; + }; + }; } export type CountSchema = MakeSchemaFrom; From 7d015eef248df8ac87ecd4405cdac731fdc2ee22 Mon Sep 17 00:00:00 2001 From: Michael Olorunnisola Date: Sun, 15 Mar 2026 22:07:08 -0400 Subject: [PATCH 14/22] [Cases] Add task creation flyout Adds an AddTaskFlyout component that lets users create tasks directly from the Tasks tab on the case view: - Title (required), description, status, priority, and due date fields - Inline validation for the required title field - Wires the existing onAddTask callback in CaseViewTasks to open/close the flyout; Add Task button now visible in both empty and populated states Co-Authored-By: Claude Sonnet 4.6 --- .../components/tasks/add_task_flyout.tsx | 190 ++++++++++++++++++ .../components/tasks/case_view_tasks.tsx | 29 ++- .../public/components/tasks/translations.ts | 30 +++ 3 files changed, 239 insertions(+), 10 deletions(-) create mode 100644 x-pack/platform/plugins/shared/cases/public/components/tasks/add_task_flyout.tsx diff --git a/x-pack/platform/plugins/shared/cases/public/components/tasks/add_task_flyout.tsx b/x-pack/platform/plugins/shared/cases/public/components/tasks/add_task_flyout.tsx new file mode 100644 index 0000000000000..d4cc0be5f31be --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/public/components/tasks/add_task_flyout.tsx @@ -0,0 +1,190 @@ +/* + * 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, { useCallback, useState } from 'react'; +import { + EuiButton, + EuiButtonEmpty, + EuiDatePicker, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiForm, + EuiFormRow, + EuiSelect, + EuiTextArea, + EuiTitle, +} from '@elastic/eui'; +import moment from 'moment'; +import type { CreateTaskRequest } from '../../containers/api'; +import { useCreateTask } from '../../containers/use_create_task'; +import { useCasesContext } from '../cases_context/use_cases_context'; +import * as i18n from './translations'; + +interface AddTaskFlyoutProps { + caseId: string; + onClose: () => void; +} + +const PRIORITY_OPTIONS = [ + { value: 'low', text: i18n.PRIORITY_LOW }, + { value: 'medium', text: i18n.PRIORITY_MEDIUM }, + { value: 'high', text: i18n.PRIORITY_HIGH }, + { value: 'critical', text: i18n.PRIORITY_CRITICAL }, +]; + +const STATUS_OPTIONS = [ + { value: 'open', text: i18n.STATUS_OPEN }, + { value: 'in_progress', text: i18n.STATUS_IN_PROGRESS }, + { value: 'completed', text: i18n.STATUS_COMPLETED }, + { value: 'cancelled', text: i18n.STATUS_CANCELLED }, +]; + +const AddTaskFlyoutComponent: React.FC = ({ caseId, onClose }) => { + const { mutate: createTask, isLoading } = useCreateTask(caseId); + const { owner } = useCasesContext(); + + const [title, setTitle] = useState(''); + const [description, setDescription] = useState(''); + const [priority, setPriority] = useState('medium'); + const [status, setStatus] = useState('open'); + const [dueDate, setDueDate] = useState(null); + const [titleError, setTitleError] = useState(null); + + const handleSubmit = useCallback(() => { + if (!title.trim()) { + setTitleError(i18n.TASK_TITLE_REQUIRED); + return; + } + + const request: CreateTaskRequest = { + title: title.trim(), + ...(description.trim() && { description: description.trim() }), + priority, + status, + ...(dueDate && { due_date: dueDate.toISOString() }), + owner: owner[0], + }; + + createTask(request, { onSuccess: onClose }); + }, [title, description, priority, status, dueDate, createTask, onClose]); + + return ( + + + +

+ {i18n.ADD_TASK} +

+
+
+ + + + + { + setTitle(e.target.value); + if (e.target.value.trim()) setTitleError(null); + }} + placeholder={i18n.TASK_TITLE_PLACEHOLDER} + fullWidth + data-test-subj="cases-add-task-title" + autoFocus + /> + + + + setDescription(e.target.value)} + placeholder={i18n.TASK_DESCRIPTION_PLACEHOLDER} + fullWidth + rows={4} + data-test-subj="cases-add-task-description" + /> + + + + setStatus(e.target.value as CreateTaskRequest['status'])} + fullWidth + data-test-subj="cases-add-task-status" + /> + + + + setPriority(e.target.value as CreateTaskRequest['priority'])} + fullWidth + data-test-subj="cases-add-task-priority" + /> + + + + + + + + + + + + + {i18n.CANCEL} + + + + + {i18n.ADD_TASK} + + + + +
+ ); +}; + +AddTaskFlyoutComponent.displayName = 'AddTaskFlyout'; + +export const AddTaskFlyout = React.memo(AddTaskFlyoutComponent); diff --git a/x-pack/platform/plugins/shared/cases/public/components/tasks/case_view_tasks.tsx b/x-pack/platform/plugins/shared/cases/public/components/tasks/case_view_tasks.tsx index 03e8f927668d3..ced4231c56742 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/tasks/case_view_tasks.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/tasks/case_view_tasks.tsx @@ -5,10 +5,11 @@ * 2.0. */ -import React from 'react'; +import React, { useState } from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { useGetTasks } from '../../containers/use_get_tasks'; import { TasksTable } from './tasks_table'; +import { AddTaskFlyout } from './add_task_flyout'; interface CaseViewTasksProps { caseId: string; @@ -16,17 +17,25 @@ interface CaseViewTasksProps { export const CaseViewTasks = React.memo(({ caseId }) => { const { data, isLoading } = useGetTasks(caseId); + const [isFlyoutOpen, setIsFlyoutOpen] = useState(false); return ( - - - - - + <> + + + setIsFlyoutOpen(true)} + /> + + + + {isFlyoutOpen && ( + setIsFlyoutOpen(false)} /> + )} + ); }); diff --git a/x-pack/platform/plugins/shared/cases/public/components/tasks/translations.ts b/x-pack/platform/plugins/shared/cases/public/components/tasks/translations.ts index 0df1303a8c24b..7a197a2b35eb7 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/tasks/translations.ts +++ b/x-pack/platform/plugins/shared/cases/public/components/tasks/translations.ts @@ -86,3 +86,33 @@ export const PRIORITY_HIGH = i18n.translate('xpack.cases.tasks.priority.high', { export const PRIORITY_CRITICAL = i18n.translate('xpack.cases.tasks.priority.critical', { defaultMessage: 'Critical', }); + +export const CANCEL = i18n.translate('xpack.cases.tasks.cancel', { + defaultMessage: 'Cancel', +}); + +export const TASK_DESCRIPTION = i18n.translate('xpack.cases.tasks.fields.description', { + defaultMessage: 'Description', +}); + +export const TASK_TITLE_PLACEHOLDER = i18n.translate('xpack.cases.tasks.fields.titlePlaceholder', { + defaultMessage: 'Enter a task title', +}); + +export const TASK_DESCRIPTION_PLACEHOLDER = i18n.translate( + 'xpack.cases.tasks.fields.descriptionPlaceholder', + { + defaultMessage: 'Describe the task (optional)', + } +); + +export const TASK_DUE_DATE_PLACEHOLDER = i18n.translate( + 'xpack.cases.tasks.fields.dueDatePlaceholder', + { + defaultMessage: 'Select a due date', + } +); + +export const TASK_TITLE_REQUIRED = i18n.translate('xpack.cases.tasks.fields.titleRequired', { + defaultMessage: 'A title is required.', +}); From 08c0c1ba7543e4b740aae4c3304825e6185efe8a Mon Sep 17 00:00:00 2001 From: Michael Olorunnisola Date: Mon, 16 Mar 2026 05:25:21 -0400 Subject: [PATCH 15/22] [Cases] Fix tasks page load: add missing GET route, disable retries, enable feature by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes for the slow/broken tasks tab initial load: 1. Add GET /api/cases/{case_id}/tasks route (get_case_tasks_route.ts). The route was missing — only POST existed for that URL, causing the UI's getCaseTasks() fetch to receive a 404/405. 2. Set retry: false and staleTime: 30s in useGetTasks. React Query's default of 3 retries with exponential backoff turned the 404 into ~7 seconds of apparent loading. staleTime avoids unnecessary refetches when switching tabs. 3. Change tasks.enabled default to true so the cases-tasks and cases-task-templates saved object types are registered automatically, fixing the 'Unsupported saved object type: cases-tasks' error on task creation. Co-Authored-By: Claude Sonnet 4.6 --- .../cases/public/containers/use_get_tasks.tsx | 2 + .../plugins/shared/cases/server/config.ts | 2 +- .../routes/api/tasks/get_case_tasks_route.ts | 96 +++++++++++++++++++ .../cases/server/routes/api/tasks/index.ts | 2 + 4 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 x-pack/platform/plugins/shared/cases/server/routes/api/tasks/get_case_tasks_route.ts diff --git a/x-pack/platform/plugins/shared/cases/public/containers/use_get_tasks.tsx b/x-pack/platform/plugins/shared/cases/public/containers/use_get_tasks.tsx index ded1474a01e82..abc372b539035 100644 --- a/x-pack/platform/plugins/shared/cases/public/containers/use_get_tasks.tsx +++ b/x-pack/platform/plugins/shared/cases/public/containers/use_get_tasks.tsx @@ -17,6 +17,8 @@ export const useGetTasks = (caseId: string, params?: FindTasksRequest) => { { enabled: Boolean(caseId), keepPreviousData: true, + retry: false, + staleTime: 30_000, } ); }; diff --git a/x-pack/platform/plugins/shared/cases/server/config.ts b/x-pack/platform/plugins/shared/cases/server/config.ts index 4b30fd82c6b39..902173c796963 100644 --- a/x-pack/platform/plugins/shared/cases/server/config.ts +++ b/x-pack/platform/plugins/shared/cases/server/config.ts @@ -64,7 +64,7 @@ export const ConfigSchema = schema.object({ enabled: schema.boolean({ defaultValue: false }), }), tasks: schema.object({ - enabled: schema.boolean({ defaultValue: false }), + enabled: schema.boolean({ defaultValue: true }), }), enabled: schema.boolean({ defaultValue: true }), }); diff --git a/x-pack/platform/plugins/shared/cases/server/routes/api/tasks/get_case_tasks_route.ts b/x-pack/platform/plugins/shared/cases/server/routes/api/tasks/get_case_tasks_route.ts new file mode 100644 index 0000000000000..6b0312e5756b1 --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/server/routes/api/tasks/get_case_tasks_route.ts @@ -0,0 +1,96 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { CASE_TASKS_URL } from '../../../../common/constants'; +import { createCaseError } from '../../../common/error'; +import { createCasesRoute } from '../create_cases_route'; +import { DEFAULT_CASES_ROUTE_SECURITY } from '../constants'; + +/** + * GET /api/cases/{case_id}/tasks + * List all tasks for a specific case + */ +export const getCaseTasksRoute = createCasesRoute({ + method: 'get', + path: CASE_TASKS_URL, + security: DEFAULT_CASES_ROUTE_SECURITY, + routerOptions: { + access: 'internal', + summary: 'List case tasks', + }, + params: { + params: schema.object({ + case_id: schema.string(), + }), + query: schema.object({ + status: schema.maybe( + schema.oneOf([ + schema.literal('open'), + schema.literal('in_progress'), + schema.literal('completed'), + schema.literal('cancelled'), + ]) + ), + priority: schema.maybe( + schema.oneOf([ + schema.literal('low'), + schema.literal('medium'), + schema.literal('high'), + schema.literal('critical'), + ]) + ), + sort_field: schema.maybe( + schema.oneOf([ + schema.literal('created_at'), + schema.literal('due_date'), + schema.literal('priority'), + schema.literal('sort_order'), + schema.literal('status'), + ]) + ), + sort_order: schema.maybe( + schema.oneOf([schema.literal('asc'), schema.literal('desc')]) + ), + page: schema.maybe(schema.number({ min: 1 })), + per_page: schema.maybe(schema.number({ min: 1, max: 100 })), + }), + }, + handler: async ({ context, request, response }) => { + try { + const caseContext = await context.cases; + const casesClient = await caseContext.getCasesClient(); + + const { case_id: caseId } = request.params; + const { status, priority, sort_field, sort_order, page, per_page } = request.query as { + status?: 'open' | 'in_progress' | 'completed' | 'cancelled'; + priority?: 'low' | 'medium' | 'high' | 'critical'; + sort_field?: 'created_at' | 'due_date' | 'priority' | 'sort_order' | 'status'; + sort_order?: 'asc' | 'desc'; + page?: number; + per_page?: number; + }; + + const result = await casesClient.tasks.find({ + caseIds: [caseId], + status, + priority, + sort_field, + sort_order, + page, + per_page, + }); + + return response.ok({ body: result }); + } catch (error) { + throw createCaseError({ + message: `Failed to get tasks for case: ${error}`, + error, + }); + } + }, +}); diff --git a/x-pack/platform/plugins/shared/cases/server/routes/api/tasks/index.ts b/x-pack/platform/plugins/shared/cases/server/routes/api/tasks/index.ts index 7382ff851e48d..02665033117ce 100644 --- a/x-pack/platform/plugins/shared/cases/server/routes/api/tasks/index.ts +++ b/x-pack/platform/plugins/shared/cases/server/routes/api/tasks/index.ts @@ -7,6 +7,7 @@ import type { ConfigType } from '../../../config'; import { postTaskRoute } from './post_task_route'; +import { getCaseTasksRoute } from './get_case_tasks_route'; import { getTaskRoute } from './get_task_route'; import { findTasksRoute } from './find_tasks_route'; import { patchTaskRoute } from './patch_task_route'; @@ -25,6 +26,7 @@ export const getTaskRoutes = (config: ConfigType) => { return [ postTaskRoute, + getCaseTasksRoute, getTaskRoute, findTasksRoute, patchTaskRoute, From 1bc358fc5b25219a96bd5412af87dd5e1416aa61 Mon Sep 17 00:00:00 2001 From: Michael Olorunnisola Date: Mon, 16 Mar 2026 07:10:13 -0400 Subject: [PATCH 16/22] [Cases] Fix 'Unsupported saved object type: cases-tasks' across all code paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three places could still throw this error even after the default change: 1. getSavedObjectsTypes (common/utils/saved_object_types.ts): task SO types were never included, so internal SO repositories created for telemetry and other internal operations could not access them. Now includes CASE_TASK_SAVED_OBJECT and CASE_TASK_TEMPLATE_SAVED_OBJECT when tasks.enabled !== false. 2. createCasesTelemetry (server/telemetry/index.ts): the internal repo was built without tasksConfig, so even with tasks enabled the repo lacked access. Now threads tasksConfig through from the plugin. 3. collectTelemetryData (server/telemetry/collect_telemetry_data.ts): getTasksTelemetryData was called unconditionally — if tasks was disabled the SO types were unregistered and the whole telemetry collection returned {}. Now skipped when tasksEnabled is false. Co-Authored-By: Claude Sonnet 4.6 --- .../shared/cases/common/utils/saved_object_types.ts | 9 +++++++++ x-pack/platform/plugins/shared/cases/server/plugin.ts | 1 + .../cases/server/telemetry/collect_telemetry_data.ts | 5 +++-- .../plugins/shared/cases/server/telemetry/index.ts | 10 ++++++++-- .../plugins/shared/cases/server/telemetry/types.ts | 1 + 5 files changed, 22 insertions(+), 4 deletions(-) diff --git a/x-pack/platform/plugins/shared/cases/common/utils/saved_object_types.ts b/x-pack/platform/plugins/shared/cases/common/utils/saved_object_types.ts index 3aeaf289bf361..f8f30887c223c 100644 --- a/x-pack/platform/plugins/shared/cases/common/utils/saved_object_types.ts +++ b/x-pack/platform/plugins/shared/cases/common/utils/saved_object_types.ts @@ -13,6 +13,8 @@ import { CASE_CONFIGURE_SAVED_OBJECT, CASE_TEMPLATE_SAVED_OBJECT, CASE_ATTACHMENT_SAVED_OBJECT, + CASE_TASK_SAVED_OBJECT, + CASE_TASK_TEMPLATE_SAVED_OBJECT, } from '../constants'; interface CasesConfigType { @@ -22,6 +24,9 @@ interface CasesConfigType { attachments?: { enabled?: boolean; }; + tasks?: { + enabled?: boolean; + }; } /** @@ -46,5 +51,9 @@ export const getSavedObjectsTypes = (config?: Partial): string[ experimentalSOs.push(CASE_ATTACHMENT_SAVED_OBJECT); } + if (config?.tasks?.enabled !== false) { + experimentalSOs.push(CASE_TASK_SAVED_OBJECT, CASE_TASK_TEMPLATE_SAVED_OBJECT); + } + return [...baseSavedObjects, ...experimentalSOs]; }; diff --git a/x-pack/platform/plugins/shared/cases/server/plugin.ts b/x-pack/platform/plugins/shared/cases/server/plugin.ts index 63f7d6305d0c1..2ca4d57931fad 100644 --- a/x-pack/platform/plugins/shared/cases/server/plugin.ts +++ b/x-pack/platform/plugins/shared/cases/server/plugin.ts @@ -152,6 +152,7 @@ export class CasePlugin logger: this.logger, kibanaVersion: this.kibanaVersion, templatesConfig: this.caseConfig.templates, + tasksConfig: this.caseConfig.tasks, }); } diff --git a/x-pack/platform/plugins/shared/cases/server/telemetry/collect_telemetry_data.ts b/x-pack/platform/plugins/shared/cases/server/telemetry/collect_telemetry_data.ts index 03fb36efe4769..e8745c6371c73 100644 --- a/x-pack/platform/plugins/shared/cases/server/telemetry/collect_telemetry_data.ts +++ b/x-pack/platform/plugins/shared/cases/server/telemetry/collect_telemetry_data.ts @@ -19,6 +19,7 @@ import type { CasesTelemetry, CollectTelemetryDataParams } from './types'; export const collectTelemetryData = async ({ savedObjectsClient, logger, + tasksEnabled = true, }: CollectTelemetryDataParams): Promise> => { try { const [ @@ -40,7 +41,7 @@ export const collectTelemetryData = async ({ getPushedTelemetryData({ savedObjectsClient, logger }), getConfigurationTelemetryData({ savedObjectsClient, logger }), getCasesSystemActionData({ savedObjectsClient, logger }), - getTasksTelemetryData({ savedObjectsClient, logger }), + tasksEnabled ? getTasksTelemetryData({ savedObjectsClient, logger }) : undefined, ]); return { @@ -52,7 +53,7 @@ export const collectTelemetryData = async ({ pushes, configuration, casesSystemAction, - tasks, + ...(tasks !== undefined && { tasks }), }; } catch (err) { logger.debug('Failed collecting Cases telemetry data'); diff --git a/x-pack/platform/plugins/shared/cases/server/telemetry/index.ts b/x-pack/platform/plugins/shared/cases/server/telemetry/index.ts index 618765a3cdd8c..2a9667f22e54c 100644 --- a/x-pack/platform/plugins/shared/cases/server/telemetry/index.ts +++ b/x-pack/platform/plugins/shared/cases/server/telemetry/index.ts @@ -32,6 +32,7 @@ interface CreateCasesTelemetryArgs { logger: Logger; kibanaVersion: PluginInitializerContext['env']['packageInfo']['version']; templatesConfig?: ConfigType['templates']; + tasksConfig?: ConfigType['tasks']; } export const createCasesTelemetry = ({ @@ -40,11 +41,12 @@ export const createCasesTelemetry = ({ usageCollection, logger, templatesConfig, + tasksConfig, }: CreateCasesTelemetryArgs) => { const getInternalSavedObjectClient = async (): Promise => { const [coreStart] = await core.getStartServices(); const soClient = coreStart.savedObjects.createInternalRepository([ - ...getSavedObjectsTypes({ templates: templatesConfig }), + ...getSavedObjectsTypes({ templates: templatesConfig, tasks: tasksConfig }), FILE_SO_TYPE, CASE_RULES_SAVED_OBJECT, ]); @@ -71,7 +73,11 @@ export const createCasesTelemetry = ({ const collectAndStore = async () => { const savedObjectsClient = await getInternalSavedObjectClient(); - const telemetryData = await collectTelemetryData({ savedObjectsClient, logger }); + const telemetryData = await collectTelemetryData({ + savedObjectsClient, + logger, + tasksEnabled: tasksConfig?.enabled !== false, + }); await savedObjectsClient.create(CASE_TELEMETRY_SAVED_OBJECT, telemetryData, { id: CASE_TELEMETRY_SAVED_OBJECT_ID, diff --git a/x-pack/platform/plugins/shared/cases/server/telemetry/types.ts b/x-pack/platform/plugins/shared/cases/server/telemetry/types.ts index 282be3e301219..fb5f033d57227 100644 --- a/x-pack/platform/plugins/shared/cases/server/telemetry/types.ts +++ b/x-pack/platform/plugins/shared/cases/server/telemetry/types.ts @@ -42,6 +42,7 @@ export interface ReferencesAggregation { export interface CollectTelemetryDataParams { savedObjectsClient: TelemetrySavedObjectsClient; logger: Logger; + tasksEnabled?: boolean; } export interface TypeLong { From c43598674662bca4ba7547c3d45b9ba3eb91a1cc Mon Sep 17 00:00:00 2001 From: Michael Olorunnisola Date: Mon, 16 Mar 2026 07:37:18 -0400 Subject: [PATCH 17/22] [Cases] Tasks: assignees, edit flyout, completion notes, sub-tasks Adds four features to the Tasks UI and backing data model: Assignees - New TaskAssigneesField component using useSuggestUserProfiles + EuiComboBox with avatar rendering and "Assign yourself" shortcut - Assignee avatars shown in the table (first 3 + overflow count) Edit tasks - New EditTaskFlyout pre-populated from the existing CaseTask - Shared TaskFormFields component used by both Add and Edit flyouts Completion notes - completion_notes optional field added to CaseTaskAttributesRt, SO mapping, service args, client params, and HTTP route schemas - Shown as a textarea in the form only when status = 'completed' Sub-tasks - Add sub-task button per row opens AddTaskFlyout with parentTaskId set - TasksTable builds a flat tree with depth metadata from parent_task_id relationships; rows render with indentation and expand/collapse toggle - Parent tasks are expanded by default Co-Authored-By: Claude Sonnet 4.6 --- .../cases/common/types/domain/task/v1.ts | 4 +- .../components/tasks/add_task_flyout.tsx | 157 ++++------ .../components/tasks/case_view_tasks.tsx | 32 ++- .../components/tasks/edit_task_flyout.tsx | 121 ++++++++ .../components/tasks/task_assignees_field.tsx | 141 +++++++++ .../components/tasks/task_form_fields.tsx | 133 +++++++++ .../public/components/tasks/tasks_table.tsx | 270 ++++++++++++++---- .../public/components/tasks/translations.ts | 45 +++ .../shared/cases/public/containers/api.ts | 2 + .../cases/server/client/tasks/client.ts | 2 + .../routes/api/tasks/patch_task_route.ts | 2 + .../routes/api/tasks/post_task_route.ts | 2 + .../server/saved_object_types/tasks/index.ts | 1 + .../cases/server/services/tasks/index.ts | 2 + .../cases/server/services/tasks/types.ts | 2 + 15 files changed, 745 insertions(+), 171 deletions(-) create mode 100644 x-pack/platform/plugins/shared/cases/public/components/tasks/edit_task_flyout.tsx create mode 100644 x-pack/platform/plugins/shared/cases/public/components/tasks/task_assignees_field.tsx create mode 100644 x-pack/platform/plugins/shared/cases/public/components/tasks/task_form_fields.tsx diff --git a/x-pack/platform/plugins/shared/cases/common/types/domain/task/v1.ts b/x-pack/platform/plugins/shared/cases/common/types/domain/task/v1.ts index f69fae71766be..f08f51a82c419 100644 --- a/x-pack/platform/plugins/shared/cases/common/types/domain/task/v1.ts +++ b/x-pack/platform/plugins/shared/cases/common/types/domain/task/v1.ts @@ -55,7 +55,9 @@ export const CaseTaskAttributesRt = rt.intersection([ updated_at: rt.union([rt.string, rt.null]), updated_by: rt.union([UserRt, rt.null]), }), - rt.exact(rt.partial({})), + rt.exact(rt.partial({ + completion_notes: rt.union([rt.string, rt.null]), + })), ]); export const CaseTaskRt = rt.intersection([ diff --git a/x-pack/platform/plugins/shared/cases/public/components/tasks/add_task_flyout.tsx b/x-pack/platform/plugins/shared/cases/public/components/tasks/add_task_flyout.tsx index d4cc0be5f31be..040c6660347c5 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/tasks/add_task_flyout.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/tasks/add_task_flyout.tsx @@ -9,8 +9,7 @@ import React, { useCallback, useState } from 'react'; import { EuiButton, EuiButtonEmpty, - EuiDatePicker, - EuiFieldText, + EuiCallOut, EuiFlexGroup, EuiFlexItem, EuiFlyout, @@ -18,64 +17,72 @@ import { EuiFlyoutFooter, EuiFlyoutHeader, EuiForm, - EuiFormRow, - EuiSelect, - EuiTextArea, + EuiSpacer, EuiTitle, } from '@elastic/eui'; -import moment from 'moment'; import type { CreateTaskRequest } from '../../containers/api'; import { useCreateTask } from '../../containers/use_create_task'; import { useCasesContext } from '../cases_context/use_cases_context'; +import { TaskFormFields } from './task_form_fields'; +import type { TaskFormState } from './task_form_fields'; import * as i18n from './translations'; interface AddTaskFlyoutProps { caseId: string; + parentTaskId?: string | null; + parentTaskTitle?: string; onClose: () => void; } -const PRIORITY_OPTIONS = [ - { value: 'low', text: i18n.PRIORITY_LOW }, - { value: 'medium', text: i18n.PRIORITY_MEDIUM }, - { value: 'high', text: i18n.PRIORITY_HIGH }, - { value: 'critical', text: i18n.PRIORITY_CRITICAL }, -]; - -const STATUS_OPTIONS = [ - { value: 'open', text: i18n.STATUS_OPEN }, - { value: 'in_progress', text: i18n.STATUS_IN_PROGRESS }, - { value: 'completed', text: i18n.STATUS_COMPLETED }, - { value: 'cancelled', text: i18n.STATUS_CANCELLED }, -]; +const DEFAULT_FORM: TaskFormState = { + title: '', + description: '', + status: 'open', + priority: 'medium', + dueDate: null, + assignees: [], + completionNotes: '', +}; -const AddTaskFlyoutComponent: React.FC = ({ caseId, onClose }) => { +export const AddTaskFlyout: React.FC = ({ + caseId, + parentTaskId, + parentTaskTitle, + onClose, +}) => { const { mutate: createTask, isLoading } = useCreateTask(caseId); const { owner } = useCasesContext(); - const [title, setTitle] = useState(''); - const [description, setDescription] = useState(''); - const [priority, setPriority] = useState('medium'); - const [status, setStatus] = useState('open'); - const [dueDate, setDueDate] = useState(null); + const [form, setForm] = useState(DEFAULT_FORM); const [titleError, setTitleError] = useState(null); + const handleChange = useCallback((updates: Partial) => { + setForm((prev) => ({ ...prev, ...updates })); + if (updates.title !== undefined && updates.title.trim()) setTitleError(null); + }, []); + const handleSubmit = useCallback(() => { - if (!title.trim()) { + if (!form.title.trim()) { setTitleError(i18n.TASK_TITLE_REQUIRED); return; } const request: CreateTaskRequest = { - title: title.trim(), - ...(description.trim() && { description: description.trim() }), - priority, - status, - ...(dueDate && { due_date: dueDate.toISOString() }), + title: form.title.trim(), + ...(form.description.trim() && { description: form.description.trim() }), + status: form.status, + priority: form.priority, + assignees: form.assignees.length > 0 ? form.assignees : undefined, + ...(form.dueDate && { due_date: form.dueDate.toISOString() }), + ...(form.completionNotes.trim() && { completion_notes: form.completionNotes.trim() }), + ...(parentTaskId != null && { parent_task_id: parentTaskId }), owner: owner[0], }; createTask(request, { onSuccess: onClose }); - }, [title, description, priority, status, dueDate, createTask, onClose]); + }, [form, parentTaskId, owner, createTask, onClose]); + + const flyoutTitle = parentTaskId ? i18n.ADD_SUBTASK : i18n.ADD_TASK; return ( = ({ caseId, onClose

- {i18n.ADD_TASK} + {flyoutTitle}

- - - { - setTitle(e.target.value); - if (e.target.value.trim()) setTitleError(null); - }} - placeholder={i18n.TASK_TITLE_PLACEHOLDER} - fullWidth - data-test-subj="cases-add-task-title" - autoFocus - /> - - - - setDescription(e.target.value)} - placeholder={i18n.TASK_DESCRIPTION_PLACEHOLDER} - fullWidth - rows={4} - data-test-subj="cases-add-task-description" - /> - - - - setStatus(e.target.value as CreateTaskRequest['status'])} - fullWidth - data-test-subj="cases-add-task-status" + {parentTaskTitle && ( + <> + - - - - setPriority(e.target.value as CreateTaskRequest['priority'])} - fullWidth - data-test-subj="cases-add-task-priority" - /> - - - - - + + + )} + + - + {i18n.CANCEL} @@ -176,7 +131,7 @@ const AddTaskFlyoutComponent: React.FC = ({ caseId, onClose isLoading={isLoading} data-test-subj="cases-add-task-flyout-submit" > - {i18n.ADD_TASK} + {flyoutTitle} @@ -184,7 +139,3 @@ const AddTaskFlyoutComponent: React.FC = ({ caseId, onClose
); }; - -AddTaskFlyoutComponent.displayName = 'AddTaskFlyout'; - -export const AddTaskFlyout = React.memo(AddTaskFlyoutComponent); diff --git a/x-pack/platform/plugins/shared/cases/public/components/tasks/case_view_tasks.tsx b/x-pack/platform/plugins/shared/cases/public/components/tasks/case_view_tasks.tsx index ced4231c56742..17b3f6be578a1 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/tasks/case_view_tasks.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/tasks/case_view_tasks.tsx @@ -7,17 +7,26 @@ import React, { useState } from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import type { CaseTask } from '../../../common/types/domain/task/v1'; import { useGetTasks } from '../../containers/use_get_tasks'; import { TasksTable } from './tasks_table'; import { AddTaskFlyout } from './add_task_flyout'; +import { EditTaskFlyout } from './edit_task_flyout'; interface CaseViewTasksProps { caseId: string; } +interface AddTaskState { + open: boolean; + parentTask: CaseTask | null; +} + export const CaseViewTasks = React.memo(({ caseId }) => { const { data, isLoading } = useGetTasks(caseId); - const [isFlyoutOpen, setIsFlyoutOpen] = useState(false); + + const [addState, setAddState] = useState({ open: false, parentTask: null }); + const [editingTask, setEditingTask] = useState(null); return ( <> @@ -27,13 +36,28 @@ export const CaseViewTasks = React.memo(({ caseId }) => { caseId={caseId} tasks={data?.tasks ?? []} isLoading={isLoading} - onAddTask={() => setIsFlyoutOpen(true)} + onAddTask={() => setAddState({ open: true, parentTask: null })} + onEditTask={(task) => setEditingTask(task)} + onAddSubTask={(parentTask) => setAddState({ open: true, parentTask })} /> - {isFlyoutOpen && ( - setIsFlyoutOpen(false)} /> + {addState.open && ( + setAddState({ open: false, parentTask: null })} + /> + )} + + {editingTask && ( + setEditingTask(null)} + /> )} ); diff --git a/x-pack/platform/plugins/shared/cases/public/components/tasks/edit_task_flyout.tsx b/x-pack/platform/plugins/shared/cases/public/components/tasks/edit_task_flyout.tsx new file mode 100644 index 0000000000000..70d590292d44d --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/public/components/tasks/edit_task_flyout.tsx @@ -0,0 +1,121 @@ +/* + * 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, { useCallback, useState } from 'react'; +import { + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiForm, + EuiTitle, +} from '@elastic/eui'; +import moment from 'moment'; +import type { CaseTask } from '../../../common/types/domain/task/v1'; +import { useUpdateTask } from '../../containers/use_update_task'; +import { TaskFormFields } from './task_form_fields'; +import type { TaskFormState } from './task_form_fields'; +import * as i18n from './translations'; + +interface EditTaskFlyoutProps { + caseId: string; + task: CaseTask; + onClose: () => void; +} + +const taskToFormState = (task: CaseTask): TaskFormState => ({ + title: task.title, + description: task.description ?? '', + status: task.status, + priority: task.priority, + dueDate: task.due_date ? moment(task.due_date) : null, + assignees: task.assignees ?? [], + completionNotes: task.completion_notes ?? '', +}); + +export const EditTaskFlyout: React.FC = ({ caseId, task, onClose }) => { + const { mutate: updateTask, isLoading } = useUpdateTask(caseId); + const [form, setForm] = useState(() => taskToFormState(task)); + const [titleError, setTitleError] = useState(null); + + const handleChange = useCallback((updates: Partial) => { + setForm((prev) => ({ ...prev, ...updates })); + if (updates.title !== undefined && updates.title.trim()) setTitleError(null); + }, []); + + const handleSubmit = useCallback(() => { + if (!form.title.trim()) { + setTitleError(i18n.TASK_TITLE_REQUIRED); + return; + } + + updateTask( + { + taskId: task.id, + request: { + version: task.version, + title: form.title.trim(), + description: form.description.trim() || undefined, + status: form.status, + priority: form.priority, + assignees: form.assignees, + due_date: form.dueDate ? form.dueDate.toISOString() : null, + completion_notes: form.completionNotes.trim() || null, + }, + }, + { onSuccess: onClose } + ); + }, [form, task, updateTask, onClose]); + + return ( + + + +

+ {i18n.EDIT_TASK} +

+
+
+ + + + + + + + + + + + {i18n.CANCEL} + + + + + {i18n.SAVE_CHANGES} + + + + +
+ ); +}; diff --git a/x-pack/platform/plugins/shared/cases/public/components/tasks/task_assignees_field.tsx b/x-pack/platform/plugins/shared/cases/public/components/tasks/task_assignees_field.tsx new file mode 100644 index 0000000000000..4263b61be5742 --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/public/components/tasks/task_assignees_field.tsx @@ -0,0 +1,141 @@ +/* + * 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 { differenceWith, isEmpty } from 'lodash'; +import React, { useCallback, useState } from 'react'; +import type { EuiComboBoxOptionOption } from '@elastic/eui'; +import { EuiComboBox, EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiHighlight, EuiLink, EuiTextColor } from '@elastic/eui'; +import type { UserProfileWithAvatar } from '@kbn/user-profile-components'; +import { getUserDisplayName, UserAvatar } from '@kbn/user-profile-components'; +import { useSuggestUserProfiles } from '../../containers/user_profiles/use_suggest_user_profiles'; +import { useBulkGetUserProfiles } from '../../containers/user_profiles/use_bulk_get_user_profiles'; +import { useGetCurrentUserProfile } from '../../containers/user_profiles/use_get_current_user_profile'; +import { useCasesContext } from '../cases_context/use_cases_context'; +import * as i18n from './translations'; + +type UserProfileOption = EuiComboBoxOptionOption & UserProfileWithAvatar; + +const toOption = (profile: UserProfileWithAvatar): UserProfileOption => + ({ + label: getUserDisplayName(profile.user), + value: profile.uid, + key: profile.uid, + ...profile, + } as UserProfileOption); + +interface TaskAssigneesFieldProps { + value: Array<{ uid: string }>; + onChange: (assignees: Array<{ uid: string }>) => void; +} + +export const TaskAssigneesField: React.FC = ({ value, onChange }) => { + const { owner: owners } = useCasesContext(); + const [searchTerm, setSearchTerm] = useState(''); + + const { data: currentUserProfile, isLoading: isLoadingCurrentUser } = useGetCurrentUserProfile(); + + const { data: suggestedProfiles = [], isLoading: isLoadingSuggested, isFetching: isFetchingSuggested } = + useSuggestUserProfiles({ name: searchTerm, owners }); + + // Bulk-get profiles for already-selected assignees that aren't in the suggestion results + const missingUids = differenceWith( + value, + suggestedProfiles, + (assignee, profile) => assignee.uid === profile.uid + ).map((a) => a.uid); + + const { data: bulkProfiles = new Map(), isLoading: isLoadingBulk } = + useBulkGetUserProfiles({ uids: missingUids }); + + const bulkProfilesArray = Array.from(bulkProfiles.values()); + + const allProfiles = [...suggestedProfiles, ...bulkProfilesArray]; + const options: UserProfileOption[] = allProfiles.map(toOption); + + const selectedOptions: UserProfileOption[] = value + .map(({ uid }) => options.find((o) => o.key === uid)) + .filter((o): o is UserProfileOption => o != null); + + const isLoading = isLoadingCurrentUser || isLoadingSuggested || isFetchingSuggested || isLoadingBulk; + + const handleChange = useCallback( + (selected: Array>) => { + onChange(selected.map((o) => ({ uid: o.value ?? '' }))); + }, + [onChange] + ); + + const handleSearchChange = useCallback((term: string) => { + if (!isEmpty(term)) setSearchTerm(term); + }, []); + + const handleSelfAssign = useCallback(() => { + if (!currentUserProfile) return; + const alreadySelected = value.some((a) => a.uid === currentUserProfile.uid); + if (!alreadySelected) onChange([...value, { uid: currentUserProfile.uid }]); + }, [currentUserProfile, value, onChange]); + + const renderOption = useCallback( + (option: EuiComboBoxOptionOption, searchValue: string, contentClassName: string) => { + const { user, data } = option as UserProfileOption; + const displayName = getUserDisplayName(user); + return ( + + + + + + + + {displayName} + + + {user.email && user.email !== displayName ? ( + + + + {user.email} + + + + ) : null} + + + ); + }, + [] + ); + + const isSelfSelected = Boolean(currentUserProfile && value.some((a) => a.uid === currentUserProfile.uid)); + + return ( + + {i18n.ASSIGN_YOURSELF} + + ) : undefined + } + > + + + ); +}; diff --git a/x-pack/platform/plugins/shared/cases/public/components/tasks/task_form_fields.tsx b/x-pack/platform/plugins/shared/cases/public/components/tasks/task_form_fields.tsx new file mode 100644 index 0000000000000..c0f1c65645261 --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/public/components/tasks/task_form_fields.tsx @@ -0,0 +1,133 @@ +/* + * 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 { + EuiDatePicker, + EuiFieldText, + EuiFormRow, + EuiSelect, + EuiTextArea, +} from '@elastic/eui'; +import moment from 'moment'; +import type { CaseTaskPriority, CaseTaskStatus } from '../../../common/types/domain/task/v1'; +import { TaskAssigneesField } from './task_assignees_field'; +import * as i18n from './translations'; + +export interface TaskFormState { + title: string; + description: string; + status: CaseTaskStatus; + priority: CaseTaskPriority; + dueDate: moment.Moment | null; + assignees: Array<{ uid: string }>; + completionNotes: string; +} + +interface TaskFormFieldsProps { + value: TaskFormState; + onChange: (updates: Partial) => void; + titleError: string | null; +} + +const PRIORITY_OPTIONS: Array<{ value: CaseTaskPriority; text: string }> = [ + { value: 'low', text: i18n.PRIORITY_LOW }, + { value: 'medium', text: i18n.PRIORITY_MEDIUM }, + { value: 'high', text: i18n.PRIORITY_HIGH }, + { value: 'critical', text: i18n.PRIORITY_CRITICAL }, +]; + +const STATUS_OPTIONS: Array<{ value: CaseTaskStatus; text: string }> = [ + { value: 'open', text: i18n.STATUS_OPEN }, + { value: 'in_progress', text: i18n.STATUS_IN_PROGRESS }, + { value: 'completed', text: i18n.STATUS_COMPLETED }, + { value: 'cancelled', text: i18n.STATUS_CANCELLED }, +]; + +export const TaskFormFields: React.FC = ({ value, onChange, titleError }) => { + const isCompleted = value.status === 'completed'; + + return ( + <> + + onChange({ title: e.target.value })} + placeholder={i18n.TASK_TITLE_PLACEHOLDER} + fullWidth + autoFocus + data-test-subj="cases-task-form-title" + /> + + + + onChange({ description: e.target.value })} + placeholder={i18n.TASK_DESCRIPTION_PLACEHOLDER} + fullWidth + rows={3} + data-test-subj="cases-task-form-description" + /> + + + + onChange({ status: e.target.value as CaseTaskStatus })} + fullWidth + data-test-subj="cases-task-form-status" + /> + + + {isCompleted && ( + + onChange({ completionNotes: e.target.value })} + placeholder={i18n.COMPLETION_NOTES_PLACEHOLDER} + fullWidth + rows={3} + data-test-subj="cases-task-form-completion-notes" + /> + + )} + + + onChange({ priority: e.target.value as CaseTaskPriority })} + fullWidth + data-test-subj="cases-task-form-priority" + /> + + + + onChange({ dueDate: date })} + dateFormat="MM/DD/YYYY" + placeholderText={i18n.TASK_DUE_DATE_PLACEHOLDER} + fullWidth + data-test-subj="cases-task-form-due-date" + /> + + + onChange({ assignees })} + /> + + ); +}; diff --git a/x-pack/platform/plugins/shared/cases/public/components/tasks/tasks_table.tsx b/x-pack/platform/plugins/shared/cases/public/components/tasks/tasks_table.tsx index 499338a2c2ec5..495fbdc712d67 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/tasks/tasks_table.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/tasks/tasks_table.tsx @@ -5,23 +5,67 @@ * 2.0. */ -import React, { useCallback } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { + EuiAvatar, EuiBasicTable, - EuiEmptyPrompt, - EuiSkeletonText, EuiBadge, + EuiButton, EuiButtonIcon, + EuiEmptyPrompt, EuiFlexGroup, EuiFlexItem, - EuiButton, + EuiSkeletonText, + EuiText, + EuiToolTip, } from '@elastic/eui'; import type { EuiBasicTableColumn } from '@elastic/eui'; +import { getUserDisplayName } from '@kbn/user-profile-components'; import type { CaseTask } from '../../../common/types/domain/task/v1'; import { useDeleteTask } from '../../containers/use_delete_task'; import { useUpdateTask } from '../../containers/use_update_task'; +import { useBulkGetUserProfiles } from '../../containers/user_profiles/use_bulk_get_user_profiles'; import * as i18n from './translations'; +// --------------------------------------------------------------------------- +// Tree building +// --------------------------------------------------------------------------- + +interface TaskRow extends CaseTask { + _depth: number; + _hasChildren: boolean; +} + +const buildFlatTree = (tasks: CaseTask[], expandedIds: Set): TaskRow[] => { + const childrenMap = new Map(); + for (const task of tasks) { + const pid = task.parent_task_id ?? null; + if (!childrenMap.has(pid)) childrenMap.set(pid, []); + childrenMap.get(pid)!.push(task); + } + + const result: TaskRow[] = []; + + const visit = (parentId: string | null, depth: number) => { + const children = childrenMap.get(parentId) ?? []; + for (const task of children) { + const taskChildren = childrenMap.get(task.id) ?? []; + const hasChildren = taskChildren.length > 0; + result.push({ ...task, _depth: depth, _hasChildren: hasChildren }); + if (hasChildren && expandedIds.has(task.id)) { + visit(task.id, depth + 1); + } + } + }; + + visit(null, 0); + return result; +}; + +// --------------------------------------------------------------------------- +// Status display +// --------------------------------------------------------------------------- + const STATUS_LABELS: Record = { open: i18n.STATUS_OPEN, in_progress: i18n.STATUS_IN_PROGRESS, @@ -36,95 +80,204 @@ const STATUS_COLORS: Record = { cancelled: 'default', }; -const PRIORITY_LABELS: Record = { - low: i18n.PRIORITY_LOW, - medium: i18n.PRIORITY_MEDIUM, - high: i18n.PRIORITY_HIGH, - critical: i18n.PRIORITY_CRITICAL, +const NEXT_STATUS: Record = { + open: 'in_progress', + in_progress: 'completed', + completed: 'open', + cancelled: 'open', }; +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + interface TasksTableProps { caseId: string; tasks: CaseTask[]; isLoading: boolean; onAddTask?: () => void; + onEditTask?: (task: CaseTask) => void; + onAddSubTask?: (parentTask: CaseTask) => void; } export const TasksTable = React.memo( - ({ caseId, tasks, isLoading, onAddTask }) => { + ({ caseId, tasks, isLoading, onAddTask, onEditTask, onAddSubTask }) => { const { mutate: deleteTask } = useDeleteTask(caseId); const { mutate: updateTask } = useUpdateTask(caseId); - const handleStatusChange = useCallback( - (task: CaseTask, newStatus: CaseTask['status']) => { - updateTask({ taskId: task.id, request: { version: task.version, status: newStatus } }); - }, - [updateTask] + const [expandedIds, setExpandedIds] = useState>(() => { + // Expand all parent tasks by default + const parentIds = new Set(tasks.map((t) => t.parent_task_id).filter(Boolean) as string[]); + return parentIds; + }); + + const toggleExpand = useCallback((taskId: string) => { + setExpandedIds((prev) => { + const next = new Set(prev); + if (next.has(taskId)) next.delete(taskId); + else next.add(taskId); + return next; + }); + }, []); + + const flatRows = useMemo(() => buildFlatTree(tasks, expandedIds), [tasks, expandedIds]); + + // Collect all unique assignee uids across all tasks + const allAssigneeUids = useMemo( + () => [...new Set(tasks.flatMap((t) => (t.assignees ?? []).map((a) => a.uid)))], + [tasks] ); - const handleDelete = useCallback( - (taskId: string) => { - deleteTask(taskId); + const { data: profileMap = new Map() } = useBulkGetUserProfiles({ uids: allAssigneeUids }); + + const handleStatusCycle = useCallback( + (task: CaseTask) => { + updateTask({ + taskId: task.id, + request: { version: task.version, status: NEXT_STATUS[task.status] }, + }); }, - [deleteTask] + [updateTask] ); - const columns: Array> = [ + const columns: Array> = [ { name: i18n.TASK_TITLE, field: 'title', - 'data-test-subj': 'cases-tasks-table-title', - truncateText: true, + 'data-test-subj': 'cases-tasks-col-title', + render: (title: string, row: TaskRow) => ( + + {row._hasChildren ? ( + + toggleExpand(row.id)} + data-test-subj={`cases-tasks-expand-${row.id}`} + /> + + ) : ( + + )} + + + {row._depth > 0 && ( + + )} + {title} + + + + ), }, { name: i18n.TASK_STATUS, field: 'status', - 'data-test-subj': 'cases-tasks-table-status', - width: '120px', - render: (status: CaseTask['status'], task: CaseTask) => ( - { - const nextStatus = status === 'open' ? 'in_progress' : status === 'in_progress' ? 'completed' : 'open'; - handleStatusChange(task, nextStatus as CaseTask['status']); - }} - onClickAriaLabel={`Change status from ${status}`} - data-test-subj={`cases-tasks-status-${status}`} - > - {STATUS_LABELS[status]} - + width: '130px', + 'data-test-subj': 'cases-tasks-col-status', + render: (status: CaseTask['status'], row: TaskRow) => ( + + handleStatusCycle(row)} + onClickAriaLabel={i18n.CLICK_TO_ADVANCE_STATUS} + data-test-subj={`cases-tasks-status-${row.id}`} + > + {STATUS_LABELS[status]} + + ), }, { - name: i18n.TASK_PRIORITY, - field: 'priority', - 'data-test-subj': 'cases-tasks-table-priority', + name: i18n.TASK_ASSIGNEES, + field: 'assignees', width: '100px', - render: (priority: CaseTask['priority']) => PRIORITY_LABELS[priority], + 'data-test-subj': 'cases-tasks-col-assignees', + render: (assignees: CaseTask['assignees']) => { + if (!assignees || assignees.length === 0) return null; + const visible = assignees.slice(0, 3); + return ( + + {visible.map(({ uid }) => { + const profile = profileMap.get(uid); + const displayName = profile ? getUserDisplayName(profile.user) : uid; + return ( + + + + + + ); + })} + {assignees.length > 3 && ( + + +{assignees.length - 3} + + )} + + ); + }, }, { name: i18n.TASK_DUE_DATE, field: 'due_date', - 'data-test-subj': 'cases-tasks-table-due-date', - width: '150px', + width: '110px', + 'data-test-subj': 'cases-tasks-col-due-date', render: (dueDate: string | null) => dueDate ? new Date(dueDate).toLocaleDateString() : '—', }, { name: i18n.TASK_ACTIONS, - field: 'actions', - 'data-test-subj': 'cases-tasks-table-actions', - width: '80px', + field: 'id', + width: '100px', + 'data-test-subj': 'cases-tasks-col-actions', actions: [ + { + name: i18n.EDIT_TASK, + render: (row: TaskRow) => ( + onEditTask?.(row)} + /> + ), + }, + { + name: i18n.ADD_SUBTASK, + render: (row: TaskRow) => ( + { + // Ensure the parent is expanded so the new child becomes visible + setExpandedIds((prev) => new Set([...prev, row.id])); + onAddSubTask?.(row); + }} + /> + ), + }, { name: i18n.DELETE_TASK, - render: (task: CaseTask) => ( + render: (row: TaskRow) => ( handleDelete(task.id)} + data-test-subj={`cases-tasks-delete-${row.id}`} + onClick={() => deleteTask(row.id)} /> ), }, @@ -141,16 +294,11 @@ export const TasksTable = React.memo( {i18n.NO_TASKS}} body={

{i18n.NO_TASKS_DESCRIPTION}

} - data-test-subj="cases-tasks-table-empty" titleSize="xs" + data-test-subj="cases-tasks-table-empty" actions={ onAddTask ? ( - + {i18n.ADD_TASK} ) : null @@ -165,12 +313,7 @@ export const TasksTable = React.memo( {onAddTask && ( - + {i18n.ADD_TASK} )} @@ -178,10 +321,11 @@ export const TasksTable = React.memo( - + items={flatRows} columns={columns} rowHeader="title" + itemId="id" data-test-subj="cases-tasks-table" /> diff --git a/x-pack/platform/plugins/shared/cases/public/components/tasks/translations.ts b/x-pack/platform/plugins/shared/cases/public/components/tasks/translations.ts index 7a197a2b35eb7..39242034efbea 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/tasks/translations.ts +++ b/x-pack/platform/plugins/shared/cases/public/components/tasks/translations.ts @@ -116,3 +116,48 @@ export const TASK_DUE_DATE_PLACEHOLDER = i18n.translate( export const TASK_TITLE_REQUIRED = i18n.translate('xpack.cases.tasks.fields.titleRequired', { defaultMessage: 'A title is required.', }); + +export const ASSIGN_YOURSELF = i18n.translate('xpack.cases.tasks.fields.assignYourself', { + defaultMessage: 'Assign yourself', +}); + +export const COMPLETION_NOTES = i18n.translate('xpack.cases.tasks.fields.completionNotes', { + defaultMessage: 'Completion notes', +}); + +export const COMPLETION_NOTES_PLACEHOLDER = i18n.translate( + 'xpack.cases.tasks.fields.completionNotesPlaceholder', + { + defaultMessage: 'Describe what was done to complete this task (optional)', + } +); + +export const EDIT_TASK = i18n.translate('xpack.cases.tasks.editTask', { + defaultMessage: 'Edit task', +}); + +export const SAVE_CHANGES = i18n.translate('xpack.cases.tasks.saveChanges', { + defaultMessage: 'Save changes', +}); + +export const ADD_SUBTASK = i18n.translate('xpack.cases.tasks.addSubtask', { + defaultMessage: 'Add sub-task', +}); + +export const SUBTASK_OF = (parentTitle: string) => + i18n.translate('xpack.cases.tasks.subtaskOf', { + values: { parentTitle }, + defaultMessage: 'Sub-task of "{parentTitle}"', + }); + +export const EXPAND_SUBTASKS = i18n.translate('xpack.cases.tasks.expandSubtasks', { + defaultMessage: 'Expand sub-tasks', +}); + +export const COLLAPSE_SUBTASKS = i18n.translate('xpack.cases.tasks.collapseSubtasks', { + defaultMessage: 'Collapse sub-tasks', +}); + +export const CLICK_TO_ADVANCE_STATUS = i18n.translate('xpack.cases.tasks.clickToAdvanceStatus', { + defaultMessage: 'Click to advance status', +}); diff --git a/x-pack/platform/plugins/shared/cases/public/containers/api.ts b/x-pack/platform/plugins/shared/cases/public/containers/api.ts index ee6eff3a378d9..918c03c7b3f62 100644 --- a/x-pack/platform/plugins/shared/cases/public/containers/api.ts +++ b/x-pack/platform/plugins/shared/cases/public/containers/api.ts @@ -718,6 +718,7 @@ export interface CreateTaskRequest { assignees?: Array<{ uid: string }>; due_date?: string | null; parent_task_id?: string | null; + completion_notes?: string | null; owner: string; } @@ -729,6 +730,7 @@ export interface UpdateTaskRequest { priority?: 'low' | 'medium' | 'high' | 'critical'; assignees?: Array<{ uid: string }>; due_date?: string | null; + completion_notes?: string | null; } export interface FindTasksRequest { diff --git a/x-pack/platform/plugins/shared/cases/server/client/tasks/client.ts b/x-pack/platform/plugins/shared/cases/server/client/tasks/client.ts index 6948e79e19c49..e7d733b584a81 100644 --- a/x-pack/platform/plugins/shared/cases/server/client/tasks/client.ts +++ b/x-pack/platform/plugins/shared/cases/server/client/tasks/client.ts @@ -27,6 +27,7 @@ export interface CreateTaskParams { assignees?: CaseTaskAssignee[]; due_date?: string | null; parent_task_id?: string | null; + completion_notes?: string | null; owner: string; } @@ -39,6 +40,7 @@ export interface UpdateTaskParams { priority?: CaseTaskPriority; assignees?: CaseTaskAssignee[]; due_date?: string | null; + completion_notes?: string | null; } export type FindTasksParams = FindTasksArgs; diff --git a/x-pack/platform/plugins/shared/cases/server/routes/api/tasks/patch_task_route.ts b/x-pack/platform/plugins/shared/cases/server/routes/api/tasks/patch_task_route.ts index 964fe04983e4a..b9503645400ba 100644 --- a/x-pack/platform/plugins/shared/cases/server/routes/api/tasks/patch_task_route.ts +++ b/x-pack/platform/plugins/shared/cases/server/routes/api/tasks/patch_task_route.ts @@ -52,6 +52,7 @@ export const patchTaskRoute = createCasesRoute({ schema.arrayOf(schema.object({ uid: schema.string() }), { maxSize: 10 }) ), due_date: schema.maybe(schema.nullable(schema.string())), + completion_notes: schema.maybe(schema.nullable(schema.string({ maxLength: 30000 }))), }), }, handler: async ({ context, request, response }) => { @@ -68,6 +69,7 @@ export const patchTaskRoute = createCasesRoute({ priority?: 'low' | 'medium' | 'high' | 'critical'; assignees?: Array<{ uid: string }>; due_date?: string | null; + completion_notes?: string | null; }; const task = await casesClient.tasks.update({ diff --git a/x-pack/platform/plugins/shared/cases/server/routes/api/tasks/post_task_route.ts b/x-pack/platform/plugins/shared/cases/server/routes/api/tasks/post_task_route.ts index 5d23015387740..a6642d9bf83b4 100644 --- a/x-pack/platform/plugins/shared/cases/server/routes/api/tasks/post_task_route.ts +++ b/x-pack/platform/plugins/shared/cases/server/routes/api/tasks/post_task_route.ts @@ -51,6 +51,7 @@ export const postTaskRoute = createCasesRoute({ ), due_date: schema.maybe(schema.nullable(schema.string())), parent_task_id: schema.maybe(schema.nullable(schema.string())), + completion_notes: schema.maybe(schema.nullable(schema.string({ maxLength: 30000 }))), owner: schema.string(), }), }, @@ -68,6 +69,7 @@ export const postTaskRoute = createCasesRoute({ assignees?: Array<{ uid: string }>; due_date?: string | null; parent_task_id?: string | null; + completion_notes?: string | null; owner: string; }; diff --git a/x-pack/platform/plugins/shared/cases/server/saved_object_types/tasks/index.ts b/x-pack/platform/plugins/shared/cases/server/saved_object_types/tasks/index.ts index 7cdd649dbdc55..4da4eb85a0ace 100644 --- a/x-pack/platform/plugins/shared/cases/server/saved_object_types/tasks/index.ts +++ b/x-pack/platform/plugins/shared/cases/server/saved_object_types/tasks/index.ts @@ -41,6 +41,7 @@ export const caseTaskSavedObjectType: SavedObjectsType = { due_date: { type: 'date' }, started_at: { type: 'date' }, completed_at: { type: 'date' }, + completion_notes: { type: 'text' }, // Ordering sort_order: { type: 'integer' }, diff --git a/x-pack/platform/plugins/shared/cases/server/services/tasks/index.ts b/x-pack/platform/plugins/shared/cases/server/services/tasks/index.ts index 118ba8bbfaacc..2a16ceb4de8c2 100644 --- a/x-pack/platform/plugins/shared/cases/server/services/tasks/index.ts +++ b/x-pack/platform/plugins/shared/cases/server/services/tasks/index.ts @@ -59,6 +59,7 @@ export class CaseTaskService { assignees = [], due_date = null, parent_task_id = null, + completion_notes = null, custom_fields = [], template_id = null, owner, @@ -91,6 +92,7 @@ export class CaseTaskService { due_date, started_at: status === 'in_progress' ? now : null, completed_at: status === 'completed' || status === 'cancelled' ? now : null, + completion_notes, sort_order, template_id, custom_fields, diff --git a/x-pack/platform/plugins/shared/cases/server/services/tasks/types.ts b/x-pack/platform/plugins/shared/cases/server/services/tasks/types.ts index 0f2e675428b00..cca47a73bd5f1 100644 --- a/x-pack/platform/plugins/shared/cases/server/services/tasks/types.ts +++ b/x-pack/platform/plugins/shared/cases/server/services/tasks/types.ts @@ -25,6 +25,7 @@ export interface CreateTaskArgs extends IndexRefresh { assignees?: CaseTaskAssignee[]; due_date?: string | null; parent_task_id?: string | null; + completion_notes?: string | null; custom_fields?: CaseTaskCustomField[]; template_id?: string | null; owner: string; @@ -46,6 +47,7 @@ export interface UpdateTaskArgs extends IndexRefresh { assignees?: CaseTaskAssignee[]; due_date?: string | null; parent_task_id?: string | null; + completion_notes?: string | null; sort_order?: number; custom_fields?: CaseTaskCustomField[]; user: User; From dbdc4f77f4065f36c918ec01be85b29ea4010264 Mon Sep 17 00:00:00 2001 From: Michael Olorunnisola Date: Mon, 16 Mar 2026 07:58:46 -0400 Subject: [PATCH 18/22] Fix assignees infinite loading, add action text labels, add task templates to settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix task_assignees_field.tsx: add useIsUserTyping hook, pass onDebounce to useSuggestUserProfiles, include isUserTyping in isLoading — matches the pattern used by case_form_fields/assignees.tsx to prevent infinite spinner - Fix tasks_table.tsx: replace EuiButtonIcon with EuiButtonEmpty in action column so edit/add subtask/delete buttons show icon + text label - Add task templates section to configure_cases settings page: - API functions (findTaskTemplates, createTaskTemplate, updateTaskTemplate, deleteTaskTemplate) + query/mutation keys in constants.ts - React Query hooks: useGetTaskTemplates, useCreateTaskTemplate, useUpdateTaskTemplate, useDeleteTaskTemplate - TaskTemplates list component with EuiBasicTable + add/edit/delete - TaskTemplateFlyout with name/description/scope fields and dynamic tasks list (title/description/priority/relative_due_days per task) - Wired into configure_cases/index.tsx below observable types section Co-Authored-By: Claude Sonnet 4.6 --- .../components/configure_cases/index.tsx | 39 ++ .../configure_cases/translations.ts | 99 +++++ .../task_templates/task_template_flyout.tsx | 339 ++++++++++++++++++ .../task_templates/task_templates.tsx | 169 +++++++++ .../components/tasks/task_assignees_field.tsx | 23 +- .../public/components/tasks/tasks_table.tsx | 24 +- .../shared/cases/public/containers/api.ts | 93 +++++ .../cases/public/containers/constants.ts | 6 + .../containers/use_create_task_template.tsx | 25 ++ .../containers/use_delete_task_template.tsx | 24 ++ .../containers/use_get_task_templates.tsx | 23 ++ .../containers/use_update_task_template.tsx | 30 ++ 12 files changed, 882 insertions(+), 12 deletions(-) create mode 100644 x-pack/platform/plugins/shared/cases/public/components/task_templates/task_template_flyout.tsx create mode 100644 x-pack/platform/plugins/shared/cases/public/components/task_templates/task_templates.tsx create mode 100644 x-pack/platform/plugins/shared/cases/public/containers/use_create_task_template.tsx create mode 100644 x-pack/platform/plugins/shared/cases/public/containers/use_delete_task_template.tsx create mode 100644 x-pack/platform/plugins/shared/cases/public/containers/use_get_task_templates.tsx create mode 100644 x-pack/platform/plugins/shared/cases/public/containers/use_update_task_template.tsx diff --git a/x-pack/platform/plugins/shared/cases/public/components/configure_cases/index.tsx b/x-pack/platform/plugins/shared/cases/public/components/configure_cases/index.tsx index 173e0ef070333..3978515cd87ae 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/configure_cases/index.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/configure_cases/index.tsx @@ -60,6 +60,9 @@ import { builderMap as customFieldsBuilderMap } from '../custom_fields/builder'; import { ObservableTypes } from '../observable_types'; import { ObservableTypesForm } from '../observable_types/form'; import { useCasesFeatures } from '../../common/use_cases_features'; +import { TaskTemplates } from '../task_templates/task_templates'; +import { TaskTemplateFlyout } from '../task_templates/task_template_flyout'; +import type { CaseTaskTemplate } from '../../../common/types/domain/task_template/v1'; const sectionWrapperCss = css` box-sizing: content-box; @@ -132,6 +135,8 @@ export const ConfigureCases: React.FC = React.memo(() => { const [templateToEdit, setTemplateToEdit] = useState(null); const [observableTypeToEdit, setObservableTypeToEdit] = useState(null); + const [taskTemplateFlyoutOpen, setTaskTemplateFlyoutOpen] = useState(false); + const [taskTemplateToEdit, setTaskTemplateToEdit] = useState(null); const { euiTheme } = useEuiTheme(); const { @@ -566,6 +571,21 @@ export const ConfigureCases: React.FC = React.memo(() => { ] ); + const onAddTaskTemplate = useCallback(() => { + setTaskTemplateToEdit(null); + setTaskTemplateFlyoutOpen(true); + }, []); + + const onEditTaskTemplate = useCallback((template: CaseTaskTemplate) => { + setTaskTemplateToEdit(template); + setTaskTemplateFlyoutOpen(true); + }, []); + + const onCloseTaskTemplateFlyout = useCallback(() => { + setTaskTemplateFlyoutOpen(false); + setTaskTemplateToEdit(null); + }, []); + const AddOrEditCustomFieldFlyout = flyOutVisibility?.type === 'customField' && flyOutVisibility?.visible ? ( @@ -738,11 +758,30 @@ export const ConfigureCases: React.FC = React.memo(() => { +
+ + + +
+ + + {ConnectorAddFlyout} {ConnectorEditFlyout} {AddOrEditCustomFieldFlyout} {AddOrEditTemplateFlyout} {AddOrEditObservableTypeFlyout} + {taskTemplateFlyoutOpen && ( + + )} diff --git a/x-pack/platform/plugins/shared/cases/public/components/configure_cases/translations.ts b/x-pack/platform/plugins/shared/cases/public/components/configure_cases/translations.ts index c38d100666c6a..4f15f433ca2ac 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/configure_cases/translations.ts +++ b/x-pack/platform/plugins/shared/cases/public/components/configure_cases/translations.ts @@ -214,3 +214,102 @@ export const SHOW_ALL_TEMPLATES = i18n.translate( defaultMessage: 'Show all templates', } ); + +export const TASK_TEMPLATES_TITLE = i18n.translate( + 'xpack.cases.configureCases.taskTemplates.title', + { + defaultMessage: 'Task templates', + } +); + +export const TASK_TEMPLATES_DESCRIPTION = i18n.translate( + 'xpack.cases.configureCases.taskTemplates.description', + { + defaultMessage: + 'Create reusable task templates that can be applied to cases to quickly add a set of predefined tasks.', + } +); + +export const ADD_TASK_TEMPLATE = i18n.translate( + 'xpack.cases.configureCases.taskTemplates.addTaskTemplate', + { + defaultMessage: 'Add task template', + } +); + +export const EDIT_TASK_TEMPLATE = i18n.translate( + 'xpack.cases.configureCases.taskTemplates.editTaskTemplate', + { + defaultMessage: 'Edit task template', + } +); + +export const DELETE_TASK_TEMPLATE = i18n.translate( + 'xpack.cases.configureCases.taskTemplates.deleteTaskTemplate', + { + defaultMessage: 'Delete task template', + } +); + +export const TASK_TEMPLATE_NAME = i18n.translate( + 'xpack.cases.configureCases.taskTemplates.name', + { + defaultMessage: 'Name', + } +); + +export const TASK_TEMPLATE_DESCRIPTION = i18n.translate( + 'xpack.cases.configureCases.taskTemplates.description_field', + { + defaultMessage: 'Description', + } +); + +export const TASK_TEMPLATE_TASKS_COUNT = i18n.translate( + 'xpack.cases.configureCases.taskTemplates.tasksCount', + { + defaultMessage: 'Tasks', + } +); + +export const NO_TASK_TEMPLATES = i18n.translate( + 'xpack.cases.configureCases.taskTemplates.noTemplates', + { + defaultMessage: 'No task templates', + } +); + +export const TASK_TEMPLATE_NAME_REQUIRED = i18n.translate( + 'xpack.cases.configureCases.taskTemplates.nameRequired', + { + defaultMessage: 'A name is required.', + } +); + +export const TASK_TEMPLATE_TASKS_REQUIRED = i18n.translate( + 'xpack.cases.configureCases.taskTemplates.tasksRequired', + { + defaultMessage: 'At least one task is required.', + } +); + +export const TASK_TEMPLATE_TASK_TITLE = i18n.translate( + 'xpack.cases.configureCases.taskTemplates.taskTitle', + { + defaultMessage: 'Task title', + } +); + +export const ADD_TASK_TO_TEMPLATE = i18n.translate( + 'xpack.cases.configureCases.taskTemplates.addTaskToTemplate', + { + defaultMessage: 'Add task', + } +); + +export const REMOVE_TASK_FROM_TEMPLATE = i18n.translate( + 'xpack.cases.configureCases.taskTemplates.removeTaskFromTemplate', + { + defaultMessage: 'Remove', + } +); diff --git a/x-pack/platform/plugins/shared/cases/public/components/task_templates/task_template_flyout.tsx b/x-pack/platform/plugins/shared/cases/public/components/task_templates/task_template_flyout.tsx new file mode 100644 index 0000000000000..6a98fa35643dc --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/public/components/task_templates/task_template_flyout.tsx @@ -0,0 +1,339 @@ +/* + * 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, { useCallback, useState } from 'react'; +import { + EuiButton, + EuiButtonEmpty, + EuiButtonIcon, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiFormRow, + EuiPanel, + EuiSelect, + EuiSpacer, + EuiText, + EuiTitle, + useGeneratedHtmlId, +} from '@elastic/eui'; +import type { CaseTaskTemplate } from '../../../common/types/domain/task_template/v1'; +import type { CreateTaskTemplateRequest } from '../../containers/api'; +import { useCreateTaskTemplate } from '../../containers/use_create_task_template'; +import { useUpdateTaskTemplate } from '../../containers/use_update_task_template'; +import { useCasesContext } from '../cases_context/use_cases_context'; +import * as i18n from '../configure_cases/translations'; +import * as taskI18n from '../tasks/translations'; + +const PRIORITY_OPTIONS = [ + { value: 'low' as const, text: taskI18n.PRIORITY_LOW }, + { value: 'medium' as const, text: taskI18n.PRIORITY_MEDIUM }, + { value: 'high' as const, text: taskI18n.PRIORITY_HIGH }, + { value: 'critical' as const, text: taskI18n.PRIORITY_CRITICAL }, +]; + +interface TaskEntry { + title: string; + description: string; + priority: 'low' | 'medium' | 'high' | 'critical'; + relative_due_days: string; // string for input, convert to number on submit +} + +const defaultTask = (): TaskEntry => ({ + title: '', + description: '', + priority: 'medium', + relative_due_days: '', +}); + +interface TaskTemplateFlyoutProps { + templateToEdit: CaseTaskTemplate | null; + onClose: () => void; +} + +export const TaskTemplateFlyout: React.FC = ({ + templateToEdit, + onClose, +}) => { + const { owner } = useCasesContext(); + const titleId = useGeneratedHtmlId(); + + const isEdit = templateToEdit != null; + + const [name, setName] = useState(templateToEdit?.name ?? ''); + const [description, setDescription] = useState(templateToEdit?.description ?? ''); + const [scope, setScope] = useState<'global' | 'space'>(templateToEdit?.scope ?? 'space'); + const [tasks, setTasks] = useState(() => { + if (templateToEdit?.tasks?.length) { + return templateToEdit.tasks.map((t) => ({ + title: t.title, + description: t.description ?? '', + priority: t.priority, + relative_due_days: t.relative_due_days != null ? String(t.relative_due_days) : '', + })); + } + return [defaultTask()]; + }); + + const [nameError, setNameError] = useState(null); + const [tasksError, setTasksError] = useState(null); + + const { mutate: createTemplate, isLoading: isCreating } = useCreateTaskTemplate(); + const { mutate: updateTemplate, isLoading: isUpdating } = useUpdateTaskTemplate(); + const isLoading = isCreating || isUpdating; + + const updateTask = useCallback( + (index: number, field: keyof TaskEntry, value: string) => { + setTasks((prev) => { + const next = [...prev]; + next[index] = { ...next[index], [field]: value }; + return next; + }); + setTasksError(null); + }, + [] + ); + + const addTask = useCallback(() => { + setTasks((prev) => [...prev, defaultTask()]); + }, []); + + const removeTask = useCallback((index: number) => { + setTasks((prev) => prev.filter((_, i) => i !== index)); + }, []); + + const validate = (): boolean => { + let valid = true; + if (!name.trim()) { + setNameError(i18n.TASK_TEMPLATE_NAME_REQUIRED); + valid = false; + } else { + setNameError(null); + } + const invalidTasks = tasks.some((t) => !t.title.trim()); + if (invalidTasks || tasks.length === 0) { + setTasksError(i18n.TASK_TEMPLATE_TASKS_REQUIRED); + valid = false; + } else { + setTasksError(null); + } + return valid; + }; + + const handleSave = useCallback(() => { + if (!validate()) return; + + const taskPayload: CreateTaskTemplateRequest['tasks'] = tasks.map((t, idx) => ({ + title: t.title.trim(), + description: t.description, + priority: t.priority, + relative_due_days: t.relative_due_days ? Number(t.relative_due_days) : null, + sort_order: idx, + subtasks: [], + })); + + if (isEdit && templateToEdit) { + updateTemplate( + { + templateId: templateToEdit.id, + request: { + version: templateToEdit.version, + name: name.trim(), + description, + scope, + tasks: taskPayload, + }, + }, + { onSuccess: onClose } + ); + } else { + createTemplate( + { + name: name.trim(), + description, + scope, + tasks: taskPayload, + owner: owner[0] ?? 'cases', + }, + { onSuccess: onClose } + ); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [name, description, scope, tasks, isEdit, templateToEdit, createTemplate, updateTemplate, onClose, owner]); + + return ( + + + +

+ {isEdit ? i18n.EDIT_TASK_TEMPLATE : i18n.ADD_TASK_TEMPLATE} +

+
+
+ + + + { + setName(e.target.value); + setNameError(null); + }} + data-test-subj="cases-task-template-name" + /> + + + + + + setDescription(e.target.value)} + data-test-subj="cases-task-template-description" + /> + + + + + + setScope(e.target.value as 'global' | 'space')} + options={[ + { value: 'space', text: 'Space' }, + { value: 'global', text: 'Global' }, + ]} + data-test-subj="cases-task-template-scope" + /> + + + + + + {i18n.TASK_TEMPLATE_TASKS_COUNT} + + {tasksError && ( + + {tasksError} + + )} + + + {tasks.map((task, idx) => ( + + + + + Task {idx + 1} + + + {tasks.length > 1 && ( + + removeTask(idx)} + data-test-subj={`cases-task-template-remove-task-${idx}`} + /> + + )} + + + + updateTask(idx, 'title', e.target.value)} + data-test-subj={`cases-task-template-task-title-${idx}`} + /> + + + + + + updateTask(idx, 'description', e.target.value)} + data-test-subj={`cases-task-template-task-description-${idx}`} + /> + + + + + updateTask(idx, 'priority', e.target.value)} + data-test-subj={`cases-task-template-task-priority-${idx}`} + /> + + + + + updateTask(idx, 'relative_due_days', e.target.value)} + placeholder="e.g. 7" + style={{ width: 80 }} + data-test-subj={`cases-task-template-task-due-days-${idx}`} + /> + + + + + ))} + + + + {i18n.ADD_TASK_TO_TEMPLATE} + + + + + + + + {taskI18n.CANCEL} + + + + + + {taskI18n.SAVE_CHANGES} + + + + + +
+ ); +}; diff --git a/x-pack/platform/plugins/shared/cases/public/components/task_templates/task_templates.tsx b/x-pack/platform/plugins/shared/cases/public/components/task_templates/task_templates.tsx new file mode 100644 index 0000000000000..08404a5410834 --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/public/components/task_templates/task_templates.tsx @@ -0,0 +1,169 @@ +/* + * 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, { useCallback } from 'react'; +import { + EuiBasicTable, + EuiButton, + EuiButtonEmpty, + EuiButtonIcon, + EuiDescribedFormGroup, + EuiFlexGroup, + EuiFlexItem, + EuiPanel, + EuiSpacer, + EuiText, +} from '@elastic/eui'; +import type { EuiBasicTableColumn } from '@elastic/eui'; +import type { CaseTaskTemplate } from '../../../common/types/domain/task_template/v1'; +import { useGetTaskTemplates } from '../../containers/use_get_task_templates'; +import { useDeleteTaskTemplate } from '../../containers/use_delete_task_template'; +import { useCasesContext } from '../cases_context/use_cases_context'; +import * as i18n from '../configure_cases/translations'; +import * as taskI18n from '../tasks/translations'; + +interface TaskTemplatesProps { + disabled: boolean; + isLoading: boolean; + onAddTemplate: () => void; + onEditTemplate: (template: CaseTaskTemplate) => void; +} + +const TaskTemplatesComponent: React.FC = ({ + disabled, + isLoading: isLoadingExternal, + onAddTemplate, + onEditTemplate, +}) => { + const { permissions, owner } = useCasesContext(); + const canModify = !disabled && permissions.settings; + + const { + data, + isLoading: isLoadingTemplates, + } = useGetTaskTemplates({ owners: owner }); + + const { mutate: deleteTemplate } = useDeleteTaskTemplate(); + + const templates = data?.templates ?? []; + const isLoading = isLoadingExternal || isLoadingTemplates; + + const handleDelete = useCallback( + (templateId: string) => { + deleteTemplate(templateId); + }, + [deleteTemplate] + ); + + if (!permissions.settings) { + return null; + } + + const columns: Array> = [ + { + field: 'name', + name: i18n.TASK_TEMPLATE_NAME, + 'data-test-subj': 'cases-task-templates-col-name', + }, + { + field: 'tasks', + name: i18n.TASK_TEMPLATE_TASKS_COUNT, + width: '80px', + render: (tasks: CaseTaskTemplate['tasks']) => ( + {tasks?.length ?? 0} + ), + }, + { + name: taskI18n.TASK_ACTIONS, + width: '100px', + actions: [ + { + name: i18n.EDIT_TASK_TEMPLATE, + render: (row: CaseTaskTemplate) => ( + onEditTemplate(row)} + data-test-subj={`cases-task-template-edit-${row.id}`} + > + {i18n.EDIT_TASK_TEMPLATE} + + ), + }, + { + name: i18n.DELETE_TASK_TEMPLATE, + render: (row: CaseTaskTemplate) => ( + handleDelete(row.id)} + data-test-subj={`cases-task-template-delete-${row.id}`} + /> + ), + }, + ], + }, + ]; + + return ( + + +

{i18n.TASK_TEMPLATES_TITLE}

+
+ + } + description={

{i18n.TASK_TEMPLATES_DESCRIPTION}

} + data-test-subj="task-templates-form-group" + > + + {templates.length === 0 && !isLoading ? ( + + + {i18n.NO_TASK_TEMPLATES} + + + + ) : ( + + items={templates} + columns={columns} + loading={isLoading} + itemId="id" + data-test-subj="cases-task-templates-table" + /> + )} + + + + + {i18n.ADD_TASK_TEMPLATE} + + + + + +
+ ); +}; + +TaskTemplatesComponent.displayName = 'TaskTemplates'; + +export const TaskTemplates = React.memo(TaskTemplatesComponent); diff --git a/x-pack/platform/plugins/shared/cases/public/components/tasks/task_assignees_field.tsx b/x-pack/platform/plugins/shared/cases/public/components/tasks/task_assignees_field.tsx index 4263b61be5742..6c8df6444e142 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/tasks/task_assignees_field.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/tasks/task_assignees_field.tsx @@ -15,6 +15,7 @@ import { useSuggestUserProfiles } from '../../containers/user_profiles/use_sugge import { useBulkGetUserProfiles } from '../../containers/user_profiles/use_bulk_get_user_profiles'; import { useGetCurrentUserProfile } from '../../containers/user_profiles/use_get_current_user_profile'; import { useCasesContext } from '../cases_context/use_cases_context'; +import { useIsUserTyping } from '../../common/use_is_user_typing'; import * as i18n from './translations'; type UserProfileOption = EuiComboBoxOptionOption & UserProfileWithAvatar; @@ -35,11 +36,12 @@ interface TaskAssigneesFieldProps { export const TaskAssigneesField: React.FC = ({ value, onChange }) => { const { owner: owners } = useCasesContext(); const [searchTerm, setSearchTerm] = useState(''); + const { isUserTyping, onContentChange, onDebounce } = useIsUserTyping(); const { data: currentUserProfile, isLoading: isLoadingCurrentUser } = useGetCurrentUserProfile(); const { data: suggestedProfiles = [], isLoading: isLoadingSuggested, isFetching: isFetchingSuggested } = - useSuggestUserProfiles({ name: searchTerm, owners }); + useSuggestUserProfiles({ name: searchTerm, owners, onDebounce }); // Bulk-get profiles for already-selected assignees that aren't in the suggestion results const missingUids = differenceWith( @@ -60,7 +62,12 @@ export const TaskAssigneesField: React.FC = ({ value, o .map(({ uid }) => options.find((o) => o.key === uid)) .filter((o): o is UserProfileOption => o != null); - const isLoading = isLoadingCurrentUser || isLoadingSuggested || isFetchingSuggested || isLoadingBulk; + const isLoading = + isLoadingCurrentUser || + isLoadingSuggested || + isFetchingSuggested || + isLoadingBulk || + isUserTyping; const handleChange = useCallback( (selected: Array>) => { @@ -69,9 +76,15 @@ export const TaskAssigneesField: React.FC = ({ value, o [onChange] ); - const handleSearchChange = useCallback((term: string) => { - if (!isEmpty(term)) setSearchTerm(term); - }, []); + const handleSearchChange = useCallback( + (term: string) => { + if (!isEmpty(term)) { + setSearchTerm(term); + } + onContentChange(term); + }, + [onContentChange] + ); const handleSelfAssign = useCallback(() => { if (!currentUserProfile) return; diff --git a/x-pack/platform/plugins/shared/cases/public/components/tasks/tasks_table.tsx b/x-pack/platform/plugins/shared/cases/public/components/tasks/tasks_table.tsx index 495fbdc712d67..560a6715b49f0 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/tasks/tasks_table.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/tasks/tasks_table.tsx @@ -11,6 +11,7 @@ import { EuiBasicTable, EuiBadge, EuiButton, + EuiButtonEmpty, EuiButtonIcon, EuiEmptyPrompt, EuiFlexGroup, @@ -246,19 +247,23 @@ export const TasksTable = React.memo( { name: i18n.EDIT_TASK, render: (row: TaskRow) => ( - onEditTask?.(row)} - /> + > + {i18n.EDIT_TASK} + ), }, { name: i18n.ADD_SUBTASK, render: (row: TaskRow) => ( - { @@ -266,19 +271,24 @@ export const TasksTable = React.memo( setExpandedIds((prev) => new Set([...prev, row.id])); onAddSubTask?.(row); }} - /> + > + {i18n.ADD_SUBTASK} + ), }, { name: i18n.DELETE_TASK, render: (row: TaskRow) => ( - deleteTask(row.id)} - /> + > + {i18n.DELETE_TASK} + ), }, ], diff --git a/x-pack/platform/plugins/shared/cases/public/containers/api.ts b/x-pack/platform/plugins/shared/cases/public/containers/api.ts index 918c03c7b3f62..1e34a51393534 100644 --- a/x-pack/platform/plugins/shared/cases/public/containers/api.ts +++ b/x-pack/platform/plugins/shared/cases/public/containers/api.ts @@ -74,12 +74,17 @@ import { INTERNAL_CASE_GET_CASES_BY_ATTACHMENT_URL, } from '../../common/constants'; import type { CaseTask } from '../../common/types/domain/task/v1'; +import type { CaseTaskTemplate } from '../../common/types/domain/task_template/v1'; import { getCaseTasksUrl, getCaseTaskDetailsUrl, getCaseTasksReorderUrl, getCaseTasksApplyTemplateUrl, } from '../../common/api'; +import { + CASES_TASK_TEMPLATES_URL, + CASE_TASK_TEMPLATE_DETAILS_URL, +} from '../../common/constants'; import { getAllConnectorTypesUrl } from '../../common/utils/connectors_api'; import { KibanaServices } from '../common/lib/kibana'; @@ -831,3 +836,91 @@ export const applyTaskTemplate = async ( ); return response; }; + +// --------------------------------------------------------------------------- +// Task Templates API +// --------------------------------------------------------------------------- + +export interface FindTaskTemplatesRequest { + scope?: 'global' | 'space'; + owners?: string[]; + search?: string; + page?: number; + per_page?: number; +} + +export interface CreateTaskTemplateRequest { + name: string; + description?: string; + scope?: 'global' | 'space'; + tags?: string[]; + tasks: Array<{ + title: string; + description?: string; + priority: 'low' | 'medium' | 'high' | 'critical'; + relative_due_days?: number | null; + sort_order?: number; + subtasks?: Array<{ + title: string; + description?: string; + priority: 'low' | 'medium' | 'high' | 'critical'; + relative_due_days?: number | null; + sort_order?: number; + }>; + }>; + owner: string; +} + +export interface UpdateTaskTemplateRequest { + version: string; + name?: string; + description?: string; + scope?: 'global' | 'space'; + tags?: string[]; + tasks?: CreateTaskTemplateRequest['tasks']; +} + +export const findTaskTemplates = async ( + params?: FindTaskTemplatesRequest, + signal?: AbortSignal +): Promise<{ templates: CaseTaskTemplate[]; total: number }> => { + const query = params + ? Object.fromEntries(Object.entries(params).filter(([, v]) => v !== undefined)) + : {}; + return KibanaServices.get().http.fetch<{ templates: CaseTaskTemplate[]; total: number }>( + CASES_TASK_TEMPLATES_URL, + { method: 'GET', query, signal } + ); +}; + +export const createTaskTemplate = async ( + request: CreateTaskTemplateRequest, + signal?: AbortSignal +): Promise => { + return KibanaServices.get().http.fetch(CASES_TASK_TEMPLATES_URL, { + method: 'POST', + body: JSON.stringify(request), + signal, + }); +}; + +export const updateTaskTemplate = async ( + templateId: string, + request: UpdateTaskTemplateRequest, + signal?: AbortSignal +): Promise => { + const url = CASE_TASK_TEMPLATE_DETAILS_URL.replace('{template_id}', templateId); + return KibanaServices.get().http.fetch(url, { + method: 'PATCH', + body: JSON.stringify(request), + signal, + }); +}; + +export const deleteTaskTemplate = async ( + templateId: string, + signal?: AbortSignal +): Promise => { + const url = CASE_TASK_TEMPLATE_DETAILS_URL.replace('{template_id}', templateId); + await KibanaServices.get().http.fetch(url, { method: 'DELETE', signal }); +}; diff --git a/x-pack/platform/plugins/shared/cases/public/containers/constants.ts b/x-pack/platform/plugins/shared/cases/public/containers/constants.ts index 30ddbd809d6be..cffcd9cd7b9dc 100644 --- a/x-pack/platform/plugins/shared/cases/public/containers/constants.ts +++ b/x-pack/platform/plugins/shared/cases/public/containers/constants.ts @@ -17,6 +17,9 @@ export const casesQueriesKeys = { tasks: ['tasks'] as const, tasksList: (caseId: string, params?: unknown) => [...casesQueriesKeys.tasks, 'list', caseId, params] as const, + taskTemplates: ['task-templates'] as const, + taskTemplatesList: (params?: unknown) => + [...casesQueriesKeys.taskTemplates, 'list', params] as const, users: ['users'] as const, connectors: ['connectors'] as const, alerts: ['alerts'] as const, @@ -91,6 +94,9 @@ export const casesMutationsKeys = { updateTask: ['update-task'] as const, deleteTask: ['delete-task'] as const, reorderTasks: ['reorder-tasks'] as const, + createTaskTemplate: ['create-task-template'] as const, + updateTaskTemplate: ['update-task-template'] as const, + deleteTaskTemplate: ['delete-task-template'] as const, }; export const inferenceKeys = { diff --git a/x-pack/platform/plugins/shared/cases/public/containers/use_create_task_template.tsx b/x-pack/platform/plugins/shared/cases/public/containers/use_create_task_template.tsx new file mode 100644 index 0000000000000..f58b68c206579 --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/public/containers/use_create_task_template.tsx @@ -0,0 +1,25 @@ +/* + * 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 { useMutation, useQueryClient } from '@kbn/react-query'; +import { createTaskTemplate } from './api'; +import type { CreateTaskTemplateRequest } from './api'; +import { casesMutationsKeys, casesQueriesKeys } from './constants'; + +export const useCreateTaskTemplate = () => { + const queryClient = useQueryClient(); + + return useMutation( + casesMutationsKeys.createTaskTemplate, + (request: CreateTaskTemplateRequest) => createTaskTemplate(request), + { + onSuccess: () => { + queryClient.invalidateQueries(casesQueriesKeys.taskTemplates); + }, + } + ); +}; diff --git a/x-pack/platform/plugins/shared/cases/public/containers/use_delete_task_template.tsx b/x-pack/platform/plugins/shared/cases/public/containers/use_delete_task_template.tsx new file mode 100644 index 0000000000000..aecdcf37dd191 --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/public/containers/use_delete_task_template.tsx @@ -0,0 +1,24 @@ +/* + * 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 { useMutation, useQueryClient } from '@kbn/react-query'; +import { deleteTaskTemplate } from './api'; +import { casesMutationsKeys, casesQueriesKeys } from './constants'; + +export const useDeleteTaskTemplate = () => { + const queryClient = useQueryClient(); + + return useMutation( + casesMutationsKeys.deleteTaskTemplate, + (templateId: string) => deleteTaskTemplate(templateId), + { + onSuccess: () => { + queryClient.invalidateQueries(casesQueriesKeys.taskTemplates); + }, + } + ); +}; diff --git a/x-pack/platform/plugins/shared/cases/public/containers/use_get_task_templates.tsx b/x-pack/platform/plugins/shared/cases/public/containers/use_get_task_templates.tsx new file mode 100644 index 0000000000000..34e43464f35dc --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/public/containers/use_get_task_templates.tsx @@ -0,0 +1,23 @@ +/* + * 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 { useQuery } from '@kbn/react-query'; +import { findTaskTemplates } from './api'; +import type { FindTaskTemplatesRequest } from './api'; +import { casesQueriesKeys } from './constants'; + +export const useGetTaskTemplates = (params?: FindTaskTemplatesRequest) => { + return useQuery( + casesQueriesKeys.taskTemplatesList(params), + ({ signal }) => findTaskTemplates(params, signal), + { + keepPreviousData: true, + retry: false, + staleTime: 30_000, + } + ); +}; diff --git a/x-pack/platform/plugins/shared/cases/public/containers/use_update_task_template.tsx b/x-pack/platform/plugins/shared/cases/public/containers/use_update_task_template.tsx new file mode 100644 index 0000000000000..faed0fe3b3888 --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/public/containers/use_update_task_template.tsx @@ -0,0 +1,30 @@ +/* + * 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 { useMutation, useQueryClient } from '@kbn/react-query'; +import { updateTaskTemplate } from './api'; +import type { UpdateTaskTemplateRequest } from './api'; +import { casesMutationsKeys, casesQueriesKeys } from './constants'; + +interface UpdateTaskTemplateParams { + templateId: string; + request: UpdateTaskTemplateRequest; +} + +export const useUpdateTaskTemplate = () => { + const queryClient = useQueryClient(); + + return useMutation( + casesMutationsKeys.updateTaskTemplate, + ({ templateId, request }: UpdateTaskTemplateParams) => updateTaskTemplate(templateId, request), + { + onSuccess: () => { + queryClient.invalidateQueries(casesQueriesKeys.taskTemplates); + }, + } + ); +}; From 794c506abd04d207546db7276623fca52b363d25 Mon Sep 17 00:00:00 2001 From: Michael Olorunnisola Date: Mon, 16 Mar 2026 08:23:41 -0400 Subject: [PATCH 19/22] Match task assignees field exactly to main case assignees flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrote TaskAssigneesField to be an exact mirror of AssigneesComponent from case_form_fields/assignees.tsx. Key fixes: - Add useAvailableCasesOwners fallback when context owners array is empty — previously passing owners:[] to the suggest API caused unexpected behavior - Use bringCurrentUserToFrontAndSort for consistent option ordering - Use isFetching (not isLoading) from useBulkGetUserProfiles, matching ref - Add isDisabled state computed the same way as the reference component - Use exact same userProfileToComboBoxOption shape as reference Co-Authored-By: Claude Sonnet 4.6 --- .../components/tasks/task_assignees_field.tsx | 144 +++++++++++------- 1 file changed, 93 insertions(+), 51 deletions(-) diff --git a/x-pack/platform/plugins/shared/cases/public/components/tasks/task_assignees_field.tsx b/x-pack/platform/plugins/shared/cases/public/components/tasks/task_assignees_field.tsx index 6c8df6444e142..8b5af0db6c586 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/tasks/task_assignees_field.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/tasks/task_assignees_field.tsx @@ -8,7 +8,15 @@ import { differenceWith, isEmpty } from 'lodash'; import React, { useCallback, useState } from 'react'; import type { EuiComboBoxOptionOption } from '@elastic/eui'; -import { EuiComboBox, EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiHighlight, EuiLink, EuiTextColor } from '@elastic/eui'; +import { + EuiComboBox, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiHighlight, + EuiLink, + EuiTextColor, +} from '@elastic/eui'; import type { UserProfileWithAvatar } from '@kbn/user-profile-components'; import { getUserDisplayName, UserAvatar } from '@kbn/user-profile-components'; import { useSuggestUserProfiles } from '../../containers/user_profiles/use_suggest_user_profiles'; @@ -16,17 +24,25 @@ import { useBulkGetUserProfiles } from '../../containers/user_profiles/use_bulk_ import { useGetCurrentUserProfile } from '../../containers/user_profiles/use_get_current_user_profile'; import { useCasesContext } from '../cases_context/use_cases_context'; import { useIsUserTyping } from '../../common/use_is_user_typing'; +import { useAvailableCasesOwners } from '../app/use_available_owners'; +import { getAllPermissionsExceptFrom } from '../../utils/permissions'; +import { bringCurrentUserToFrontAndSort } from '../user_profiles/sort'; import * as i18n from './translations'; -type UserProfileOption = EuiComboBoxOptionOption & UserProfileWithAvatar; +type UserProfileComboBoxOption = EuiComboBoxOptionOption & UserProfileWithAvatar; -const toOption = (profile: UserProfileWithAvatar): UserProfileOption => +const userProfileToComboBoxOption = (userProfile: UserProfileWithAvatar): UserProfileComboBoxOption => ({ - label: getUserDisplayName(profile.user), - value: profile.uid, - key: profile.uid, - ...profile, - } as UserProfileOption); + label: getUserDisplayName(userProfile.user), + value: userProfile.uid, + key: userProfile.uid, + user: userProfile.user, + data: userProfile.data, + } as UserProfileComboBoxOption); + +const comboBoxOptionToAssignee = (option: EuiComboBoxOptionOption) => ({ + uid: option.value ?? '', +}); interface TaskAssigneesFieldProps { value: Array<{ uid: string }>; @@ -35,48 +51,46 @@ interface TaskAssigneesFieldProps { export const TaskAssigneesField: React.FC = ({ value, onChange }) => { const { owner: owners } = useCasesContext(); + const availableOwners = useAvailableCasesOwners(getAllPermissionsExceptFrom('delete')); const [searchTerm, setSearchTerm] = useState(''); const { isUserTyping, onContentChange, onDebounce } = useIsUserTyping(); - - const { data: currentUserProfile, isLoading: isLoadingCurrentUser } = useGetCurrentUserProfile(); - - const { data: suggestedProfiles = [], isLoading: isLoadingSuggested, isFetching: isFetchingSuggested } = - useSuggestUserProfiles({ name: searchTerm, owners, onDebounce }); - - // Bulk-get profiles for already-selected assignees that aren't in the suggestion results - const missingUids = differenceWith( + const hasOwners = owners.length > 0; + + const { data: currentUserProfile, isLoading: isLoadingCurrentUserProfile } = + useGetCurrentUserProfile(); + + const { + data: userProfiles = [], + isLoading: isLoadingSuggest, + isFetching: isFetchingSuggest, + } = useSuggestUserProfiles({ + name: searchTerm, + owners: hasOwners ? owners : availableOwners, + onDebounce, + }); + + const assigneesWithoutProfiles = differenceWith( value, - suggestedProfiles, - (assignee, profile) => assignee.uid === profile.uid - ).map((a) => a.uid); + userProfiles, + (assignee, userProfile) => assignee.uid === userProfile.uid + ); - const { data: bulkProfiles = new Map(), isLoading: isLoadingBulk } = - useBulkGetUserProfiles({ uids: missingUids }); + const { data: bulkUserProfiles = new Map(), isFetching: isLoadingBulkGetUserProfiles } = + useBulkGetUserProfiles({ uids: assigneesWithoutProfiles.map((assignee) => assignee.uid) }); - const bulkProfilesArray = Array.from(bulkProfiles.values()); + const bulkUserProfilesAsArray = Array.from(bulkUserProfiles).map(([_, profile]) => profile); - const allProfiles = [...suggestedProfiles, ...bulkProfilesArray]; - const options: UserProfileOption[] = allProfiles.map(toOption); + const options = + bringCurrentUserToFrontAndSort(currentUserProfile, [ + ...userProfiles, + ...bulkUserProfilesAsArray, + ])?.map((userProfile) => userProfileToComboBoxOption(userProfile)) ?? []; - const selectedOptions: UserProfileOption[] = value + const selectedOptions: UserProfileComboBoxOption[] = value .map(({ uid }) => options.find((o) => o.key === uid)) - .filter((o): o is UserProfileOption => o != null); - - const isLoading = - isLoadingCurrentUser || - isLoadingSuggested || - isFetchingSuggested || - isLoadingBulk || - isUserTyping; - - const handleChange = useCallback( - (selected: Array>) => { - onChange(selected.map((o) => ({ uid: o.value ?? '' }))); - }, - [onChange] - ); + .filter((o): o is UserProfileComboBoxOption => o != null); - const handleSearchChange = useCallback( + const onSearchComboChange = useCallback( (term: string) => { if (!isEmpty(term)) { setSearchTerm(term); @@ -86,7 +100,14 @@ export const TaskAssigneesField: React.FC = ({ value, o [onContentChange] ); - const handleSelfAssign = useCallback(() => { + const onComboChange = useCallback( + (selected: Array>) => { + onChange(selected.map((option) => comboBoxOptionToAssignee(option))); + }, + [onChange] + ); + + const onSelfAssign = useCallback(() => { if (!currentUserProfile) return; const alreadySelected = value.some((a) => a.uid === currentUserProfile.uid); if (!alreadySelected) onChange([...value, { uid: currentUserProfile.uid }]); @@ -94,14 +115,19 @@ export const TaskAssigneesField: React.FC = ({ value, o const renderOption = useCallback( (option: EuiComboBoxOptionOption, searchValue: string, contentClassName: string) => { - const { user, data } = option as UserProfileOption; + const { user, data } = option as UserProfileComboBoxOption; const displayName = getUserDisplayName(user); return ( - + - + - + {displayName} @@ -123,15 +149,30 @@ export const TaskAssigneesField: React.FC = ({ value, o [] ); - const isSelfSelected = Boolean(currentUserProfile && value.some((a) => a.uid === currentUserProfile.uid)); + const isLoading = + isLoadingCurrentUserProfile || + isLoadingBulkGetUserProfiles || + isLoadingSuggest || + isFetchingSuggest || + isUserTyping; + + const isDisabled = isLoadingCurrentUserProfile || isLoadingBulkGetUserProfiles; + + const isCurrentUserSelected = Boolean( + value.find((assignee) => assignee.uid === currentUserProfile?.uid) + ); return ( + {i18n.ASSIGN_YOURSELF} ) : undefined @@ -141,10 +182,11 @@ export const TaskAssigneesField: React.FC = ({ value, o fullWidth async isLoading={isLoading} + isDisabled={isDisabled} options={options} selectedOptions={selectedOptions} - onChange={handleChange} - onSearchChange={handleSearchChange} + onChange={onComboChange} + onSearchChange={onSearchComboChange} renderOption={renderOption} rowHeight={35} data-test-subj="cases-task-assignees-combobox" From f06b022c92180513a9e37599b96407b3cf62c329 Mon Sep 17 00:00:00 2001 From: Michael Olorunnisola Date: Mon, 16 Mar 2026 08:46:08 -0400 Subject: [PATCH 20/22] Fix tasks tab hiding case view navigation tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CaseViewTasks was missing CaseViewTabs — every other tab content component (Activity, Attachments, SimilarCases) renders CaseViewTabs at its top so the navigation persists. Added CaseViewTabs with activeTab=TASKS to CaseViewTasks and updated CaseViewPage to pass caseData + searchTerm instead of just caseId. Co-Authored-By: Claude Sonnet 4.6 --- .../components/case_view/case_view_page.tsx | 2 +- .../components/tasks/case_view_tasks.tsx | 32 +++++++++++++------ 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/x-pack/platform/plugins/shared/cases/public/components/case_view/case_view_page.tsx b/x-pack/platform/plugins/shared/cases/public/components/case_view/case_view_page.tsx index 45c96521d4373..cf31a5cdc0f32 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/case_view/case_view_page.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/case_view/case_view_page.tsx @@ -188,7 +188,7 @@ export const CaseViewPage = React.memo( )} {activeTabId === CASE_VIEW_PAGE_TABS.TASKS && ( - + )} diff --git a/x-pack/platform/plugins/shared/cases/public/components/tasks/case_view_tasks.tsx b/x-pack/platform/plugins/shared/cases/public/components/tasks/case_view_tasks.tsx index 17b3f6be578a1..83cc996e43207 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/tasks/case_view_tasks.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/tasks/case_view_tasks.tsx @@ -8,13 +8,17 @@ import React, { useState } from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import type { CaseTask } from '../../../common/types/domain/task/v1'; +import type { CaseUI } from '../../../common/ui/types'; +import { CASE_VIEW_PAGE_TABS } from '../../../common/types'; import { useGetTasks } from '../../containers/use_get_tasks'; +import { CaseViewTabs } from '../case_view/case_view_tabs'; import { TasksTable } from './tasks_table'; import { AddTaskFlyout } from './add_task_flyout'; import { EditTaskFlyout } from './edit_task_flyout'; interface CaseViewTasksProps { - caseId: string; + caseData: CaseUI; + searchTerm?: string; } interface AddTaskState { @@ -22,7 +26,8 @@ interface AddTaskState { parentTask: CaseTask | null; } -export const CaseViewTasks = React.memo(({ caseId }) => { +export const CaseViewTasks = React.memo(({ caseData, searchTerm }) => { + const caseId = caseData.id; const { data, isLoading } = useGetTasks(caseId); const [addState, setAddState] = useState({ open: false, parentTask: null }); @@ -32,14 +37,23 @@ export const CaseViewTasks = React.memo(({ caseId }) => { <> - setAddState({ open: true, parentTask: null })} - onEditTask={(task) => setEditingTask(task)} - onAddSubTask={(parentTask) => setAddState({ open: true, parentTask })} + + + + setAddState({ open: true, parentTask: null })} + onEditTask={(task) => setEditingTask(task)} + onAddSubTask={(parentTask) => setAddState({ open: true, parentTask })} + /> + + From 6163df2c690608d02b6fecebcd4f2bd19c94cefc Mon Sep 17 00:00:00 2001 From: Michael Olorunnisola Date: Mon, 16 Mar 2026 09:37:35 -0400 Subject: [PATCH 21/22] Add task checklist indicator and apply-template from cases UI Checklist visual: - Add EuiCheckbox to the title column of the tasks table; checked = completed, unchecked = open. Toggling the checkbox calls updateTask directly so there is no round-trip through the status-cycle badge - Completed task titles render with line-through + subdued colour so the done/not-done state is immediately scannable at a glance - Status badge remains for in_progress / cancelled granularity Apply template from cases UI: - useApplyTaskTemplate hook wraps the existing applyTaskTemplate API call and invalidates the task list query on success - ApplyTemplateModal: EuiModal with EuiSelectable list of templates (shows task count per template); confirm button calls applyTemplate then closes - ApplyTemplateModal shown from CaseViewTasks; "Apply template" button added to the TasksTable toolbar and empty-state actions so it is reachable in both the populated and empty list states Co-Authored-By: Claude Sonnet 4.6 --- .../components/tasks/apply_template_modal.tsx | 129 ++++++++++++++++ .../components/tasks/case_view_tasks.tsx | 10 ++ .../public/components/tasks/tasks_table.tsx | 141 +++++++++++++----- .../public/components/tasks/translations.ts | 42 ++++++ .../cases/public/containers/constants.ts | 1 + .../containers/use_apply_task_template.tsx | 31 ++++ 6 files changed, 319 insertions(+), 35 deletions(-) create mode 100644 x-pack/platform/plugins/shared/cases/public/components/tasks/apply_template_modal.tsx create mode 100644 x-pack/platform/plugins/shared/cases/public/containers/use_apply_task_template.tsx diff --git a/x-pack/platform/plugins/shared/cases/public/components/tasks/apply_template_modal.tsx b/x-pack/platform/plugins/shared/cases/public/components/tasks/apply_template_modal.tsx new file mode 100644 index 0000000000000..f363dff1bfbf7 --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/public/components/tasks/apply_template_modal.tsx @@ -0,0 +1,129 @@ +/* + * 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, { useState } from 'react'; +import { + EuiButton, + EuiButtonEmpty, + EuiCallOut, + EuiEmptyPrompt, + EuiFlexGroup, + EuiFlexItem, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiSelectable, + EuiSkeletonText, + EuiText, +} from '@elastic/eui'; +import type { EuiSelectableOption } from '@elastic/eui'; +import { useGetTaskTemplates } from '../../containers/use_get_task_templates'; +import { useApplyTaskTemplate } from '../../containers/use_apply_task_template'; +import { useCasesContext } from '../cases_context/use_cases_context'; +import * as i18n from './translations'; + +interface ApplyTemplateModalProps { + caseId: string; + onClose: () => void; +} + +export const ApplyTemplateModal: React.FC = ({ caseId, onClose }) => { + const { owner } = useCasesContext(); + const [selectedId, setSelectedId] = useState(null); + + const { data, isLoading: isLoadingTemplates } = useGetTaskTemplates({ owners: owner }); + const { mutate: applyTemplate, isLoading: isApplying, isError } = useApplyTaskTemplate(); + + const templates = data?.templates ?? []; + + const options: Array> = templates.map((t) => ({ + label: t.name, + key: t.id, + templateId: t.id, + checked: selectedId === t.id ? 'on' : undefined, + append: ( + + {t.tasks?.length ?? 0} {t.tasks?.length === 1 ? 'task' : 'tasks'} + + ), + })); + + const handleChange = (newOptions: Array>) => { + const selected = newOptions.find((o) => o.checked === 'on'); + setSelectedId(selected?.key ?? null); + }; + + const handleApply = () => { + if (!selectedId) return; + applyTemplate( + { caseId, templateId: selectedId, owner: owner[0] ?? 'cases' }, + { onSuccess: onClose } + ); + }; + + return ( + + + {i18n.APPLY_TEMPLATE} + + + + {isError && ( + + )} + + {isLoadingTemplates ? ( + + ) : templates.length === 0 ? ( + {i18n.NO_TASK_TEMPLATES_AVAILABLE}} + body={

{i18n.NO_TASK_TEMPLATES_AVAILABLE_DESCRIPTION}

} + titleSize="xs" + data-test-subj="cases-apply-template-empty" + /> + ) : ( + + + + {i18n.APPLY_TEMPLATE_DESCRIPTION} + + + + + options={options} + onChange={handleChange} + singleSelection + listProps={{ bordered: true, rowHeight: 40 }} + data-test-subj="cases-apply-template-selectable" + > + {(list) => list} + + + + )} +
+ + + + {i18n.CANCEL} + + + {i18n.APPLY_TEMPLATE_CONFIRM} + + +
+ ); +}; diff --git a/x-pack/platform/plugins/shared/cases/public/components/tasks/case_view_tasks.tsx b/x-pack/platform/plugins/shared/cases/public/components/tasks/case_view_tasks.tsx index 83cc996e43207..cd92441c0c7e9 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/tasks/case_view_tasks.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/tasks/case_view_tasks.tsx @@ -15,6 +15,7 @@ import { CaseViewTabs } from '../case_view/case_view_tabs'; import { TasksTable } from './tasks_table'; import { AddTaskFlyout } from './add_task_flyout'; import { EditTaskFlyout } from './edit_task_flyout'; +import { ApplyTemplateModal } from './apply_template_modal'; interface CaseViewTasksProps { caseData: CaseUI; @@ -32,6 +33,7 @@ export const CaseViewTasks = React.memo(({ caseData, searchT const [addState, setAddState] = useState({ open: false, parentTask: null }); const [editingTask, setEditingTask] = useState(null); + const [applyTemplateOpen, setApplyTemplateOpen] = useState(false); return ( <> @@ -51,6 +53,7 @@ export const CaseViewTasks = React.memo(({ caseData, searchT onAddTask={() => setAddState({ open: true, parentTask: null })} onEditTask={(task) => setEditingTask(task)} onAddSubTask={(parentTask) => setAddState({ open: true, parentTask })} + onApplyTemplate={() => setApplyTemplateOpen(true)} />
@@ -73,6 +76,13 @@ export const CaseViewTasks = React.memo(({ caseData, searchT onClose={() => setEditingTask(null)} /> )} + + {applyTemplateOpen && ( + setApplyTemplateOpen(false)} + /> + )} ); }); diff --git a/x-pack/platform/plugins/shared/cases/public/components/tasks/tasks_table.tsx b/x-pack/platform/plugins/shared/cases/public/components/tasks/tasks_table.tsx index 560a6715b49f0..833a556d81579 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/tasks/tasks_table.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/tasks/tasks_table.tsx @@ -13,12 +13,14 @@ import { EuiButton, EuiButtonEmpty, EuiButtonIcon, + EuiCheckbox, EuiEmptyPrompt, EuiFlexGroup, EuiFlexItem, EuiSkeletonText, EuiText, EuiToolTip, + useGeneratedHtmlId, } from '@elastic/eui'; import type { EuiBasicTableColumn } from '@elastic/eui'; import { getUserDisplayName } from '@kbn/user-profile-components'; @@ -88,6 +90,70 @@ const NEXT_STATUS: Record = { cancelled: 'open', }; +// --------------------------------------------------------------------------- +// Title cell with checkbox +// --------------------------------------------------------------------------- + +interface TaskTitleCellProps { + row: TaskRow; + expandedIds: Set; + onToggleExpand: (id: string) => void; + onToggleComplete: (task: TaskRow) => void; +} + +const TaskTitleCell: React.FC = ({ + row, + expandedIds, + onToggleExpand, + onToggleComplete, +}) => { + const checkboxId = useGeneratedHtmlId({ prefix: 'task-check' }); + const isCompleted = row.status === 'completed'; + + return ( + + {row._hasChildren ? ( + + onToggleExpand(row.id)} + data-test-subj={`cases-tasks-expand-${row.id}`} + /> + + ) : ( + + )} + + onToggleComplete(row)} + aria-label={isCompleted ? i18n.MARK_INCOMPLETE : i18n.MARK_COMPLETE} + data-test-subj={`cases-tasks-check-${row.id}`} + /> + + + + {row._depth > 0 && ( + + )} + {row.title} + + + + ); +}; + // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- @@ -99,10 +165,11 @@ interface TasksTableProps { onAddTask?: () => void; onEditTask?: (task: CaseTask) => void; onAddSubTask?: (parentTask: CaseTask) => void; + onApplyTemplate?: () => void; } export const TasksTable = React.memo( - ({ caseId, tasks, isLoading, onAddTask, onEditTask, onAddSubTask }) => { + ({ caseId, tasks, isLoading, onAddTask, onEditTask, onAddSubTask, onApplyTemplate }) => { const { mutate: deleteTask } = useDeleteTask(caseId); const { mutate: updateTask } = useUpdateTask(caseId); @@ -147,34 +214,20 @@ export const TasksTable = React.memo( field: 'title', 'data-test-subj': 'cases-tasks-col-title', render: (title: string, row: TaskRow) => ( - - {row._hasChildren ? ( - - toggleExpand(row.id)} - data-test-subj={`cases-tasks-expand-${row.id}`} - /> - - ) : ( - - )} - - - {row._depth > 0 && ( - - )} - {title} - - - + { + updateTask({ + taskId: task.id, + request: { + version: task.version, + status: task.status === 'completed' ? 'open' : 'completed', + }, + }); + }} + /> ), }, { @@ -307,11 +360,22 @@ export const TasksTable = React.memo( titleSize="xs" data-test-subj="cases-tasks-table-empty" actions={ - onAddTask ? ( - - {i18n.ADD_TASK} - - ) : null + + {onAddTask && ( + + + {i18n.ADD_TASK} + + + )} + {onApplyTemplate && ( + + + {i18n.APPLY_TEMPLATE} + + + )} + } /> ); @@ -320,7 +384,14 @@ export const TasksTable = React.memo( return ( - + + {onApplyTemplate && ( + + + {i18n.APPLY_TEMPLATE} + + + )} {onAddTask && ( diff --git a/x-pack/platform/plugins/shared/cases/public/components/tasks/translations.ts b/x-pack/platform/plugins/shared/cases/public/components/tasks/translations.ts index 39242034efbea..13561a10e0c09 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/tasks/translations.ts +++ b/x-pack/platform/plugins/shared/cases/public/components/tasks/translations.ts @@ -161,3 +161,45 @@ export const COLLAPSE_SUBTASKS = i18n.translate('xpack.cases.tasks.collapseSubta export const CLICK_TO_ADVANCE_STATUS = i18n.translate('xpack.cases.tasks.clickToAdvanceStatus', { defaultMessage: 'Click to advance status', }); + +export const MARK_COMPLETE = i18n.translate('xpack.cases.tasks.markComplete', { + defaultMessage: 'Mark as complete', +}); + +export const MARK_INCOMPLETE = i18n.translate('xpack.cases.tasks.markIncomplete', { + defaultMessage: 'Mark as incomplete', +}); + +export const APPLY_TEMPLATE = i18n.translate('xpack.cases.tasks.applyTemplate', { + defaultMessage: 'Apply template', +}); + +export const APPLY_TEMPLATE_DESCRIPTION = i18n.translate( + 'xpack.cases.tasks.applyTemplateDescription', + { + defaultMessage: 'Select a task template to add its tasks to this case.', + } +); + +export const APPLY_TEMPLATE_CONFIRM = i18n.translate('xpack.cases.tasks.applyTemplateConfirm', { + defaultMessage: 'Apply', +}); + +export const APPLY_TEMPLATE_ERROR = i18n.translate('xpack.cases.tasks.applyTemplateError', { + defaultMessage: 'Failed to apply template. Please try again.', +}); + +export const NO_TASK_TEMPLATES_AVAILABLE = i18n.translate( + 'xpack.cases.tasks.noTaskTemplatesAvailable', + { + defaultMessage: 'No task templates', + } +); + +export const NO_TASK_TEMPLATES_AVAILABLE_DESCRIPTION = i18n.translate( + 'xpack.cases.tasks.noTaskTemplatesAvailableDescription', + { + defaultMessage: + 'Create task templates in case settings to quickly add predefined sets of tasks.', + } +); diff --git a/x-pack/platform/plugins/shared/cases/public/containers/constants.ts b/x-pack/platform/plugins/shared/cases/public/containers/constants.ts index cffcd9cd7b9dc..98fcdafc0eee0 100644 --- a/x-pack/platform/plugins/shared/cases/public/containers/constants.ts +++ b/x-pack/platform/plugins/shared/cases/public/containers/constants.ts @@ -97,6 +97,7 @@ export const casesMutationsKeys = { createTaskTemplate: ['create-task-template'] as const, updateTaskTemplate: ['update-task-template'] as const, deleteTaskTemplate: ['delete-task-template'] as const, + applyTaskTemplate: ['apply-task-template'] as const, }; export const inferenceKeys = { diff --git a/x-pack/platform/plugins/shared/cases/public/containers/use_apply_task_template.tsx b/x-pack/platform/plugins/shared/cases/public/containers/use_apply_task_template.tsx new file mode 100644 index 0000000000000..5e3d7c4456c1f --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/public/containers/use_apply_task_template.tsx @@ -0,0 +1,31 @@ +/* + * 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 { useMutation, useQueryClient } from '@kbn/react-query'; +import { applyTaskTemplate } from './api'; +import { casesMutationsKeys, casesQueriesKeys } from './constants'; + +interface ApplyTemplateParams { + caseId: string; + templateId: string; + owner: string; +} + +export const useApplyTaskTemplate = () => { + const queryClient = useQueryClient(); + + return useMutation( + casesMutationsKeys.applyTaskTemplate, + ({ caseId, templateId, owner }: ApplyTemplateParams) => + applyTaskTemplate(caseId, templateId, owner), + { + onSuccess: (_data, { caseId }) => { + queryClient.invalidateQueries(casesQueriesKeys.tasksList(caseId)); + }, + } + ); +}; From 96a3a9d5fab799f15047593b816e353eea015f84 Mon Sep 17 00:00:00 2001 From: Michael Olorunnisola Date: Mon, 16 Mar 2026 13:37:28 -0400 Subject: [PATCH 22/22] [Cases] Add task settings page, board view, and task status improvements - Add standalone /task-settings page with task statuses and task templates - Add kanban board view for tasks with drag-and-drop between status columns - Add task reordering within and across columns - Fix task status toggling not persisting (ConfigurationPartialAttributesRt missing taskStatuses) - Fix missing built-in statuses in task form after any custom status is saved - Fix task create/update API rejecting custom status keys (was validating literal union) - Handle orphaned task statuses: tasks with deleted statuses show as "Unassigned" / "No status" - Make column visibility toggles in board view use EuiSwitch with improved spacing - Wire up task card checkboxes in board view to toggle done/open Co-Authored-By: Claude Sonnet 4.6 --- .../cases/common/constants/application.ts | 1 + .../cases/common/types/api/configure/v1.ts | 12 +- .../cases/common/types/domain/configure/v1.ts | 58 ++- .../cases/common/types/domain/task/v1.ts | 49 +- .../plugins/shared/cases/common/ui/types.ts | 4 +- .../public/common/navigation/deep_links.ts | 11 + .../cases/public/common/navigation/hooks.ts | 10 + .../cases/public/common/navigation/paths.ts | 3 + .../cases/public/components/app/routes.tsx | 13 + .../components/configure_cases/index.tsx | 39 -- .../configure_cases/task_status_flyout.tsx | 197 +++++++ .../configure_cases/task_statuses.tsx | 213 ++++++++ .../public/components/task_settings/index.tsx | 163 ++++++ .../components/tasks/case_view_tasks.tsx | 174 ++++++- .../public/components/tasks/task_card.tsx | 227 ++++++++ .../components/tasks/task_form_fields.tsx | 23 +- .../public/components/tasks/tasks_board.tsx | 261 ++++++++++ .../public/components/tasks/tasks_table.tsx | 484 +++++++++++------- .../public/components/tasks/translations.ts | 30 ++ .../components/tasks/use_task_statuses.ts | 17 + .../components/use_breadcrumbs/index.ts | 7 + .../shared/cases/public/containers/api.ts | 10 +- .../cases/public/containers/configure/api.ts | 2 + .../configure/use_persist_configuration.tsx | 3 + .../public/containers/use_reorder_tasks.tsx | 67 +++ .../public/containers/use_update_task.tsx | 24 +- .../cases/server/common/types/configure.ts | 12 +- .../routes/api/tasks/patch_task_route.ts | 11 +- .../routes/api/tasks/post_task_route.ts | 11 +- 29 files changed, 1817 insertions(+), 319 deletions(-) create mode 100644 x-pack/platform/plugins/shared/cases/public/components/configure_cases/task_status_flyout.tsx create mode 100644 x-pack/platform/plugins/shared/cases/public/components/configure_cases/task_statuses.tsx create mode 100644 x-pack/platform/plugins/shared/cases/public/components/task_settings/index.tsx create mode 100644 x-pack/platform/plugins/shared/cases/public/components/tasks/task_card.tsx create mode 100644 x-pack/platform/plugins/shared/cases/public/components/tasks/tasks_board.tsx create mode 100644 x-pack/platform/plugins/shared/cases/public/components/tasks/use_task_statuses.ts create mode 100644 x-pack/platform/plugins/shared/cases/public/containers/use_reorder_tasks.tsx diff --git a/x-pack/platform/plugins/shared/cases/common/constants/application.ts b/x-pack/platform/plugins/shared/cases/common/constants/application.ts index bf83559535b06..617add7931f44 100644 --- a/x-pack/platform/plugins/shared/cases/common/constants/application.ts +++ b/x-pack/platform/plugins/shared/cases/common/constants/application.ts @@ -29,6 +29,7 @@ export const CASE_VIEW_TAB_PATH = `${CASE_VIEW_PATH}/?tabId=:tabId` as const; export const CASES_TEMPLATES_PATH = '/templates' as const; export const CASES_CREATE_TEMPLATE_PATH = `${CASES_TEMPLATES_PATH}/create` as const; export const CASES_EDIT_TEMPLATE_PATH = `${CASES_TEMPLATES_PATH}/:templateId/edit` as const; +export const CASES_TASK_SETTINGS_PATH = '/task-settings' as const; /** * The main Cases application is in the stack management under the * Alerts and Insights section. To do that, Cases registers to the management diff --git a/x-pack/platform/plugins/shared/cases/common/types/api/configure/v1.ts b/x-pack/platform/plugins/shared/cases/common/types/api/configure/v1.ts index e5682d314f726..af3ff32b6b96f 100644 --- a/x-pack/platform/plugins/shared/cases/common/types/api/configure/v1.ts +++ b/x-pack/platform/plugins/shared/cases/common/types/api/configure/v1.ts @@ -27,7 +27,11 @@ import { CustomFieldNumberTypeRt, } from '../../domain'; import type { Configurations, Configuration } from '../../domain/configure/v1'; -import { ConfigurationBasicWithoutOwnerRt, ClosureTypeRt } from '../../domain/configure/v1'; +import { + ConfigurationBasicWithoutOwnerRt, + ClosureTypeRt, + TaskStatusesConfigurationRt, +} from '../../domain/configure/v1'; import { CaseConnectorRt } from '../../domain/connector/v1'; import { CaseBaseOptionalFieldsRequestRt } from '../case/v1'; import { @@ -189,6 +193,7 @@ export const ConfigurationRequestRt = rt.intersection([ customFields: CustomFieldsConfigurationRt, templates: TemplatesConfigurationRt, observableTypes: ObservableTypesConfigurationRt, + taskStatuses: TaskStatusesConfigurationRt, }) ), ]); @@ -210,11 +215,12 @@ export const CaseConfigureRequestParamsRt = rt.strict({ export const ConfigurationPatchRequestRt = rt.intersection([ rt.exact( rt.partial({ - closure_type: ConfigurationBasicWithoutOwnerRt.type.props.closure_type, - connector: ConfigurationBasicWithoutOwnerRt.type.props.connector, + closure_type: ConfigurationBasicWithoutOwnerRt.types[0].type.props.closure_type, + connector: ConfigurationBasicWithoutOwnerRt.types[0].type.props.connector, customFields: CustomFieldsConfigurationRt, templates: TemplatesConfigurationRt, observableTypes: ObservableTypesConfigurationRt, + taskStatuses: TaskStatusesConfigurationRt, }) ), rt.strict({ version: rt.string }), diff --git a/x-pack/platform/plugins/shared/cases/common/types/domain/configure/v1.ts b/x-pack/platform/plugins/shared/cases/common/types/domain/configure/v1.ts index b7d9a09791590..5db893ba9f9d2 100644 --- a/x-pack/platform/plugins/shared/cases/common/types/domain/configure/v1.ts +++ b/x-pack/platform/plugins/shared/cases/common/types/domain/configure/v1.ts @@ -15,6 +15,7 @@ import { } from '../custom_field/v1'; import { CaseBaseOptionalFieldsRt } from '../case/v1'; import { CaseObservableTypeRt } from '../observable/v1'; +import { TaskStatusDefinitionRt } from '../task/v1'; export const ClosureTypeRt = rt.union([ rt.literal('close-by-user'), @@ -107,28 +108,40 @@ export const TemplateConfigurationRt = rt.intersection([ export const TemplatesConfigurationRt = rt.array(TemplateConfigurationRt); -export const ConfigurationBasicWithoutOwnerRt = rt.strict({ - /** - * The external connector - */ - connector: CaseConnectorRt, - /** - * Whether to close the case after it has been synced with the external system - */ - closure_type: ClosureTypeRt, - /** - * The custom fields configured for the case - */ - customFields: CustomFieldsConfigurationRt, - /** - * Templates configured for the case - */ - templates: TemplatesConfigurationRt, - /** - * Observable types configured for the case - */ - observableTypes: ObservableTypesConfigurationRt, -}); +export const TaskStatusesConfigurationRt = rt.array(TaskStatusDefinitionRt); + +export const ConfigurationBasicWithoutOwnerRt = rt.intersection([ + rt.strict({ + /** + * The external connector + */ + connector: CaseConnectorRt, + /** + * Whether to close the case after it has been synced with the external system + */ + closure_type: ClosureTypeRt, + /** + * The custom fields configured for the case + */ + customFields: CustomFieldsConfigurationRt, + /** + * Templates configured for the case + */ + templates: TemplatesConfigurationRt, + /** + * Observable types configured for the case + */ + observableTypes: ObservableTypesConfigurationRt, + }), + rt.exact( + rt.partial({ + /** + * Custom task status definitions for this configuration + */ + taskStatuses: TaskStatusesConfigurationRt, + }) + ), +]); export const CasesConfigureBasicRt = rt.intersection([ ConfigurationBasicWithoutOwnerRt, @@ -175,3 +188,4 @@ export type Configuration = rt.TypeOf; export type Configurations = rt.TypeOf; export type ObservableTypesConfiguration = rt.TypeOf; export type ObservableTypeConfiguration = rt.TypeOf; +export type TaskStatusesConfiguration = rt.TypeOf; diff --git a/x-pack/platform/plugins/shared/cases/common/types/domain/task/v1.ts b/x-pack/platform/plugins/shared/cases/common/types/domain/task/v1.ts index f08f51a82c419..a72df12497195 100644 --- a/x-pack/platform/plugins/shared/cases/common/types/domain/task/v1.ts +++ b/x-pack/platform/plugins/shared/cases/common/types/domain/task/v1.ts @@ -8,12 +8,47 @@ import * as rt from 'io-ts'; import { UserRt } from '../user/v1'; -export const CaseTaskStatusRt = rt.keyof({ - open: null, - in_progress: null, - completed: null, - cancelled: null, -}); +export const CaseTaskStatusRt = rt.string; + +export const TaskStatusDefinitionRt = rt.intersection([ + rt.strict({ + key: rt.string, + label: rt.string, + color: rt.string, + }), + rt.exact(rt.partial({ disabled: rt.boolean })), +]); + +export const TaskStatusesConfigurationRt = rt.array(TaskStatusDefinitionRt); + +/** Keys of the four built-in statuses that can be disabled but never deleted. */ +export const BUILTIN_STATUS_KEYS: ReadonlySet = new Set([ + 'open', + 'in_progress', + 'done', + 'cancelled', +]); + +export const DEFAULT_TASK_STATUSES: Array> = [ + { key: 'open', label: 'Open', color: 'default' }, + { key: 'in_progress', label: 'In progress', color: 'primary' }, + { key: 'done', label: 'Done', color: 'success' }, + { key: 'cancelled', label: 'Cancelled', color: 'default' }, +]; + +/** + * Merge stored task statuses with the 4 built-in defaults. + * Built-ins always appear first, preserving any stored overrides (label/color/disabled). + * Custom statuses (not in BUILTIN_STATUS_KEYS) are appended after. + */ +export function mergeTaskStatusesWithDefaults( + stored: TaskStatusDefinition[] +): TaskStatusDefinition[] { + const storedByKey = new Map(stored.map((s) => [s.key, s])); + const builtins = DEFAULT_TASK_STATUSES.map((def) => storedByKey.get(def.key) ?? def); + const custom = stored.filter((s) => !BUILTIN_STATUS_KEYS.has(s.key)); + return [...builtins, ...custom]; +} export const CaseTaskPriorityRt = rt.keyof({ low: null, @@ -80,6 +115,8 @@ export const CaseTaskSummaryRt = rt.strict({ }); export type CaseTaskStatus = rt.TypeOf; +export type TaskStatusDefinition = rt.TypeOf; +export type TaskStatusesConfiguration = rt.TypeOf; export type CaseTaskPriority = rt.TypeOf; export type CaseTaskAssignee = rt.TypeOf; export type CaseTaskCustomField = rt.TypeOf; diff --git a/x-pack/platform/plugins/shared/cases/common/ui/types.ts b/x-pack/platform/plugins/shared/cases/common/ui/types.ts index 5ad23b01467d3..a9cd712259ba0 100644 --- a/x-pack/platform/plugins/shared/cases/common/ui/types.ts +++ b/x-pack/platform/plugins/shared/cases/common/ui/types.ts @@ -162,7 +162,9 @@ export type CasesConfigurationUI = Pick< | 'version' | 'owner' | 'observableTypes' ->; +> & { + taskStatuses?: Array<{ key: string; label: string; color: string; disabled?: boolean }>; +}; export type CasesConfigurationUICustomField = CasesConfigurationUI['customFields'][number]; export type CasesConfigurationUITemplate = CasesConfigurationUI['templates'][number]; diff --git a/x-pack/platform/plugins/shared/cases/public/common/navigation/deep_links.ts b/x-pack/platform/plugins/shared/cases/public/common/navigation/deep_links.ts index 4d4fc28cca725..4282e4fbc6665 100644 --- a/x-pack/platform/plugins/shared/cases/public/common/navigation/deep_links.ts +++ b/x-pack/platform/plugins/shared/cases/public/common/navigation/deep_links.ts @@ -12,6 +12,7 @@ import { getCreateCasePath, getCasesConfigurePath, getCasesTemplatesPath, + getCasesTaskSettingsPath, } from './paths'; export const CasesDeepLinkId = { @@ -19,6 +20,7 @@ export const CasesDeepLinkId = { casesCreate: 'cases_create', casesConfigure: 'cases_configure', casesTemplates: 'cases_templates', + casesTaskSettings: 'cases_task_settings', } as const; export type ICasesDeepLinkId = (typeof CasesDeepLinkId)[keyof typeof CasesDeepLinkId]; @@ -62,6 +64,15 @@ export const getCasesDeepLinks = ({ } as T & { id: ICasesDeepLinkId }); } + deepLinks.push({ + title: i18n.translate('xpack.cases.navigation.taskSettings', { + defaultMessage: 'Task settings', + }), + ...(extend[CasesDeepLinkId.casesTaskSettings] ?? {}), + id: CasesDeepLinkId.casesTaskSettings, + path: getCasesTaskSettingsPath(basePath), + } as T & { id: ICasesDeepLinkId }); + return { title: i18n.translate('xpack.cases.navigation.cases', { defaultMessage: 'Cases', diff --git a/x-pack/platform/plugins/shared/cases/public/common/navigation/hooks.ts b/x-pack/platform/plugins/shared/cases/public/common/navigation/hooks.ts index 21a27cfd514a2..6677d08dc1f63 100644 --- a/x-pack/platform/plugins/shared/cases/public/common/navigation/hooks.ts +++ b/x-pack/platform/plugins/shared/cases/public/common/navigation/hooks.ts @@ -14,6 +14,7 @@ import { CASES_CREATE_PATH, CASES_CREATE_TEMPLATE_PATH, CASES_TEMPLATES_PATH, + CASES_TASK_SETTINGS_PATH, } from '../../../common/constants'; import { useNavigation } from '../lib/kibana'; import type { ICasesDeepLinkId } from './deep_links'; @@ -77,6 +78,7 @@ const navigationMapping = { configure: { path: CASES_CONFIGURE_PATH }, templates: { path: CASES_TEMPLATES_PATH }, createTemplate: { path: CASES_CREATE_TEMPLATE_PATH }, + taskSettings: { path: CASES_TASK_SETTINGS_PATH }, }; export const useAllCasesNavigation = () => { @@ -120,6 +122,14 @@ export const useCasesCreateTemplateNavigation = () => { return { getCasesCreateTemplateUrl, navigateToCasesCreateTemplate }; }; +export const useCasesTaskSettingsNavigation = () => { + const [getCasesTaskSettingsUrl, navigateToCasesTaskSettings] = useCasesNavigation({ + path: navigationMapping.taskSettings.path, + deepLinkId: APP_ID, + }); + return { getCasesTaskSettingsUrl, navigateToCasesTaskSettings }; +}; + export const useTemplateViewParams = () => useParams(); type GetEditTemplateUrl = (pathParams: TemplateViewPathParams, absolute?: boolean) => string; diff --git a/x-pack/platform/plugins/shared/cases/public/common/navigation/paths.ts b/x-pack/platform/plugins/shared/cases/public/common/navigation/paths.ts index 97df62a49d601..c1ee3cafbf746 100644 --- a/x-pack/platform/plugins/shared/cases/public/common/navigation/paths.ts +++ b/x-pack/platform/plugins/shared/cases/public/common/navigation/paths.ts @@ -17,6 +17,7 @@ import { CASES_TEMPLATES_PATH, CASES_CREATE_TEMPLATE_PATH, CASES_EDIT_TEMPLATE_PATH, + CASES_TASK_SETTINGS_PATH, } from '../../../common/constants'; import type { CASE_VIEW_PAGE_TABS } from '../../../common/types'; @@ -43,6 +44,8 @@ export const getCaseViewWithCommentPath = (casesBasePath: string) => normalizePath(`${casesBasePath}${CASE_VIEW_COMMENT_PATH}`); export const getCasesTemplatesPath = (casesBasePath: string) => normalizePath(`${casesBasePath}${CASES_TEMPLATES_PATH}`); +export const getCasesTaskSettingsPath = (casesBasePath: string) => + normalizePath(`${casesBasePath}${CASES_TASK_SETTINGS_PATH}`); export const getCasesCreateTemplatePath = (casesBasePath: string) => normalizePath(`${casesBasePath}${CASES_CREATE_TEMPLATE_PATH}`); export const getCasesEditTemplatePath = (casesBasePath: string) => diff --git a/x-pack/platform/plugins/shared/cases/public/components/app/routes.tsx b/x-pack/platform/plugins/shared/cases/public/components/app/routes.tsx index 90553d7a33a3b..7de26e6a4958e 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/app/routes.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/app/routes.tsx @@ -27,6 +27,7 @@ import { getCasesTemplatesPath, getCasesCreateTemplatePath, getCasesEditTemplatePath, + getCasesTaskSettingsPath, } from '../../common/navigation'; import { NoPrivilegesPage } from '../no_privileges'; import * as i18n from './translations'; @@ -51,6 +52,8 @@ const AllCasesTemplatesLazy: React.FC = lazy( () => import('../templates_v2/pages/all_templates_page') ); +const TaskSettingsPageLazy: React.FC = lazy(() => import('../task_settings')); + const CasesRoutesComponent: React.FC = ({ actionsNavigation, ruleDetailsNavigation, @@ -127,6 +130,16 @@ const CasesRoutesComponent: React.FC = ({ )} + + {permissions.settings ? ( + }> + + + ) : ( + + )} + + {/* NOTE: current case view implementation retains some local state between renders, eg. when going from one case directly to another one. as a short term fix, we are forcing the component remount. */} }> diff --git a/x-pack/platform/plugins/shared/cases/public/components/configure_cases/index.tsx b/x-pack/platform/plugins/shared/cases/public/components/configure_cases/index.tsx index 3978515cd87ae..173e0ef070333 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/configure_cases/index.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/configure_cases/index.tsx @@ -60,9 +60,6 @@ import { builderMap as customFieldsBuilderMap } from '../custom_fields/builder'; import { ObservableTypes } from '../observable_types'; import { ObservableTypesForm } from '../observable_types/form'; import { useCasesFeatures } from '../../common/use_cases_features'; -import { TaskTemplates } from '../task_templates/task_templates'; -import { TaskTemplateFlyout } from '../task_templates/task_template_flyout'; -import type { CaseTaskTemplate } from '../../../common/types/domain/task_template/v1'; const sectionWrapperCss = css` box-sizing: content-box; @@ -135,8 +132,6 @@ export const ConfigureCases: React.FC = React.memo(() => { const [templateToEdit, setTemplateToEdit] = useState(null); const [observableTypeToEdit, setObservableTypeToEdit] = useState(null); - const [taskTemplateFlyoutOpen, setTaskTemplateFlyoutOpen] = useState(false); - const [taskTemplateToEdit, setTaskTemplateToEdit] = useState(null); const { euiTheme } = useEuiTheme(); const { @@ -571,21 +566,6 @@ export const ConfigureCases: React.FC = React.memo(() => { ] ); - const onAddTaskTemplate = useCallback(() => { - setTaskTemplateToEdit(null); - setTaskTemplateFlyoutOpen(true); - }, []); - - const onEditTaskTemplate = useCallback((template: CaseTaskTemplate) => { - setTaskTemplateToEdit(template); - setTaskTemplateFlyoutOpen(true); - }, []); - - const onCloseTaskTemplateFlyout = useCallback(() => { - setTaskTemplateFlyoutOpen(false); - setTaskTemplateToEdit(null); - }, []); - const AddOrEditCustomFieldFlyout = flyOutVisibility?.type === 'customField' && flyOutVisibility?.visible ? ( @@ -758,30 +738,11 @@ export const ConfigureCases: React.FC = React.memo(() => { -
- - - -
- - - {ConnectorAddFlyout} {ConnectorEditFlyout} {AddOrEditCustomFieldFlyout} {AddOrEditTemplateFlyout} {AddOrEditObservableTypeFlyout} - {taskTemplateFlyoutOpen && ( - - )} diff --git a/x-pack/platform/plugins/shared/cases/public/components/configure_cases/task_status_flyout.tsx b/x-pack/platform/plugins/shared/cases/public/components/configure_cases/task_status_flyout.tsx new file mode 100644 index 0000000000000..4a9ec691a6dd2 --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/public/components/configure_cases/task_status_flyout.tsx @@ -0,0 +1,197 @@ +/* + * 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, { useState } from 'react'; +import { + EuiBadge, + EuiButton, + EuiButtonEmpty, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiFormRow, + EuiSpacer, + EuiTitle, + useGeneratedHtmlId, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import type { TaskStatusDefinition } from '../../../common/types/domain/task/v1'; + +interface TaskStatusFlyoutProps { + statusToEdit: TaskStatusDefinition | null; + onSave: (status: TaskStatusDefinition) => void; + onClose: () => void; +} + +// EUI badge colors that render with visible color fills +const BADGE_COLOR_OPTIONS = [ + { value: 'default', label: 'Grey' }, + { value: 'primary', label: 'Blue' }, + { value: 'success', label: 'Green' }, + { value: 'warning', label: 'Yellow' }, + { value: 'danger', label: 'Red' }, + { value: 'accent', label: 'Pink' }, +]; + +const TITLE_ADD = i18n.translate('xpack.cases.configure.taskStatusFlyout.titleAdd', { + defaultMessage: 'Add task status', +}); + +const TITLE_EDIT = i18n.translate('xpack.cases.configure.taskStatusFlyout.titleEdit', { + defaultMessage: 'Edit task status', +}); + +const KEY_LABEL = i18n.translate('xpack.cases.configure.taskStatusFlyout.keyLabel', { + defaultMessage: 'Key', +}); + +const KEY_HELP = i18n.translate('xpack.cases.configure.taskStatusFlyout.keyHelp', { + defaultMessage: + 'Unique identifier used internally (e.g. "in_review"). Lowercase letters, numbers, and underscores only.', +}); + +const LABEL_LABEL = i18n.translate('xpack.cases.configure.taskStatusFlyout.labelLabel', { + defaultMessage: 'Label', +}); + +const COLOR_LABEL = i18n.translate('xpack.cases.configure.taskStatusFlyout.colorLabel', { + defaultMessage: 'Color', +}); + +const SAVE = i18n.translate('xpack.cases.configure.taskStatusFlyout.save', { + defaultMessage: 'Save', +}); + +const CANCEL = i18n.translate('xpack.cases.configure.taskStatusFlyout.cancel', { + defaultMessage: 'Cancel', +}); + +export const TaskStatusFlyout: React.FC = ({ + statusToEdit, + onSave, + onClose, +}) => { + const titleId = useGeneratedHtmlId(); + const [key, setKey] = useState(statusToEdit?.key ?? ''); + const [label, setLabel] = useState(statusToEdit?.label ?? ''); + const [color, setColor] = useState(statusToEdit?.color ?? 'default'); + + const isEditMode = statusToEdit !== null; + const isKeyValid = /^[a-z0-9_]+$/.test(key) && key.length > 0; + const isLabelValid = label.trim().length > 0; + const isValid = isKeyValid && isLabelValid; + + const handleSave = () => { + if (!isValid) return; + onSave({ key: key.trim(), label: label.trim(), color }); + }; + + return ( + + + +

{isEditMode ? TITLE_EDIT : TITLE_ADD}

+
+
+ + + 0 && !isKeyValid} + error={i18n.translate('xpack.cases.configure.taskStatusFlyout.keyError', { + defaultMessage: 'Key must be lowercase letters, numbers, and underscores only.', + })} + > + setKey(e.target.value)} + disabled={isEditMode} + data-test-subj="task-status-flyout-key" + /> + + + + + + setLabel(e.target.value)} + data-test-subj="task-status-flyout-label" + /> + + + + + + + {BADGE_COLOR_OPTIONS.map((opt) => ( + + setColor(opt.value)} + onClickAriaLabel={`Select ${opt.label} color`} + data-test-subj={`task-status-flyout-color-${opt.value}`} + style={{ + cursor: 'pointer', + outline: + color === opt.value + ? '2px solid currentColor' + : '2px solid transparent', + outlineOffset: 2, + minWidth: 56, + textAlign: 'center', + }} + > + {opt.label} + + + ))} + + + + + + + {label || key || 'Status'} + + + + + + + + {CANCEL} + + + + + + {SAVE} + + + + + +
+ ); +}; + +TaskStatusFlyout.displayName = 'TaskStatusFlyout'; diff --git a/x-pack/platform/plugins/shared/cases/public/components/configure_cases/task_statuses.tsx b/x-pack/platform/plugins/shared/cases/public/components/configure_cases/task_statuses.tsx new file mode 100644 index 0000000000000..4c8cbd94da9b4 --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/public/components/configure_cases/task_statuses.tsx @@ -0,0 +1,213 @@ +/* + * 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, { useMemo } from 'react'; +import { + EuiBadge, + EuiButtonEmpty, + EuiButtonIcon, + EuiDescribedFormGroup, + EuiFlexGroup, + EuiFlexItem, + EuiPanel, + EuiSpacer, + EuiSwitch, + EuiText, + EuiToolTip, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import type { TaskStatusDefinition } from '../../../common/types/domain/task/v1'; +import { BUILTIN_STATUS_KEYS, mergeTaskStatusesWithDefaults } from '../../../common/types/domain/task/v1'; +import { useCasesContext } from '../cases_context/use_cases_context'; + +interface TaskStatusesProps { + /** Statuses currently stored in the configure SO (may be empty = use defaults). */ + taskStatuses: TaskStatusDefinition[]; + disabled: boolean; + isLoading: boolean; + onAdd: () => void; + onEdit: (status: TaskStatusDefinition) => void; + /** Called with the full updated list. */ + onChange: (statuses: TaskStatusDefinition[]) => void; +} + +const TITLE = i18n.translate('xpack.cases.configure.taskStatuses.title', { + defaultMessage: 'Task statuses', +}); + +const DESCRIPTION = i18n.translate('xpack.cases.configure.taskStatuses.description', { + defaultMessage: + 'Define the statuses available for tasks. Built-in statuses can be disabled but not removed. Custom statuses can be added and deleted.', +}); + +const ADD_STATUS = i18n.translate('xpack.cases.configure.taskStatuses.addStatus', { + defaultMessage: 'Add status', +}); + +const EDIT_STATUS = i18n.translate('xpack.cases.configure.taskStatuses.editStatus', { + defaultMessage: 'Edit status', +}); + +const DELETE_STATUS = i18n.translate('xpack.cases.configure.taskStatuses.deleteStatus', { + defaultMessage: 'Delete status', +}); + +const BUILTIN_TOOLTIP = i18n.translate('xpack.cases.configure.taskStatuses.builtinTooltip', { + defaultMessage: 'Built-in statuses cannot be deleted, but can be disabled.', +}); + +export const TaskStatuses: React.FC = ({ + taskStatuses, + disabled, + isLoading, + onAdd, + onEdit, + onChange, +}) => { + const { permissions } = useCasesContext(); + + const mergedStatuses = useMemo(() => mergeTaskStatusesWithDefaults(taskStatuses), [taskStatuses]); + + if (!permissions.settings) { + return null; + } + + const canModify = !disabled && permissions.settings; + + const handleToggleDisabled = (key: string, currentDisabled: boolean) => { + const updated = mergedStatuses.map((s) => + s.key === key ? { ...s, disabled: !currentDisabled } : s + ); + onChange(updated); + }; + + const handleDelete = (key: string) => { + // Only custom statuses can be deleted + const updated = mergedStatuses.filter((s) => s.key !== key); + onChange(updated); + }; + + return ( + {TITLE}} + description={

{DESCRIPTION}

} + data-test-subj="task-statuses-form-group" + > + + {mergedStatuses.map((status) => { + const isBuiltin = BUILTIN_STATUS_KEYS.has(status.key); + const isDisabled = status.disabled === true; + + return ( + + {/* Color badge */} + + + {status.label} + + + + {/* Key hint */} + + + {status.key} + {isBuiltin && ( + + (built-in) + + )} + + + + {/* Enable/disable toggle */} + + handleToggleDisabled(status.key, isDisabled)} + disabled={!canModify} + data-test-subj={`task-status-toggle-${status.key}`} + compressed + /> + + + {/* Edit button */} + + onEdit(status)} + data-test-subj={`task-status-edit-${status.key}`} + /> + + + {/* Delete — only for custom statuses */} + + {isBuiltin ? ( + + + + ) : ( + handleDelete(status.key)} + data-test-subj={`task-status-delete-${status.key}`} + /> + )} + + + ); + })} + + + + + + + {ADD_STATUS} + + + + + + +
+ ); +}; + +TaskStatuses.displayName = 'TaskStatuses'; diff --git a/x-pack/platform/plugins/shared/cases/public/components/task_settings/index.tsx b/x-pack/platform/plugins/shared/cases/public/components/task_settings/index.tsx new file mode 100644 index 0000000000000..86364f9354971 --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/public/components/task_settings/index.tsx @@ -0,0 +1,163 @@ +/* + * 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, { useCallback, useState } from 'react'; +import { EuiPageBody, EuiPageSection, EuiSpacer } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { HeaderPage } from '../header_page'; +import { useCasesTaskSettingsBreadcrumbs } from '../use_breadcrumbs'; +import { useGetCaseConfiguration } from '../../containers/configure/use_get_case_configuration'; +import { usePersistConfiguration } from '../../containers/configure/use_persist_configuration'; +import { TaskStatuses } from '../configure_cases/task_statuses'; +import { TaskStatusFlyout } from '../configure_cases/task_status_flyout'; +import { TaskTemplates } from '../task_templates/task_templates'; +import { TaskTemplateFlyout } from '../task_templates/task_template_flyout'; +import { mergeTaskStatusesWithDefaults } from '../../../common/types/domain/task/v1'; +import type { TaskStatusDefinition } from '../../../common/types/domain/task/v1'; +import type { CaseTaskTemplate } from '../../../common/types/domain/task_template/v1'; +import { addOrReplaceField } from '../utils'; + +const PAGE_TITLE = i18n.translate('xpack.cases.taskSettings.pageTitle', { + defaultMessage: 'Task settings', +}); + +export const TaskSettingsPage: React.FC = () => { + useCasesTaskSettingsBreadcrumbs(); + + const { + data: currentConfiguration, + isLoading: loadingCaseConfigure, + } = useGetCaseConfiguration(); + + const { + mutate: persistCaseConfigure, + isLoading: isPersistingConfiguration, + } = usePersistConfiguration(); + + const { + connector, + closureType, + customFields, + templates, + id: configurationId, + version: configurationVersion, + taskStatuses: configuredTaskStatuses = [], + } = currentConfiguration; + + const isLoadingCaseConfiguration = loadingCaseConfigure || isPersistingConfiguration; + + const [taskStatusFlyoutOpen, setTaskStatusFlyoutOpen] = useState(false); + const [taskStatusToEdit, setTaskStatusToEdit] = useState(null); + const [taskTemplateFlyoutOpen, setTaskTemplateFlyoutOpen] = useState(false); + const [taskTemplateToEdit, setTaskTemplateToEdit] = useState(null); + + const onChangeTaskStatuses = useCallback( + (updatedStatuses: TaskStatusDefinition[]) => { + persistCaseConfigure({ + connector, + customFields, + templates, + id: configurationId, + version: configurationVersion, + closureType, + taskStatuses: updatedStatuses, + }); + }, + [ + closureType, + configurationId, + configurationVersion, + connector, + customFields, + templates, + persistCaseConfigure, + ] + ); + + const onAddTaskStatus = useCallback(() => { + setTaskStatusToEdit(null); + setTaskStatusFlyoutOpen(true); + }, []); + + const onEditTaskStatus = useCallback((status: TaskStatusDefinition) => { + setTaskStatusToEdit(status); + setTaskStatusFlyoutOpen(true); + }, []); + + const onCloseTaskStatusFlyout = useCallback(() => { + setTaskStatusFlyoutOpen(false); + setTaskStatusToEdit(null); + }, []); + + const onSaveTaskStatus = useCallback( + (status: TaskStatusDefinition) => { + onChangeTaskStatuses(addOrReplaceField(mergeTaskStatusesWithDefaults(configuredTaskStatuses), status)); + onCloseTaskStatusFlyout(); + }, + [configuredTaskStatuses, onChangeTaskStatuses, onCloseTaskStatusFlyout] + ); + + const onAddTaskTemplate = useCallback(() => { + setTaskTemplateToEdit(null); + setTaskTemplateFlyoutOpen(true); + }, []); + + const onEditTaskTemplate = useCallback((template: CaseTaskTemplate) => { + setTaskTemplateToEdit(template); + setTaskTemplateFlyoutOpen(true); + }, []); + + const onCloseTaskTemplateFlyout = useCallback(() => { + setTaskTemplateFlyoutOpen(false); + setTaskTemplateToEdit(null); + }, []); + + return ( + + + + + + + + + + {taskStatusFlyoutOpen && ( + + )} + + {taskTemplateFlyoutOpen && ( + + )} + + + ); +}; + +TaskSettingsPage.displayName = 'TaskSettingsPage'; + +// eslint-disable-next-line import/no-default-export +export default TaskSettingsPage; diff --git a/x-pack/platform/plugins/shared/cases/public/components/tasks/case_view_tasks.tsx b/x-pack/platform/plugins/shared/cases/public/components/tasks/case_view_tasks.tsx index cd92441c0c7e9..148f95e7daeb0 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/tasks/case_view_tasks.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/tasks/case_view_tasks.tsx @@ -5,17 +5,31 @@ * 2.0. */ -import React, { useState } from 'react'; -import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import React, { useState, useMemo } from 'react'; +import { + EuiButton, + EuiButtonEmpty, + EuiButtonGroup, + EuiFlexGroup, + EuiFlexItem, + EuiPopover, + EuiPopoverTitle, + EuiSpacer, + EuiSwitch, + EuiText, +} from '@elastic/eui'; import type { CaseTask } from '../../../common/types/domain/task/v1'; import type { CaseUI } from '../../../common/ui/types'; import { CASE_VIEW_PAGE_TABS } from '../../../common/types'; import { useGetTasks } from '../../containers/use_get_tasks'; import { CaseViewTabs } from '../case_view/case_view_tabs'; import { TasksTable } from './tasks_table'; +import { TasksBoard } from './tasks_board'; import { AddTaskFlyout } from './add_task_flyout'; import { EditTaskFlyout } from './edit_task_flyout'; import { ApplyTemplateModal } from './apply_template_modal'; +import { useTaskStatuses } from './use_task_statuses'; +import * as i18n from './translations'; interface CaseViewTasksProps { caseData: CaseUI; @@ -27,13 +41,44 @@ interface AddTaskState { parentTask: CaseTask | null; } +const VIEW_TOGGLE_OPTIONS = [ + { id: 'list', label: i18n.VIEW_LIST, iconType: 'list' }, + { id: 'board', label: i18n.VIEW_BOARD, iconType: 'visTable' }, +]; + export const CaseViewTasks = React.memo(({ caseData, searchTerm }) => { const caseId = caseData.id; const { data, isLoading } = useGetTasks(caseId); + const allStatuses = useTaskStatuses(); const [addState, setAddState] = useState({ open: false, parentTask: null }); const [editingTask, setEditingTask] = useState(null); const [applyTemplateOpen, setApplyTemplateOpen] = useState(false); + const [viewMode, setViewMode] = useState<'list' | 'board'>('list'); + const [columnPopoverOpen, setColumnPopoverOpen] = useState(false); + const [hiddenStatuses, setHiddenStatuses] = useState>(new Set()); + + const visibleStatuses = useMemo( + () => + hiddenStatuses.size === 0 + ? undefined + : new Set(allStatuses.filter((s) => !hiddenStatuses.has(s.key)).map((s) => s.key)), + [allStatuses, hiddenStatuses] + ); + + const toggleStatus = (key: string) => { + setHiddenStatuses((prev) => { + const next = new Set(prev); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + return next; + }); + }; + + const hasTasks = (data?.tasks.length ?? 0) > 0 || isLoading; return ( <> @@ -44,19 +89,118 @@ export const CaseViewTasks = React.memo(({ caseData, searchT activeTab={CASE_VIEW_PAGE_TABS.TASKS} searchTerm={searchTerm} /> - - - setAddState({ open: true, parentTask: null })} - onEditTask={(task) => setEditingTask(task)} - onAddSubTask={(parentTask) => setAddState({ open: true, parentTask })} - onApplyTemplate={() => setApplyTemplateOpen(true)} - /> - - + + {/* Single unified toolbar: actions left, view toggle right */} + {hasTasks && ( + <> + + + + setAddState({ open: true, parentTask: null })} + data-test-subj="cases-tasks-add-task" + > + {i18n.ADD_TASK} + + + + setApplyTemplateOpen(true)} + data-test-subj="cases-tasks-apply-template" + > + {i18n.APPLY_TEMPLATE} + + + + {/* spacer pushes view toggle to the right */} + + + {viewMode === 'board' && ( + + setColumnPopoverOpen(false)} + panelPaddingSize="none" + button={ + setColumnPopoverOpen((o) => !o)} + data-test-subj="cases-tasks-column-toggle" + > + {i18n.COLUMNS} + + } + > + {i18n.VISIBLE_COLUMNS} +
+ {allStatuses.map(({ key, label }) => ( + + + {label} + + + toggleStatus(key)} + compressed + data-test-subj={`cases-tasks-col-toggle-${key}`} + /> + + + ))} +
+
+
+ )} + + + setViewMode(id as 'list' | 'board')} + buttonSize="s" + isIconOnly + data-test-subj="cases-tasks-view-toggle" + /> + +
+ + + )} + + {viewMode === 'list' ? ( + setAddState({ open: true, parentTask: null })} + onEditTask={(task) => setEditingTask(task)} + onAddSubTask={(parentTask) => setAddState({ open: true, parentTask })} + onApplyTemplate={() => setApplyTemplateOpen(true)} + /> + ) : ( + setEditingTask(task)} + visibleStatuses={visibleStatuses} + /> + )}
diff --git a/x-pack/platform/plugins/shared/cases/public/components/tasks/task_card.tsx b/x-pack/platform/plugins/shared/cases/public/components/tasks/task_card.tsx new file mode 100644 index 0000000000000..e38093de38d12 --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/public/components/tasks/task_card.tsx @@ -0,0 +1,227 @@ +/* + * 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 { + EuiAvatar, + EuiButtonEmpty, + EuiCheckbox, + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, + EuiPanel, + EuiText, + EuiToolTip, + useEuiTheme, + useGeneratedHtmlId, +} from '@elastic/eui'; +import type { UserProfileWithAvatar } from '@kbn/user-profile-components'; +import { getUserDisplayName } from '@kbn/user-profile-components'; +import type { CaseTask } from '../../../common/types/domain/task/v1'; +import * as i18n from './translations'; + +interface TaskCardProps { + task: CaseTask; + profileMap: Map; + subTasks?: CaseTask[]; + onEdit?: (task: CaseTask) => void; + onDelete?: (taskId: string) => void; + onToggleComplete?: (task: CaseTask) => void; + dragHandleProps?: Record; +} + +export const TaskCard: React.FC = ({ + task, + profileMap, + subTasks, + onEdit, + onDelete, + onToggleComplete, + dragHandleProps, +}) => { + const { euiTheme } = useEuiTheme(); + const checkboxId = useGeneratedHtmlId({ prefix: 'task-card-check' }); + const isCompleted = task.status === 'done'; + const assignees = task.assignees ?? []; + const visibleAssignees = assignees.slice(0, 3); + + return ( + + + {/* Drag handle + title */} + + + {dragHandleProps && ( + + + ⠿ + + + )} + + onToggleComplete?.(task)} + aria-label={isCompleted ? i18n.MARK_INCOMPLETE : i18n.MARK_COMPLETE} + data-test-subj={`cases-tasks-card-check-${task.id}`} + /> + + + + {task.title} + + + + + + {/* Due date */} + {task.due_date && ( + + + {new Date(task.due_date).toLocaleDateString()} + + + )} + + {/* Assignees + actions */} + + + + + {visibleAssignees.map(({ uid }) => { + const profile = profileMap.get(uid); + const displayName = profile ? getUserDisplayName(profile.user) : uid; + return ( + + + + + + ); + })} + {assignees.length > 3 && ( + + + +{assignees.length - 3} + + + )} + + + + + {onEdit && ( + + onEdit(task)} + data-test-subj={`cases-tasks-card-edit-${task.id}`} + /> + + )} + {onDelete && ( + + onDelete(task.id)} + data-test-subj={`cases-tasks-card-delete-${task.id}`} + /> + + )} + + + + + + {/* Sub-tasks */} + {subTasks && subTasks.length > 0 && ( + <> + + + + + + {i18n.SUBTASKS_LABEL(subTasks.length)} + + + {subTasks.map((sub) => { + const subCheckId = `sub-check-${sub.id}`; + const subIsCompleted = sub.status === 'done'; + return ( + + + + onToggleComplete?.(sub)} + aria-label={subIsCompleted ? i18n.MARK_INCOMPLETE : i18n.MARK_COMPLETE} + data-test-subj={`cases-tasks-card-check-${sub.id}`} + /> + + + + {sub.title} + + + + + ); + })} + + )} + + + ); +}; diff --git a/x-pack/platform/plugins/shared/cases/public/components/tasks/task_form_fields.tsx b/x-pack/platform/plugins/shared/cases/public/components/tasks/task_form_fields.tsx index c0f1c65645261..a5a761422d0c9 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/tasks/task_form_fields.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/tasks/task_form_fields.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React from 'react'; +import React, { useMemo } from 'react'; import { EuiDatePicker, EuiFieldText, @@ -16,6 +16,7 @@ import { import moment from 'moment'; import type { CaseTaskPriority, CaseTaskStatus } from '../../../common/types/domain/task/v1'; import { TaskAssigneesField } from './task_assignees_field'; +import { useTaskStatuses } from './use_task_statuses'; import * as i18n from './translations'; export interface TaskFormState { @@ -41,15 +42,19 @@ const PRIORITY_OPTIONS: Array<{ value: CaseTaskPriority; text: string }> = [ { value: 'critical', text: i18n.PRIORITY_CRITICAL }, ]; -const STATUS_OPTIONS: Array<{ value: CaseTaskStatus; text: string }> = [ - { value: 'open', text: i18n.STATUS_OPEN }, - { value: 'in_progress', text: i18n.STATUS_IN_PROGRESS }, - { value: 'completed', text: i18n.STATUS_COMPLETED }, - { value: 'cancelled', text: i18n.STATUS_CANCELLED }, -]; - export const TaskFormFields: React.FC = ({ value, onChange, titleError }) => { - const isCompleted = value.status === 'completed'; + const taskStatuses = useTaskStatuses(); + const STATUS_OPTIONS = useMemo(() => { + const options = taskStatuses + .filter((s) => !s.disabled) + .map((s) => ({ value: s.key as CaseTaskStatus, text: s.label })); + const isKnown = options.some((o) => o.value === value.status); + if (!isKnown) { + options.unshift({ value: value.status, text: i18n.NO_STATUS }); + } + return options; + }, [taskStatuses, value.status]); + const isCompleted = value.status === 'done'; return ( <> diff --git a/x-pack/platform/plugins/shared/cases/public/components/tasks/tasks_board.tsx b/x-pack/platform/plugins/shared/cases/public/components/tasks/tasks_board.tsx new file mode 100644 index 0000000000000..0a8802b10a2b3 --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/public/components/tasks/tasks_board.tsx @@ -0,0 +1,261 @@ +/* + * 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, { useCallback, useMemo } from 'react'; +import type { DropResult } from '@elastic/eui'; +import { + EuiBadge, + EuiDragDropContext, + EuiDraggable, + EuiDroppable, + euiDragDropReorder, + EuiFlexGroup, + EuiFlexItem, + EuiPanel, + EuiText, + EuiTitle, +} from '@elastic/eui'; +import type { CaseTask } from '../../../common/types/domain/task/v1'; +import { useDeleteTask } from '../../containers/use_delete_task'; +import { useUpdateTask } from '../../containers/use_update_task'; +import { useReorderTasks } from '../../containers/use_reorder_tasks'; +import { useBulkGetUserProfiles } from '../../containers/user_profiles/use_bulk_get_user_profiles'; +import { useTaskStatuses } from './use_task_statuses'; +import { TaskCard } from './task_card'; +import * as i18n from './translations'; + +const UNASSIGNED_KEY = '__unassigned__'; + +interface TasksBoardProps { + caseId: string; + tasks: CaseTask[]; + onEditTask?: (task: CaseTask) => void; + visibleStatuses?: Set; +} + +export const TasksBoard = React.memo( + ({ caseId, tasks, onEditTask, visibleStatuses }) => { + const { mutate: deleteTask } = useDeleteTask(caseId); + const { mutate: updateTask } = useUpdateTask(caseId); + const { mutate: reorderTasks } = useReorderTasks(); + const allStatuses = useTaskStatuses(); + + const visibleStatusList = useMemo( + () => + visibleStatuses + ? allStatuses.filter((s) => visibleStatuses.has(s.key)) + : allStatuses, + [allStatuses, visibleStatuses] + ); + + const allAssigneeUids = useMemo( + () => [...new Set(tasks.flatMap((t) => (t.assignees ?? []).map((a) => a.uid)))], + [tasks] + ); + const { data: profileMap = new Map() } = useBulkGetUserProfiles({ uids: allAssigneeUids }); + + // Group tasks by status. Top-level tasks are shown as cards; + // sub-tasks are embedded inside their parent's card. + const subTasksByParent = useMemo(() => { + const map = new Map(); + for (const task of tasks) { + if (task.parent_task_id) { + const bucket = map.get(task.parent_task_id) ?? []; + bucket.push(task); + map.set(task.parent_task_id, bucket); + } + } + return map; + }, [tasks]); + + const knownStatusKeys = useMemo(() => new Set(allStatuses.map((s) => s.key)), [allStatuses]); + + const columnTasks = useMemo(() => { + const byStatus: Record = {}; + for (const s of allStatuses) { + byStatus[s.key] = []; + } + byStatus[UNASSIGNED_KEY] = []; + for (const task of tasks) { + if (!task.parent_task_id) { + const key = knownStatusKeys.has(task.status) ? task.status : UNASSIGNED_KEY; + const bucket = byStatus[key] ?? (byStatus[key] = []); + bucket.push(task); + } + } + return byStatus; + }, [allStatuses, tasks, knownStatusKeys]); + + const onDragEnd = useCallback( + ({ source, destination }: DropResult) => { + if (!source || !destination) return; + + const sourceStatus = source.droppableId; + const destStatus = destination.droppableId; + + if (sourceStatus === destStatus) { + const reordered = euiDragDropReorder( + columnTasks[sourceStatus], + source.index, + destination.index + ); + reorderTasks({ + caseId, + orderedTaskIds: reordered.map((t) => t.id), + parentTaskId: null, + }); + } else { + const task = columnTasks[sourceStatus]?.[source.index]; + if (task) { + updateTask({ + taskId: task.id, + request: { version: task.version, status: destStatus }, + }); + } + } + }, + [caseId, columnTasks, reorderTasks, updateTask] + ); + + return ( + + + {(columnTasks[UNASSIGNED_KEY]?.length ?? 0) > 0 && ( + + + + + +

{i18n.UNASSIGNED_COLUMN_LABEL}

+
+
+ + {columnTasks[UNASSIGNED_KEY].length} + +
+ + {columnTasks[UNASSIGNED_KEY].map((task, idx) => ( + + {(provided) => ( + deleteTask(taskId)} + onToggleComplete={(t) => + updateTask({ + taskId: t.id, + request: { version: t.version, status: t.status === 'done' ? 'open' : 'done' }, + }) + } + dragHandleProps={ + (provided.dragHandleProps ?? {}) as Record + } + /> + )} + + ))} + +
+
+ )} + {visibleStatusList.map(({ key, label, color }) => { + const colTasks = columnTasks[key] ?? []; + return ( + + + + + +

{label}

+
+
+ + {colTasks.length} + +
+ + + {colTasks.length === 0 ? ( + + {i18n.NO_TASKS} + + ) : ( + colTasks.map((task, idx) => ( + + {(provided) => ( + deleteTask(taskId)} + onToggleComplete={(t) => + updateTask({ + taskId: t.id, + request: { version: t.version, status: t.status === 'done' ? 'open' : 'done' }, + }) + } + dragHandleProps={ + (provided.dragHandleProps ?? {}) as Record + } + /> + )} + + )) + )} + +
+
+ ); + })} +
+
+ ); + } +); + +TasksBoard.displayName = 'TasksBoard'; diff --git a/x-pack/platform/plugins/shared/cases/public/components/tasks/tasks_table.tsx b/x-pack/platform/plugins/shared/cases/public/components/tasks/tasks_table.tsx index 833a556d81579..28eafff8e4ae9 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/tasks/tasks_table.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/tasks/tasks_table.tsx @@ -6,30 +6,45 @@ */ import React, { useCallback, useMemo, useState } from 'react'; +import type { DropResult } from '@elastic/eui'; import { EuiAvatar, - EuiBasicTable, EuiBadge, EuiButton, EuiButtonEmpty, EuiButtonIcon, EuiCheckbox, + EuiContextMenuItem, + EuiContextMenuPanel, + EuiDragDropContext, + EuiDraggable, + EuiDroppable, + euiDragDropReorder, EuiEmptyPrompt, EuiFlexGroup, EuiFlexItem, + EuiIcon, + EuiPopover, EuiSkeletonText, EuiText, EuiToolTip, + useEuiTheme, useGeneratedHtmlId, } from '@elastic/eui'; -import type { EuiBasicTableColumn } from '@elastic/eui'; import { getUserDisplayName } from '@kbn/user-profile-components'; import type { CaseTask } from '../../../common/types/domain/task/v1'; import { useDeleteTask } from '../../containers/use_delete_task'; import { useUpdateTask } from '../../containers/use_update_task'; +import { useReorderTasks } from '../../containers/use_reorder_tasks'; import { useBulkGetUserProfiles } from '../../containers/user_profiles/use_bulk_get_user_profiles'; +import { useTaskStatuses } from './use_task_statuses'; import * as i18n from './translations'; +// Grid column definition shared between the header row and every data row. +// Uses fr units so the table expands to fill available width. +// Drag handle and actions are fixed; title gets the most space; the rest share equally. +const GRID_COLUMNS = '24px 3fr 1.2fr 1fr 1fr 40px'; + // --------------------------------------------------------------------------- // Tree building // --------------------------------------------------------------------------- @@ -65,30 +80,7 @@ const buildFlatTree = (tasks: CaseTask[], expandedIds: Set): TaskRow[] = return result; }; -// --------------------------------------------------------------------------- -// Status display -// --------------------------------------------------------------------------- - -const STATUS_LABELS: Record = { - open: i18n.STATUS_OPEN, - in_progress: i18n.STATUS_IN_PROGRESS, - completed: i18n.STATUS_COMPLETED, - cancelled: i18n.STATUS_CANCELLED, -}; - -const STATUS_COLORS: Record = { - open: 'default', - in_progress: 'primary', - completed: 'success', - cancelled: 'default', -}; - -const NEXT_STATUS: Record = { - open: 'in_progress', - in_progress: 'completed', - completed: 'open', - cancelled: 'open', -}; +// Status display maps are built dynamically from configured statuses in the component. // --------------------------------------------------------------------------- // Title cell with checkbox @@ -107,8 +99,9 @@ const TaskTitleCell: React.FC = ({ onToggleExpand, onToggleComplete, }) => { + const { euiTheme } = useEuiTheme(); const checkboxId = useGeneratedHtmlId({ prefix: 'task-check' }); - const isCompleted = row.status === 'completed'; + const isCompleted = row.status === 'done'; return ( = ({ {row._depth > 0 && ( - + )} {row.title} @@ -154,6 +151,103 @@ const TaskTitleCell: React.FC = ({ ); }; +// --------------------------------------------------------------------------- +// Actions popover +// --------------------------------------------------------------------------- + +interface TaskActionsPopoverProps { + row: TaskRow; + onEdit?: (task: CaseTask) => void; + onAddSubTask?: (task: CaseTask) => void; + onDelete: (taskId: string) => void; + onExpandParent?: (taskId: string) => void; +} + +const TaskActionsPopover: React.FC = ({ + row, + onEdit, + onAddSubTask, + onDelete, + onExpandParent, +}) => { + const buttonId = useGeneratedHtmlId({ prefix: 'task-actions' }); + const [isOpen, setIsOpen] = useState(false); + + const closePopover = useCallback(() => setIsOpen(false), []); + const openPopover = useCallback(() => setIsOpen(true), []); + + const items = useMemo( + () => [ + ...(onEdit + ? [ + { + closePopover(); + onEdit(row); + }} + > + {i18n.EDIT_TASK} + , + ] + : []), + ...(onAddSubTask + ? [ + { + closePopover(); + onExpandParent?.(row.id); + onAddSubTask(row); + }} + > + {i18n.ADD_SUBTASK} + , + ] + : []), + { + closePopover(); + onDelete(row.id); + }} + > + {i18n.DELETE_TASK} + , + ], + [row, onEdit, onAddSubTask, onDelete, onExpandParent, closePopover] + ); + + return ( + + } + > + + + ); +}; + // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- @@ -170,8 +264,29 @@ interface TasksTableProps { export const TasksTable = React.memo( ({ caseId, tasks, isLoading, onAddTask, onEditTask, onAddSubTask, onApplyTemplate }) => { + const { euiTheme } = useEuiTheme(); const { mutate: deleteTask } = useDeleteTask(caseId); const { mutate: updateTask } = useUpdateTask(caseId); + const { mutate: reorderTasks } = useReorderTasks(); + const configuredStatuses = useTaskStatuses(); + + const statusLabelMap = useMemo( + () => Object.fromEntries(configuredStatuses.map((s) => [s.key, s.label])), + [configuredStatuses] + ); + const statusColorMap = useMemo( + () => Object.fromEntries(configuredStatuses.map((s) => [s.key, s.color])), + [configuredStatuses] + ); + // Maps each status key to the next one in the configured order (wraps around) + const nextStatusMap = useMemo(() => { + const map: Record = {}; + for (let i = 0; i < configuredStatuses.length; i++) { + map[configuredStatuses[i].key] = + configuredStatuses[(i + 1) % configuredStatuses.length].key; + } + return map; + }, [configuredStatuses]); const [expandedIds, setExpandedIds] = useState>(() => { // Expand all parent tasks by default @@ -200,153 +315,33 @@ export const TasksTable = React.memo( const handleStatusCycle = useCallback( (task: CaseTask) => { + const nextStatus = nextStatusMap[task.status] ?? configuredStatuses[0]?.key ?? task.status; updateTask({ taskId: task.id, - request: { version: task.version, status: NEXT_STATUS[task.status] }, + request: { version: task.version, status: nextStatus }, }); }, - [updateTask] + [updateTask, nextStatusMap, configuredStatuses] ); - const columns: Array> = [ - { - name: i18n.TASK_TITLE, - field: 'title', - 'data-test-subj': 'cases-tasks-col-title', - render: (title: string, row: TaskRow) => ( - { - updateTask({ - taskId: task.id, - request: { - version: task.version, - status: task.status === 'completed' ? 'open' : 'completed', - }, - }); - }} - /> - ), - }, - { - name: i18n.TASK_STATUS, - field: 'status', - width: '130px', - 'data-test-subj': 'cases-tasks-col-status', - render: (status: CaseTask['status'], row: TaskRow) => ( - - handleStatusCycle(row)} - onClickAriaLabel={i18n.CLICK_TO_ADVANCE_STATUS} - data-test-subj={`cases-tasks-status-${row.id}`} - > - {STATUS_LABELS[status]} - - - ), - }, - { - name: i18n.TASK_ASSIGNEES, - field: 'assignees', - width: '100px', - 'data-test-subj': 'cases-tasks-col-assignees', - render: (assignees: CaseTask['assignees']) => { - if (!assignees || assignees.length === 0) return null; - const visible = assignees.slice(0, 3); - return ( - - {visible.map(({ uid }) => { - const profile = profileMap.get(uid); - const displayName = profile ? getUserDisplayName(profile.user) : uid; - return ( - - - - - - ); - })} - {assignees.length > 3 && ( - - +{assignees.length - 3} - - )} - - ); - }, - }, - { - name: i18n.TASK_DUE_DATE, - field: 'due_date', - width: '110px', - 'data-test-subj': 'cases-tasks-col-due-date', - render: (dueDate: string | null) => - dueDate ? new Date(dueDate).toLocaleDateString() : '—', - }, - { - name: i18n.TASK_ACTIONS, - field: 'id', - width: '100px', - 'data-test-subj': 'cases-tasks-col-actions', - actions: [ - { - name: i18n.EDIT_TASK, - render: (row: TaskRow) => ( - onEditTask?.(row)} - > - {i18n.EDIT_TASK} - - ), - }, - { - name: i18n.ADD_SUBTASK, - render: (row: TaskRow) => ( - { - // Ensure the parent is expanded so the new child becomes visible - setExpandedIds((prev) => new Set([...prev, row.id])); - onAddSubTask?.(row); - }} - > - {i18n.ADD_SUBTASK} - - ), - }, - { - name: i18n.DELETE_TASK, - render: (row: TaskRow) => ( - deleteTask(row.id)} - > - {i18n.DELETE_TASK} - - ), - }, - ], + // Only top-level tasks are reorderable; subtasks reorder within their parent group + const onDragEnd = useCallback( + ({ source, destination }: DropResult) => { + if (!source || !destination || source.index === destination.index) return; + const reordered = euiDragDropReorder(flatRows, source.index, destination.index); + // Group by parent_task_id and call reorder for each affected group + const groupedIds = new Map(); + for (const row of reordered) { + const pid = row.parent_task_id ?? null; + if (!groupedIds.has(pid)) groupedIds.set(pid, []); + groupedIds.get(pid)!.push(row.id); + } + for (const [parentTaskId, orderedTaskIds] of groupedIds.entries()) { + reorderTasks({ caseId, orderedTaskIds, parentTaskId }); + } }, - ]; + [flatRows, caseId, reorderTasks] + ); if (isLoading) { return ; @@ -382,35 +377,128 @@ export const TasksTable = React.memo( } return ( - - - - {onApplyTemplate && ( - - - {i18n.APPLY_TEMPLATE} - - - )} - - {onAddTask && ( - - {i18n.ADD_TASK} - - )} - - - - - - items={flatRows} - columns={columns} - rowHeader="title" - itemId="id" - data-test-subj="cases-tasks-table" - /> - - + <> + {/* Column header row */} +
+ + {i18n.TASK_TITLE} + {i18n.TASK_STATUS} + {i18n.TASK_ASSIGNEES} + {i18n.TASK_DUE_DATE} + +
+ + + {flatRows.map((row, idx) => ( + + {(provided) => ( +
+ {/* Drag handle */} + + + + {/* Title */} + { + updateTask({ + taskId: task.id, + request: { + version: task.version, + status: task.status === 'done' ? 'open' : 'done', + }, + }); + }} + /> + {/* Status */} +
+ + handleStatusCycle(row)} + onClickAriaLabel={i18n.CLICK_TO_ADVANCE_STATUS} + data-test-subj={`cases-tasks-status-${row.id}`} + > + {statusLabelMap[row.status] ?? i18n.NO_STATUS} + + +
+ {/* Assignees */} +
+ {row.assignees && row.assignees.length > 0 && ( + + {row.assignees.slice(0, 3).map(({ uid }) => { + const profile = profileMap.get(uid); + const displayName = profile ? getUserDisplayName(profile.user) : uid; + return ( + + + + + + ); + })} + {row.assignees.length > 3 && ( + + +{row.assignees.length - 3} + + )} + + )} +
+ {/* Due date */} + + {row.due_date ? new Date(row.due_date).toLocaleDateString() : '—'} + + {/* Actions */} + deleteTask(taskId)} + onExpandParent={(taskId) => + setExpandedIds((prev) => new Set([...prev, taskId])) + } + /> +
+ )} +
+ ))} +
+
+ ); } ); diff --git a/x-pack/platform/plugins/shared/cases/public/components/tasks/translations.ts b/x-pack/platform/plugins/shared/cases/public/components/tasks/translations.ts index 13561a10e0c09..abd29ed7984b3 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/tasks/translations.ts +++ b/x-pack/platform/plugins/shared/cases/public/components/tasks/translations.ts @@ -203,3 +203,33 @@ export const NO_TASK_TEMPLATES_AVAILABLE_DESCRIPTION = i18n.translate( 'Create task templates in case settings to quickly add predefined sets of tasks.', } ); + +export const VIEW_LIST = i18n.translate('xpack.cases.tasks.viewList', { + defaultMessage: 'List', +}); + +export const VIEW_BOARD = i18n.translate('xpack.cases.tasks.viewBoard', { + defaultMessage: 'Board', +}); + +export const COLUMNS = i18n.translate('xpack.cases.tasks.columns', { + defaultMessage: 'Columns', +}); + +export const VISIBLE_COLUMNS = i18n.translate('xpack.cases.tasks.visibleColumns', { + defaultMessage: 'Visible columns', +}); + +export const NO_STATUS = i18n.translate('xpack.cases.tasks.status.noStatus', { + defaultMessage: 'No status', +}); + +export const UNASSIGNED_COLUMN_LABEL = i18n.translate('xpack.cases.tasks.board.unassigned', { + defaultMessage: 'Unassigned', +}); + +export const SUBTASKS_LABEL = (count: number) => + i18n.translate('xpack.cases.tasks.subtasksLabel', { + values: { count }, + defaultMessage: '{count} sub-task{count, plural, one {} other {s}}', + }); diff --git a/x-pack/platform/plugins/shared/cases/public/components/tasks/use_task_statuses.ts b/x-pack/platform/plugins/shared/cases/public/components/tasks/use_task_statuses.ts new file mode 100644 index 0000000000000..346ceabbad59a --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/public/components/tasks/use_task_statuses.ts @@ -0,0 +1,17 @@ +/* + * 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 { useGetCaseConfiguration } from '../../containers/configure/use_get_case_configuration'; +import { mergeTaskStatusesWithDefaults } from '../../../common/types/domain/task/v1'; +import type { TaskStatusDefinition } from '../../../common/types/domain/task/v1'; + +export type { TaskStatusDefinition }; + +export const useTaskStatuses = (): TaskStatusDefinition[] => { + const { data: configuration } = useGetCaseConfiguration(); + return mergeTaskStatusesWithDefaults(configuration?.taskStatuses ?? []); +}; diff --git a/x-pack/platform/plugins/shared/cases/public/components/use_breadcrumbs/index.ts b/x-pack/platform/plugins/shared/cases/public/components/use_breadcrumbs/index.ts index 3b01ec7741a88..b1f4c1c0bdeb6 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/use_breadcrumbs/index.ts +++ b/x-pack/platform/plugins/shared/cases/public/components/use_breadcrumbs/index.ts @@ -27,6 +27,9 @@ const getCasesBreadcrumbTitle = (deepLinkId: ICasesDeepLinkId): string => { [CasesDeepLinkId.casesTemplates]: i18n.translate('xpack.cases.breadcrumbs.templates', { defaultMessage: 'Templates', }), + [CasesDeepLinkId.casesTaskSettings]: i18n.translate('xpack.cases.breadcrumbs.taskSettings', { + defaultMessage: 'Task settings', + }), }; return titles[deepLinkId]; }; @@ -129,6 +132,10 @@ export const useCasesTitleBreadcrumbs = (caseTitle: string) => { }, [caseTitle, appTitle, getAppUrl, applyBreadcrumbs]); }; +export const useCasesTaskSettingsBreadcrumbs = () => { + useCasesBreadcrumbs(CasesDeepLinkId.casesTaskSettings); +}; + export const useCasesTemplatesBreadcrumbs = (templateTitle?: string) => { const { appId, appTitle } = useApplication(); const { getAppUrl } = useNavigation(appId); diff --git a/x-pack/platform/plugins/shared/cases/public/containers/api.ts b/x-pack/platform/plugins/shared/cases/public/containers/api.ts index 1e34a51393534..fa7d3546c5d53 100644 --- a/x-pack/platform/plugins/shared/cases/public/containers/api.ts +++ b/x-pack/platform/plugins/shared/cases/public/containers/api.ts @@ -718,7 +718,7 @@ export const getSimilarCases = async ({ export interface CreateTaskRequest { title: string; description?: string; - status?: 'open' | 'in_progress' | 'completed' | 'cancelled'; + status?: string; priority?: 'low' | 'medium' | 'high' | 'critical'; assignees?: Array<{ uid: string }>; due_date?: string | null; @@ -731,7 +731,7 @@ export interface UpdateTaskRequest { version: string; title?: string; description?: string; - status?: 'open' | 'in_progress' | 'completed' | 'cancelled'; + status?: string; priority?: 'low' | 'medium' | 'high' | 'critical'; assignees?: Array<{ uid: string }>; due_date?: string | null; @@ -747,15 +747,17 @@ export interface FindTasksRequest { per_page?: number; } +export type CaseTasksResponse = { tasks: CaseTask[]; total: number }; + export const getCaseTasks = async ( caseId: string, params?: FindTasksRequest, signal?: AbortSignal -): Promise<{ tasks: CaseTask[]; total: number }> => { +): Promise => { const query = params ? Object.fromEntries(Object.entries(params).filter(([, v]) => v !== undefined)) : {}; - const response = await KibanaServices.get().http.fetch<{ tasks: CaseTask[]; total: number }>( + const response = await KibanaServices.get().http.fetch( getCaseTasksUrl(caseId), { method: 'GET', diff --git a/x-pack/platform/plugins/shared/cases/public/containers/configure/api.ts b/x-pack/platform/plugins/shared/cases/public/containers/configure/api.ts index 4fb6149e3cb2b..e92e4b0ed1251 100644 --- a/x-pack/platform/plugins/shared/cases/public/containers/configure/api.ts +++ b/x-pack/platform/plugins/shared/cases/public/containers/configure/api.ts @@ -125,6 +125,7 @@ const convertConfigureResponseToCasesConfigure = ( connector, owner, observableTypes, + taskStatuses, } = configuration; return { @@ -137,5 +138,6 @@ const convertConfigureResponseToCasesConfigure = ( connector, owner, observableTypes, + taskStatuses, }; }; diff --git a/x-pack/platform/plugins/shared/cases/public/containers/configure/use_persist_configuration.tsx b/x-pack/platform/plugins/shared/cases/public/containers/configure/use_persist_configuration.tsx index a29505c830968..34581f78c86ba 100644 --- a/x-pack/platform/plugins/shared/cases/public/containers/configure/use_persist_configuration.tsx +++ b/x-pack/platform/plugins/shared/cases/public/containers/configure/use_persist_configuration.tsx @@ -35,6 +35,7 @@ export const usePersistConfiguration = () => { templates, connector, observableTypes, + taskStatuses, }: Request) => { if (isEmpty(id) || isEmpty(version)) { return postCaseConfigure({ @@ -44,6 +45,7 @@ export const usePersistConfiguration = () => { templates: templates ?? [], owner: owner[0], observableTypes, + taskStatuses, }); } @@ -54,6 +56,7 @@ export const usePersistConfiguration = () => { customFields: customFields ?? [], templates: templates ?? [], observableTypes, + taskStatuses, }); }, { diff --git a/x-pack/platform/plugins/shared/cases/public/containers/use_reorder_tasks.tsx b/x-pack/platform/plugins/shared/cases/public/containers/use_reorder_tasks.tsx new file mode 100644 index 0000000000000..83545a9c4fbcf --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/public/containers/use_reorder_tasks.tsx @@ -0,0 +1,67 @@ +/* + * 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 { useMutation, useQueryClient } from '@kbn/react-query'; +import type { CaseTask } from '../../common/types/domain/task/v1'; +import type { CaseTasksResponse } from './api'; +import { reorderTasks } from './api'; +import { casesMutationsKeys, casesQueriesKeys } from './constants'; + +interface ReorderTasksParams { + caseId: string; + orderedTaskIds: string[]; + parentTaskId: string | null; +} + +export const useReorderTasks = () => { + const queryClient = useQueryClient(); + + return useMutation( + casesMutationsKeys.reorderTasks, + ({ caseId, orderedTaskIds, parentTaskId }: ReorderTasksParams) => + reorderTasks(caseId, orderedTaskIds, parentTaskId), + { + onMutate: async ({ caseId, orderedTaskIds, parentTaskId }) => { + const queryKey = casesQueriesKeys.tasksList(caseId); + await queryClient.cancelQueries(queryKey); + + const previousData = queryClient.getQueryData(queryKey); + + if (previousData) { + const taskById = new Map(previousData.tasks.map((t) => [t.id, t])); + const affectedSet = new Set(orderedTaskIds); + + // Reorder tasks in the affected group while preserving positions of others. + const affectedInOrder = orderedTaskIds + .map((id) => taskById.get(id)) + .filter((t): t is CaseTask => t !== undefined); + + let affectedIdx = 0; + const newTasks = previousData.tasks.map((t) => { + const pid = t.parent_task_id ?? null; + if (pid === parentTaskId && affectedSet.has(t.id)) { + return affectedInOrder[affectedIdx++] ?? t; + } + return t; + }); + + queryClient.setQueryData(queryKey, { ...previousData, tasks: newTasks }); + } + + return { previousData, queryKey }; + }, + onError: (_err, _vars, context) => { + if (context?.previousData) { + queryClient.setQueryData(context.queryKey, context.previousData); + } + }, + onSettled: (_data, _err, { caseId }) => { + queryClient.invalidateQueries(casesQueriesKeys.tasksList(caseId)); + }, + } + ); +}; diff --git a/x-pack/platform/plugins/shared/cases/public/containers/use_update_task.tsx b/x-pack/platform/plugins/shared/cases/public/containers/use_update_task.tsx index 9b5f7ec595000..49cc4d2d21562 100644 --- a/x-pack/platform/plugins/shared/cases/public/containers/use_update_task.tsx +++ b/x-pack/platform/plugins/shared/cases/public/containers/use_update_task.tsx @@ -6,7 +6,7 @@ */ import { useMutation, useQueryClient } from '@kbn/react-query'; -import type { UpdateTaskRequest } from './api'; +import type { UpdateTaskRequest, CaseTasksResponse } from './api'; import { updateTask } from './api'; import type { ServerError } from '../types'; import { useCasesToast } from '../common/use_cases_toast'; @@ -22,10 +22,28 @@ export const useUpdateTask = (caseId: string) => { updateTask(caseId, taskId, request), { mutationKey: casesMutationsKeys.updateTask, - onError: (error: ServerError) => { + onMutate: async ({ taskId, request }) => { + const queryKey = casesQueriesKeys.tasksList(caseId); + await queryClient.cancelQueries(queryKey); + + const previousData = queryClient.getQueryData(queryKey); + + if (previousData) { + const newTasks = previousData.tasks.map((t) => + t.id === taskId ? { ...t, ...request } : t + ); + queryClient.setQueryData(queryKey, { ...previousData, tasks: newTasks }); + } + + return { previousData, queryKey }; + }, + onError: (error: ServerError, _vars, context) => { + if (context?.previousData) { + queryClient.setQueryData(context.queryKey, context.previousData); + } showErrorToast(error, { title: i18n.ERROR_TITLE }); }, - onSuccess: () => { + onSettled: () => { queryClient.invalidateQueries(casesQueriesKeys.tasksList(caseId)); }, } diff --git a/x-pack/platform/plugins/shared/cases/server/common/types/configure.ts b/x-pack/platform/plugins/shared/cases/server/common/types/configure.ts index 0ee185ace3739..6c2114ce66db6 100644 --- a/x-pack/platform/plugins/shared/cases/server/common/types/configure.ts +++ b/x-pack/platform/plugins/shared/cases/server/common/types/configure.ts @@ -18,6 +18,7 @@ import { ConfigurationActivityFieldsRt, ConfigurationAttributesRt, ConfigurationBasicWithoutOwnerRt, + TaskStatusesConfigurationRt, } from '../../../common/types/domain'; import type { ConnectorPersisted } from './connectors'; import type { User, UserProfile } from './user'; @@ -33,8 +34,16 @@ export interface ConfigurationPersistedAttributes { customFields?: PersistedCustomFieldsConfiguration; templates?: PersistedTemplatesConfiguration; observableTypes?: PersistedObservableTypesConfiguration; + taskStatuses?: PersistedTaskStatusesConfiguration; } +type PersistedTaskStatusesConfiguration = Array<{ + key: string; + label: string; + color: string; + disabled?: boolean; +}>; + type PersistedObservableTypesConfiguration = Array<{ key: string; label: string; @@ -72,11 +81,12 @@ export type ConfigurationTransformedAttributes = ConfigurationAttributes; export type ConfigurationSavedObjectTransformed = SavedObject; export const ConfigurationPartialAttributesRt = rt.intersection([ - rt.exact(rt.partial(ConfigurationBasicWithoutOwnerRt.type.props)), + rt.exact(rt.partial(ConfigurationBasicWithoutOwnerRt.types[0].type.props)), rt.exact(rt.partial(ConfigurationActivityFieldsRt.type.props)), rt.exact( rt.partial({ owner: rt.string, + taskStatuses: TaskStatusesConfigurationRt, }) ), ]); diff --git a/x-pack/platform/plugins/shared/cases/server/routes/api/tasks/patch_task_route.ts b/x-pack/platform/plugins/shared/cases/server/routes/api/tasks/patch_task_route.ts index b9503645400ba..6d6cb86bfdeb4 100644 --- a/x-pack/platform/plugins/shared/cases/server/routes/api/tasks/patch_task_route.ts +++ b/x-pack/platform/plugins/shared/cases/server/routes/api/tasks/patch_task_route.ts @@ -32,14 +32,7 @@ export const patchTaskRoute = createCasesRoute({ version: schema.string(), title: schema.maybe(schema.string({ minLength: 1, maxLength: 160 })), description: schema.maybe(schema.string({ maxLength: 30000 })), - status: schema.maybe( - schema.oneOf([ - schema.literal('open'), - schema.literal('in_progress'), - schema.literal('completed'), - schema.literal('cancelled'), - ]) - ), + status: schema.maybe(schema.string()), priority: schema.maybe( schema.oneOf([ schema.literal('low'), @@ -65,7 +58,7 @@ export const patchTaskRoute = createCasesRoute({ version: string; title?: string; description?: string; - status?: 'open' | 'in_progress' | 'completed' | 'cancelled'; + status?: string; priority?: 'low' | 'medium' | 'high' | 'critical'; assignees?: Array<{ uid: string }>; due_date?: string | null; diff --git a/x-pack/platform/plugins/shared/cases/server/routes/api/tasks/post_task_route.ts b/x-pack/platform/plugins/shared/cases/server/routes/api/tasks/post_task_route.ts index a6642d9bf83b4..c301a11e61549 100644 --- a/x-pack/platform/plugins/shared/cases/server/routes/api/tasks/post_task_route.ts +++ b/x-pack/platform/plugins/shared/cases/server/routes/api/tasks/post_task_route.ts @@ -30,14 +30,7 @@ export const postTaskRoute = createCasesRoute({ body: schema.object({ title: schema.string({ minLength: 1, maxLength: 160 }), description: schema.maybe(schema.string({ maxLength: 30000 })), - status: schema.maybe( - schema.oneOf([ - schema.literal('open'), - schema.literal('in_progress'), - schema.literal('completed'), - schema.literal('cancelled'), - ]) - ), + status: schema.maybe(schema.string()), priority: schema.maybe( schema.oneOf([ schema.literal('low'), @@ -64,7 +57,7 @@ export const postTaskRoute = createCasesRoute({ const body = request.body as { title: string; description?: string; - status?: 'open' | 'in_progress' | 'completed' | 'cancelled'; + status?: string; priority?: 'low' | 'medium' | 'high' | 'critical'; assignees?: Array<{ uid: string }>; due_date?: string | null;