diff --git a/src/platform/plugins/shared/workflows_management/public/features/workflow_list/ui/use_workflow_bulk_actions.test.tsx b/src/platform/plugins/shared/workflows_management/public/features/workflow_list/ui/use_workflow_bulk_actions.test.tsx index 13bcc9527ea5e..fd4cdf129884b 100644 --- a/src/platform/plugins/shared/workflows_management/public/features/workflow_list/ui/use_workflow_bulk_actions.test.tsx +++ b/src/platform/plugins/shared/workflows_management/public/features/workflow_list/ui/use_workflow_bulk_actions.test.tsx @@ -101,6 +101,9 @@ describe('useWorkflowBulkActions', () => { beforeEach(() => { jest.clearAllMocks(); + mockApplication.capabilities.workflowsManagement.deleteWorkflow = true; + mockApplication.capabilities.workflowsManagement.updateWorkflow = true; + mockApplication.capabilities.workflowsManagement.readWorkflow = true; }); it('returns panels and modals', () => { @@ -158,6 +161,25 @@ describe('useWorkflowBulkActions', () => { expect(deleteItem).toBeDefined(); }); + it('does not include delete action when a managed workflow is selected', () => { + const managedWorkflow = createMockWorkflow({ managed: true }); + const { result } = renderHook( + () => + useWorkflowBulkActions({ + ...defaultProps, + selectedWorkflows: [managedWorkflow], + allWorkflows: [managedWorkflow], + }), + { wrapper } + ); + + const mainPanel = result.current.panels[0]; + const deleteItem = mainPanel.items?.find( + (item) => 'key' in item && item.key === 'workflows-bulk-action-delete' + ); + expect(deleteItem).toBeUndefined(); + }); + it('includes export action when there are exportable workflows', () => { const { result } = renderHook(() => useWorkflowBulkActions(defaultProps), { wrapper }); diff --git a/src/platform/plugins/shared/workflows_management/public/features/workflow_list/ui/use_workflow_bulk_actions.tsx b/src/platform/plugins/shared/workflows_management/public/features/workflow_list/ui/use_workflow_bulk_actions.tsx index a045b14d3bf8c..f56f218f07c81 100644 --- a/src/platform/plugins/shared/workflows_management/public/features/workflow_list/ui/use_workflow_bulk_actions.tsx +++ b/src/platform/plugins/shared/workflows_management/public/features/workflow_list/ui/use_workflow_bulk_actions.tsx @@ -69,15 +69,27 @@ export const useWorkflowBulkActions = ({ const canReadWorkflow = application?.capabilities.workflowsManagement.readWorkflow; const isDisabled = selectedWorkflows.length === 0; + const hasManagedWorkflows = selectedWorkflows.some((workflow) => workflow.managed === true); + const canDeleteSelectedWorkflows = canDeleteWorkflow && !hasManagedWorkflows; const handleDeleteWorkflows = useCallback(() => { + if (hasManagedWorkflows) { + return; + } onAction(); setShowDeleteModal(true); - }, [onAction]); + }, [hasManagedWorkflows, onAction]); const confirmDelete = useCallback(() => { - const ids = selectedWorkflows.map((workflow) => workflow.id); + const ids = selectedWorkflows + .filter((workflow) => workflow.managed !== true) + .map((workflow) => workflow.id); const count = ids.length; + if (count === 0) { + setShowDeleteModal(false); + deselectWorkflows(); + return; + } setShowDeleteModal(false); deselectWorkflows(); @@ -231,7 +243,7 @@ export const useWorkflowBulkActions = ({ }); } - if (mainPanelItems.length > 0 && canDeleteWorkflow) { + if (mainPanelItems.length > 0 && canDeleteSelectedWorkflows) { mainPanelItems.push({ isSeparator: true as const, key: 'bulk-actions-separator', @@ -239,7 +251,7 @@ export const useWorkflowBulkActions = ({ }); } - if (canDeleteWorkflow) { + if (canDeleteSelectedWorkflows) { mainPanelItems.push({ name: ( @@ -268,7 +280,7 @@ export const useWorkflowBulkActions = ({ }, [ selectedWorkflows, canUpdateWorkflow, - canDeleteWorkflow, + canDeleteSelectedWorkflows, canReadWorkflow, isDisabled, handleEnableWorkflows, diff --git a/src/platform/plugins/shared/workflows_management/public/features/workflow_list/ui/workflow_list.authorization.test.tsx b/src/platform/plugins/shared/workflows_management/public/features/workflow_list/ui/workflow_list.authorization.test.tsx index 09f0ce5a6ed01..e97b32670f6e9 100644 --- a/src/platform/plugins/shared/workflows_management/public/features/workflow_list/ui/workflow_list.authorization.test.tsx +++ b/src/platform/plugins/shared/workflows_management/public/features/workflow_list/ui/workflow_list.authorization.test.tsx @@ -284,6 +284,36 @@ describe('Authorization matrix', () => { } ); + it('disables the delete row action for managed workflows', async () => { + setKibanaCapabilities({ + createWorkflow: true, + updateWorkflow: true, + deleteWorkflow: true, + executeWorkflow: true, + }); + renderList({ item: createWorkflowListItem({ id: 'managed-wf', managed: true }) }); + + await openFirstRowCollapsedActions(); + + expect(screen.getByTestId('deleteWorkflowAction')).toBeDisabled(); + }); + + it('explains why managed workflows cannot be deleted', async () => { + setKibanaCapabilities({ + createWorkflow: true, + updateWorkflow: true, + deleteWorkflow: true, + executeWorkflow: true, + }); + renderList({ item: createWorkflowListItem({ id: 'managed-wf', managed: true }) }); + + await openFirstRowCollapsedActions(); + const deleteAction = screen.getByTestId('deleteWorkflowAction'); + await userEvent.hover(deleteAction.parentElement ?? deleteAction); + + expect(await screen.findByText('Managed workflows cannot be deleted')).toBeInTheDocument(); + }); + it('disables the enabled switch when the workflow is invalid even if update is granted', () => { setKibanaCapabilities({ createWorkflow: false, 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..ac1299bb237b7 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 @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { render, screen } from '@testing-library/react'; +import { render, screen, within } from '@testing-library/react'; import React from 'react'; import type { WorkflowListDto, WorkflowListItemDto, WorkflowsSearchParams } from '@kbn/workflows'; import { createMockWorkflowsCapabilities as mockCreateMockWorkflowsCapabilities } from '@kbn/workflows-ui/mocks'; @@ -106,6 +106,9 @@ jest.mock('../../run_workflow/ui/workflow_execute_modal', () => ({ jest.mock('../../../shared/ui', () => ({ getRunTooltipContent: () => 'Run', + ManagedWorkflowBadge: ({ dataTestSubj = 'managedWorkflowBadge' }: { dataTestSubj?: string }) => ( + {'Managed'} + ), StatusBadge: ({ status }: { status: string }) => {status}, WorkflowStatus: ({ valid }: { valid: boolean }) => {valid ? 'Valid' : 'Invalid'}, })); @@ -287,6 +290,38 @@ describe('WorkflowList', () => { expect(screen.getByText('A workflow for testing')).toBeInTheDocument(); }); + it('renders the managed badge as the first tag for managed workflows', () => { + const workflow = createMockWorkflow({ + managed: true, + definition: { + version: '1', + name: 'My Test Workflow', + enabled: true, + triggers: [], + steps: [], + tags: ['custom', 'second'], + }, + }); + + mockUseWorkflows.mockReturnValue({ + data: createMockWorkflowListDto([workflow]), + isLoading: false, + error: null, + refetch: mockRefetch, + }); + + renderComponent(); + + const tagsCell = screen.getByTestId('workflowTags'); + const managedBadge = within(tagsCell).getByTestId('managedWorkflowBadge'); + const firstWorkflowTag = within(tagsCell).getByText('custom'); + + expect(managedBadge).toHaveTextContent('Managed'); + expect(managedBadge.compareDocumentPosition(firstWorkflowTag)).toEqual( + Node.DOCUMENT_POSITION_FOLLOWING + ); + }); + 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..03df6fdee3db6 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'; @@ -171,7 +176,7 @@ export const WorkflowListTable = ({ }), width: '18%', render: (value: unknown, item: WorkflowListItemDto) => ( - + ), }, { @@ -324,15 +329,20 @@ export const WorkflowListTable = ({ onClick: (item: WorkflowListItemDto) => onExportWorkflow(item), }, { - enabled: () => canDeleteWorkflow, + enabled: (item) => canDeleteWorkflow && item.managed !== true, type: 'icon', color: 'danger', name: i18n.translate('workflows.workflowList.delete', { defaultMessage: 'Delete' }), 'data-test-subj': 'deleteWorkflowAction', icon: 'trash', - description: i18n.translate('workflows.workflowList.delete', { - defaultMessage: 'Delete workflow', - }), + description: (item: WorkflowListItemDto) => + item.managed === true + ? i18n.translate('workflows.workflowList.deleteManagedDisabled', { + defaultMessage: 'Managed workflows cannot be deleted', + }) + : i18n.translate('workflows.workflowList.deleteDescription', { + defaultMessage: 'Delete workflow', + }), onClick: (item: WorkflowListItemDto) => onDeleteWorkflow(item), }, ], @@ -416,15 +426,25 @@ const overflowPopoverStyle = css` overflow: auto; `; -const WorkflowTagsCell = ({ tags }: { tags: readonly string[] | undefined }) => { +const WorkflowTagsCell = ({ + tags, + isManaged, +}: { + tags: readonly string[] | undefined; + isManaged: boolean; +}) => { const [isOpen, setIsOpen] = useState(false); const toggle = useCallback(() => setIsOpen((prev) => !prev), []); const close = useCallback(() => setIsOpen(false), []); - if (!tags || tags.length === 0) return null; + if (!isManaged && (!tags || tags.length === 0)) return null; - const visible = tags.slice(0, MAX_VISIBLE_TAGS); - const hidden = tags.slice(MAX_VISIBLE_TAGS); + const workflowTags = tags ?? []; + const visibleWorkflowTags = workflowTags.slice( + 0, + isManaged ? MAX_VISIBLE_TAGS - 1 : MAX_VISIBLE_TAGS + ); + const hidden = workflowTags.slice(visibleWorkflowTags.length); return ( css={tagsRowStyle} data-test-subj="workflowTags" > - {visible.map((tag) => ( + {isManaged ? ( + + + + ) : null} + {visibleWorkflowTags.map((tag) => ( {tag} 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..563239c5241e5 --- /dev/null +++ b/src/platform/plugins/shared/workflows_management/public/shared/ui/managed_workflow_badge.tsx @@ -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", 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 56ec660c8535a..a67ec1f0dc399 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) => (