diff --git a/src/platform/plugins/shared/workflows_management/public/assets/empty_state.svg b/src/platform/plugins/shared/workflows_management/public/assets/empty_state.svg new file mode 100644 index 0000000000000..ddf93a32c69db --- /dev/null +++ b/src/platform/plugins/shared/workflows_management/public/assets/empty_state.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/src/platform/plugins/shared/workflows_management/public/components/index.ts b/src/platform/plugins/shared/workflows_management/public/components/index.ts new file mode 100644 index 0000000000000..8bb805c497465 --- /dev/null +++ b/src/platform/plugins/shared/workflows_management/public/components/index.ts @@ -0,0 +1,10 @@ +/* + * 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". + */ + +export { WorkflowsEmptyState } from './workflows_empty_state'; diff --git a/src/platform/plugins/shared/workflows_management/public/components/workflows_empty_state/index.ts b/src/platform/plugins/shared/workflows_management/public/components/workflows_empty_state/index.ts new file mode 100644 index 0000000000000..8bb805c497465 --- /dev/null +++ b/src/platform/plugins/shared/workflows_management/public/components/workflows_empty_state/index.ts @@ -0,0 +1,10 @@ +/* + * 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". + */ + +export { WorkflowsEmptyState } from './workflows_empty_state'; diff --git a/src/platform/plugins/shared/workflows_management/public/components/workflows_empty_state/workflows_empty_state.test.tsx b/src/platform/plugins/shared/workflows_management/public/components/workflows_empty_state/workflows_empty_state.test.tsx new file mode 100644 index 0000000000000..356b079f3d228 --- /dev/null +++ b/src/platform/plugins/shared/workflows_management/public/components/workflows_empty_state/workflows_empty_state.test.tsx @@ -0,0 +1,84 @@ +/* + * 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 { I18nProvider } from '@kbn/i18n-react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import React from 'react'; +import { WorkflowsEmptyState } from './workflows_empty_state'; + +// Mock useKibana hook +jest.mock('@kbn/kibana-react-plugin/public', () => ({ + useKibana: () => ({ + services: { + http: { + basePath: { + prepend: (path: string) => `/mock-base-path${path}`, + }, + }, + }, + }), +})); + +const renderWithIntl = (component: React.ReactElement) => { + return render({component}); +}; + +describe('WorkflowsEmptyState', () => { + it('renders the empty state with title and description', () => { + renderWithIntl(); + + expect(screen.getByText('Get Started with Workflows')).toBeInTheDocument(); + expect(screen.getByText(/Workflows let you automate and orchestrate/)).toBeInTheDocument(); + expect(screen.getByText(/Start by creating a workflow/)).toBeInTheDocument(); + }); + + it('renders the create button when user can create workflows', () => { + const onCreateWorkflow = jest.fn(); + renderWithIntl( + + ); + + const createButton = screen.getByText('Create a new workflow'); + expect(createButton).toBeInTheDocument(); + + fireEvent.click(createButton); + expect(onCreateWorkflow).toHaveBeenCalledTimes(1); + }); + + it('does not render the create button when user cannot create workflows', () => { + renderWithIntl(); + + expect(screen.queryByText('Create a new workflow')).not.toBeInTheDocument(); + }); + + it('does not render the create button when onCreateWorkflow is not provided', () => { + renderWithIntl(); + + expect(screen.queryByText('Create a new workflow')).not.toBeInTheDocument(); + }); + + it('renders the footer with documentation link', () => { + renderWithIntl(); + + expect(screen.getByText('Need help?')).toBeInTheDocument(); + expect(screen.getByText('Read documentation')).toBeInTheDocument(); + }); + + it('renders the illustration image', () => { + renderWithIntl(); + + const images = screen.getAllByRole('presentation'); + const mainImage = images.find((img) => img.tagName === 'IMG'); + expect(mainImage).toBeInTheDocument(); + expect(mainImage).toHaveAttribute( + 'src', + '/mock-base-path/plugins/workflowsManagement/assets/empty_state.svg' + ); + }); +}); diff --git a/src/platform/plugins/shared/workflows_management/public/components/workflows_empty_state/workflows_empty_state.tsx b/src/platform/plugins/shared/workflows_management/public/components/workflows_empty_state/workflows_empty_state.tsx new file mode 100644 index 0000000000000..c30757321fd62 --- /dev/null +++ b/src/platform/plugins/shared/workflows_management/public/components/workflows_empty_state/workflows_empty_state.tsx @@ -0,0 +1,90 @@ +/* + * 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 { EuiButton, EuiEmptyPrompt, EuiImage, EuiLink, EuiTitle } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import React from 'react'; + +interface WorkflowsEmptyStateProps { + onCreateWorkflow?: () => void; + canCreateWorkflow?: boolean; +} + +export function WorkflowsEmptyState({ + onCreateWorkflow, + canCreateWorkflow = false, +}: WorkflowsEmptyStateProps) { + const { http } = useKibana().services; + return ( + + } + title={ +

+ +

+ } + layout="horizontal" + color="plain" + body={ + <> +

+ +

+

+ +

+ + } + actions={ + canCreateWorkflow && onCreateWorkflow ? ( + + + + ) : null + } + footer={ + <> + + + + + {' '} + + + + + } + /> + ); +} diff --git a/src/platform/plugins/shared/workflows_management/public/features/workflow_list/ui/index.tsx b/src/platform/plugins/shared/workflows_management/public/features/workflow_list/ui/index.tsx index 4614d31392b87..cffd9382f61f0 100644 --- a/src/platform/plugins/shared/workflows_management/public/features/workflow_list/ui/index.tsx +++ b/src/platform/plugins/shared/workflows_management/public/features/workflow_list/ui/index.tsx @@ -20,26 +20,29 @@ import { EuiText, useEuiTheme, } from '@elastic/eui'; +import type { CriteriaWithPagination } from '@elastic/eui/src/components/basic_table/basic_table'; +import { i18n } from '@kbn/i18n'; +import { FormattedRelative } from '@kbn/i18n-react'; import { useKibana } from '@kbn/kibana-react-plugin/public'; import type { WorkflowListItemDto } from '@kbn/workflows'; import React, { useCallback, useMemo, useState } from 'react'; import { Link } from 'react-router-dom'; -import { FormattedRelative } from '@kbn/i18n-react'; -import type { CriteriaWithPagination } from '@elastic/eui/src/components/basic_table/basic_table'; -import { i18n } from '@kbn/i18n'; +import { WorkflowsEmptyState } from '../../../components'; import { useWorkflowActions } from '../../../entities/workflows/model/use_workflow_actions'; import { useWorkflows } from '../../../entities/workflows/model/use_workflows'; +import { getStatusLabel } from '../../../shared/translations'; +import { getExecutionStatusIcon } from '../../../shared/ui'; +import { shouldShowWorkflowsEmptyState } from '../../../shared/utils/workflow_utils'; import type { WorkflowsSearchParams } from '../../../types'; import { WORKFLOWS_TABLE_PAGE_SIZE_OPTIONS } from '../constants'; -import { getExecutionStatusIcon } from '../../../shared/ui'; -import { getStatusLabel } from '../../../shared/translations'; interface WorkflowListProps { search: WorkflowsSearchParams; setSearch: (search: WorkflowsSearchParams) => void; + onCreateWorkflow?: () => void; } -export function WorkflowList({ search, setSearch }: WorkflowListProps) { +export function WorkflowList({ search, setSearch, onCreateWorkflow }: WorkflowListProps) { const { euiTheme } = useEuiTheme(); const { application, notifications } = useKibana().services; const { data: workflows, isLoading: isLoadingWorkflows, error } = useWorkflows(search); @@ -299,6 +302,20 @@ export function WorkflowList({ search, setSearch }: WorkflowListProps) { return Error loading workflows; } + // Show empty state if no workflows exist and no filters are applied + if (shouldShowWorkflowsEmptyState(workflows, search)) { + return ( + + + + + + ); + } + const showStart = (search.page - 1) * search.limit + 1; let showEnd = search.page * search.limit; if (showEnd > (workflows!._pagination.total || 0)) { diff --git a/src/platform/plugins/shared/workflows_management/public/pages/workflows/index.tsx b/src/platform/plugins/shared/workflows_management/public/pages/workflows/index.tsx index 32a8d7c39abcd..4c892e85b2715 100644 --- a/src/platform/plugins/shared/workflows_management/public/pages/workflows/index.tsx +++ b/src/platform/plugins/shared/workflows_management/public/pages/workflows/index.tsx @@ -9,28 +9,29 @@ import { EuiButton, + EuiFilterGroup, EuiFlexGroup, EuiFlexItem, EuiHorizontalRule, EuiPageHeader, EuiPageTemplate, EuiSpacer, - EuiFilterGroup, useEuiTheme, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { useKibana } from '@kbn/kibana-react-plugin/public'; import React, { useState } from 'react'; -import { WORKFLOWS_TABLE_INITIAL_PAGE_SIZE } from '../../features/workflow_list/constants'; -import type { WorkflowsSearchParams } from '../../types'; import { useWorkflowActions } from '../../entities/workflows/model/use_workflow_actions'; +import { useWorkflowFiltersOptions } from '../../entities/workflows/model/use_workflow_stats'; import { useWorkflows } from '../../entities/workflows/model/use_workflows'; -import { WorkflowList } from '../../features/workflow_list/ui'; import { WorkflowExecutionStatsBar } from '../../features/workflow_executions_stats/ui'; -import { useWorkflowFiltersOptions } from '../../entities/workflows/model/use_workflow_stats'; -import { WorkflowSearchField } from '../../widgets/workflow_search_field/ui/workflow_search_field'; +import { WORKFLOWS_TABLE_INITIAL_PAGE_SIZE } from '../../features/workflow_list/constants'; +import { WorkflowList } from '../../features/workflow_list/ui'; +import { shouldShowWorkflowsEmptyState } from '../../shared/utils/workflow_utils'; +import type { WorkflowsSearchParams } from '../../types'; import { WorkflowsFilterPopover } from '../../widgets/workflow_filter_popover/workflow_filter_popover'; +import { WorkflowSearchField } from '../../widgets/workflow_search_field/ui/workflow_search_field'; const workflowTemplateYaml = `name: New workflow enabled: false @@ -54,10 +55,13 @@ export function WorkflowsPage() { query: '', }); - const { refetch } = useWorkflows(search); + const { data: workflows, refetch } = useWorkflows(search); const canCreateWorkflow = application?.capabilities.workflowsManagement.createWorkflow; + // Check if we should show empty state + const shouldShowEmptyState = shouldShowWorkflowsEmptyState(workflows, search); + chrome!.setBreadcrumbs([ { text: i18n.translate('workflows.breadcrumbs.title', { defaultMessage: 'Workflows' }), @@ -117,7 +121,7 @@ export function WorkflowsPage() { > @@ -127,54 +131,62 @@ export function WorkflowsPage() { - - - - setSearch((prevState) => { - return { ...prevState, query }; - }) - } - /> - - - - { - setSearch((prevState) => { - return { ...prevState, enabled: newValues }; - }); - }} - /> - - - - - { - setSearch((prevState) => { - return { ...prevState, createdBy: newValues }; - }); - }} - /> - - - + {!shouldShowEmptyState && ( + <> + + + + setSearch((prevState) => { + return { ...prevState, query }; + }) + } + /> + + + + { + setSearch((prevState) => { + return { ...prevState, enabled: newValues }; + }); + }} + /> + + + + + { + setSearch((prevState) => { + return { ...prevState, createdBy: newValues }; + }); + }} + /> + + + - - - + + + + + )} - + ); diff --git a/src/platform/plugins/shared/workflows_management/public/shared/utils/workflow_utils.ts b/src/platform/plugins/shared/workflows_management/public/shared/utils/workflow_utils.ts new file mode 100644 index 0000000000000..2e6e80f0ca602 --- /dev/null +++ b/src/platform/plugins/shared/workflows_management/public/shared/utils/workflow_utils.ts @@ -0,0 +1,29 @@ +/* + * 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 type { WorkflowsSearchParams } from '../../types'; + +export interface WorkflowsData { + _pagination: { + total: number; + }; +} + +export function shouldShowWorkflowsEmptyState( + workflows: WorkflowsData | undefined, + search: WorkflowsSearchParams +): boolean { + const hasNoWorkflows = workflows?._pagination.total === 0; + const hasNoFilters = + !search.query && + (!search.enabled || search.enabled.length === 0) && + (!search.createdBy || search.createdBy.length === 0); + + return hasNoWorkflows && hasNoFilters; +}