From e612dad442c906a1e5de1a9db8122b7a0285851e Mon Sep 17 00:00:00 2001 From: Omri Rosner Date: Mon, 25 May 2026 21:16:08 +0300 Subject: [PATCH 1/3] [One Workflow] Add managed workflow badge and read-only YAML Closes elastic/security-team#17549 --- .../workflow_list/ui/workflow_list.test.tsx | 16 ++++ .../workflow_list/ui/workflow_list_table.tsx | 12 ++- .../ui/workflow_detail_header.test.tsx | 29 +++++++- .../ui/workflow_detail_header.tsx | 67 ++++++++++++++--- .../public/shared/ui/index.ts | 1 + .../shared/ui/managed_workflow_badge.tsx | 40 ++++++++++ .../get_workflow_tooltip_content.tsx | 7 ++ .../ui/workflow_yaml_editor.test.tsx | 74 ++++++++++++++++++- .../ui/workflow_yaml_editor.tsx | 39 ++++++---- 9 files changed, 258 insertions(+), 27 deletions(-) create mode 100644 src/platform/plugins/shared/workflows_management/public/shared/ui/managed_workflow_badge.tsx diff --git a/src/platform/plugins/shared/workflows_management/public/features/workflow_list/ui/workflow_list.test.tsx b/src/platform/plugins/shared/workflows_management/public/features/workflow_list/ui/workflow_list.test.tsx index 4dd40741bec1e..d6abcb2fa4463 100644 --- a/src/platform/plugins/shared/workflows_management/public/features/workflow_list/ui/workflow_list.test.tsx +++ b/src/platform/plugins/shared/workflows_management/public/features/workflow_list/ui/workflow_list.test.tsx @@ -106,6 +106,9 @@ jest.mock('../../run_workflow/ui/workflow_execute_modal', () => ({ jest.mock('../../../shared/ui', () => ({ getRunTooltipContent: () => 'Run', + ManagedWorkflowBadge: ({ dataTestSubj }: { dataTestSubj?: string }) => ( + {'Managed'} + ), StatusBadge: ({ status }: { status: string }) => {status}, WorkflowStatus: ({ valid }: { valid: boolean }) => {valid ? 'Valid' : 'Invalid'}, })); @@ -287,6 +290,19 @@ describe('WorkflowList', () => { expect(screen.getByText('A workflow for testing')).toBeInTheDocument(); }); + it('renders a managed badge for managed workflows', () => { + mockUseWorkflows.mockReturnValue({ + data: createMockWorkflowListDto([createMockWorkflow({ managed: true })]), + isLoading: false, + error: null, + refetch: mockRefetch, + }); + + renderComponent(); + + expect(screen.getByTestId('workflowManagedBadge-wf-1')).toHaveTextContent('Managed'); + }); + it('shows "No description" for workflows without description', () => { mockUseWorkflows.mockReturnValue({ data: createMockWorkflowListDto([createMockWorkflow({ description: '' })]), diff --git a/src/platform/plugins/shared/workflows_management/public/features/workflow_list/ui/workflow_list_table.tsx b/src/platform/plugins/shared/workflows_management/public/features/workflow_list/ui/workflow_list_table.tsx index e06b1a657cd9b..8318bcc4b0879 100644 --- a/src/platform/plugins/shared/workflows_management/public/features/workflow_list/ui/workflow_list_table.tsx +++ b/src/platform/plugins/shared/workflows_management/public/features/workflow_list/ui/workflow_list_table.tsx @@ -29,7 +29,12 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import type { WorkflowListItemDto } from '@kbn/workflows'; import { WorkflowTriggersAndSteps } from './workflow_triggers_and_steps'; -import { getRunTooltipContent, StatusBadge, WorkflowStatus } from '../../../shared/ui'; +import { + getRunTooltipContent, + ManagedWorkflowBadge, + StatusBadge, + WorkflowStatus, +} from '../../../shared/ui'; import { NextExecutionTime } from '../../../shared/ui/next_execution_time'; import { WORKFLOWS_TABLE_PAGE_SIZE_OPTIONS } from '../constants'; @@ -136,6 +141,11 @@ export const WorkflowListTable = ({ )} + {item.managed === true ? ( + + + + ) : null} diff --git a/src/platform/plugins/shared/workflows_management/public/pages/workflow_detail/ui/workflow_detail_header.test.tsx b/src/platform/plugins/shared/workflows_management/public/pages/workflow_detail/ui/workflow_detail_header.test.tsx index e7adbec5fd986..198c5a3a88e9f 100644 --- a/src/platform/plugins/shared/workflows_management/public/pages/workflow_detail/ui/workflow_detail_header.test.tsx +++ b/src/platform/plugins/shared/workflows_management/public/pages/workflow_detail/ui/workflow_detail_header.test.tsx @@ -91,18 +91,20 @@ describe('WorkflowDetailHeader', () => { hasYamlSchemaValidationErrors = false, serverValid = true, isSaving = false, + isManaged = false, }: { isValid?: boolean; hasChanges?: boolean; hasYamlSchemaValidationErrors?: boolean; serverValid?: boolean; isSaving?: boolean; + isManaged?: boolean; } = {} ) => { const store = createMockStore(); // Set up the workflow in the store (with server-side valid flag) - store.dispatch(setWorkflow({ ...mockWorkflow, valid: serverValid })); + store.dispatch(setWorkflow({ ...mockWorkflow, managed: isManaged, valid: serverValid })); store.dispatch(setYamlString(hasChanges ? 'modified yaml' : mockWorkflow.yaml)); if (!isValid) { @@ -216,6 +218,31 @@ describe('WorkflowDetailHeader', () => { expect(button).toBeEnabled(); }); + it('shows the managed badge for managed workflows', () => { + const result = renderWithProviders(, { + isManaged: true, + }); + + expect(result.getByTestId('workflowDetailManagedBadge')).toHaveTextContent('Managed'); + }); + + it('keeps the enabled toggle editable for managed workflows', () => { + const result = renderWithProviders(, { + isManaged: true, + }); + + expect(result.getByRole('switch')).not.toBeDisabled(); + }); + + it('disables saving managed workflow YAML', () => { + const result = renderWithProviders(, { + hasChanges: true, + isManaged: true, + }); + + expect(result.getByTestId('saveWorkflowHeaderButton')).toBeDisabled(); + }); + it('shows the unsaved changes confirmation when running with unsaved changes', () => { const result = renderWithProviders(, { hasChanges: true, diff --git a/src/platform/plugins/shared/workflows_management/public/pages/workflow_detail/ui/workflow_detail_header.tsx b/src/platform/plugins/shared/workflows_management/public/pages/workflow_detail/ui/workflow_detail_header.tsx index 4e3f892e70a77..5768b293f12cb 100644 --- a/src/platform/plugins/shared/workflows_management/public/pages/workflow_detail/ui/workflow_detail_header.tsx +++ b/src/platform/plugins/shared/workflows_management/public/pages/workflow_detail/ui/workflow_detail_header.tsx @@ -57,7 +57,11 @@ import { useWorkflowUrlState, type WorkflowUrlStateTabType, } from '../../../hooks/use_workflow_url_state'; -import { getSaveWorkflowTooltipContent, getTestRunTooltipContent } from '../../../shared/ui'; +import { + getSaveWorkflowTooltipContent, + getTestRunTooltipContent, + ManagedWorkflowBadge, +} from '../../../shared/ui'; import { WorkflowUnsavedChangesBadge } from '../../../widgets/workflow_yaml_editor/ui/workflow_unsaved_changes_badge'; const executionsTabReadExecutionDisabledTooltip = i18n.translate( @@ -113,6 +117,33 @@ export interface WorkflowDetailHeaderProps { setHighlightDiff: React.Dispatch>; } +interface GetSaveWorkflowButtonDisabledParams { + isExecutionsTab: boolean; + canSaveWorkflow: boolean; + isLoading: boolean; + isSaving: boolean; + isManagedWorkflow: boolean; + isYamlSynced: boolean; + hasUnsavedChanges: boolean; +} + +const getSaveWorkflowButtonDisabled = ({ + isExecutionsTab, + canSaveWorkflow, + isLoading, + isSaving, + isManagedWorkflow, + isYamlSynced, + hasUnsavedChanges, +}: GetSaveWorkflowButtonDisabledParams) => + isExecutionsTab || + !canSaveWorkflow || + isLoading || + isSaving || + isManagedWorkflow || + !isYamlSynced || + !hasUnsavedChanges; + export const WorkflowDetailHeader = React.memo( ({ isLoading, highlightDiff, setHighlightDiff }: WorkflowDetailHeaderProps) => { const { id: workflowId } = useParams<{ id?: string }>(); @@ -154,6 +185,7 @@ export const WorkflowDetailHeader = React.memo( }), [workflow] ); + const isManagedWorkflow = workflow?.managed === true; const saveYaml = useSaveYaml(); const isSaving = useSelector(selectIsSavingYaml); @@ -194,12 +226,29 @@ export const WorkflowDetailHeader = React.memo( canSaveWorkflow: isCreate ? canCreateWorkflow : canUpdateWorkflow, isCreate, hasUnsavedChanges, + isManagedWorkflow, }); - }, [isExecutionsTab, workflowId, canCreateWorkflow, canUpdateWorkflow, hasUnsavedChanges]); + }, [ + isExecutionsTab, + workflowId, + canCreateWorkflow, + canUpdateWorkflow, + hasUnsavedChanges, + isManagedWorkflow, + ]); const canSaveWorkflow = useMemo(() => { return workflowId ? canUpdateWorkflow : canCreateWorkflow; }, [canUpdateWorkflow, canCreateWorkflow, workflowId]); + const saveWorkflowButtonDisabled = getSaveWorkflowButtonDisabled({ + isExecutionsTab, + canSaveWorkflow, + isLoading, + isSaving, + isManagedWorkflow, + isYamlSynced, + hasUnsavedChanges, + }); const handleRunClickWithUnsavedCheck = useCallback(() => { const shouldSkipUnsavedRunConfirmation = @@ -265,6 +314,11 @@ export const WorkflowDetailHeader = React.memo( + {isManagedWorkflow ? ( + + + + ) : null} diff --git a/src/platform/plugins/shared/workflows_management/public/shared/ui/index.ts b/src/platform/plugins/shared/workflows_management/public/shared/ui/index.ts index 48b0c44b20d9c..c8033082d343b 100644 --- a/src/platform/plugins/shared/workflows_management/public/shared/ui/index.ts +++ b/src/platform/plugins/shared/workflows_management/public/shared/ui/index.ts @@ -21,3 +21,4 @@ export { } from './workflow_action_buttons/get_workflow_tooltip_content'; export { FormattedRelativeEnhanced } from './formatted_relative_enhanced/formatted_relative_enhanced'; export { withTooltip } from './with_tooltip'; +export { ManagedWorkflowBadge } from './managed_workflow_badge'; diff --git a/src/platform/plugins/shared/workflows_management/public/shared/ui/managed_workflow_badge.tsx b/src/platform/plugins/shared/workflows_management/public/shared/ui/managed_workflow_badge.tsx new file mode 100644 index 0000000000000..1e95454091739 --- /dev/null +++ b/src/platform/plugins/shared/workflows_management/public/shared/ui/managed_workflow_badge.tsx @@ -0,0 +1,40 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { EuiBadge, EuiToolTip } from '@elastic/eui'; +import React from 'react'; +import { i18n } from '@kbn/i18n'; + +interface ManagedWorkflowBadgeProps { + dataTestSubj?: string; +} + +const managedWorkflowLabel = i18n.translate('workflows.managedWorkflowBadge.label', { + defaultMessage: 'Managed', +}); + +const managedWorkflowTooltip = i18n.translate('workflows.managedWorkflowBadge.tooltip', { + defaultMessage: 'Elastic manages this workflow.', +}); + +export const ManagedWorkflowBadge = ({ + dataTestSubj = 'managedWorkflowBadge', +}: ManagedWorkflowBadgeProps) => ( + + + {managedWorkflowLabel} + + +); diff --git a/src/platform/plugins/shared/workflows_management/public/shared/ui/workflow_action_buttons/get_workflow_tooltip_content.tsx b/src/platform/plugins/shared/workflows_management/public/shared/ui/workflow_action_buttons/get_workflow_tooltip_content.tsx index 53cf36a86ffab..f1977dec62088 100644 --- a/src/platform/plugins/shared/workflows_management/public/shared/ui/workflow_action_buttons/get_workflow_tooltip_content.tsx +++ b/src/platform/plugins/shared/workflows_management/public/shared/ui/workflow_action_buttons/get_workflow_tooltip_content.tsx @@ -105,12 +105,14 @@ interface GetSaveWorkflowTooltipContentProps { canSaveWorkflow: boolean; isCreate: boolean; hasUnsavedChanges: boolean; + isManagedWorkflow?: boolean; } export function getSaveWorkflowTooltipContent({ isExecutionsTab, canSaveWorkflow, isCreate, hasUnsavedChanges, + isManagedWorkflow = false, }: GetSaveWorkflowTooltipContentProps) { if (isExecutionsTab) { return i18n.translate('workflows.actionButtons.saveWorkflow.executionsTab', { @@ -128,6 +130,11 @@ export function getSaveWorkflowTooltipContent({ }); } } + if (isManagedWorkflow) { + return i18n.translate('workflows.actionButtons.saveWorkflow.managedWorkflow', { + defaultMessage: 'Managed workflow YAML is read-only', + }); + } if (!hasUnsavedChanges) { return i18n.translate('workflows.actionButtons.saveWorkflow.noChanges', { defaultMessage: 'No changes to save', diff --git a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/ui/workflow_yaml_editor.test.tsx b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/ui/workflow_yaml_editor.test.tsx index 823ff68d24fe5..b6745ce6cf827 100644 --- a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/ui/workflow_yaml_editor.test.tsx +++ b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/ui/workflow_yaml_editor.test.tsx @@ -15,7 +15,12 @@ import { monaco, YAML_LANG_ID } from '@kbn/monaco'; import type { WorkflowYAMLEditorProps } from './workflow_yaml_editor'; import { WorkflowYAMLEditor } from './workflow_yaml_editor'; import { useSaveYaml } from '../../../entities/workflows/model/use_save_yaml'; -import { setActiveTab, setExecution, setYamlString } from '../../../entities/workflows/store'; +import { + setActiveTab, + setExecution, + setWorkflow, + setYamlString, +} from '../../../entities/workflows/store'; import { createMockStore } from '../../../entities/workflows/store/__mocks__/store.mock'; import { saveYamlThunk } from '../../../entities/workflows/store/workflow_detail/thunks/save_yaml_thunk'; import { getTestProvider } from '../../../shared/mocks/test_providers'; @@ -24,7 +29,7 @@ import { getCompletionItemProvider } from '../lib/autocomplete/get_completion_it // Mock the YamlEditor component to avoid Monaco complexity in tests jest.mock('../../../shared/ui/yaml_editor', () => ({ - YamlEditor: ({ value, onChange, editorDidMount, ...props }: YamlEditorProps) => ( + YamlEditor: ({ value, onChange, editorDidMount, options }: YamlEditorProps) => (