From 91c584d766b8983694868a2869363d7ad5ebe4c0 Mon Sep 17 00:00:00 2001 From: Spencer Date: Tue, 22 Jun 2021 09:41:38 -0700 Subject: [PATCH 01/17] remove duplicate apm-rum deps from devDeps (#102838) Co-authored-by: spalger --- package.json | 2 -- 1 file changed, 2 deletions(-) diff --git a/package.json b/package.json index 873dffeed38f8a..f654f5f62ba9ce 100644 --- a/package.json +++ b/package.json @@ -446,8 +446,6 @@ "@bazel/typescript": "^3.5.1", "@cypress/snapshot": "^2.1.7", "@cypress/webpack-preprocessor": "^5.6.0", - "@elastic/apm-rum": "^5.6.1", - "@elastic/apm-rum-react": "^1.2.5", "@elastic/eslint-config-kibana": "link:bazel-bin/packages/elastic-eslint-config-kibana", "@elastic/eslint-plugin-eui": "0.0.2", "@elastic/github-checks-reporter": "0.0.20b3", From 537fcf4ff2eefd15ffb928e002327849fc17639b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20S=C3=A1nchez?= Date: Tue, 22 Jun 2021 18:49:34 +0200 Subject: [PATCH 02/17] [Security Solution][Endpoint] Don't create event filters list from manifest manager (#102618) * Check if endpoint event filters list exists before create and create it without specific id * Removes creation of endpoint event filters list in manifest manager Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../create_endoint_event_filters_list.ts | 82 ------------------- .../exception_lists/exception_list_client.ts | 13 --- .../server/endpoint/lib/artifacts/lists.ts | 2 - 3 files changed, 97 deletions(-) delete mode 100644 x-pack/plugins/lists/server/services/exception_lists/create_endoint_event_filters_list.ts diff --git a/x-pack/plugins/lists/server/services/exception_lists/create_endoint_event_filters_list.ts b/x-pack/plugins/lists/server/services/exception_lists/create_endoint_event_filters_list.ts deleted file mode 100644 index 94a049d10cc45d..00000000000000 --- a/x-pack/plugins/lists/server/services/exception_lists/create_endoint_event_filters_list.ts +++ /dev/null @@ -1,82 +0,0 @@ -/* - * 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 { SavedObjectsClientContract } from 'kibana/server'; -import uuid from 'uuid'; -import { Version } from '@kbn/securitysolution-io-ts-types'; -import type { ExceptionListSchema } from '@kbn/securitysolution-io-ts-list-types'; -import { getSavedObjectType } from '@kbn/securitysolution-list-utils'; -import { - ENDPOINT_EVENT_FILTERS_LIST_DESCRIPTION, - ENDPOINT_EVENT_FILTERS_LIST_ID, - ENDPOINT_EVENT_FILTERS_LIST_NAME, -} from '@kbn/securitysolution-list-constants'; - -import { ExceptionListSoSchema } from '../../schemas/saved_objects'; - -import { transformSavedObjectToExceptionList } from './utils'; - -interface CreateEndpointEventFiltersListOptions { - savedObjectsClient: SavedObjectsClientContract; - user: string; - tieBreaker?: string; - version: Version; -} - -/** - * Creates the Endpoint Trusted Apps agnostic list if it does not yet exist - * - * @param savedObjectsClient - * @param user - * @param tieBreaker - * @param version - */ -export const createEndpointEventFiltersList = async ({ - savedObjectsClient, - user, - tieBreaker, - version, -}: CreateEndpointEventFiltersListOptions): Promise => { - const savedObjectType = getSavedObjectType({ namespaceType: 'agnostic' }); - const dateNow = new Date().toISOString(); - try { - const savedObject = await savedObjectsClient.create( - savedObjectType, - { - comments: undefined, - created_at: dateNow, - created_by: user, - description: ENDPOINT_EVENT_FILTERS_LIST_DESCRIPTION, - entries: undefined, - immutable: false, - item_id: undefined, - list_id: ENDPOINT_EVENT_FILTERS_LIST_ID, - list_type: 'list', - meta: undefined, - name: ENDPOINT_EVENT_FILTERS_LIST_NAME, - os_types: [], - tags: [], - tie_breaker_id: tieBreaker ?? uuid.v4(), - type: 'endpoint_events', - updated_by: user, - version, - }, - { - // We intentionally hard coding the id so that there can only be one Event Filters list within the space - id: ENDPOINT_EVENT_FILTERS_LIST_ID, - } - ); - - return transformSavedObjectToExceptionList({ savedObject }); - } catch (err) { - if (savedObjectsClient.errors.isConflictError(err)) { - return null; - } else { - throw err; - } - } -}; diff --git a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts index 4ccff2dd000b9b..77e82bf0f75783 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts @@ -54,7 +54,6 @@ import { } from './find_exception_list_items'; import { createEndpointList } from './create_endpoint_list'; import { createEndpointTrustedAppsList } from './create_endpoint_trusted_apps_list'; -import { createEndpointEventFiltersList } from './create_endoint_event_filters_list'; export class ExceptionListClient { private readonly user: string; @@ -120,18 +119,6 @@ export class ExceptionListClient { }); }; - /** - * Create the Endpoint Event Filters Agnostic list if it does not yet exist (`null` is returned if it does exist) - */ - public createEndpointEventFiltersList = async (): Promise => { - const { savedObjectsClient, user } = this; - return createEndpointEventFiltersList({ - savedObjectsClient, - user, - version: 1, - }); - }; - /** * This is the same as "createListItem" except it applies specifically to the agnostic endpoint list and will * auto-call the "createEndpointList" for you so that you have the best chance of the agnostic endpoint diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts index f5d3b30bf15faa..e27a09efd97108 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts @@ -140,8 +140,6 @@ export async function getEndpointEventFiltersList( policyId ? ` or exception-list-agnostic.attributes.tags:\"policy:${policyId}\"` : '' })`; - await eClient.createEndpointEventFiltersList(); - return getFilteredEndpointExceptionList( eClient, schemaVersion, From 3da2ac8927eaaeae7fecfb49aba9687454358310 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Tue, 22 Jun 2021 18:11:24 +0100 Subject: [PATCH 03/17] chore(NA): moving @kbn/ui-framework into bazel (#102908) --- .../monorepo-packages.asciidoc | 1 + package.json | 2 +- packages/BUILD.bazel | 1 + packages/kbn-ui-framework/BUILD.bazel | 47 +++++++++++++++++++ x-pack/package.json | 3 -- yarn.lock | 2 +- 6 files changed, 51 insertions(+), 5 deletions(-) create mode 100644 packages/kbn-ui-framework/BUILD.bazel diff --git a/docs/developer/getting-started/monorepo-packages.asciidoc b/docs/developer/getting-started/monorepo-packages.asciidoc index 48d0d40d0abb06..e8b950a696f55d 100644 --- a/docs/developer/getting-started/monorepo-packages.asciidoc +++ b/docs/developer/getting-started/monorepo-packages.asciidoc @@ -104,6 +104,7 @@ yarn kbn watch-bazel - @kbn/storybook - @kbn/telemetry-utils - @kbn/tinymath +- @kbn/ui-framework - @kbn/ui-shared-deps - @kbn/utility-types - @kbn/utils diff --git a/package.json b/package.json index f654f5f62ba9ce..36fa086657adff 100644 --- a/package.json +++ b/package.json @@ -154,7 +154,7 @@ "@kbn/server-route-repository": "link:bazel-bin/packages/kbn-server-route-repository", "@kbn/std": "link:bazel-bin/packages/kbn-std", "@kbn/tinymath": "link:bazel-bin/packages/kbn-tinymath", - "@kbn/ui-framework": "link:packages/kbn-ui-framework", + "@kbn/ui-framework": "link:bazel-bin/packages/kbn-ui-framework", "@kbn/ui-shared-deps": "link:bazel-bin/packages/kbn-ui-shared-deps", "@kbn/utility-types": "link:bazel-bin/packages/kbn-utility-types", "@kbn/common-utils": "link:bazel-bin/packages/kbn-common-utils", diff --git a/packages/BUILD.bazel b/packages/BUILD.bazel index 70a3d1eacc7c58..b1c3f580c6baf8 100644 --- a/packages/BUILD.bazel +++ b/packages/BUILD.bazel @@ -48,6 +48,7 @@ filegroup( "//packages/kbn-storybook:build", "//packages/kbn-telemetry-tools:build", "//packages/kbn-tinymath:build", + "//packages/kbn-ui-framework:build", "//packages/kbn-ui-shared-deps:build", "//packages/kbn-utility-types:build", "//packages/kbn-utils:build", diff --git a/packages/kbn-ui-framework/BUILD.bazel b/packages/kbn-ui-framework/BUILD.bazel new file mode 100644 index 00000000000000..f8cf5035bdc5f3 --- /dev/null +++ b/packages/kbn-ui-framework/BUILD.bazel @@ -0,0 +1,47 @@ +load("@build_bazel_rules_nodejs//:index.bzl", "js_library", "pkg_npm") + +PKG_BASE_NAME = "kbn-ui-framework" +PKG_REQUIRE_NAME = "@kbn/ui-framework" + +SOURCE_FILES = glob([ + "dist/**/*", +]) + +SRCS = SOURCE_FILES + +filegroup( + name = "srcs", + srcs = SRCS, +) + +NPM_MODULE_EXTRA_FILES = [ + "package.json", + "README.md", +] + +DEPS = [] + +js_library( + name = PKG_BASE_NAME, + srcs = NPM_MODULE_EXTRA_FILES + [ + ":srcs", + ], + deps = DEPS, + package_name = PKG_REQUIRE_NAME, + visibility = ["//visibility:public"], +) + +pkg_npm( + name = "npm_module", + deps = [ + ":%s" % PKG_BASE_NAME, + ] +) + +filegroup( + name = "build", + srcs = [ + ":npm_module", + ], + visibility = ["//visibility:public"], +) diff --git a/x-pack/package.json b/x-pack/package.json index 01571cbb823fd6..1397a3da810722 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -28,8 +28,5 @@ "devDependencies": { "@kbn/plugin-helpers": "link:../packages/kbn-plugin-helpers", "@kbn/test": "link:../packages/kbn-test" - }, - "dependencies": { - "@kbn/ui-framework": "link:../packages/kbn-ui-framework" } } \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 153309ad56f191..953e7907590e77 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2788,7 +2788,7 @@ version "0.0.0" uid "" -"@kbn/ui-framework@link:packages/kbn-ui-framework": +"@kbn/ui-framework@link:bazel-bin/packages/kbn-ui-framework": version "0.0.0" uid "" From c5e8df02c1ced111c26e607867cf4d73c940ad7b Mon Sep 17 00:00:00 2001 From: Jonathan Buttner <56361221+jonathan-buttner@users.noreply.github.com> Date: Tue, 22 Jun 2021 13:52:03 -0400 Subject: [PATCH 04/17] [Cases] RBAC Bugs (#101325) * Adding feature flag for auth * Hiding SOs and adding consumer field * First pass at adding security changes * Consumer as the app's plugin ID * Create addConsumerToSO migration helper * Fix mapping's SO consumer * Add test for CasesActions * Declare hidden types on SO client * Restructure integration tests * Init spaces_only integration tests * Implementing the cases security string * Adding security plugin tests for cases * Rough concept for authorization class * Adding comments * Fix merge * Get requiredPrivileges for classes * Check privillages * Ensure that all classes are available * Success if hasAllRequested is true * Failure if hasAllRequested is false * Adding schema updates for feature plugin * Seperate basic from trial * Enable SIR on integration tests * Starting the plumbing for authorization in plugin * Unit tests working * Move find route logic to case client * Create integration test helper functions * Adding auth to create call * Create getClassFilter helper * Add class attribute to find request * Create getFindAuthorizationFilter * Ensure savedObject is authorized in find method * Include fields for authorization * Combine authorization filter with cases & subcases filter * Fix isAuthorized flag * Fix merge issue * Create/delete spaces & users before and after tests * Add more user and roles * [Cases] Convert filters from strings to KueryNode (#95288) * [Cases] RBAC: Rename class to scope (#95535) * [Cases][RBAC] Rename scope to owner (#96035) * [Cases] RBAC: Create & Find integration tests (#95511) * [Cases] Cases client enchantment (#95923) * [Cases] Authorization and Client Audit Logger (#95477) * Starting audit logger * Finishing auth audit logger * Fixing tests and types * Adding audit event creator * Renaming class to scope * Adding audit logger messages to create and find * Adding comments and fixing import issue * Fixing type errors * Fixing tests and adding username to message * Addressing PR feedback * Removing unneccessary log and generating id * Fixing module issue and remove expect.anything * [Cases] Migrate sub cases routes to a client (#96461) * Adding sub cases client * Move sub case routes to case client * Throw when attempting to access the sub cases client * Fixing throw and removing user ans soclients * [Cases] RBAC: Migrate routes' unit tests to integration tests (#96374) Co-authored-by: Jonathan Buttner * [Cases] Move remaining HTTP functionality to client (#96507) * Moving deletes and find for attachments * Moving rest of comment apis * Migrating configuration routes to client * Finished moving routes, starting utils refactor * Refactoring utilites and fixing integration tests * Addressing PR feedback * Fixing mocks and types * Fixing integration tests * Renaming status_stats * Fixing test type errors * Adding plugins to kibana.json * Adding cases to required plugin * [Cases] Refactoring authorization (#97483) * Refactoring authorization * Wrapping auth calls in helper for try catch * Reverting name change * Hardcoding the saved object types * Switching ensure to owner array * [Cases] Add authorization to configuration & cases routes (#97228) * [Cases] Attachments RBAC (#97756) * Starting rbac for comments * Adding authorization to rest of comment apis * Starting the comment rbac tests * Fixing some of the rbac tests * Adding some integration tests * Starting patch tests * Working tests for comments * Working tests * Fixing some tests * Fixing type issues from pulling in master * Fixing connector tests that only work in trial license * Attempting to fix cypress * Mock return of array for configure * Fixing cypress test * Cleaning up * Addressing PR comments * Reducing operations * [Cases] Add RBAC to remaining Cases APIs (#98762) * Starting rbac for comments * Adding authorization to rest of comment apis * Starting the comment rbac tests * Fixing some of the rbac tests * Adding some integration tests * Starting patch tests * Working tests for comments * Working tests * Fixing some tests * Fixing type issues from pulling in master * Fixing connector tests that only work in trial license * Attempting to fix cypress * Mock return of array for configure * Fixing cypress test * Cleaning up * Working case update tests * Addressing PR comments * Reducing operations * Working rbac push case tests * Starting stats apis * Working status tests * User action tests and fixing migration errors * Fixing type errors * including error in message * Addressing pr feedback * Fixing some type errors * [Cases] Add space only tests (#99409) * Starting spaces tests * Finishing space only tests * Refactoring createCaseWithConnector * Fixing spelling * Addressing PR feedback and creating alert tests * Fixing mocks * [Cases] Add security only tests (#99679) * Starting spaces tests * Finishing space only tests * Refactoring createCaseWithConnector * Fixing spelling * Addressing PR feedback and creating alert tests * Fixing mocks * Starting security only tests * Adding remainder security only tests * Using helper objects * Fixing type error for null space * Renaming utility variables * Refactoring users and roles for security only tests * Adding sub feature * [Cases] Cleaning up the services and TODOs (#99723) * Cleaning up the service intialization * Fixing type errors * Adding comments for the api * Working test for cases client * Fix type error * Adding generated docs * Adding more docs and cleaning up types * Cleaning up readme * More clean up and links * Changing some file names * Renaming docs * Integration tests for cases privs and fixes (#100038) * [Cases] RBAC on UI (#99478) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> * Fixing case ids by alert id route call * [Cases] Fixing UI feature permissions and adding UI tests (#100074) * Integration tests for cases privs and fixes * Fixing ui cases permissions and adding tests * Adding test for collection failure and fixing jest * Renaming variables * Fixing type error * Adding some comments * Validate cases features * Fix new schema * Adding owner param for the status stats * Fix get case status tests * Adjusting permissions text and fixing status * Address PR feedback * Adding top level feature back * Fixing feature privileges * Renaming * Removing uneeded else * Fixing tests and adding cases merge tests * [Cases][Security Solution] Basic license security solution API tests (#100925) * Cleaning up the fixture plugins * Adding basic feature test * renaming to unsecuredSavedObjectsClient (#101215) * [Cases] RBAC Refactoring audit logging (#100952) * Refactoring audit logging * Adding unit tests for authorization classes * Addressing feedback and adding util tests * return undefined on empty array * fixing eslint * conditional rendering the recently created cases * Remove unnecessary Array.from * Cleaning up overview page for permissions * Fixing log message for attachments * hiding add to cases button * Disable the Cases app from the global nav * Hide the add to cases button from detections * Fixing merge * Making progress on removing icons * Hding edit icons on detail view * Trying to get connector error msg tests working * Removing test * Disable error callouts * Fixing spacing and removing cases tab one no read * Adding read only badge * Cleaning up and adding badge * Wrapping in use effect * Default toasting permissions errors * Removing actions icon on comments * Addressing feedback * Fixing type Co-authored-by: Christos Nasikas Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../components/add_comment/index.test.tsx | 26 ++- .../public/components/add_comment/index.tsx | 58 +++---- .../public/components/all_cases/header.tsx | 32 ++-- .../components/all_cases/nav_buttons.tsx | 5 +- .../public/components/all_cases/table.tsx | 26 +-- .../components/all_cases/translations.ts | 8 + .../public/components/callout/helpers.tsx | 11 -- .../public/components/callout/translations.ts | 9 - .../components/case_action_bar/actions.tsx | 5 +- .../components/case_action_bar/index.test.tsx | 1 + .../components/case_action_bar/index.tsx | 27 +-- .../public/components/case_view/index.tsx | 15 +- .../components/edit_connector/index.test.tsx | 55 +++++- .../components/edit_connector/index.tsx | 43 +++-- .../components/recent_cases/index.test.tsx | 1 + .../public/components/recent_cases/index.tsx | 3 + .../recent_cases/no_cases/index.test.tsx | 13 +- .../recent_cases/no_cases/index.tsx | 30 ++-- .../components/recent_cases/recent_cases.tsx | 4 +- .../components/recent_cases/translations.ts | 4 + .../cases/public/components/status/button.tsx | 9 +- .../public/components/status/status.test.tsx | 9 +- .../cases/public/components/status/status.tsx | 8 +- .../public/components/tag_list/index.test.tsx | 16 +- .../public/components/tag_list/index.tsx | 10 +- .../use_push_to_service/index.test.tsx | 153 +++++++++++++++++ .../components/use_push_to_service/index.tsx | 11 +- .../components/user_action_tree/index.tsx | 37 +++-- .../user_action_content_toolbar.test.tsx | 9 +- .../user_action_content_toolbar.tsx | 29 ++-- .../user_action_property_actions.tsx | 6 +- .../containers/configure/translations.ts | 8 + .../containers/configure/use_connectors.tsx | 46 ++++-- x-pack/plugins/cases/public/mocks.ts | 21 +++ .../server/authorization/audit_logger.test.ts | 8 +- x-pack/plugins/cases/server/plugin.ts | 2 +- .../components/app/cases/callout/helpers.tsx | 11 -- .../app/cases/callout/translations.ts | 15 -- .../components/app/cases/translations.ts | 14 ++ .../public/hooks/use_readonly_header.tsx | 40 +++++ .../public/pages/cases/all_cases.tsx | 25 +-- .../public/pages/cases/case_details.tsx | 30 ++-- .../public/pages/cases/configure_cases.tsx | 12 +- .../public/pages/cases/create_case.tsx | 12 +- .../security_solution/common/constants.ts | 3 + .../detection_alerts/attach_to_case.spec.ts | 2 +- .../cases/components/callout/helpers.tsx | 11 -- .../cases/components/callout/translations.ts | 15 -- .../add_to_case_action.test.tsx | 6 +- .../timeline_actions/add_to_case_action.tsx | 28 ++-- .../public/cases/pages/case.tsx | 7 - .../public/cases/pages/case_details.tsx | 18 +- .../public/cases/pages/configure_cases.tsx | 13 +- .../public/cases/pages/create_case.tsx | 16 +- .../public/cases/pages/index.test.tsx | 91 ++++++++++ .../public/cases/pages/index.tsx | 77 ++++++--- .../public/cases/pages/translations.ts | 21 +++ .../components/header_global/index.test.tsx | 51 ++++++ .../common/components/header_global/index.tsx | 23 ++- .../components/recent_cases/index.tsx | 5 +- .../components/sidebar/sidebar.test.tsx | 72 ++++++++ .../overview/components/sidebar/sidebar.tsx | 16 +- .../components/flyout/header/index.test.tsx | 156 +++++++++++------- .../components/flyout/header/index.tsx | 12 +- .../security_solution/server/plugin.ts | 20 ++- .../observability_security.ts | 10 +- .../page_objects/observability_page.ts | 16 +- 67 files changed, 1114 insertions(+), 492 deletions(-) create mode 100644 x-pack/plugins/cases/public/mocks.ts create mode 100644 x-pack/plugins/observability/public/hooks/use_readonly_header.tsx create mode 100644 x-pack/plugins/security_solution/public/cases/pages/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/header_global/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/overview/components/sidebar/sidebar.test.tsx diff --git a/x-pack/plugins/cases/public/components/add_comment/index.test.tsx b/x-pack/plugins/cases/public/components/add_comment/index.test.tsx index 19c303840fc1a6..078db1e6dbe6da 100644 --- a/x-pack/plugins/cases/public/components/add_comment/index.test.tsx +++ b/x-pack/plugins/cases/public/components/add_comment/index.test.tsx @@ -14,7 +14,7 @@ import { TestProviders } from '../../common/mock'; import { CommentRequest, CommentType, SECURITY_SOLUTION_OWNER } from '../../../common'; import { usePostComment } from '../../containers/use_post_comment'; -import { AddComment, AddCommentRefObject } from '.'; +import { AddComment, AddCommentProps, AddCommentRefObject } from '.'; import { CasesTimelineIntegrationProvider } from '../timeline_context'; import { timelineIntegrationMock } from '../__mock__/timeline'; @@ -25,10 +25,9 @@ const onCommentSaving = jest.fn(); const onCommentPosted = jest.fn(); const postComment = jest.fn(); -const addCommentProps = { +const addCommentProps: AddCommentProps = { caseId: '1234', - disabled: false, - insertQuote: null, + userCanCrud: true, onCommentSaving, onCommentPosted, showLoading: false, @@ -94,11 +93,11 @@ describe('AddComment ', () => { ).toBeTruthy(); }); - it('should disable submit button when disabled prop passed', () => { + it('should disable submit button when isLoading is true', () => { usePostCommentMock.mockImplementation(() => ({ ...defaultPostComment, isLoading: true })); const wrapper = mount( - + ); @@ -107,12 +106,23 @@ describe('AddComment ', () => { ).toBeTruthy(); }); + it('should hide the component when the user does not have crud permissions', () => { + usePostCommentMock.mockImplementation(() => ({ ...defaultPostComment, isLoading: true })); + const wrapper = mount( + + + + ); + + expect(wrapper.find(`[data-test-subj="add-comment"]`).exists()).toBeFalsy(); + }); + it('should insert a quote', async () => { const sampleQuote = 'what a cool quote'; const ref = React.createRef(); const wrapper = mount( - + ); @@ -143,7 +153,7 @@ describe('AddComment ', () => { const wrapper = mount( - + ); diff --git a/x-pack/plugins/cases/public/components/add_comment/index.tsx b/x-pack/plugins/cases/public/components/add_comment/index.tsx index 04104f0b9471d0..6604f3d2b8bc8f 100644 --- a/x-pack/plugins/cases/public/components/add_comment/index.tsx +++ b/x-pack/plugins/cases/public/components/add_comment/index.tsx @@ -33,9 +33,9 @@ export interface AddCommentRefObject { addQuote: (quote: string) => void; } -interface AddCommentProps { +export interface AddCommentProps { caseId: string; - disabled?: boolean; + userCanCrud?: boolean; onCommentSaving?: () => void; onCommentPosted: (newCase: Case) => void; showLoading?: boolean; @@ -45,7 +45,7 @@ interface AddCommentProps { export const AddComment = React.memo( forwardRef( ( - { caseId, disabled, onCommentPosted, onCommentSaving, showLoading = true, subCaseId }, + { caseId, userCanCrud, onCommentPosted, onCommentSaving, showLoading = true, subCaseId }, ref ) => { const owner = useOwnerContext(); @@ -91,31 +91,33 @@ export const AddComment = React.memo( return ( {isLoading && showLoading && } -
- - {i18n.ADD_COMMENT} - - ), - }} - /> - - + {userCanCrud && ( +
+ + {i18n.ADD_COMMENT} + + ), + }} + /> + + + )}
); } diff --git a/x-pack/plugins/cases/public/components/all_cases/header.tsx b/x-pack/plugins/cases/public/components/all_cases/header.tsx index 7452fe7e44b3c4..73dcc18b971083 100644 --- a/x-pack/plugins/cases/public/components/all_cases/header.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/header.tsx @@ -52,17 +52,27 @@ export const CasesTableHeader: FunctionComponent = ({ wrap={true} data-test-subj="all-cases-header" > - - - - - - + {userCanCrud ? ( + <> + + + + + + + + + ) : ( + // doesn't include the horizontal bar that divides the buttons and other padding since we don't have any buttons + // to the right + + + + )} ); diff --git a/x-pack/plugins/cases/public/components/all_cases/nav_buttons.tsx b/x-pack/plugins/cases/public/components/all_cases/nav_buttons.tsx index e29551f43c2bd5..b8755d03e0b001 100644 --- a/x-pack/plugins/cases/public/components/all_cases/nav_buttons.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/nav_buttons.tsx @@ -17,7 +17,6 @@ interface OwnProps { actionsErrors: ErrorMessage[]; configureCasesNavigation: CasesNavigation; createCaseNavigation: CasesNavigation; - userCanCrud: boolean; } type Props = OwnProps; @@ -26,14 +25,13 @@ export const NavButtons: FunctionComponent = ({ actionsErrors, configureCasesNavigation, createCaseNavigation, - userCanCrud, }) => ( } titleTooltip={!isEmpty(actionsErrors) ? actionsErrors[0].title : ''} @@ -41,7 +39,6 @@ export const NavButtons: FunctionComponent = ({ = ({ {i18n.NO_CASES}} titleSize="xs" - body={i18n.NO_CASES_BODY} + body={userCanCrud ? i18n.NO_CASES_BODY : i18n.NO_CASES_BODY_READ_ONLY} actions={ - - {i18n.ADD_NEW_CASE} - + userCanCrud && ( + + {i18n.ADD_NEW_CASE} + + ) } /> } diff --git a/x-pack/plugins/cases/public/components/all_cases/translations.ts b/x-pack/plugins/cases/public/components/all_cases/translations.ts index 0f535b771ec8a3..8da90f32fabdf2 100644 --- a/x-pack/plugins/cases/public/components/all_cases/translations.ts +++ b/x-pack/plugins/cases/public/components/all_cases/translations.ts @@ -12,11 +12,19 @@ export * from '../../common/translations'; export const NO_CASES = i18n.translate('xpack.cases.caseTable.noCases.title', { defaultMessage: 'No Cases', }); + export const NO_CASES_BODY = i18n.translate('xpack.cases.caseTable.noCases.body', { defaultMessage: 'There are no cases to display. Please create a new case or change your filter settings above.', }); +export const NO_CASES_BODY_READ_ONLY = i18n.translate( + 'xpack.cases.caseTable.noCases.readonly.body', + { + defaultMessage: 'There are no cases to display. Please change your filter settings above.', + } +); + export const ADD_NEW_CASE = i18n.translate('xpack.cases.caseTable.addNewCase', { defaultMessage: 'Add New Case', }); diff --git a/x-pack/plugins/cases/public/components/callout/helpers.tsx b/x-pack/plugins/cases/public/components/callout/helpers.tsx index 29b17cd426c58b..fdd49ad17168de 100644 --- a/x-pack/plugins/cases/public/components/callout/helpers.tsx +++ b/x-pack/plugins/cases/public/components/callout/helpers.tsx @@ -5,18 +5,7 @@ * 2.0. */ -import React from 'react'; import md5 from 'md5'; -import * as i18n from './translations'; -import { ErrorMessage } from './types'; - -export const permissionsReadOnlyErrorMessage: ErrorMessage = { - id: 'read-only-privileges-error', - title: i18n.READ_ONLY_FEATURE_TITLE, - description: <>{i18n.READ_ONLY_FEATURE_MSG}, - errorType: 'warning', -}; - export const createCalloutId = (ids: string[], delimiter: string = '|'): string => md5(ids.join(delimiter)); diff --git a/x-pack/plugins/cases/public/components/callout/translations.ts b/x-pack/plugins/cases/public/components/callout/translations.ts index dca622e60c863b..8b0ad31dba88e3 100644 --- a/x-pack/plugins/cases/public/components/callout/translations.ts +++ b/x-pack/plugins/cases/public/components/callout/translations.ts @@ -7,15 +7,6 @@ import { i18n } from '@kbn/i18n'; -export const READ_ONLY_FEATURE_TITLE = i18n.translate('xpack.cases.readOnlyFeatureTitle', { - defaultMessage: 'You cannot open new or update existing cases', -}); - -export const READ_ONLY_FEATURE_MSG = i18n.translate('xpack.cases.readOnlyFeatureDescription', { - defaultMessage: - 'You only have privileges to view cases. If you need to open and update cases, contact your Kibana administrator.', -}); - export const DISMISS_CALLOUT = i18n.translate('xpack.cases.dismissErrorsPushServiceCallOutTitle', { defaultMessage: 'Dismiss', }); diff --git a/x-pack/plugins/cases/public/components/case_action_bar/actions.tsx b/x-pack/plugins/cases/public/components/case_action_bar/actions.tsx index c2578dc3debdb6..6816575d649f75 100644 --- a/x-pack/plugins/cases/public/components/case_action_bar/actions.tsx +++ b/x-pack/plugins/cases/public/components/case_action_bar/actions.tsx @@ -19,14 +19,12 @@ interface CaseViewActions { allCasesNavigation: CasesNavigation; caseData: Case; currentExternalIncident: CaseService | null; - disabled?: boolean; } const ActionsComponent: React.FC = ({ allCasesNavigation, caseData, currentExternalIncident, - disabled = false, }) => { // Delete case const { @@ -39,7 +37,6 @@ const ActionsComponent: React.FC = ({ const propertyActions = useMemo( () => [ { - disabled, iconType: 'trash', label: i18n.DELETE_CASE(), onClick: handleToggleModal, @@ -54,7 +51,7 @@ const ActionsComponent: React.FC = ({ ] : []), ], - [disabled, handleToggleModal, currentExternalIncident] + [handleToggleModal, currentExternalIncident] ); if (isDeleted) { diff --git a/x-pack/plugins/cases/public/components/case_action_bar/index.test.tsx b/x-pack/plugins/cases/public/components/case_action_bar/index.test.tsx index 724d35b20df535..3040b0fe47a470 100644 --- a/x-pack/plugins/cases/public/components/case_action_bar/index.test.tsx +++ b/x-pack/plugins/cases/public/components/case_action_bar/index.test.tsx @@ -26,6 +26,7 @@ describe('CaseActionBar', () => { onRefresh, onUpdateField, currentExternalIncident: null, + userCanCrud: true, }; beforeEach(() => { diff --git a/x-pack/plugins/cases/public/components/case_action_bar/index.tsx b/x-pack/plugins/cases/public/components/case_action_bar/index.tsx index d8e012b0721065..3448d112dadd12 100644 --- a/x-pack/plugins/cases/public/components/case_action_bar/index.tsx +++ b/x-pack/plugins/cases/public/components/case_action_bar/index.tsx @@ -40,7 +40,7 @@ interface CaseActionBarProps { allCasesNavigation: CasesNavigation; caseData: Case; currentExternalIncident: CaseService | null; - disabled?: boolean; + userCanCrud: boolean; disableAlerting: boolean; isLoading: boolean; onRefresh: () => void; @@ -50,8 +50,8 @@ const CaseActionBarComponent: React.FC = ({ allCasesNavigation, caseData, currentExternalIncident, - disabled = false, disableAlerting, + userCanCrud, isLoading, onRefresh, onUpdateField, @@ -87,7 +87,7 @@ const CaseActionBarComponent: React.FC = ({ @@ -108,7 +108,7 @@ const CaseActionBarComponent: React.FC = ({ - {!disableAlerting && ( + {userCanCrud && !disableAlerting && ( @@ -122,7 +122,7 @@ const CaseActionBarComponent: React.FC = ({ @@ -134,14 +134,15 @@ const CaseActionBarComponent: React.FC = ({ {i18n.CASE_REFRESH} - - - + {userCanCrud && ( + + + + )} diff --git a/x-pack/plugins/cases/public/components/case_view/index.tsx b/x-pack/plugins/cases/public/components/case_view/index.tsx index df57e49073a604..05f1c6727b1680 100644 --- a/x-pack/plugins/cases/public/components/case_view/index.tsx +++ b/x-pack/plugins/cases/public/components/case_view/index.tsx @@ -230,7 +230,9 @@ export const CaseComponent = React.memo( [updateCase, fetchCaseUserActions, caseId, subCaseId] ); - const { loading: isLoadingConnectors, connectors } = useConnectors(); + const { loading: isLoadingConnectors, connectors, permissionsError } = useConnectors({ + toastPermissionsErrors: false, + }); const [connectorName, isValidConnector] = useMemo(() => { const connector = connectors.find((c) => c.id === caseData.connector.id); @@ -363,7 +365,7 @@ export const CaseComponent = React.memo( allCasesNavigation={allCasesNavigation} caseData={caseData} currentExternalIncident={currentExternalIncident} - disabled={!userCanCrud} + userCanCrud={userCanCrud} disableAlerting={ruleDetailsNavigation == null} isLoading={isLoading && (updateKey === 'status' || updateKey === 'settings')} onRefresh={handleRefresh} @@ -406,7 +408,7 @@ export const CaseComponent = React.memo( useFetchAlertData={useFetchAlertData} userCanCrud={userCanCrud} /> - {(caseData.type !== CaseType.collection || hasDataToPush) && ( + {(caseData.type !== CaseType.collection || hasDataToPush) && userCanCrud && ( <> ( @@ -450,16 +451,15 @@ export const CaseComponent = React.memo( /> ( onSubmit={onSubmitConnector} selectedConnector={caseData.connector.id} userActions={caseUserActions} + permissionsError={permissionsError} /> diff --git a/x-pack/plugins/cases/public/components/edit_connector/index.test.tsx b/x-pack/plugins/cases/public/components/edit_connector/index.test.tsx index 1385e8e8664c37..33efb7e447583e 100644 --- a/x-pack/plugins/cases/public/components/edit_connector/index.test.tsx +++ b/x-pack/plugins/cases/public/components/edit_connector/index.test.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { mount } from 'enzyme'; import { waitFor } from '@testing-library/react'; -import { EditConnector } from './index'; +import { EditConnector, EditConnectorProps } from './index'; import { getFormMock, useFormMock } from '../__mock__/form'; import { TestProviders } from '../../common/mock'; import { connectorsMock } from '../../containers/configure/mock'; @@ -21,9 +21,9 @@ jest.mock('../../common/lib/kibana'); const useKibanaMock = useKibana as jest.Mocked; const onSubmit = jest.fn(); -const defaultProps = { +const defaultProps: EditConnectorProps = { connectors: connectorsMock, - disabled: false, + userCanCrud: true, isLoading: false, onSubmit, selectedConnector: 'none', @@ -144,4 +144,53 @@ describe('EditConnector ', () => { expect(wrapper.find(`[data-test-subj="connector-loading"]`).last().exists()).toBeTruthy() ); }); + + it('does not allow the connector to be edited when the user does not have write permissions', async () => { + const props = { ...defaultProps, userCanCrud: false }; + const wrapper = mount( + + + + ); + await waitFor(() => + expect(wrapper.find(`[data-test-subj="connector-edit"]`).exists()).toBeFalsy() + ); + }); + + it('displays the permissions error message when one is provided', async () => { + const props = { ...defaultProps, permissionsError: 'error message' }; + const wrapper = mount( + + + + ); + + await waitFor(() => { + expect( + wrapper.find(`[data-test-subj="edit-connector-permissions-error-msg"]`).exists() + ).toBeTruthy(); + + expect( + wrapper.find(`[data-test-subj="edit-connector-no-connectors-msg"]`).exists() + ).toBeFalsy(); + }); + }); + + it('displays the default none connector message', async () => { + const props = { ...defaultProps }; + const wrapper = mount( + + + + ); + + await waitFor(() => { + expect( + wrapper.find(`[data-test-subj="edit-connector-permissions-error-msg"]`).exists() + ).toBeFalsy(); + expect( + wrapper.find(`[data-test-subj="edit-connector-no-connectors-msg"]`).exists() + ).toBeTruthy(); + }); + }); }); diff --git a/x-pack/plugins/cases/public/components/edit_connector/index.tsx b/x-pack/plugins/cases/public/components/edit_connector/index.tsx index ad6b5a5e7cddf5..570f6e34d25287 100644 --- a/x-pack/plugins/cases/public/components/edit_connector/index.tsx +++ b/x-pack/plugins/cases/public/components/edit_connector/index.tsx @@ -30,7 +30,7 @@ import { schema } from './schema'; import { getConnectorFieldsFromUserActions } from './helpers'; import * as i18n from './translations'; -interface EditConnectorProps { +export interface EditConnectorProps { caseFields: ConnectorTypeFields['fields']; connectors: ActionConnector[]; isLoading: boolean; @@ -42,8 +42,9 @@ interface EditConnectorProps { ) => void; selectedConnector: string; userActions: CaseUserActions[]; - disabled?: boolean; + userCanCrud?: boolean; hideConnectorServiceNowSir?: boolean; + permissionsError?: string; } const MyFlexGroup = styled(EuiFlexGroup)` @@ -104,12 +105,13 @@ export const EditConnector = React.memo( ({ caseFields, connectors, - disabled = false, + userCanCrud = true, hideConnectorServiceNowSir = false, isLoading, onSubmit, selectedConnector, userActions, + permissionsError, }: EditConnectorProps) => { const { form } = useForm({ defaultValue: { connectorId: selectedConnector }, @@ -203,6 +205,18 @@ export const EditConnector = React.memo( }); }, [dispatch]); + /** + * if this evaluates to true it means that the connector was likely deleted because the case connector was set to something + * other than none but we don't find it in the list of connectors returned from the actions plugin + */ + const connectorFromCaseMissing = currentConnector == null && selectedConnector !== 'none'; + + /** + * True if the chosen connector from the form was the "none" connector or no connector was in the case. The + * currentConnector will be null initially and after the form initializes if the case connector is "none" + */ + const connectorUndefinedOrNone = currentConnector == null || currentConnector?.id === 'none'; + return ( @@ -210,11 +224,10 @@ export const EditConnector = React.memo(

{i18n.CONNECTORS}

{isLoading && } - {!isLoading && !editConnector && ( + {!isLoading && !editConnector && userCanCrud && ( - {(currentConnector == null || currentConnector?.id === 'none') && // Connector is none or not defined. - !(currentConnector === null && selectedConnector !== 'none') && // Connector has not been deleted. - !editConnector && ( - + {!editConnector && permissionsError ? ( + + {permissionsError} + + ) : ( + // if we're not editing the connectors and the connector specified in the case was found and the connector + // is undefined or explicitly set to none + !editConnector && + !connectorFromCaseMissing && + connectorUndefinedOrNone && ( + {i18n.NO_CONNECTOR} - )} + ) + )} ; createCaseNavigation: CasesNavigation; + hasWritePermissions: boolean; maxCasesToShow: number; } @@ -29,6 +30,7 @@ const RecentCasesComponent = ({ caseDetailsNavigation, createCaseNavigation, maxCasesToShow, + hasWritePermissions, }: Omit) => { const currentUser = useCurrentUser(); const [recentCasesFilterBy, setRecentCasesFilterBy] = useState( @@ -77,6 +79,7 @@ const RecentCasesComponent = ({ createCaseNavigation={createCaseNavigation} filterOptions={recentCasesFilterOptions} maxCasesToShow={maxCasesToShow} + hasWritePermissions={hasWritePermissions} /> diff --git a/x-pack/plugins/cases/public/components/recent_cases/no_cases/index.test.tsx b/x-pack/plugins/cases/public/components/recent_cases/no_cases/index.test.tsx index 0295632cc137ae..10fef0bb82df94 100644 --- a/x-pack/plugins/cases/public/components/recent_cases/no_cases/index.test.tsx +++ b/x-pack/plugins/cases/public/components/recent_cases/no_cases/index.test.tsx @@ -16,11 +16,22 @@ describe('RecentCases', () => { const createCaseHref = '/create'; const wrapper = mount( - + ); expect(wrapper.find(`[data-test-subj="no-cases-create-case"]`).first().prop('href')).toEqual( createCaseHref ); }); + + it('displays a message without a link to create a case when the user does not have write permissions', () => { + const createCaseHref = '/create'; + const wrapper = mount( + + + + ); + expect(wrapper.find(`[data-test-subj="no-cases-create-case"]`).exists()).toBeFalsy(); + expect(wrapper.find(`[data-test-subj="no-cases-readonly"]`).exists()).toBeTruthy(); + }); }); diff --git a/x-pack/plugins/cases/public/components/recent_cases/no_cases/index.tsx b/x-pack/plugins/cases/public/components/recent_cases/no_cases/index.tsx index df0efcec4552cc..a5b90943a219a7 100644 --- a/x-pack/plugins/cases/public/components/recent_cases/no_cases/index.tsx +++ b/x-pack/plugins/cases/public/components/recent_cases/no_cases/index.tsx @@ -10,16 +10,26 @@ import React from 'react'; import { EuiLink } from '@elastic/eui'; import * as i18n from '../translations'; -const NoCasesComponent = ({ createCaseHref }: { createCaseHref: string }) => ( - <> - {i18n.NO_CASES} - {` ${i18n.START_A_NEW_CASE}`} - {'!'} - -); +const NoCasesComponent = ({ + createCaseHref, + hasWritePermissions, +}: { + createCaseHref: string; + hasWritePermissions: boolean; +}) => { + return hasWritePermissions ? ( + <> + {i18n.NO_CASES} + {` ${i18n.START_A_NEW_CASE}`} + {'!'} + + ) : ( + {i18n.NO_CASES_READ_ONLY} + ); +}; NoCasesComponent.displayName = 'NoCasesComponent'; diff --git a/x-pack/plugins/cases/public/components/recent_cases/recent_cases.tsx b/x-pack/plugins/cases/public/components/recent_cases/recent_cases.tsx index 5b4313530e4904..bfe44dda6c6efc 100644 --- a/x-pack/plugins/cases/public/components/recent_cases/recent_cases.tsx +++ b/x-pack/plugins/cases/public/components/recent_cases/recent_cases.tsx @@ -31,6 +31,7 @@ export interface RecentCasesProps { caseDetailsNavigation: CasesNavigation; createCaseNavigation: CasesNavigation; maxCasesToShow: number; + hasWritePermissions: boolean; } const usePrevious = (value: Partial) => { @@ -45,6 +46,7 @@ export const RecentCasesComp = ({ createCaseNavigation, filterOptions, maxCasesToShow, + hasWritePermissions, }: RecentCasesProps) => { const previousFilterOptions = usePrevious(filterOptions); const { data, loading, setFilters } = useGetCases({ @@ -65,7 +67,7 @@ export const RecentCasesComp = ({ return isLoadingCases ? ( ) : !isLoadingCases && data.cases.length === 0 ? ( - + ) : ( <> {data.cases.map((c, i) => ( diff --git a/x-pack/plugins/cases/public/components/recent_cases/translations.ts b/x-pack/plugins/cases/public/components/recent_cases/translations.ts index c8f6c349d8f720..653bda4be2ebc0 100644 --- a/x-pack/plugins/cases/public/components/recent_cases/translations.ts +++ b/x-pack/plugins/cases/public/components/recent_cases/translations.ts @@ -22,6 +22,10 @@ export const NO_CASES = i18n.translate('xpack.cases.recentCases.noCasesMessage', defaultMessage: 'No cases have been created yet. Put your detective hat on and', }); +export const NO_CASES_READ_ONLY = i18n.translate('xpack.cases.recentCases.noCasesMessageReadOnly', { + defaultMessage: 'No cases have been created yet.', +}); + export const RECENT_CASES = i18n.translate('xpack.cases.recentCases.recentCasesSidebarTitle', { defaultMessage: 'Recent cases', }); diff --git a/x-pack/plugins/cases/public/components/status/button.tsx b/x-pack/plugins/cases/public/components/status/button.tsx index 623afeb43c5965..675d83c759bc77 100644 --- a/x-pack/plugins/cases/public/components/status/button.tsx +++ b/x-pack/plugins/cases/public/components/status/button.tsx @@ -13,7 +13,6 @@ import { statuses } from './config'; interface Props { status: CaseStatuses; - disabled: boolean; isLoading: boolean; onStatusChanged: (status: CaseStatuses) => void; } @@ -21,12 +20,7 @@ interface Props { // Rotate over the statuses. open -> in-progress -> closes -> open... const getNextItem = (item: number) => (item + 1) % caseStatuses.length; -const StatusActionButtonComponent: React.FC = ({ - status, - onStatusChanged, - disabled, - isLoading, -}) => { +const StatusActionButtonComponent: React.FC = ({ status, onStatusChanged, isLoading }) => { const indexOfCurrentStatus = useMemo( () => caseStatuses.findIndex((caseStatus) => caseStatus === status), [status] @@ -41,7 +35,6 @@ const StatusActionButtonComponent: React.FC = ({ diff --git a/x-pack/plugins/cases/public/components/status/status.test.tsx b/x-pack/plugins/cases/public/components/status/status.test.tsx index 4d13e57fbdee71..a685256741c432 100644 --- a/x-pack/plugins/cases/public/components/status/status.test.tsx +++ b/x-pack/plugins/cases/public/components/status/status.test.tsx @@ -42,17 +42,14 @@ describe('Stats', () => { ).toBe(false); }); - it('it renders with the pop over disabled when initialized disabled', async () => { + it('renders without the arrow and is not clickable when initialized disabled', async () => { const wrapper = mount( ); expect( - wrapper - .find(`[data-test-subj="status-badge-open"] .euiBadge__iconButton`) - .first() - .prop('disabled') - ).toBe(true); + wrapper.find(`[data-test-subj="status-badge-open"] .euiBadge__iconButton`).exists() + ).toBeFalsy(); }); it('it calls onClick when pressing the badge', async () => { diff --git a/x-pack/plugins/cases/public/components/status/status.tsx b/x-pack/plugins/cases/public/components/status/status.tsx index 3b832ce155400c..3c186313a151a4 100644 --- a/x-pack/plugins/cases/public/components/status/status.tsx +++ b/x-pack/plugins/cases/public/components/status/status.tsx @@ -29,18 +29,18 @@ const StatusComponent: React.FC = ({ const props = useMemo( () => ({ color: type === StatusAll ? allCaseStatus[StatusAll].color : statuses[type].color, - ...(withArrow ? { iconType: 'arrowDown', iconSide: 'right' as const } : {}), + // if we are disabled, don't show the arrow and don't allow the user to click + ...(withArrow && !disabled ? { iconType: 'arrowDown', iconSide: 'right' as const } : {}), + ...(!disabled ? { iconOnClick: onClick } : { iconOnClick: noop }), }), - [withArrow, type] + [disabled, onClick, withArrow, type] ); return ( {type === StatusAll ? allCaseStatus[StatusAll].label : statuses[type].label} diff --git a/x-pack/plugins/cases/public/components/tag_list/index.test.tsx b/x-pack/plugins/cases/public/components/tag_list/index.test.tsx index b3fbcd30d4e978..2ced7502b3c3fe 100644 --- a/x-pack/plugins/cases/public/components/tag_list/index.test.tsx +++ b/x-pack/plugins/cases/public/components/tag_list/index.test.tsx @@ -8,13 +8,12 @@ import React from 'react'; import { mount } from 'enzyme'; -import { TagList } from '.'; +import { TagList, TagListProps } from '.'; import { getFormMock } from '../__mock__/form'; import { TestProviders } from '../../common/mock'; import { waitFor } from '@testing-library/react'; import { useForm } from '../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form'; import { useGetTags } from '../../containers/use_get_tags'; -import { SECURITY_SOLUTION_OWNER } from '../../../common'; jest.mock('../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form'); jest.mock('../../containers/use_get_tags'); @@ -33,12 +32,11 @@ jest.mock('@elastic/eui', () => { }; }); const onSubmit = jest.fn(); -const defaultProps = { - disabled: false, +const defaultProps: TagListProps = { + userCanCrud: true, isLoading: false, onSubmit, tags: [], - owner: [SECURITY_SOLUTION_OWNER], }; describe('TagList ', () => { @@ -110,15 +108,13 @@ describe('TagList ', () => { expect(wrapper.find(`[data-test-subj="tag-pepsi"]`).last().exists()).toBeTruthy(); }); - it('Renders disabled button', () => { - const props = { ...defaultProps, disabled: true }; + it('does not render when the user does not have write permissions', () => { + const props = { ...defaultProps, userCanCrud: false }; const wrapper = mount( ); - expect( - wrapper.find(`[data-test-subj="tag-list-edit-button"]`).last().prop('disabled') - ).toBeTruthy(); + expect(wrapper.find(`[data-test-subj="tag-list-edit"]`).exists()).toBeFalsy(); }); }); diff --git a/x-pack/plugins/cases/public/components/tag_list/index.tsx b/x-pack/plugins/cases/public/components/tag_list/index.tsx index f2605933696796..4e8946a6589a33 100644 --- a/x-pack/plugins/cases/public/components/tag_list/index.tsx +++ b/x-pack/plugins/cases/public/components/tag_list/index.tsx @@ -27,12 +27,11 @@ import { Tags } from './tags'; const CommonUseField = getUseField({ component: Field }); -interface TagListProps { - disabled?: boolean; +export interface TagListProps { + userCanCrud?: boolean; isLoading: boolean; onSubmit: (a: string[]) => void; tags: string[]; - owner: string[]; } const MyFlexGroup = styled(EuiFlexGroup)` @@ -45,7 +44,7 @@ const MyFlexGroup = styled(EuiFlexGroup)` `; export const TagList = React.memo( - ({ disabled = false, isLoading, onSubmit, tags, owner }: TagListProps) => { + ({ userCanCrud = true, isLoading, onSubmit, tags }: TagListProps) => { const initialState = { tags }; const { form } = useForm({ defaultValue: initialState, @@ -86,11 +85,10 @@ export const TagList = React.memo(

{i18n.TAGS}

{isLoading && } - {!isLoading && ( + {!isLoading && userCanCrud && ( { expect(errorsMsg[0].id).toEqual('closed-case-push-error'); }); }); + + describe('user does not have write permissions', () => { + const noWriteProps = { ...defaultArgs, userCanCrud: false }; + + it('does not display a message when user does not have a premium license', async () => { + (useGetActionLicense as jest.Mock).mockImplementation(() => ({ + isLoading: false, + actionLicense: { + ...actionLicense, + enabledInLicense: false, + }, + })); + await act(async () => { + const { result, waitForNextUpdate } = renderHook( + () => usePushToService(noWriteProps), + { + wrapper: ({ children }) => {children}, + } + ); + await waitForNextUpdate(); + expect(result.current.pushCallouts).toBeNull(); + }); + }); + + it('does not display a message when user does not have case enabled in config', async () => { + (useGetActionLicense as jest.Mock).mockImplementation(() => ({ + isLoading: false, + actionLicense: { + ...actionLicense, + enabledInConfig: false, + }, + })); + await act(async () => { + const { result, waitForNextUpdate } = renderHook( + () => usePushToService(noWriteProps), + { + wrapper: ({ children }) => {children}, + } + ); + await waitForNextUpdate(); + expect(result.current.pushCallouts).toBeNull(); + }); + }); + + it('does not display a message when user does not have any connector configured', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook( + () => + usePushToService({ + ...noWriteProps, + connectors: [], + connector: { + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, + }, + }), + { + wrapper: ({ children }) => {children}, + } + ); + await waitForNextUpdate(); + expect(result.current.pushCallouts).toBeNull(); + }); + }); + + it('does not display a message when user does have a connector but is configured to none', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook( + () => + usePushToService({ + ...noWriteProps, + connector: { + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, + }, + }), + { + wrapper: ({ children }) => {children}, + } + ); + await waitForNextUpdate(); + expect(result.current.pushCallouts).toBeNull(); + }); + }); + + it('does not display a message when connector is deleted', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook( + () => + usePushToService({ + ...noWriteProps, + connector: { + id: 'not-exist', + name: 'not-exist', + type: ConnectorTypes.none, + fields: null, + }, + isValidConnector: false, + }), + { + wrapper: ({ children }) => {children}, + } + ); + await waitForNextUpdate(); + expect(result.current.pushCallouts).toBeNull(); + }); + }); + + it('does not display a message when connector is deleted with empty connectors', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook( + () => + usePushToService({ + ...noWriteProps, + connectors: [], + connector: { + id: 'not-exist', + name: 'not-exist', + type: ConnectorTypes.none, + fields: null, + }, + isValidConnector: false, + }), + { + wrapper: ({ children }) => {children}, + } + ); + await waitForNextUpdate(); + expect(result.current.pushCallouts).toBeNull(); + }); + }); + + it('does not display a message when case is closed', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook( + () => + usePushToService({ + ...noWriteProps, + caseStatus: CaseStatuses.closed, + }), + { + wrapper: ({ children }) => {children}, + } + ); + await waitForNextUpdate(); + expect(result.current.pushCallouts).toBeNull(); + }); + }); + }); }); diff --git a/x-pack/plugins/cases/public/components/use_push_to_service/index.tsx b/x-pack/plugins/cases/public/components/use_push_to_service/index.tsx index 00b88d372584b6..6f711150b77443 100644 --- a/x-pack/plugins/cases/public/components/use_push_to_service/index.tsx +++ b/x-pack/plugins/cases/public/components/use_push_to_service/index.tsx @@ -67,9 +67,17 @@ export const usePushToService = ({ const errorsMsg = useMemo(() => { let errors: ErrorMessage[] = []; + + // these message require that the user do some sort of write action as a result of the message, readonly users won't + // be able to perform such an action so let's not display the error to the user in that situation + if (!userCanCrud) { + return errors; + } + if (actionLicense != null && !actionLicense.enabledInLicense) { errors = [...errors, getLicenseError()]; } + if (connectors.length === 0 && connector.id === 'none' && !loadingLicense) { errors = [ ...errors, @@ -136,12 +144,13 @@ export const usePushToService = ({ }, ]; } + if (actionLicense != null && !actionLicense.enabledInConfig) { errors = [...errors, getKibanaConfigError()]; } return errors; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [actionLicense, caseStatus, connectors.length, connector, loadingLicense]); + }, [actionLicense, caseStatus, connectors.length, connector, loadingLicense, userCanCrud]); const pushToServiceButton = useMemo( () => ( diff --git a/x-pack/plugins/cases/public/components/user_action_tree/index.tsx b/x-pack/plugins/cases/public/components/user_action_tree/index.tsx index f9bd941547078e..c7cc71da929477 100644 --- a/x-pack/plugins/cases/public/components/user_action_tree/index.tsx +++ b/x-pack/plugins/cases/public/components/user_action_tree/index.tsx @@ -241,7 +241,7 @@ export const UserActionTree = React.memo( () => ( ), }), @@ -363,10 +363,10 @@ export const UserActionTree = React.memo( id={comment.id} editLabel={i18n.EDIT_COMMENT} quoteLabel={i18n.QUOTE} - disabled={!userCanCrud} isLoading={isLoadingIds.includes(comment.id)} onEdit={handleManageMarkdownEditId.bind(null, comment.id)} onQuote={handleManageQuote.bind(null, comment.comment)} + userCanCrud={userCanCrud} /> ), }, @@ -571,19 +571,24 @@ export const UserActionTree = React.memo( ] ); - const bottomActions = [ - { - username: ( - - ), - 'data-test-subj': 'add-comment', - timelineIcon: ( - - ), - className: 'isEdit', - children: MarkdownNewComment, - }, - ]; + const bottomActions = userCanCrud + ? [ + { + username: ( + + ), + 'data-test-subj': 'add-comment', + timelineIcon: ( + + ), + className: 'isEdit', + children: MarkdownNewComment, + }, + ] + : []; const comments = [...userActions, ...bottomActions]; diff --git a/x-pack/plugins/cases/public/components/user_action_tree/user_action_content_toolbar.test.tsx b/x-pack/plugins/cases/public/components/user_action_tree/user_action_content_toolbar.test.tsx index a5244e14ad2432..155e9e2323e645 100644 --- a/x-pack/plugins/cases/public/components/user_action_tree/user_action_content_toolbar.test.tsx +++ b/x-pack/plugins/cases/public/components/user_action_tree/user_action_content_toolbar.test.tsx @@ -7,7 +7,10 @@ import React from 'react'; import { mount, ReactWrapper } from 'enzyme'; -import { UserActionContentToolbar } from './user_action_content_toolbar'; +import { + UserActionContentToolbar, + UserActionContentToolbarProps, +} from './user_action_content_toolbar'; jest.mock('react-router-dom', () => { const originalModule = jest.requireActual('react-router-dom'); @@ -28,12 +31,12 @@ jest.mock('../../common/lib/kibana', () => ({ }), })); -const props = { +const props: UserActionContentToolbarProps = { getCaseDetailHrefWithCommentId: jest.fn().mockReturnValue('case-detail-url-with-comment-id-1'), id: '1', editLabel: 'edit', quoteLabel: 'quote', - disabled: false, + userCanCrud: true, isLoading: false, onEdit: jest.fn(), onQuote: jest.fn(), diff --git a/x-pack/plugins/cases/public/components/user_action_tree/user_action_content_toolbar.tsx b/x-pack/plugins/cases/public/components/user_action_tree/user_action_content_toolbar.tsx index 7adaffce22c54a..5fa12b8cfa4344 100644 --- a/x-pack/plugins/cases/public/components/user_action_tree/user_action_content_toolbar.tsx +++ b/x-pack/plugins/cases/public/components/user_action_tree/user_action_content_toolbar.tsx @@ -11,15 +11,15 @@ import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { UserActionCopyLink } from './user_action_copy_link'; import { UserActionPropertyActions } from './user_action_property_actions'; -interface UserActionContentToolbarProps { +export interface UserActionContentToolbarProps { id: string; getCaseDetailHrefWithCommentId: (commentId: string) => string; editLabel: string; quoteLabel: string; - disabled: boolean; isLoading: boolean; onEdit: (id: string) => void; onQuote: (id: string) => void; + userCanCrud: boolean; } const UserActionContentToolbarComponent = ({ @@ -27,26 +27,27 @@ const UserActionContentToolbarComponent = ({ getCaseDetailHrefWithCommentId, editLabel, quoteLabel, - disabled, isLoading, onEdit, onQuote, + userCanCrud, }: UserActionContentToolbarProps) => ( - - - + {userCanCrud && ( + + + + )} ); diff --git a/x-pack/plugins/cases/public/components/user_action_tree/user_action_property_actions.tsx b/x-pack/plugins/cases/public/components/user_action_tree/user_action_property_actions.tsx index 44b5baf3246cc7..ebc83de1ef36a1 100644 --- a/x-pack/plugins/cases/public/components/user_action_tree/user_action_property_actions.tsx +++ b/x-pack/plugins/cases/public/components/user_action_tree/user_action_property_actions.tsx @@ -14,7 +14,6 @@ interface UserActionPropertyActionsProps { id: string; editLabel: string; quoteLabel: string; - disabled: boolean; isLoading: boolean; onEdit: (id: string) => void; onQuote: (id: string) => void; @@ -24,7 +23,6 @@ const UserActionPropertyActionsComponent = ({ id, editLabel, quoteLabel, - disabled, isLoading, onEdit, onQuote, @@ -35,19 +33,17 @@ const UserActionPropertyActionsComponent = ({ const propertyActions = useMemo( () => [ { - disabled, iconType: 'pencil', label: editLabel, onClick: onEditClick, }, { - disabled, iconType: 'quote', label: quoteLabel, onClick: onQuoteClick, }, ], - [disabled, editLabel, quoteLabel, onEditClick, onQuoteClick] + [editLabel, quoteLabel, onEditClick, onQuoteClick] ); return ( <> diff --git a/x-pack/plugins/cases/public/containers/configure/translations.ts b/x-pack/plugins/cases/public/containers/configure/translations.ts index e77b9f57c8f4c7..01900b8850c195 100644 --- a/x-pack/plugins/cases/public/containers/configure/translations.ts +++ b/x-pack/plugins/cases/public/containers/configure/translations.ts @@ -12,3 +12,11 @@ export * from '../translations'; export const SUCCESS_CONFIGURE = i18n.translate('xpack.cases.configure.successSaveToast', { defaultMessage: 'Saved external connection settings', }); + +export const READ_PERMISSIONS_ERROR_MSG = i18n.translate( + 'xpack.cases.configure.readPermissionsErrorDescription', + { + defaultMessage: + 'You do not have permissions to view connectors. If you would like to view the connectors associated with this case, contact your Kibana administrator.', + } +); diff --git a/x-pack/plugins/cases/public/containers/configure/use_connectors.tsx b/x-pack/plugins/cases/public/containers/configure/use_connectors.tsx index 3b91c77d0235a0..e350146c650ce3 100644 --- a/x-pack/plugins/cases/public/containers/configure/use_connectors.tsx +++ b/x-pack/plugins/cases/public/containers/configure/use_connectors.tsx @@ -7,26 +7,40 @@ import { useState, useEffect, useCallback, useRef } from 'react'; -import * as i18n from '../translations'; import { fetchConnectors } from './api'; import { ActionConnector } from './types'; import { useToasts } from '../../common/lib/kibana'; +import * as i18n from './translations'; + +interface ConnectorsState { + loading: boolean; + connectors: ActionConnector[]; + permissionsError?: string; +} export interface UseConnectorsResponse { loading: boolean; connectors: ActionConnector[]; refetchConnectors: () => void; + permissionsError?: string; } -export const useConnectors = (): UseConnectorsResponse => { +/** + * Retrieves the configured case connectors + * + * @param toastPermissionsErrors boolean controlling whether 403 and 401 errors should be displayed in a toast error + */ +export const useConnectors = ({ + toastPermissionsErrors = true, +}: { + toastPermissionsErrors?: boolean; +} = {}): UseConnectorsResponse => { const toasts = useToasts(); - const [state, setState] = useState<{ - loading: boolean; - connectors: ActionConnector[]; - }>({ + const [state, setState] = useState({ loading: true, connectors: [], }); + const isCancelledRef = useRef(false); const abortCtrlRef = useRef(new AbortController()); @@ -49,15 +63,26 @@ export const useConnectors = (): UseConnectorsResponse => { } } catch (error) { if (!isCancelledRef.current) { + let permissionsError: string | undefined; if (error.name !== 'AbortError') { - toasts.addError( - error.body && error.body.message ? new Error(error.body.message) : error, - { title: i18n.ERROR_TITLE } - ); + // if the error was related to permissions then let's return a boilerplate error message describing the problem + if (error.body?.statusCode === 403 || error.body?.statusCode === 401) { + permissionsError = i18n.READ_PERMISSIONS_ERROR_MSG; + } + + // if the error was not permissions related then toast it + // if it was permissions related (permissionsError was defined) and the caller wants to toast, then create a toast + if (permissionsError === undefined || toastPermissionsErrors) { + toasts.addError( + error.body && error.body.message ? new Error(error.body.message) : error, + { title: i18n.ERROR_TITLE } + ); + } } setState({ loading: false, connectors: [], + permissionsError, }); } } @@ -77,5 +102,6 @@ export const useConnectors = (): UseConnectorsResponse => { loading: state.loading, connectors: state.connectors, refetchConnectors, + permissionsError: state.permissionsError, }; }; diff --git a/x-pack/plugins/cases/public/mocks.ts b/x-pack/plugins/cases/public/mocks.ts new file mode 100644 index 00000000000000..c543baa4774756 --- /dev/null +++ b/x-pack/plugins/cases/public/mocks.ts @@ -0,0 +1,21 @@ +/* + * 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 { CasesUiStart } from './types'; + +const createStartContract = (): jest.Mocked => ({ + getAllCases: jest.fn(), + getAllCasesSelectorModal: jest.fn(), + getCaseView: jest.fn(), + getConfigureCases: jest.fn(), + getCreateCase: jest.fn(), + getRecentCases: jest.fn(), +}); + +export const casesPluginMock = { + createStartContract, +}; diff --git a/x-pack/plugins/cases/server/authorization/audit_logger.test.ts b/x-pack/plugins/cases/server/authorization/audit_logger.test.ts index d54b5164b10b94..48c6e9ebcd07a8 100644 --- a/x-pack/plugins/cases/server/authorization/audit_logger.test.ts +++ b/x-pack/plugins/cases/server/authorization/audit_logger.test.ts @@ -143,7 +143,7 @@ describe('audit_logger', () => { // for reference: https://github.com/facebook/jest/issues/9409#issuecomment-629272237 // This loops through all operation keys - it.each(Array.from(Object.keys(Operations)))( + it.each(Object.keys(Operations))( `creates the correct audit event for operation: "%s" without an error or entity`, (operationKey) => { // forcing the cast here because using a string throws a type error @@ -156,7 +156,7 @@ describe('audit_logger', () => { ); // This loops through all operation keys - it.each(Array.from(Object.keys(Operations)))( + it.each(Object.keys(Operations))( `creates the correct audit event for operation: "%s" with an error but no entity`, (operationKey) => { // forcing the cast here because using a string throws a type error @@ -170,7 +170,7 @@ describe('audit_logger', () => { ); // This loops through all operation keys - it.each(Array.from(Object.keys(Operations)))( + it.each(Object.keys(Operations))( `creates the correct audit event for operation: "%s" with an error and entity`, (operationKey) => { // forcing the cast here because using a string throws a type error @@ -188,7 +188,7 @@ describe('audit_logger', () => { ); // This loops through all operation keys - it.each(Array.from(Object.keys(Operations)))( + it.each(Object.keys(Operations))( `creates the correct audit event for operation: "%s" without an error but with an entity`, (operationKey) => { // forcing the cast here because using a string throws a type error diff --git a/x-pack/plugins/cases/server/plugin.ts b/x-pack/plugins/cases/server/plugin.ts index 28b9cf9e4e032a..b1e2f61a595eef 100644 --- a/x-pack/plugins/cases/server/plugin.ts +++ b/x-pack/plugins/cases/server/plugin.ts @@ -72,7 +72,7 @@ export class CasePlugin { this.clientFactory = new CasesClientFactory(this.log); } - public async setup(core: CoreSetup, plugins: PluginsSetup) { + public setup(core: CoreSetup, plugins: PluginsSetup) { const config = createConfig(this.initializerContext); if (!config.enabled) { diff --git a/x-pack/plugins/observability/public/components/app/cases/callout/helpers.tsx b/x-pack/plugins/observability/public/components/app/cases/callout/helpers.tsx index 29b17cd426c58b..fdd49ad17168de 100644 --- a/x-pack/plugins/observability/public/components/app/cases/callout/helpers.tsx +++ b/x-pack/plugins/observability/public/components/app/cases/callout/helpers.tsx @@ -5,18 +5,7 @@ * 2.0. */ -import React from 'react'; import md5 from 'md5'; -import * as i18n from './translations'; -import { ErrorMessage } from './types'; - -export const permissionsReadOnlyErrorMessage: ErrorMessage = { - id: 'read-only-privileges-error', - title: i18n.READ_ONLY_FEATURE_TITLE, - description: <>{i18n.READ_ONLY_FEATURE_MSG}, - errorType: 'warning', -}; - export const createCalloutId = (ids: string[], delimiter: string = '|'): string => md5(ids.join(delimiter)); diff --git a/x-pack/plugins/observability/public/components/app/cases/callout/translations.ts b/x-pack/plugins/observability/public/components/app/cases/callout/translations.ts index cb7236b445be12..20bb57daf5841b 100644 --- a/x-pack/plugins/observability/public/components/app/cases/callout/translations.ts +++ b/x-pack/plugins/observability/public/components/app/cases/callout/translations.ts @@ -7,21 +7,6 @@ import { i18n } from '@kbn/i18n'; -export const READ_ONLY_FEATURE_TITLE = i18n.translate( - 'xpack.observability.cases.readOnlyFeatureTitle', - { - defaultMessage: 'You cannot open new or update existing cases', - } -); - -export const READ_ONLY_FEATURE_MSG = i18n.translate( - 'xpack.observability.cases.readOnlyFeatureDescription', - { - defaultMessage: - 'You only have privileges to view cases. If you need to open and update cases, contact your Kibana administrator.', - } -); - export const DISMISS_CALLOUT = i18n.translate( 'xpack.observability.cases.dismissErrorsPushServiceCallOutTitle', { diff --git a/x-pack/plugins/observability/public/components/app/cases/translations.ts b/x-pack/plugins/observability/public/components/app/cases/translations.ts index 1a5abe218edf52..a85b0bc744e66a 100644 --- a/x-pack/plugins/observability/public/components/app/cases/translations.ts +++ b/x-pack/plugins/observability/public/components/app/cases/translations.ts @@ -201,3 +201,17 @@ export const CONNECTORS = i18n.translate('xpack.observability.cases.caseView.con export const EDIT_CONNECTOR = i18n.translate('xpack.observability.cases.caseView.editConnector', { defaultMessage: 'Change external incident management system', }); + +export const READ_ONLY_BADGE_TEXT = i18n.translate( + 'xpack.observability.cases.badge.readOnly.text', + { + defaultMessage: 'Read only', + } +); + +export const READ_ONLY_BADGE_TOOLTIP = i18n.translate( + 'xpack.observability.cases.badge.readOnly.tooltip', + { + defaultMessage: 'Unable to create or edit cases', + } +); diff --git a/x-pack/plugins/observability/public/hooks/use_readonly_header.tsx b/x-pack/plugins/observability/public/hooks/use_readonly_header.tsx new file mode 100644 index 00000000000000..4d8779e1ea150a --- /dev/null +++ b/x-pack/plugins/observability/public/hooks/use_readonly_header.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useCallback, useEffect } from 'react'; + +import * as i18n from '../components/app/cases/translations'; +import { useGetUserCasesPermissions } from '../hooks/use_get_user_cases_permissions'; +import { useKibana } from '../utils/kibana_react'; + +/** + * This component places a read-only icon badge in the header if user only has read permissions + */ +export function useReadonlyHeader() { + const userPermissions = useGetUserCasesPermissions(); + const chrome = useKibana().services.chrome; + + // if the user is read only then display the glasses badge in the global navigation header + const setBadge = useCallback(() => { + if (userPermissions != null && !userPermissions.crud && userPermissions.read) { + chrome.setBadge({ + text: i18n.READ_ONLY_BADGE_TEXT, + tooltip: i18n.READ_ONLY_BADGE_TOOLTIP, + iconType: 'glasses', + }); + } + }, [chrome, userPermissions]); + + useEffect(() => { + setBadge(); + + // remove the icon after the component unmounts + return () => { + chrome.setBadge(); + }; + }, [setBadge, chrome]); +} diff --git a/x-pack/plugins/observability/public/pages/cases/all_cases.tsx b/x-pack/plugins/observability/public/pages/cases/all_cases.tsx index f73f3b4cf57d75..442104a7106017 100644 --- a/x-pack/plugins/observability/public/pages/cases/all_cases.tsx +++ b/x-pack/plugins/observability/public/pages/cases/all_cases.tsx @@ -10,35 +10,28 @@ import React from 'react'; import { AllCases } from '../../components/app/cases/all_cases'; import * as i18n from '../../components/app/cases/translations'; -import { permissionsReadOnlyErrorMessage, CaseCallOut } from '../../components/app/cases/callout'; import { CaseFeatureNoPermissions } from './feature_no_permissions'; import { useGetUserCasesPermissions } from '../../hooks/use_get_user_cases_permissions'; import { usePluginContext } from '../../hooks/use_plugin_context'; +import { useReadonlyHeader } from '../../hooks/use_readonly_header'; import { casesBreadcrumbs } from './links'; import { useBreadcrumbs } from '../../hooks/use_breadcrumbs'; export const AllCasesPage = React.memo(() => { const userPermissions = useGetUserCasesPermissions(); const { ObservabilityPageTemplate } = usePluginContext(); + useReadonlyHeader(); useBreadcrumbs([casesBreadcrumbs.cases]); return userPermissions == null || userPermissions?.read ? ( - <> - {userPermissions != null && !userPermissions?.crud && userPermissions?.read && ( - - )} - {i18n.PAGE_TITLE}, - }} - > - - - + {i18n.PAGE_TITLE}, + }} + > + + ) : ( ); diff --git a/x-pack/plugins/observability/public/pages/cases/case_details.tsx b/x-pack/plugins/observability/public/pages/cases/case_details.tsx index 6adf5ad286808f..f93cb5c4e7919a 100644 --- a/x-pack/plugins/observability/public/pages/cases/case_details.tsx +++ b/x-pack/plugins/observability/public/pages/cases/case_details.tsx @@ -5,45 +5,35 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect } from 'react'; import { useParams } from 'react-router-dom'; import { CaseView } from '../../components/app/cases/case_view'; import { useGetUserCasesPermissions } from '../../hooks/use_get_user_cases_permissions'; import { useKibana } from '../../utils/kibana_react'; import { CASES_APP_ID } from '../../components/app/cases/constants'; -import { CaseCallOut, permissionsReadOnlyErrorMessage } from '../../components/app/cases/callout'; +import { useReadonlyHeader } from '../../hooks/use_readonly_header'; export const CaseDetailsPage = React.memo(() => { const { application: { getUrlForApp, navigateToUrl }, } = useKibana().services; + const casesUrl = getUrlForApp(CASES_APP_ID); const userPermissions = useGetUserCasesPermissions(); const { detailName: caseId, subCaseId } = useParams<{ detailName?: string; subCaseId?: string; }>(); + useReadonlyHeader(); - const casesUrl = getUrlForApp(CASES_APP_ID); - if (userPermissions != null && !userPermissions.read) { - navigateToUrl(casesUrl); - return null; - } + useEffect(() => { + if (userPermissions != null && !userPermissions.read) { + navigateToUrl(casesUrl); + } + }, [casesUrl, navigateToUrl, userPermissions]); return caseId != null ? ( - <> - {userPermissions != null && !userPermissions?.crud && userPermissions?.read && ( - - )} - - + ) : null; }); diff --git a/x-pack/plugins/observability/public/pages/cases/configure_cases.tsx b/x-pack/plugins/observability/public/pages/cases/configure_cases.tsx index a4df4855b0204d..9676eb7eba1470 100644 --- a/x-pack/plugins/observability/public/pages/cases/configure_cases.tsx +++ b/x-pack/plugins/observability/public/pages/cases/configure_cases.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useCallback } from 'react'; +import React, { useCallback, useEffect } from 'react'; import styled from 'styled-components'; import { EuiButtonEmpty } from '@elastic/eui'; @@ -38,10 +38,12 @@ function ConfigureCasesPageComponent() { const { formatUrl } = useFormatUrl(CASES_APP_ID); const href = formatUrl(getCaseUrl()); useBreadcrumbs([{ ...casesBreadcrumbs.cases, href }, casesBreadcrumbs.configure]); - if (userPermissions != null && !userPermissions.read) { - navigateToUrl(casesUrl); - return null; - } + + useEffect(() => { + if (userPermissions != null && !userPermissions.read) { + navigateToUrl(casesUrl); + } + }, [casesUrl, userPermissions, navigateToUrl]); return ( { const { formatUrl } = useFormatUrl(CASES_APP_ID); const href = formatUrl(getCaseUrl()); useBreadcrumbs([{ ...casesBreadcrumbs.cases, href }, casesBreadcrumbs.create]); - if (userPermissions != null && !userPermissions.crud) { - navigateToUrl(casesUrl); - return null; - } + + useEffect(() => { + if (userPermissions != null && !userPermissions.crud) { + navigateToUrl(casesUrl); + } + }, [casesUrl, navigateToUrl, userPermissions]); return ( { }); it('should not allow user with read only privileges to attach alerts to cases', () => { - cy.get(ATTACH_ALERT_TO_CASE_BUTTON).first().should('be.disabled'); + cy.get(ATTACH_ALERT_TO_CASE_BUTTON).should('not.exist'); }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/callout/helpers.tsx b/x-pack/plugins/security_solution/public/cases/components/callout/helpers.tsx index 29b17cd426c58b..fdd49ad17168de 100644 --- a/x-pack/plugins/security_solution/public/cases/components/callout/helpers.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/callout/helpers.tsx @@ -5,18 +5,7 @@ * 2.0. */ -import React from 'react'; import md5 from 'md5'; -import * as i18n from './translations'; -import { ErrorMessage } from './types'; - -export const permissionsReadOnlyErrorMessage: ErrorMessage = { - id: 'read-only-privileges-error', - title: i18n.READ_ONLY_FEATURE_TITLE, - description: <>{i18n.READ_ONLY_FEATURE_MSG}, - errorType: 'warning', -}; - export const createCalloutId = (ids: string[], delimiter: string = '|'): string => md5(ids.join(delimiter)); diff --git a/x-pack/plugins/security_solution/public/cases/components/callout/translations.ts b/x-pack/plugins/security_solution/public/cases/components/callout/translations.ts index db4809126452f9..617995cc366b06 100644 --- a/x-pack/plugins/security_solution/public/cases/components/callout/translations.ts +++ b/x-pack/plugins/security_solution/public/cases/components/callout/translations.ts @@ -7,21 +7,6 @@ import { i18n } from '@kbn/i18n'; -export const READ_ONLY_FEATURE_TITLE = i18n.translate( - 'xpack.securitySolution.cases.readOnlyFeatureTitle', - { - defaultMessage: 'You cannot open new or update existing cases', - } -); - -export const READ_ONLY_FEATURE_MSG = i18n.translate( - 'xpack.securitySolution.cases.readOnlyFeatureDescription', - { - defaultMessage: - 'You only have privileges to view cases. If you need to open and update cases, contact your Kibana administrator.', - } -); - export const DISMISS_CALLOUT = i18n.translate( 'xpack.securitySolution.cases.dismissErrorsPushServiceCallOutTitle', { diff --git a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.test.tsx b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.test.tsx index 77fa9e8b3cc8c9..02047c774ca6f5 100644 --- a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.test.tsx @@ -200,7 +200,7 @@ describe('AddToCaseAction', () => { ).toBeTruthy(); }); - it('disabled when user does not have crud permissions', () => { + it('hides the icon when user does not have crud permissions', () => { (useGetUserCasesPermissions as jest.Mock).mockReturnValue({ crud: false, read: true, @@ -212,8 +212,6 @@ describe('AddToCaseAction', () => { ); - expect( - wrapper.find(`[data-test-subj="attach-alert-to-case-button"]`).first().prop('isDisabled') - ).toBeTruthy(); + expect(wrapper.find(`[data-test-subj="attach-alert-to-case-button"]`).exists()).toBeFalsy(); }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx index eaad912a4dc51c..7025bff1ce49a6 100644 --- a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx @@ -208,19 +208,21 @@ const AddToCaseActionComponent: React.FC = ({ return ( <> - - - - - + {userCanCrud && ( + + + + + + )} {isCreateCaseFlyoutOpen && ( { return userPermissions == null || userPermissions?.read ? ( <> - {userPermissions != null && !userPermissions?.crud && userPermissions?.read && ( - - )} diff --git a/x-pack/plugins/security_solution/public/cases/pages/case_details.tsx b/x-pack/plugins/security_solution/public/cases/pages/case_details.tsx index 73077334268626..a086409e55df52 100644 --- a/x-pack/plugins/security_solution/public/cases/pages/case_details.tsx +++ b/x-pack/plugins/security_solution/public/cases/pages/case_details.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect } from 'react'; import { useParams } from 'react-router-dom'; import { SecurityPageName } from '../../app/types'; @@ -16,7 +16,6 @@ import { useGetUserCasesPermissions, useKibana } from '../../common/lib/kibana'; import { getCaseUrl } from '../../common/components/link_to'; import { navTabs } from '../../app/home/home_navigations'; import { CaseView } from '../components/case_view'; -import { permissionsReadOnlyErrorMessage, CaseCallOut } from '../components/callout'; import { CASES_APP_ID } from '../../../common/constants'; export const CaseDetailsPage = React.memo(() => { @@ -30,20 +29,15 @@ export const CaseDetailsPage = React.memo(() => { }>(); const search = useGetUrlSearch(navTabs.case); - if (userPermissions != null && !userPermissions.read) { - navigateToApp(CASES_APP_ID, { path: getCaseUrl(search) }); - return null; - } + useEffect(() => { + if (userPermissions != null && !userPermissions.read) { + navigateToApp(CASES_APP_ID, { path: getCaseUrl(search) }); + } + }, [navigateToApp, userPermissions, search]); return caseId != null ? ( <> - {userPermissions != null && !userPermissions?.crud && userPermissions?.read && ( - - )} { [search] ); - if (userPermissions != null && !userPermissions.read) { - navigateToApp(CASES_APP_ID, { path: getCaseUrl(search) }); - return null; - } + useEffect(() => { + if (userPermissions != null && !userPermissions.read) { + navigateToApp(CASES_APP_ID, { + path: getCaseUrl(search), + }); + } + }, [navigateToApp, userPermissions, search]); const HeaderWrapper = styled.div` padding-top: ${({ theme }) => theme.eui.paddingSizes.l}; diff --git a/x-pack/plugins/security_solution/public/cases/pages/create_case.tsx b/x-pack/plugins/security_solution/public/cases/pages/create_case.tsx index 19f97bae60ebe9..3c5197f19eff12 100644 --- a/x-pack/plugins/security_solution/public/cases/pages/create_case.tsx +++ b/x-pack/plugins/security_solution/public/cases/pages/create_case.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useMemo } from 'react'; +import React, { useEffect, useMemo } from 'react'; import { SecurityPageName } from '../../app/types'; import { getCaseUrl } from '../../common/components/link_to'; @@ -25,6 +25,7 @@ export const CreateCasePage = React.memo(() => { const { application: { navigateToApp }, } = useKibana().services; + const backOptions = useMemo( () => ({ href: getCaseUrl(search), @@ -34,12 +35,13 @@ export const CreateCasePage = React.memo(() => { [search] ); - if (userPermissions != null && !userPermissions.crud) { - navigateToApp(CASES_APP_ID, { - path: getCaseUrl(search), - }); - return null; - } + useEffect(() => { + if (userPermissions != null && !userPermissions.crud) { + navigateToApp(CASES_APP_ID, { + path: getCaseUrl(search), + }); + } + }, [userPermissions, navigateToApp, search]); return ( <> diff --git a/x-pack/plugins/security_solution/public/cases/pages/index.test.tsx b/x-pack/plugins/security_solution/public/cases/pages/index.test.tsx new file mode 100644 index 00000000000000..0d12d63fdc244c --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/pages/index.test.tsx @@ -0,0 +1,91 @@ +/* + * 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 { mount } from 'enzyme'; +import { BrowserRouter as Router } from 'react-router-dom'; + +import { useGetUserCasesPermissions, useKibana } from '../../common/lib/kibana'; +import { TestProviders } from '../../common/mock'; +import { Case } from '.'; + +const useKibanaMock = useKibana as jest.Mocked; +jest.mock('../../common/lib/kibana'); + +const mockedSetBadge = jest.fn(); + +describe('CaseContainerComponent', () => { + beforeEach(() => { + jest.clearAllMocks(); + useKibanaMock().services.chrome.setBadge = mockedSetBadge; + }); + + it('does not display the readonly glasses badge when the user has write permissions', () => { + (useGetUserCasesPermissions as jest.Mock).mockReturnValue({ + crud: true, + read: false, + }); + + mount( + + + + + + ); + + expect(mockedSetBadge).not.toBeCalled(); + }); + + it('does not display the readonly glasses badge when the user has neither write nor read permissions', () => { + (useGetUserCasesPermissions as jest.Mock).mockReturnValue({ + crud: false, + read: false, + }); + + mount( + + + + + + ); + + expect(mockedSetBadge).not.toBeCalled(); + }); + + it('does not display the readonly glasses badge when the user has null permissions', () => { + (useGetUserCasesPermissions as jest.Mock).mockReturnValue(null); + + mount( + + + + + + ); + + expect(mockedSetBadge).not.toBeCalled(); + }); + + it('displays the readonly glasses badge read permissions but not write', () => { + (useGetUserCasesPermissions as jest.Mock).mockReturnValue({ + crud: false, + read: true, + }); + + mount( + + + + + + ); + + expect(mockedSetBadge).toBeCalledTimes(1); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/pages/index.tsx b/x-pack/plugins/security_solution/public/cases/pages/index.tsx index 314bdc9bfd117f..fca19cf5c70a7e 100644 --- a/x-pack/plugins/security_solution/public/cases/pages/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/pages/index.tsx @@ -5,13 +5,15 @@ * 2.0. */ -import React from 'react'; - +import React, { useEffect } from 'react'; import { Route, Switch } from 'react-router-dom'; + +import * as i18n from './translations'; import { CaseDetailsPage } from './case_details'; import { CasesPage } from './case'; import { CreateCasePage } from './create_case'; import { ConfigureCasesPage } from './configure_cases'; +import { useGetUserCasesPermissions, useKibana } from '../../common/lib/kibana'; const casesPagePath = ''; const caseDetailsPagePath = `${casesPagePath}/:detailName`; @@ -21,30 +23,51 @@ const subCaseDetailsPagePathWithCommentId = `${subCaseDetailsPagePath}/:commentI const createCasePagePath = `${casesPagePath}/create`; const configureCasesPagePath = `${casesPagePath}/configure`; -const CaseContainerComponent: React.FC = () => ( - - - - - - - - - - - - - - - - - - - - - - - -); +const CaseContainerComponent: React.FC = () => { + const userPermissions = useGetUserCasesPermissions(); + const chrome = useKibana().services.chrome; + + useEffect(() => { + // if the user is read only then display the glasses badge in the global navigation header + if (userPermissions != null && !userPermissions.crud && userPermissions.read) { + chrome.setBadge({ + text: i18n.READ_ONLY_BADGE_TEXT, + tooltip: i18n.READ_ONLY_BADGE_TOOLTIP, + iconType: 'glasses', + }); + } + + // remove the icon after the component unmounts + return () => { + chrome.setBadge(); + }; + }, [userPermissions, chrome]); + + return ( + + + + + + + + + + + + + + + + + + + + + + + + ); +}; export const Case = React.memo(CaseContainerComponent); diff --git a/x-pack/plugins/security_solution/public/cases/pages/translations.ts b/x-pack/plugins/security_solution/public/cases/pages/translations.ts index 1a811a3fd7bbc3..6768401b3f608e 100644 --- a/x-pack/plugins/security_solution/public/cases/pages/translations.ts +++ b/x-pack/plugins/security_solution/public/cases/pages/translations.ts @@ -157,3 +157,24 @@ export const GO_TO_DOCUMENTATION = i18n.translate( export const CONNECTORS = i18n.translate('xpack.securitySolution.cases.caseView.connectors', { defaultMessage: 'External Incident Management System', }); + +export const EDIT_CONNECTOR = i18n.translate( + 'xpack.securitySolution.cases.caseView.editConnector', + { + defaultMessage: 'Change external incident management system', + } +); + +export const READ_ONLY_BADGE_TEXT = i18n.translate( + 'xpack.securitySolution.cases.badge.readOnly.text', + { + defaultMessage: 'Read only', + } +); + +export const READ_ONLY_BADGE_TOOLTIP = i18n.translate( + 'xpack.securitySolution.cases.badge.readOnly.tooltip', + { + defaultMessage: 'Unable to create or edit cases', + } +); diff --git a/x-pack/plugins/security_solution/public/common/components/header_global/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/header_global/index.test.tsx new file mode 100644 index 00000000000000..96a7eacb7fb08c --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/header_global/index.test.tsx @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { mount } from 'enzyme'; + +import { useGetUserCasesPermissions } from '../../../common/lib/kibana'; +import { TestProviders } from '../../../common/mock'; +import { HeaderGlobal } from '.'; + +jest.mock('../../../common/lib/kibana'); + +describe('HeaderGlobal', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('does not display the cases tab when the user does not have read permissions', () => { + (useGetUserCasesPermissions as jest.Mock).mockReturnValue({ + crud: false, + read: false, + }); + + const wrapper = mount( + + + + ); + + expect(wrapper.find(`[data-test-subj="navigation-case"]`).exists()).toBeFalsy(); + }); + + it('displays the cases tab when the user has read permissions', () => { + (useGetUserCasesPermissions as jest.Mock).mockReturnValue({ + crud: true, + read: true, + }); + + const wrapper = mount( + + + + ); + + expect(wrapper.find(`[data-test-subj="navigation-case"]`).exists()).toBeTruthy(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/header_global/index.tsx b/x-pack/plugins/security_solution/public/common/components/header_global/index.tsx index 4a7ac8a148f647..e91905183aab10 100644 --- a/x-pack/plugins/security_solution/public/common/components/header_global/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/header_global/index.tsx @@ -19,7 +19,7 @@ import { MlPopover } from '../ml_popover/ml_popover'; import { SiemNavigation } from '../navigation'; import * as i18n from './translations'; import { useGetUrlSearch } from '../navigation/use_get_url_search'; -import { useKibana } from '../../lib/kibana'; +import { useGetUserCasesPermissions, useKibana } from '../../lib/kibana'; import { APP_ID, ADD_DATA_PATH, APP_DETECTIONS_PATH } from '../../../../common/constants'; import { useGlobalHeaderPortal } from '../../hooks/use_global_header_portal'; import { LinkAnchor } from '../links'; @@ -91,6 +91,18 @@ export const HeaderGlobal = React.memo( }, [navigateToApp, search] ); + + const hasCasesReadPermissions = useGetUserCasesPermissions()?.read; + + // build a list of tabs to exclude + const tabsToExclude = new Set([ + ...(hideDetectionEngine ? [SecurityPageName.detections] : []), + ...(!hasCasesReadPermissions ? [SecurityPageName.case] : []), + ]); + + // include the tab if it is not in the set of excluded ones + const tabsToDisplay = pickBy((_, key) => !tabsToExclude.has(key), navTabs); + return ( @@ -109,14 +121,7 @@ export const HeaderGlobal = React.memo( - key !== SecurityPageName.detections, navTabs) - : navTabs - } - /> + diff --git a/x-pack/plugins/security_solution/public/overview/components/recent_cases/index.tsx b/x-pack/plugins/security_solution/public/overview/components/recent_cases/index.tsx index 996835296fcc4b..cb7733e3049850 100644 --- a/x-pack/plugins/security_solution/public/overview/components/recent_cases/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/recent_cases/index.tsx @@ -13,7 +13,7 @@ import { getCreateCaseUrl, } from '../../../common/components/link_to/redirect_to_case'; import { useFormatUrl } from '../../../common/components/link_to'; -import { useKibana } from '../../../common/lib/kibana'; +import { useGetUserCasesPermissions, useKibana } from '../../../common/lib/kibana'; import { APP_ID, CASES_APP_ID } from '../../../../common/constants'; import { SecurityPageName } from '../../../app/types'; import { AllCasesNavProps } from '../../../cases/components/all_cases'; @@ -26,6 +26,8 @@ const RecentCasesComponent = () => { application: { navigateToApp }, } = useKibana().services; + const hasWritePermissions = useGetUserCasesPermissions()?.crud ?? false; + return casesUi.getRecentCases({ allCasesNavigation: { href: formatUrl(getCaseUrl()), @@ -60,6 +62,7 @@ const RecentCasesComponent = () => { }); }, }, + hasWritePermissions, maxCasesToShow: MAX_CASES_TO_SHOW, owner: [APP_ID], }); diff --git a/x-pack/plugins/security_solution/public/overview/components/sidebar/sidebar.test.tsx b/x-pack/plugins/security_solution/public/overview/components/sidebar/sidebar.test.tsx new file mode 100644 index 00000000000000..76c5663644a789 --- /dev/null +++ b/x-pack/plugins/security_solution/public/overview/components/sidebar/sidebar.test.tsx @@ -0,0 +1,72 @@ +/* + * 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 { mount } from 'enzyme'; +import { waitFor } from '@testing-library/react'; +import { TestProviders } from '../../../common/mock'; +import { Sidebar } from './sidebar'; +import { useGetUserCasesPermissions, useKibana } from '../../../common/lib/kibana'; +import { casesPluginMock } from '../../../../../cases/public/mocks'; +import { CasesUiStart } from '../../../../../cases/public'; + +jest.mock('../../../common/lib/kibana'); + +const useKibanaMock = useKibana as jest.MockedFunction; + +describe('Sidebar', () => { + let casesMock: jest.Mocked; + + beforeEach(() => { + casesMock = casesPluginMock.createStartContract(); + casesMock.getRecentCases.mockImplementation(() => <>{'test'}); + useKibanaMock.mockReturnValue(({ + services: { + cases: casesMock, + application: { + // these are needed by the RecentCases component if it is rendered. + navigateToApp: jest.fn(), + getUrlForApp: jest.fn(() => ''), + }, + }, + } as unknown) as ReturnType); + }); + + it('does not render the recently created cases section when the user does not have read permissions', async () => { + (useGetUserCasesPermissions as jest.Mock).mockReturnValue({ + crud: false, + read: false, + }); + + await waitFor(() => + mount( + + {}} /> + + ) + ); + + expect(casesMock.getRecentCases).not.toHaveBeenCalled(); + }); + + it('does render the recently created cases section when the user has read permissions', async () => { + (useGetUserCasesPermissions as jest.Mock).mockReturnValue({ + crud: false, + read: true, + }); + + await waitFor(() => + mount( + + {}} /> + + ) + ); + + expect(casesMock.getRecentCases).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/overview/components/sidebar/sidebar.tsx b/x-pack/plugins/security_solution/public/overview/components/sidebar/sidebar.tsx index 77cfa220f07220..b8701f3ef1639d 100644 --- a/x-pack/plugins/security_solution/public/overview/components/sidebar/sidebar.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/sidebar/sidebar.tsx @@ -18,6 +18,7 @@ import { SidebarHeader } from '../../../common/components/sidebar_header'; import * as i18n from '../../pages/translations'; import { RecentCases } from '../recent_cases'; +import { useGetUserCasesPermissions } from '../../../common/lib/kibana'; const SidebarFlexGroup = styled(EuiFlexGroup)` width: 305px; @@ -46,13 +47,20 @@ export const Sidebar = React.memo<{ [recentTimelinesFilterBy, setRecentTimelinesFilterBy] ); + // only render the recently created cases view if the user has at least read permissions + const hasCasesReadPermissions = useGetUserCasesPermissions()?.read; + return ( - - - + {hasCasesReadPermissions && ( + <> + + + - + + + )} {recentTimelinesFilters} diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.test.tsx index 68b4f2e4a0c31c..206fcb2dc087ca 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; -import { useKibana } from '../../../../common/lib/kibana'; +import { useKibana, useGetUserCasesPermissions } from '../../../../common/lib/kibana'; import { TestProviders, mockIndexNames, mockIndexPattern } from '../../../../common/mock'; import { TimelineId } from '../../../../../common/types/timeline'; import { useTimelineKpis } from '../../../containers/kpis'; @@ -57,7 +57,7 @@ const defaultMocks = { loading: false, selectedPatterns: mockIndexNames, }; -describe('Timeline KPIs', () => { +describe('header', () => { const mount = useMountAppended(); beforeEach(() => { @@ -75,86 +75,124 @@ describe('Timeline KPIs', () => { jest.clearAllMocks(); }); - describe('when the data is not loading and the response contains data', () => { + describe('AddToCaseButton', () => { beforeEach(() => { mockUseTimelineKpis.mockReturnValue([false, mockUseTimelineKpiResponse]); }); - it('renders the component, labels and values succesfully', async () => { + + it('renders the button when the user has write permissions', () => { + (useGetUserCasesPermissions as jest.Mock).mockReturnValue({ + crud: true, + read: false, + }); + const wrapper = mount( ); - expect(wrapper.find('[data-test-subj="siem-timeline-kpis"]').exists()).toEqual(true); - // label - expect(wrapper.find('[data-test-subj="siem-timeline-process-kpi"]').first().text()).toEqual( - expect.stringContaining('Processes') - ); - // value - expect(wrapper.find('[data-test-subj="siem-timeline-process-kpi"]').first().text()).toEqual( - expect.stringContaining('1') - ); - }); - }); - describe('when the data is loading', () => { - beforeEach(() => { - mockUseTimelineKpis.mockReturnValue([true, mockUseTimelineKpiResponse]); + expect(wrapper.find('[data-test-subj="attach-timeline-case-button"]').exists()).toBeTruthy(); }); - it('renders a loading indicator for values', async () => { + + it('does not render the button when the user does not have write permissions', () => { + (useGetUserCasesPermissions as jest.Mock).mockReturnValue({ + crud: false, + read: false, + }); + const wrapper = mount( ); - expect(wrapper.find('[data-test-subj="siem-timeline-process-kpi"]').first().text()).toEqual( - expect.stringContaining('--') - ); + + expect(wrapper.find('[data-test-subj="attach-timeline-case-button"]').exists()).toBeFalsy(); }); }); - describe('when the response is null and timeline is blank', () => { - beforeEach(() => { - mockUseTimelineKpis.mockReturnValue([false, null]); + describe('Timeline KPIs', () => { + describe('when the data is not loading and the response contains data', () => { + beforeEach(() => { + mockUseTimelineKpis.mockReturnValue([false, mockUseTimelineKpiResponse]); + }); + it('renders the component, labels and values successfully', async () => { + const wrapper = mount( + + + + ); + expect(wrapper.find('[data-test-subj="siem-timeline-kpis"]').exists()).toEqual(true); + // label + expect(wrapper.find('[data-test-subj="siem-timeline-process-kpi"]').first().text()).toEqual( + expect.stringContaining('Processes') + ); + // value + expect(wrapper.find('[data-test-subj="siem-timeline-process-kpi"]').first().text()).toEqual( + expect.stringContaining('1') + ); + }); }); - it('renders labels and the default empty string', async () => { - const wrapper = mount( - - - - ); - expect(wrapper.find('[data-test-subj="siem-timeline-process-kpi"]').first().text()).toEqual( - expect.stringContaining('Processes') - ); - expect(wrapper.find('[data-test-subj="siem-timeline-process-kpi"]').first().text()).toEqual( - expect.stringContaining(getEmptyValue()) - ); + describe('when the data is loading', () => { + beforeEach(() => { + mockUseTimelineKpis.mockReturnValue([true, mockUseTimelineKpiResponse]); + }); + it('renders a loading indicator for values', async () => { + const wrapper = mount( + + + + ); + expect(wrapper.find('[data-test-subj="siem-timeline-process-kpi"]').first().text()).toEqual( + expect.stringContaining('--') + ); + }); }); - }); - describe('when the response contains numbers larger than one thousand', () => { - beforeEach(() => { - mockUseTimelineKpis.mockReturnValue([false, mockUseTimelineLargeKpiResponse]); + describe('when the response is null and timeline is blank', () => { + beforeEach(() => { + mockUseTimelineKpis.mockReturnValue([false, null]); + }); + it('renders labels and the default empty string', async () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="siem-timeline-process-kpi"]').first().text()).toEqual( + expect.stringContaining('Processes') + ); + expect(wrapper.find('[data-test-subj="siem-timeline-process-kpi"]').first().text()).toEqual( + expect.stringContaining(getEmptyValue()) + ); + }); }); - it('formats the numbers correctly', async () => { - const wrapper = mount( - - - - ); - expect(wrapper.find('[data-test-subj="siem-timeline-process-kpi"]').first().text()).toEqual( - expect.stringContaining('1k') - ); - expect(wrapper.find('[data-test-subj="siem-timeline-user-kpi"]').first().text()).toEqual( - expect.stringContaining('1m') - ); - expect(wrapper.find('[data-test-subj="siem-timeline-source-ip-kpi"]').first().text()).toEqual( - expect.stringContaining('1b') - ); - expect(wrapper.find('[data-test-subj="siem-timeline-host-kpi"]').first().text()).toEqual( - expect.stringContaining('999') - ); + + describe('when the response contains numbers larger than one thousand', () => { + beforeEach(() => { + mockUseTimelineKpis.mockReturnValue([false, mockUseTimelineLargeKpiResponse]); + }); + it('formats the numbers correctly', async () => { + const wrapper = mount( + + + + ); + expect(wrapper.find('[data-test-subj="siem-timeline-process-kpi"]').first().text()).toEqual( + expect.stringContaining('1k') + ); + expect(wrapper.find('[data-test-subj="siem-timeline-user-kpi"]').first().text()).toEqual( + expect.stringContaining('1m') + ); + expect( + wrapper.find('[data-test-subj="siem-timeline-source-ip-kpi"]').first().text() + ).toEqual(expect.stringContaining('1b')); + expect(wrapper.find('[data-test-subj="siem-timeline-host-kpi"]').first().text()).toEqual( + expect.stringContaining('999') + ); + }); }); }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx index dd8cdb818cad75..216282b72920c5 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx @@ -35,7 +35,7 @@ import { TimerangeInput } from '../../../../../common/search_strategy'; import { AddToCaseButton } from '../add_to_case_button'; import { AddTimelineButton } from '../add_timeline_button'; import { SaveTimelineButton } from '../../timeline/header/save_timeline_button'; -import { useKibana } from '../../../../common/lib/kibana'; +import { useGetUserCasesPermissions, useKibana } from '../../../../common/lib/kibana'; import { InspectButton } from '../../../../common/components/inspect'; import { useTimelineKpis } from '../../../containers/kpis'; import { esQuery } from '../../../../../../../../src/plugins/data/public'; @@ -319,6 +319,8 @@ const FlyoutHeaderComponent: React.FC = ({ timelineId }) => { filterQuery: combinedQueries?.filterQuery ?? '', }); + const hasWritePermissions = useGetUserCasesPermissions()?.crud ?? false; + return ( @@ -350,9 +352,11 @@ const FlyoutHeaderComponent: React.FC = ({ timelineId }) => { - - - + {hasWritePermissions && ( + + + + )} diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index 7e4d0989af413d..ac9d854f18211f 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -129,17 +129,23 @@ export interface PluginSetup {} // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface PluginStart {} -const securitySubPlugins = [ +const casesSubPlugin = `${APP_ID}:${SecurityPageName.case}`; + +/** + * Don't include cases here so that the sub feature can govern whether Cases is enabled in the navigation + */ +const securitySubPluginsNoCases = [ APP_ID, `${APP_ID}:${SecurityPageName.overview}`, `${APP_ID}:${SecurityPageName.detections}`, `${APP_ID}:${SecurityPageName.hosts}`, `${APP_ID}:${SecurityPageName.network}`, `${APP_ID}:${SecurityPageName.timelines}`, - `${APP_ID}:${SecurityPageName.case}`, `${APP_ID}:${SecurityPageName.administration}`, ]; +const allSecuritySubPlugins = [...securitySubPluginsNoCases, casesSubPlugin]; + export class Plugin implements IPlugin { private readonly logger: Logger; private readonly config: ConfigType; @@ -305,7 +311,7 @@ export class Plugin implements IPlugin { await PageObjects.common.navigateToActualUrl('observabilityCases'); - await PageObjects.observability.expectCreateCaseButtonDisabled(); + await PageObjects.observability.expectCreateCaseButtonMissing(); }); - it(`shows read-only callout`, async () => { - await PageObjects.observability.expectReadOnlyCallout(); + it(`shows read-only glasses badge`, async () => { + await PageObjects.observability.expectReadOnlyGlassesBadge(); }); it(`does not allow a case to be created`, async () => { @@ -151,7 +151,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); // expect redirection to observability cases landing - await PageObjects.observability.expectCreateCaseButtonDisabled(); + await PageObjects.observability.expectCreateCaseButtonMissing(); }); it(`does not allow a case to be edited`, async () => { @@ -162,7 +162,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { shouldUseHashForSubUrl: false, } ); - await PageObjects.observability.expectAddCommentButtonDisabled(); + await PageObjects.observability.expectAddCommentButtonMissing(); }); }); diff --git a/x-pack/test/functional/page_objects/observability_page.ts b/x-pack/test/functional/page_objects/observability_page.ts index 95016c31d10541..d9e413d473adf3 100644 --- a/x-pack/test/functional/page_objects/observability_page.ts +++ b/x-pack/test/functional/page_objects/observability_page.ts @@ -20,14 +20,12 @@ export function ObservabilityPageProvider({ getService, getPageObjects }: FtrPro expect(disabledAttr).to.be(null); }, - async expectCreateCaseButtonDisabled() { - const button = await testSubjects.find('createNewCaseBtn', 20000); - const disabledAttr = await button.getAttribute('disabled'); - expect(disabledAttr).to.be('true'); + async expectCreateCaseButtonMissing() { + await testSubjects.missingOrFail('createNewCaseBtn'); }, - async expectReadOnlyCallout() { - await testSubjects.existOrFail('case-callout-e41900b01c9ef0fa81dd6ff326083fb3'); + async expectReadOnlyGlassesBadge() { + await testSubjects.existOrFail('headerBadge'); }, async expectNoReadOnlyCallout() { @@ -44,10 +42,8 @@ export function ObservabilityPageProvider({ getService, getPageObjects }: FtrPro expect(disabledAttr).to.be(null); }, - async expectAddCommentButtonDisabled() { - const button = await testSubjects.find('submit-comment', 20000); - const disabledAttr = await button.getAttribute('disabled'); - expect(disabledAttr).to.be('true'); + async expectAddCommentButtonMissing() { + await testSubjects.missingOrFail('submit-comment'); }, async expectForbidden() { From bfbe6ab0b248b2160a4abb468483bbb15edf4e11 Mon Sep 17 00:00:00 2001 From: Kevin Logan <56395104+kevinlog@users.noreply.github.com> Date: Tue, 22 Jun 2021 14:01:24 -0400 Subject: [PATCH 05/17] [Security Solution] show case names in isolation success message (#102664) --- x-pack/plugins/cases/common/api/cases/case.ts | 25 ++++++++ .../plugins/cases/common/api/cases/comment.ts | 16 ----- .../classes/client.casesclient.md | 28 ++++----- .../interfaces/attachments_add.addargs.md | 4 +- ...attachments_client.attachmentssubclient.md | 16 ++--- .../attachments_delete.deleteallargs.md | 4 +- .../attachments_delete.deleteargs.md | 6 +- .../interfaces/attachments_get.findargs.md | 4 +- ...ttachments_get.getallalertsattachtocase.md | 2 +- .../interfaces/attachments_get.getallargs.md | 6 +- .../interfaces/attachments_get.getargs.md | 4 +- .../attachments_update.updateargs.md | 6 +- .../interfaces/cases_client.casessubclient.md | 30 +++++----- .../cases_get.caseidsbyalertidparams.md | 40 ------------- .../cases_get.casesbyalertidparams.md | 40 +++++++++++++ .../interfaces/cases_get.getparams.md | 6 +- .../interfaces/cases_push.pushparams.md | 4 +- .../configure_client.configuresubclient.md | 8 +-- .../interfaces/stats_client.statssubclient.md | 2 +- .../sub_cases_client.subcasesclient.md | 8 +-- .../user_actions_client.useractionget.md | 4 +- ...ser_actions_client.useractionssubclient.md | 2 +- .../docs/cases_client/modules/cases_get.md | 6 +- .../cases/server/client/cases/client.ts | 12 ++-- .../plugins/cases/server/client/cases/get.ts | 47 +++++++++++++-- x-pack/plugins/cases/server/client/mocks.ts | 2 +- .../routes/api/cases/alerts/get_cases.ts | 4 +- .../plugins/cases/server/routes/api/index.ts | 4 +- .../components/host_isolation/index.tsx | 20 +++---- .../components/host_isolation/isolate.tsx | 11 +++- .../components/host_isolation/unisolate.tsx | 11 +++- .../detection_engine/alerts/mock.ts | 4 +- .../detection_engine/alerts/types.ts | 2 +- .../alerts/use_cases_from_alerts.test.tsx | 2 +- .../alerts/use_cases_from_alerts.tsx | 4 +- .../endpoint/routes/actions/isolation.ts | 14 +++-- .../case_api_integration/common/lib/utils.ts | 5 +- .../common/lib/validation.ts | 27 +++++++++ .../tests/common/alerts/get_cases.ts | 58 +++++++++---------- .../tests/common/alerts/get_cases.ts | 38 ++++++------ .../tests/common/alerts/get_cases.ts | 17 +++--- 41 files changed, 318 insertions(+), 235 deletions(-) delete mode 100644 x-pack/plugins/cases/docs/cases_client/interfaces/cases_get.caseidsbyalertidparams.md create mode 100644 x-pack/plugins/cases/docs/cases_client/interfaces/cases_get.casesbyalertidparams.md create mode 100644 x-pack/test/case_api_integration/common/lib/validation.ts diff --git a/x-pack/plugins/cases/common/api/cases/case.ts b/x-pack/plugins/cases/common/api/cases/case.ts index b3f7952a61ee7f..a72eda5bb1207e 100644 --- a/x-pack/plugins/cases/common/api/cases/case.ts +++ b/x-pack/plugins/cases/common/api/cases/case.ts @@ -14,6 +14,28 @@ import { CasesStatusResponseRt, CaseStatusRt } from './status'; import { CaseConnectorRt, ESCaseConnector } from '../connectors'; import { SubCaseResponseRt } from './sub_case'; +const BucketsAggs = rt.array( + rt.type({ + key: rt.string, + }) +); + +export const GetCaseIdsByAlertIdAggsRt = rt.type({ + references: rt.type({ + doc_count: rt.number, + caseIds: rt.type({ + buckets: BucketsAggs, + }), + }), +}); + +export const CasesByAlertIdRt = rt.array( + rt.type({ + id: rt.string, + title: rt.string, + }) +); + export enum CaseType { collection = 'collection', individual = 'individual', @@ -311,3 +333,6 @@ export type ESCasePatchRequest = Omit & { export type AllTagsFindRequest = rt.TypeOf; export type AllReportersFindRequest = AllTagsFindRequest; + +export type GetCaseIdsByAlertIdAggs = rt.TypeOf; +export type CasesByAlertId = rt.TypeOf; diff --git a/x-pack/plugins/cases/common/api/cases/comment.ts b/x-pack/plugins/cases/common/api/cases/comment.ts index 5bc8da95639c85..746c28f9942392 100644 --- a/x-pack/plugins/cases/common/api/cases/comment.ts +++ b/x-pack/plugins/cases/common/api/cases/comment.ts @@ -10,21 +10,6 @@ import { SavedObjectFindOptionsRt } from '../saved_object'; import { UserRT } from '../user'; -const BucketsAggs = rt.array( - rt.type({ - key: rt.string, - }) -); - -export const GetCaseIdsByAlertIdAggsRt = rt.type({ - references: rt.type({ - doc_count: rt.number, - caseIds: rt.type({ - buckets: BucketsAggs, - }), - }), -}); - /** * this is used to differentiate between an alert attached to a top-level case and a group of alerts that should only * be attached to a sub case. The reason we need this is because an alert group comment will have references to both a case and @@ -152,4 +137,3 @@ export type CommentPatchRequest = rt.TypeOf; export type CommentPatchAttributes = rt.TypeOf; export type CommentRequestUserType = rt.TypeOf; export type CommentRequestAlertType = rt.TypeOf; -export type GetCaseIdsByAlertIdAggs = rt.TypeOf; diff --git a/x-pack/plugins/cases/docs/cases_client/classes/client.casesclient.md b/x-pack/plugins/cases/docs/cases_client/classes/client.casesclient.md index a20f018cffeb8d..bd07a44a2bfdf3 100644 --- a/x-pack/plugins/cases/docs/cases_client/classes/client.casesclient.md +++ b/x-pack/plugins/cases/docs/cases_client/classes/client.casesclient.md @@ -45,7 +45,7 @@ Client wrapper that contains accessor methods for individual entities within the **Returns:** [*CasesClient*](client.casesclient.md) -Defined in: [client.ts:28](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/client.ts#L28) +Defined in: [client.ts:28](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/client.ts#L28) ## Properties @@ -53,7 +53,7 @@ Defined in: [client.ts:28](https://github.com/jonathan-buttner/kibana/blob/0e98e • `Private` `Readonly` **\_attachments**: [*AttachmentsSubClient*](../interfaces/attachments_client.attachmentssubclient.md) -Defined in: [client.ts:24](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/client.ts#L24) +Defined in: [client.ts:24](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/client.ts#L24) ___ @@ -61,7 +61,7 @@ ___ • `Private` `Readonly` **\_cases**: [*CasesSubClient*](../interfaces/cases_client.casessubclient.md) -Defined in: [client.ts:23](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/client.ts#L23) +Defined in: [client.ts:23](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/client.ts#L23) ___ @@ -69,7 +69,7 @@ ___ • `Private` `Readonly` **\_casesClientInternal**: *CasesClientInternal* -Defined in: [client.ts:22](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/client.ts#L22) +Defined in: [client.ts:22](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/client.ts#L22) ___ @@ -77,7 +77,7 @@ ___ • `Private` `Readonly` **\_configure**: [*ConfigureSubClient*](../interfaces/configure_client.configuresubclient.md) -Defined in: [client.ts:27](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/client.ts#L27) +Defined in: [client.ts:27](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/client.ts#L27) ___ @@ -85,7 +85,7 @@ ___ • `Private` `Readonly` **\_stats**: [*StatsSubClient*](../interfaces/stats_client.statssubclient.md) -Defined in: [client.ts:28](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/client.ts#L28) +Defined in: [client.ts:28](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/client.ts#L28) ___ @@ -93,7 +93,7 @@ ___ • `Private` `Readonly` **\_subCases**: [*SubCasesClient*](../interfaces/sub_cases_client.subcasesclient.md) -Defined in: [client.ts:26](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/client.ts#L26) +Defined in: [client.ts:26](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/client.ts#L26) ___ @@ -101,7 +101,7 @@ ___ • `Private` `Readonly` **\_userActions**: [*UserActionsSubClient*](../interfaces/user_actions_client.useractionssubclient.md) -Defined in: [client.ts:25](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/client.ts#L25) +Defined in: [client.ts:25](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/client.ts#L25) ## Accessors @@ -113,7 +113,7 @@ Retrieves an interface for interacting with attachments (comments) entities. **Returns:** [*AttachmentsSubClient*](../interfaces/attachments_client.attachmentssubclient.md) -Defined in: [client.ts:50](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/client.ts#L50) +Defined in: [client.ts:50](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/client.ts#L50) ___ @@ -125,7 +125,7 @@ Retrieves an interface for interacting with cases entities. **Returns:** [*CasesSubClient*](../interfaces/cases_client.casessubclient.md) -Defined in: [client.ts:43](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/client.ts#L43) +Defined in: [client.ts:43](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/client.ts#L43) ___ @@ -137,7 +137,7 @@ Retrieves an interface for interacting with the configuration of external connec **Returns:** [*ConfigureSubClient*](../interfaces/configure_client.configuresubclient.md) -Defined in: [client.ts:76](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/client.ts#L76) +Defined in: [client.ts:76](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/client.ts#L76) ___ @@ -149,7 +149,7 @@ Retrieves an interface for retrieving statistics related to the cases entities. **Returns:** [*StatsSubClient*](../interfaces/stats_client.statssubclient.md) -Defined in: [client.ts:83](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/client.ts#L83) +Defined in: [client.ts:83](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/client.ts#L83) ___ @@ -163,7 +163,7 @@ Currently this functionality is disabled and will throw an error if this functio **Returns:** [*SubCasesClient*](../interfaces/sub_cases_client.subcasesclient.md) -Defined in: [client.ts:66](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/client.ts#L66) +Defined in: [client.ts:66](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/client.ts#L66) ___ @@ -175,4 +175,4 @@ Retrieves an interface for interacting with the user actions associated with the **Returns:** [*UserActionsSubClient*](../interfaces/user_actions_client.useractionssubclient.md) -Defined in: [client.ts:57](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/client.ts#L57) +Defined in: [client.ts:57](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/client.ts#L57) diff --git a/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_add.addargs.md b/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_add.addargs.md index d5233ab6d8cb4f..f8f7babd15b909 100644 --- a/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_add.addargs.md +++ b/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_add.addargs.md @@ -21,7 +21,7 @@ The arguments needed for creating a new attachment to a case. The case ID that this attachment will be associated with -Defined in: [attachments/add.ts:305](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/attachments/add.ts#L305) +Defined in: [attachments/add.ts:305](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/attachments/add.ts#L305) ___ @@ -31,4 +31,4 @@ ___ The attachment values. -Defined in: [attachments/add.ts:309](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/attachments/add.ts#L309) +Defined in: [attachments/add.ts:309](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/attachments/add.ts#L309) diff --git a/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_client.attachmentssubclient.md b/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_client.attachmentssubclient.md index 1a9a687aa812b1..57141796f6f673 100644 --- a/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_client.attachmentssubclient.md +++ b/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_client.attachmentssubclient.md @@ -35,7 +35,7 @@ Adds an attachment to a case. **Returns:** *Promise*<[*ICaseResponse*](typedoc_interfaces.icaseresponse.md)\> -Defined in: [attachments/client.ts:35](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/attachments/client.ts#L35) +Defined in: [attachments/client.ts:35](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/attachments/client.ts#L35) ___ @@ -53,7 +53,7 @@ Deletes a single attachment for a specific case. **Returns:** *Promise* -Defined in: [attachments/client.ts:43](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/attachments/client.ts#L43) +Defined in: [attachments/client.ts:43](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/attachments/client.ts#L43) ___ @@ -71,7 +71,7 @@ Deletes all attachments associated with a single case. **Returns:** *Promise* -Defined in: [attachments/client.ts:39](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/attachments/client.ts#L39) +Defined in: [attachments/client.ts:39](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/attachments/client.ts#L39) ___ @@ -89,7 +89,7 @@ Retrieves all comments matching the search criteria. **Returns:** *Promise*<[*ICommentsResponse*](typedoc_interfaces.icommentsresponse.md)\> -Defined in: [attachments/client.ts:47](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/attachments/client.ts#L47) +Defined in: [attachments/client.ts:47](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/attachments/client.ts#L47) ___ @@ -107,7 +107,7 @@ Retrieves a single attachment for a case. **Returns:** *Promise*<{ `comment`: *string* ; `owner`: *string* ; `type`: user } & { `associationType`: AssociationType ; `created_at`: *string* ; `created_by`: { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } ; `owner`: *string* ; `pushed_at`: ``null`` \| *string* ; `pushed_by`: ``null`` \| { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } ; `updated_at`: ``null`` \| *string* ; `updated_by`: ``null`` \| { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } } & { `id`: *string* ; `version`: *string* } & { `alertId`: *string* \| *string*[] ; `index`: *string* \| *string*[] ; `owner`: *string* ; `rule`: { id: string \| null; name: string \| null; } ; `type`: alert \| generatedAlert } & { `associationType`: AssociationType ; `created_at`: *string* ; `created_by`: { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } ; `owner`: *string* ; `pushed_at`: ``null`` \| *string* ; `pushed_by`: ``null`` \| { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } ; `updated_at`: ``null`` \| *string* ; `updated_by`: ``null`` \| { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } } & { `id`: *string* ; `version`: *string* }\> -Defined in: [attachments/client.ts:59](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/attachments/client.ts#L59) +Defined in: [attachments/client.ts:59](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/attachments/client.ts#L59) ___ @@ -125,7 +125,7 @@ Gets all attachments for a single case. **Returns:** *Promise*<[*IAllCommentsResponse*](typedoc_interfaces.iallcommentsresponse.md)\> -Defined in: [attachments/client.ts:55](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/attachments/client.ts#L55) +Defined in: [attachments/client.ts:55](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/attachments/client.ts#L55) ___ @@ -143,7 +143,7 @@ Retrieves all alerts attach to a case given a single case ID **Returns:** *Promise*<{ `attached_at`: *string* ; `id`: *string* ; `index`: *string* }[]\> -Defined in: [attachments/client.ts:51](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/attachments/client.ts#L51) +Defined in: [attachments/client.ts:51](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/attachments/client.ts#L51) ___ @@ -163,4 +163,4 @@ The request must include all fields for the attachment. Even the fields that are **Returns:** *Promise*<[*ICaseResponse*](typedoc_interfaces.icaseresponse.md)\> -Defined in: [attachments/client.ts:65](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/attachments/client.ts#L65) +Defined in: [attachments/client.ts:65](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/attachments/client.ts#L65) diff --git a/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_delete.deleteallargs.md b/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_delete.deleteallargs.md index 437758a0147f28..d134c92e282a38 100644 --- a/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_delete.deleteallargs.md +++ b/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_delete.deleteallargs.md @@ -21,7 +21,7 @@ Parameters for deleting all comments of a case or sub case. The case ID to delete all attachments for -Defined in: [attachments/delete.ts:31](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/attachments/delete.ts#L31) +Defined in: [attachments/delete.ts:31](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/attachments/delete.ts#L31) ___ @@ -31,4 +31,4 @@ ___ If specified the caseID will be ignored and this value will be used to find a sub case for deleting all the attachments -Defined in: [attachments/delete.ts:35](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/attachments/delete.ts#L35) +Defined in: [attachments/delete.ts:35](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/attachments/delete.ts#L35) diff --git a/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_delete.deleteargs.md b/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_delete.deleteargs.md index 1afa5679161d99..a1c177bad8a09c 100644 --- a/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_delete.deleteargs.md +++ b/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_delete.deleteargs.md @@ -22,7 +22,7 @@ Parameters for deleting a single attachment of a case or sub case. The attachment ID to delete -Defined in: [attachments/delete.ts:49](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/attachments/delete.ts#L49) +Defined in: [attachments/delete.ts:49](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/attachments/delete.ts#L49) ___ @@ -32,7 +32,7 @@ ___ The case ID to delete an attachment from -Defined in: [attachments/delete.ts:45](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/attachments/delete.ts#L45) +Defined in: [attachments/delete.ts:45](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/attachments/delete.ts#L45) ___ @@ -42,4 +42,4 @@ ___ If specified the caseID will be ignored and this value will be used to find a sub case for deleting the attachment -Defined in: [attachments/delete.ts:53](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/attachments/delete.ts#L53) +Defined in: [attachments/delete.ts:53](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/attachments/delete.ts#L53) diff --git a/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_get.findargs.md b/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_get.findargs.md index dc0da295b26d20..dcd4deb28b687e 100644 --- a/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_get.findargs.md +++ b/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_get.findargs.md @@ -21,7 +21,7 @@ Parameters for finding attachments of a case The case ID for finding associated attachments -Defined in: [attachments/get.ts:47](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/attachments/get.ts#L47) +Defined in: [attachments/get.ts:47](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/attachments/get.ts#L47) ___ @@ -48,4 +48,4 @@ Optional parameters for filtering the returned attachments | `sortOrder` | *undefined* \| ``"desc"`` \| ``"asc"`` | | `subCaseId` | *undefined* \| *string* | -Defined in: [attachments/get.ts:51](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/attachments/get.ts#L51) +Defined in: [attachments/get.ts:51](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/attachments/get.ts#L51) diff --git a/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_get.getallalertsattachtocase.md b/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_get.getallalertsattachtocase.md index 541d1cf8f1d803..d935823054b037 100644 --- a/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_get.getallalertsattachtocase.md +++ b/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_get.getallalertsattachtocase.md @@ -18,4 +18,4 @@ The ID of the case to retrieve the alerts from -Defined in: [attachments/get.ts:87](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/attachments/get.ts#L87) +Defined in: [attachments/get.ts:87](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/attachments/get.ts#L87) diff --git a/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_get.getallargs.md b/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_get.getallargs.md index ae67f85e96fc02..9577e89b460741 100644 --- a/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_get.getallargs.md +++ b/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_get.getallargs.md @@ -22,7 +22,7 @@ Parameters for retrieving all attachments of a case The case ID to retrieve all attachments for -Defined in: [attachments/get.ts:61](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/attachments/get.ts#L61) +Defined in: [attachments/get.ts:61](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/attachments/get.ts#L61) ___ @@ -32,7 +32,7 @@ ___ Optionally include the attachments associated with a sub case -Defined in: [attachments/get.ts:65](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/attachments/get.ts#L65) +Defined in: [attachments/get.ts:65](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/attachments/get.ts#L65) ___ @@ -42,4 +42,4 @@ ___ If included the case ID will be ignored and the attachments will be retrieved from the specified ID of the sub case -Defined in: [attachments/get.ts:69](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/attachments/get.ts#L69) +Defined in: [attachments/get.ts:69](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/attachments/get.ts#L69) diff --git a/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_get.getargs.md b/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_get.getargs.md index 2fc569985f9802..5530ad8bd936e6 100644 --- a/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_get.getargs.md +++ b/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_get.getargs.md @@ -19,7 +19,7 @@ The ID of the attachment to retrieve -Defined in: [attachments/get.ts:80](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/attachments/get.ts#L80) +Defined in: [attachments/get.ts:80](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/attachments/get.ts#L80) ___ @@ -29,4 +29,4 @@ ___ The ID of the case to retrieve an attachment from -Defined in: [attachments/get.ts:76](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/attachments/get.ts#L76) +Defined in: [attachments/get.ts:76](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/attachments/get.ts#L76) diff --git a/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_update.updateargs.md b/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_update.updateargs.md index 4b2dd7b404e7a1..ce586a6bfdfbdf 100644 --- a/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_update.updateargs.md +++ b/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_update.updateargs.md @@ -22,7 +22,7 @@ Parameters for updating a single attachment The ID of the case that is associated with this attachment -Defined in: [attachments/update.ts:32](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/attachments/update.ts#L32) +Defined in: [attachments/update.ts:32](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/attachments/update.ts#L32) ___ @@ -32,7 +32,7 @@ ___ The ID of a sub case, if specified a sub case will be searched for to perform the attachment update instead of on a case -Defined in: [attachments/update.ts:40](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/attachments/update.ts#L40) +Defined in: [attachments/update.ts:40](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/attachments/update.ts#L40) ___ @@ -42,4 +42,4 @@ ___ The full attachment request with the fields updated with appropriate values -Defined in: [attachments/update.ts:36](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/attachments/update.ts#L36) +Defined in: [attachments/update.ts:36](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/attachments/update.ts#L36) diff --git a/x-pack/plugins/cases/docs/cases_client/interfaces/cases_client.casessubclient.md b/x-pack/plugins/cases/docs/cases_client/interfaces/cases_client.casessubclient.md index d86308720cb95b..52cf2fbaf1ef12 100644 --- a/x-pack/plugins/cases/docs/cases_client/interfaces/cases_client.casessubclient.md +++ b/x-pack/plugins/cases/docs/cases_client/interfaces/cases_client.casessubclient.md @@ -14,7 +14,7 @@ API for interacting with the cases entities. - [delete](cases_client.casessubclient.md#delete) - [find](cases_client.casessubclient.md#find) - [get](cases_client.casessubclient.md#get) -- [getCaseIDsByAlertID](cases_client.casessubclient.md#getcaseidsbyalertid) +- [getCasesByAlertID](cases_client.casessubclient.md#getcasesbyalertid) - [getReporters](cases_client.casessubclient.md#getreporters) - [getTags](cases_client.casessubclient.md#gettags) - [push](cases_client.casessubclient.md#push) @@ -36,7 +36,7 @@ Creates a case. **Returns:** *Promise*<[*ICaseResponse*](typedoc_interfaces.icaseresponse.md)\> -Defined in: [cases/client.ts:48](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/cases/client.ts#L48) +Defined in: [cases/client.ts:49](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/cases/client.ts#L49) ___ @@ -56,7 +56,7 @@ Delete a case and all its comments. **Returns:** *Promise* -Defined in: [cases/client.ts:72](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/cases/client.ts#L72) +Defined in: [cases/client.ts:73](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/cases/client.ts#L73) ___ @@ -76,7 +76,7 @@ If the `owner` field is left empty then all the cases that the user has access t **Returns:** *Promise*<[*ICasesFindResponse*](typedoc_interfaces.icasesfindresponse.md)\> -Defined in: [cases/client.ts:54](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/cases/client.ts#L54) +Defined in: [cases/client.ts:55](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/cases/client.ts#L55) ___ @@ -94,25 +94,25 @@ Retrieves a single case with the specified ID. **Returns:** *Promise*<[*ICaseResponse*](typedoc_interfaces.icaseresponse.md)\> -Defined in: [cases/client.ts:58](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/cases/client.ts#L58) +Defined in: [cases/client.ts:59](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/cases/client.ts#L59) ___ -### getCaseIDsByAlertID +### getCasesByAlertID -▸ **getCaseIDsByAlertID**(`params`: [*CaseIDsByAlertIDParams*](cases_get.caseidsbyalertidparams.md)): *Promise* +▸ **getCasesByAlertID**(`params`: [*CasesByAlertIDParams*](cases_get.casesbyalertidparams.md)): *Promise*<{ `id`: *string* ; `title`: *string* }[]\> -Retrieves the case IDs given a single alert ID +Retrieves the cases ID and title that have the requested alert attached to them #### Parameters | Name | Type | | :------ | :------ | -| `params` | [*CaseIDsByAlertIDParams*](cases_get.caseidsbyalertidparams.md) | +| `params` | [*CasesByAlertIDParams*](cases_get.casesbyalertidparams.md) | -**Returns:** *Promise* +**Returns:** *Promise*<{ `id`: *string* ; `title`: *string* }[]\> -Defined in: [cases/client.ts:84](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/cases/client.ts#L84) +Defined in: [cases/client.ts:85](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/cases/client.ts#L85) ___ @@ -131,7 +131,7 @@ Retrieves all the reporters across all accessible cases. **Returns:** *Promise*<{ `email`: *undefined* \| ``null`` \| *string* ; `full_name`: *undefined* \| ``null`` \| *string* ; `username`: *undefined* \| ``null`` \| *string* }[]\> -Defined in: [cases/client.ts:80](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/cases/client.ts#L80) +Defined in: [cases/client.ts:81](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/cases/client.ts#L81) ___ @@ -150,7 +150,7 @@ Retrieves all the tags across all cases the user making the request has access t **Returns:** *Promise* -Defined in: [cases/client.ts:76](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/cases/client.ts#L76) +Defined in: [cases/client.ts:77](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/cases/client.ts#L77) ___ @@ -168,7 +168,7 @@ Pushes a specific case to an external system. **Returns:** *Promise*<[*ICaseResponse*](typedoc_interfaces.icaseresponse.md)\> -Defined in: [cases/client.ts:62](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/cases/client.ts#L62) +Defined in: [cases/client.ts:63](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/cases/client.ts#L63) ___ @@ -186,4 +186,4 @@ Update the specified cases with the passed in values. **Returns:** *Promise*<[*ICasesResponse*](typedoc_interfaces.icasesresponse.md)\> -Defined in: [cases/client.ts:66](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/cases/client.ts#L66) +Defined in: [cases/client.ts:67](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/cases/client.ts#L67) diff --git a/x-pack/plugins/cases/docs/cases_client/interfaces/cases_get.caseidsbyalertidparams.md b/x-pack/plugins/cases/docs/cases_client/interfaces/cases_get.caseidsbyalertidparams.md deleted file mode 100644 index 274b7a8f2d4314..00000000000000 --- a/x-pack/plugins/cases/docs/cases_client/interfaces/cases_get.caseidsbyalertidparams.md +++ /dev/null @@ -1,40 +0,0 @@ -[Cases Client API Interface](../cases_client_api.md) / [cases/get](../modules/cases_get.md) / CaseIDsByAlertIDParams - -# Interface: CaseIDsByAlertIDParams - -[cases/get](../modules/cases_get.md).CaseIDsByAlertIDParams - -Parameters for finding cases IDs using an alert ID - -## Table of contents - -### Properties - -- [alertID](cases_get.caseidsbyalertidparams.md#alertid) -- [options](cases_get.caseidsbyalertidparams.md#options) - -## Properties - -### alertID - -• **alertID**: *string* - -The alert ID to search for - -Defined in: [cases/get.ts:42](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/cases/get.ts#L42) - -___ - -### options - -• **options**: *object* - -The filtering options when searching for associated cases. - -#### Type declaration - -| Name | Type | -| :------ | :------ | -| `owner` | *undefined* \| *string* \| *string*[] | - -Defined in: [cases/get.ts:46](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/cases/get.ts#L46) diff --git a/x-pack/plugins/cases/docs/cases_client/interfaces/cases_get.casesbyalertidparams.md b/x-pack/plugins/cases/docs/cases_client/interfaces/cases_get.casesbyalertidparams.md new file mode 100644 index 00000000000000..4992ed035721be --- /dev/null +++ b/x-pack/plugins/cases/docs/cases_client/interfaces/cases_get.casesbyalertidparams.md @@ -0,0 +1,40 @@ +[Cases Client API Interface](../cases_client_api.md) / [cases/get](../modules/cases_get.md) / CasesByAlertIDParams + +# Interface: CasesByAlertIDParams + +[cases/get](../modules/cases_get.md).CasesByAlertIDParams + +Parameters for finding cases IDs using an alert ID + +## Table of contents + +### Properties + +- [alertID](cases_get.casesbyalertidparams.md#alertid) +- [options](cases_get.casesbyalertidparams.md#options) + +## Properties + +### alertID + +• **alertID**: *string* + +The alert ID to search for + +Defined in: [cases/get.ts:44](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/cases/get.ts#L44) + +___ + +### options + +• **options**: *object* + +The filtering options when searching for associated cases. + +#### Type declaration + +| Name | Type | +| :------ | :------ | +| `owner` | *undefined* \| *string* \| *string*[] | + +Defined in: [cases/get.ts:48](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/cases/get.ts#L48) diff --git a/x-pack/plugins/cases/docs/cases_client/interfaces/cases_get.getparams.md b/x-pack/plugins/cases/docs/cases_client/interfaces/cases_get.getparams.md index a528b7ce6256d1..a4dfc7301e5434 100644 --- a/x-pack/plugins/cases/docs/cases_client/interfaces/cases_get.getparams.md +++ b/x-pack/plugins/cases/docs/cases_client/interfaces/cases_get.getparams.md @@ -22,7 +22,7 @@ The parameters for retrieving a case Case ID -Defined in: [cases/get.ts:110](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/cases/get.ts#L110) +Defined in: [cases/get.ts:145](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/cases/get.ts#L145) ___ @@ -32,7 +32,7 @@ ___ Whether to include the attachments for a case in the response -Defined in: [cases/get.ts:114](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/cases/get.ts#L114) +Defined in: [cases/get.ts:149](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/cases/get.ts#L149) ___ @@ -42,4 +42,4 @@ ___ Whether to include the attachments for all children of a case in the response -Defined in: [cases/get.ts:118](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/cases/get.ts#L118) +Defined in: [cases/get.ts:153](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/cases/get.ts#L153) diff --git a/x-pack/plugins/cases/docs/cases_client/interfaces/cases_push.pushparams.md b/x-pack/plugins/cases/docs/cases_client/interfaces/cases_push.pushparams.md index 979e30cb31d3f1..0ed510700af8af 100644 --- a/x-pack/plugins/cases/docs/cases_client/interfaces/cases_push.pushparams.md +++ b/x-pack/plugins/cases/docs/cases_client/interfaces/cases_push.pushparams.md @@ -21,7 +21,7 @@ Parameters for pushing a case to an external system The ID of a case -Defined in: [cases/push.ts:53](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/cases/push.ts#L53) +Defined in: [cases/push.ts:53](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/cases/push.ts#L53) ___ @@ -31,4 +31,4 @@ ___ The ID of an external system to push to -Defined in: [cases/push.ts:57](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/cases/push.ts#L57) +Defined in: [cases/push.ts:57](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/cases/push.ts#L57) diff --git a/x-pack/plugins/cases/docs/cases_client/interfaces/configure_client.configuresubclient.md b/x-pack/plugins/cases/docs/cases_client/interfaces/configure_client.configuresubclient.md index cf69b101ce2bce..98a6c3a2fcbbf3 100644 --- a/x-pack/plugins/cases/docs/cases_client/interfaces/configure_client.configuresubclient.md +++ b/x-pack/plugins/cases/docs/cases_client/interfaces/configure_client.configuresubclient.md @@ -31,7 +31,7 @@ Creates a configuration if one does not already exist. If one exists it is delet **Returns:** *Promise*<[*ICasesConfigureResponse*](typedoc_interfaces.icasesconfigureresponse.md)\> -Defined in: [configure/client.ts:98](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/configure/client.ts#L98) +Defined in: [configure/client.ts:98](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/configure/client.ts#L98) ___ @@ -50,7 +50,7 @@ Retrieves the external connector configuration for a particular case owner. **Returns:** *Promise*<{} \| [*ICasesConfigureResponse*](typedoc_interfaces.icasesconfigureresponse.md)\> -Defined in: [configure/client.ts:80](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/configure/client.ts#L80) +Defined in: [configure/client.ts:80](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/configure/client.ts#L80) ___ @@ -62,7 +62,7 @@ Retrieves the valid external connectors supported by the cases plugin. **Returns:** *Promise* -Defined in: [configure/client.ts:84](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/configure/client.ts#L84) +Defined in: [configure/client.ts:84](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/configure/client.ts#L84) ___ @@ -81,4 +81,4 @@ Updates a particular configuration with new values. **Returns:** *Promise*<[*ICasesConfigureResponse*](typedoc_interfaces.icasesconfigureresponse.md)\> -Defined in: [configure/client.ts:91](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/configure/client.ts#L91) +Defined in: [configure/client.ts:91](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/configure/client.ts#L91) diff --git a/x-pack/plugins/cases/docs/cases_client/interfaces/stats_client.statssubclient.md b/x-pack/plugins/cases/docs/cases_client/interfaces/stats_client.statssubclient.md index 761b34b5205ecd..cc0f30055597d2 100644 --- a/x-pack/plugins/cases/docs/cases_client/interfaces/stats_client.statssubclient.md +++ b/x-pack/plugins/cases/docs/cases_client/interfaces/stats_client.statssubclient.md @@ -29,4 +29,4 @@ Retrieves the total number of open, closed, and in-progress cases. **Returns:** *Promise*<{ `count_closed_cases`: *number* ; `count_in_progress_cases`: *number* ; `count_open_cases`: *number* }\> -Defined in: [stats/client.ts:34](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/stats/client.ts#L34) +Defined in: [stats/client.ts:34](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/stats/client.ts#L34) diff --git a/x-pack/plugins/cases/docs/cases_client/interfaces/sub_cases_client.subcasesclient.md b/x-pack/plugins/cases/docs/cases_client/interfaces/sub_cases_client.subcasesclient.md index c83c68620e8acd..5c0369709c0f0d 100644 --- a/x-pack/plugins/cases/docs/cases_client/interfaces/sub_cases_client.subcasesclient.md +++ b/x-pack/plugins/cases/docs/cases_client/interfaces/sub_cases_client.subcasesclient.md @@ -31,7 +31,7 @@ Deletes the specified entities and their attachments. **Returns:** *Promise* -Defined in: [sub_cases/client.ts:68](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/sub_cases/client.ts#L68) +Defined in: [sub_cases/client.ts:68](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/sub_cases/client.ts#L68) ___ @@ -49,7 +49,7 @@ Retrieves the sub cases matching the search criteria. **Returns:** *Promise*<[*ISubCasesFindResponse*](typedoc_interfaces.isubcasesfindresponse.md)\> -Defined in: [sub_cases/client.ts:72](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/sub_cases/client.ts#L72) +Defined in: [sub_cases/client.ts:72](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/sub_cases/client.ts#L72) ___ @@ -67,7 +67,7 @@ Retrieves a single sub case. **Returns:** *Promise*<[*ISubCaseResponse*](typedoc_interfaces.isubcaseresponse.md)\> -Defined in: [sub_cases/client.ts:76](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/sub_cases/client.ts#L76) +Defined in: [sub_cases/client.ts:76](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/sub_cases/client.ts#L76) ___ @@ -86,4 +86,4 @@ Updates the specified sub cases to the new values included in the request. **Returns:** *Promise*<[*ISubCasesResponse*](typedoc_interfaces.isubcasesresponse.md)\> -Defined in: [sub_cases/client.ts:80](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/sub_cases/client.ts#L80) +Defined in: [sub_cases/client.ts:80](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/sub_cases/client.ts#L80) diff --git a/x-pack/plugins/cases/docs/cases_client/interfaces/user_actions_client.useractionget.md b/x-pack/plugins/cases/docs/cases_client/interfaces/user_actions_client.useractionget.md index f992a4116c800a..5f0cc89239fd8a 100644 --- a/x-pack/plugins/cases/docs/cases_client/interfaces/user_actions_client.useractionget.md +++ b/x-pack/plugins/cases/docs/cases_client/interfaces/user_actions_client.useractionget.md @@ -21,7 +21,7 @@ Parameters for retrieving user actions for a particular case The ID of the case -Defined in: [user_actions/client.ts:19](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/user_actions/client.ts#L19) +Defined in: [user_actions/client.ts:19](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/user_actions/client.ts#L19) ___ @@ -31,4 +31,4 @@ ___ If specified then a sub case will be used for finding all the user actions -Defined in: [user_actions/client.ts:23](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/user_actions/client.ts#L23) +Defined in: [user_actions/client.ts:23](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/user_actions/client.ts#L23) diff --git a/x-pack/plugins/cases/docs/cases_client/interfaces/user_actions_client.useractionssubclient.md b/x-pack/plugins/cases/docs/cases_client/interfaces/user_actions_client.useractionssubclient.md index e838a72159befa..df2641adf5a8c4 100644 --- a/x-pack/plugins/cases/docs/cases_client/interfaces/user_actions_client.useractionssubclient.md +++ b/x-pack/plugins/cases/docs/cases_client/interfaces/user_actions_client.useractionssubclient.md @@ -28,4 +28,4 @@ Retrieves all user actions for a particular case. **Returns:** *Promise*<[*ICaseUserActionsResponse*](typedoc_interfaces.icaseuseractionsresponse.md)\> -Defined in: [user_actions/client.ts:33](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/user_actions/client.ts#L33) +Defined in: [user_actions/client.ts:33](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/user_actions/client.ts#L33) diff --git a/x-pack/plugins/cases/docs/cases_client/modules/cases_get.md b/x-pack/plugins/cases/docs/cases_client/modules/cases_get.md index acfa0b918aa9a4..d4ca13501294a1 100644 --- a/x-pack/plugins/cases/docs/cases_client/modules/cases_get.md +++ b/x-pack/plugins/cases/docs/cases_client/modules/cases_get.md @@ -6,7 +6,7 @@ ### Interfaces -- [CaseIDsByAlertIDParams](../interfaces/cases_get.caseidsbyalertidparams.md) +- [CasesByAlertIDParams](../interfaces/cases_get.casesbyalertidparams.md) - [GetParams](../interfaces/cases_get.getparams.md) ### Functions @@ -31,7 +31,7 @@ Retrieves the reporters from all the cases. **Returns:** *Promise* -Defined in: [cases/get.ts:255](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/cases/get.ts#L255) +Defined in: [cases/get.ts:290](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/cases/get.ts#L290) ___ @@ -50,4 +50,4 @@ Retrieves the tags from all the cases. **Returns:** *Promise* -Defined in: [cases/get.ts:205](https://github.com/jonathan-buttner/kibana/blob/0e98e105663/x-pack/plugins/cases/server/client/cases/get.ts#L205) +Defined in: [cases/get.ts:240](https://github.com/jonathan-buttner/kibana/blob/b65ed845242/x-pack/plugins/cases/server/client/cases/get.ts#L240) diff --git a/x-pack/plugins/cases/server/client/cases/client.ts b/x-pack/plugins/cases/server/client/cases/client.ts index 8a17ff9bd0ec11..0932308c2e269a 100644 --- a/x-pack/plugins/cases/server/client/cases/client.ts +++ b/x-pack/plugins/cases/server/client/cases/client.ts @@ -12,6 +12,7 @@ import { User, AllTagsFindRequest, AllReportersFindRequest, + CasesByAlertId, } from '../../../common'; import { CasesClient } from '../client'; import { CasesClientInternal } from '../client_internal'; @@ -28,9 +29,9 @@ import { create } from './create'; import { deleteCases } from './delete'; import { find } from './find'; import { - CaseIDsByAlertIDParams, + CasesByAlertIDParams, get, - getCaseIDsByAlertID, + getCasesByAlertID, GetParams, getReporters, getTags, @@ -79,9 +80,9 @@ export interface CasesSubClient { */ getReporters(params: AllReportersFindRequest): Promise; /** - * Retrieves the case IDs given a single alert ID + * Retrieves the cases ID and title that have the requested alert attached to them */ - getCaseIDsByAlertID(params: CaseIDsByAlertIDParams): Promise; + getCasesByAlertID(params: CasesByAlertIDParams): Promise; } /** @@ -103,8 +104,7 @@ export const createCasesSubClient = ( delete: (ids: string[]) => deleteCases(ids, clientArgs), getTags: (params: AllTagsFindRequest) => getTags(params, clientArgs), getReporters: (params: AllReportersFindRequest) => getReporters(params, clientArgs), - getCaseIDsByAlertID: (params: CaseIDsByAlertIDParams) => - getCaseIDsByAlertID(params, clientArgs), + getCasesByAlertID: (params: CasesByAlertIDParams) => getCasesByAlertID(params, clientArgs), }; return Object.freeze(casesSubClient); diff --git a/x-pack/plugins/cases/server/client/cases/get.ts b/x-pack/plugins/cases/server/client/cases/get.ts index f908a8f091ef3b..3df1891391c75e 100644 --- a/x-pack/plugins/cases/server/client/cases/get.ts +++ b/x-pack/plugins/cases/server/client/cases/get.ts @@ -25,6 +25,8 @@ import { CasesByAlertIDRequest, CasesByAlertIDRequestRt, ENABLE_CASE_CONNECTOR, + CasesByAlertId, + CasesByAlertIdRt, } from '../../../common'; import { countAlertsForID, createCaseError, flattenCaseSavedObject } from '../../common'; import { CasesClientArgs } from '..'; @@ -35,7 +37,7 @@ import { CasesService } from '../../services'; /** * Parameters for finding cases IDs using an alert ID */ -export interface CaseIDsByAlertIDParams { +export interface CasesByAlertIDParams { /** * The alert ID to search for */ @@ -47,15 +49,15 @@ export interface CaseIDsByAlertIDParams { } /** - * Case Client wrapper function for retrieving the case IDs that have a particular alert ID + * Case Client wrapper function for retrieving the case IDs and titles that have a particular alert ID * attached to them. This handles RBAC before calling the saved object API. * * @ignore */ -export const getCaseIDsByAlertID = async ( - { alertID, options }: CaseIDsByAlertIDParams, +export const getCasesByAlertID = async ( + { alertID, options }: CasesByAlertIDParams, clientArgs: CasesClientArgs -): Promise => { +): Promise => { const { unsecuredSavedObjectsClient, caseService, logger, authorization } = clientArgs; try { @@ -75,12 +77,15 @@ export const getCaseIDsByAlertID = async ( Operations.getCaseIDsByAlertID.savedObjectType ); + // This will likely only return one comment saved object, the response aggregation will contain + // the keys we need to retrieve the cases const commentsWithAlert = await caseService.getCaseIdsByAlertId({ unsecuredSavedObjectsClient, alertId: alertID, filter, }); + // make sure the comments returned have the right owner ensureSavedObjectsAreAuthorized( commentsWithAlert.saved_objects.map((comment) => ({ owner: comment.attributes.owner, @@ -88,7 +93,37 @@ export const getCaseIDsByAlertID = async ( })) ); - return CasesService.getCaseIDsFromAlertAggs(commentsWithAlert); + const caseIds = CasesService.getCaseIDsFromAlertAggs(commentsWithAlert); + + // if we didn't find any case IDs then let's return early because there's nothing to request + if (caseIds.length <= 0) { + return []; + } + + const casesInfo = await caseService.getCases({ + unsecuredSavedObjectsClient, + caseIds, + }); + + // if there was an error retrieving one of the cases (maybe it was deleted, but the alert comment still existed) + // just ignore it + const validCasesInfo = casesInfo.saved_objects.filter( + (caseInfo) => caseInfo.error === undefined + ); + + ensureSavedObjectsAreAuthorized( + validCasesInfo.map((caseInfo) => ({ + owner: caseInfo.attributes.owner, + id: caseInfo.id, + })) + ); + + return CasesByAlertIdRt.encode( + validCasesInfo.map((caseInfo) => ({ + id: caseInfo.id, + title: caseInfo.attributes.title, + })) + ); } catch (error) { throw createCaseError({ message: `Failed to get case IDs using alert ID: ${alertID} options: ${JSON.stringify( diff --git a/x-pack/plugins/cases/server/client/mocks.ts b/x-pack/plugins/cases/server/client/mocks.ts index f6a36369c0b033..f7c27166ee9104 100644 --- a/x-pack/plugins/cases/server/client/mocks.ts +++ b/x-pack/plugins/cases/server/client/mocks.ts @@ -28,7 +28,7 @@ const createCasesSubClientMock = (): CasesSubClientMock => { delete: jest.fn(), getTags: jest.fn(), getReporters: jest.fn(), - getCaseIDsByAlertID: jest.fn(), + getCasesByAlertID: jest.fn(), }; }; diff --git a/x-pack/plugins/cases/server/routes/api/cases/alerts/get_cases.ts b/x-pack/plugins/cases/server/routes/api/cases/alerts/get_cases.ts index f4b53a921ef881..3471c1dec62088 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/alerts/get_cases.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/alerts/get_cases.ts @@ -12,7 +12,7 @@ import { RouteDeps } from '../../types'; import { escapeHatch, wrapError } from '../../utils'; import { CASE_ALERTS_URL, CasesByAlertIDRequest } from '../../../../../common'; -export function initGetCaseIdsByAlertIdApi({ router, logger }: RouteDeps) { +export function initGetCasesByAlertIdApi({ router, logger }: RouteDeps) { router.get( { path: CASE_ALERTS_URL, @@ -33,7 +33,7 @@ export function initGetCaseIdsByAlertIdApi({ router, logger }: RouteDeps) { const options = request.query as CasesByAlertIDRequest; return response.ok({ - body: await casesClient.cases.getCaseIDsByAlertID({ alertID, options }), + body: await casesClient.cases.getCasesByAlertID({ alertID, options }), }); } catch (error) { logger.error( diff --git a/x-pack/plugins/cases/server/routes/api/index.ts b/x-pack/plugins/cases/server/routes/api/index.ts index 011464a73396f2..266ea9ddb0f18c 100644 --- a/x-pack/plugins/cases/server/routes/api/index.ts +++ b/x-pack/plugins/cases/server/routes/api/index.ts @@ -38,7 +38,7 @@ import { initPatchSubCasesApi } from './sub_case/patch_sub_cases'; import { initFindSubCasesApi } from './sub_case/find_sub_cases'; import { initDeleteSubCasesApi } from './sub_case/delete_sub_cases'; import { ENABLE_CASE_CONNECTOR } from '../../../common'; -import { initGetCaseIdsByAlertIdApi } from './cases/alerts/get_cases'; +import { initGetCasesByAlertIdApi } from './cases/alerts/get_cases'; import { initGetAllAlertsAttachToCaseApi } from './comments/get_alerts'; /** @@ -89,6 +89,6 @@ export function initCaseApi(deps: RouteDeps) { // Tags initGetTagsApi(deps); // Alerts - initGetCaseIdsByAlertIdApi(deps); + initGetCasesByAlertIdApi(deps); initGetAllAlertsAttachToCaseApi(deps); } diff --git a/x-pack/plugins/security_solution/public/detections/components/host_isolation/index.tsx b/x-pack/plugins/security_solution/public/detections/components/host_isolation/index.tsx index 42d53f97d478b4..ef311a7ca43b17 100644 --- a/x-pack/plugins/security_solution/public/detections/components/host_isolation/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/host_isolation/index.tsx @@ -41,27 +41,27 @@ export const HostIsolationPanel = React.memo( return findAlertId ? findAlertId[0] : ''; }, [details]); - const { caseIds } = useCasesFromAlerts({ alertId }); + const { casesInfo } = useCasesFromAlerts({ alertId }); // Cases related components to be used in both isolate and unisolate actions from the alert details flyout entry point - const caseCount: number = useMemo(() => caseIds.length, [caseIds]); + const caseCount: number = useMemo(() => casesInfo.length, [casesInfo]); const casesList = useMemo( () => - caseIds.map((id, index) => { + casesInfo.map((caseInfo, index) => { return ( -
  • - +
  • +
  • ); }), - [caseIds] + [casesInfo] ); const associatedCases = useMemo(() => { @@ -90,7 +90,7 @@ export const HostIsolationPanel = React.memo( endpointId={endpointId} hostName={hostName} cases={associatedCases} - caseIds={caseIds} + casesInfo={casesInfo} cancelCallback={cancelCallback} /> ) : ( @@ -98,7 +98,7 @@ export const HostIsolationPanel = React.memo( endpointId={endpointId} hostName={hostName} cases={associatedCases} - caseIds={caseIds} + casesInfo={casesInfo} cancelCallback={cancelCallback} /> ); diff --git a/x-pack/plugins/security_solution/public/detections/components/host_isolation/isolate.tsx b/x-pack/plugins/security_solution/public/detections/components/host_isolation/isolate.tsx index afc2951e26e1fe..b209c2f9c6e24e 100644 --- a/x-pack/plugins/security_solution/public/detections/components/host_isolation/isolate.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/host_isolation/isolate.tsx @@ -15,24 +15,29 @@ import { EndpointIsolateForm, EndpointIsolateSuccess, } from '../../../common/components/endpoint/host_isolation'; +import { CasesFromAlertsResponse } from '../../containers/detection_engine/alerts/types'; export const IsolateHost = React.memo( ({ endpointId, hostName, cases, - caseIds, + casesInfo, cancelCallback, }: { endpointId: string; hostName: string; cases: ReactNode; - caseIds: string[]; + casesInfo: CasesFromAlertsResponse; cancelCallback: () => void; }) => { const [comment, setComment] = useState(''); const [isIsolated, setIsIsolated] = useState(false); + const caseIds: string[] = casesInfo.map((caseInfo): string => { + return caseInfo.id; + }); + const { loading, isolateHost } = useHostIsolation({ endpointId, comment, caseIds }); const confirmHostIsolation = useCallback(async () => { @@ -47,7 +52,7 @@ export const IsolateHost = React.memo( [] ); - const caseCount: number = useMemo(() => caseIds.length, [caseIds]); + const caseCount: number = useMemo(() => casesInfo.length, [casesInfo]); const hostIsolatedSuccess = useMemo(() => { return ( diff --git a/x-pack/plugins/security_solution/public/detections/components/host_isolation/unisolate.tsx b/x-pack/plugins/security_solution/public/detections/components/host_isolation/unisolate.tsx index 71f7cadda2f68c..ad8e8eaddb39e3 100644 --- a/x-pack/plugins/security_solution/public/detections/components/host_isolation/unisolate.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/host_isolation/unisolate.tsx @@ -15,24 +15,29 @@ import { EndpointUnisolateForm, } from '../../../common/components/endpoint/host_isolation'; import { useHostUnisolation } from '../../containers/detection_engine/alerts/use_host_unisolation'; +import { CasesFromAlertsResponse } from '../../containers/detection_engine/alerts/types'; export const UnisolateHost = React.memo( ({ endpointId, hostName, cases, - caseIds, + casesInfo, cancelCallback, }: { endpointId: string; hostName: string; cases: ReactNode; - caseIds: string[]; + casesInfo: CasesFromAlertsResponse; cancelCallback: () => void; }) => { const [comment, setComment] = useState(''); const [isUnIsolated, setIsUnIsolated] = useState(false); + const caseIds: string[] = casesInfo.map((caseInfo): string => { + return caseInfo.id; + }); + const { loading, unIsolateHost } = useHostUnisolation({ endpointId, comment, caseIds }); const confirmHostUnIsolation = useCallback(async () => { @@ -47,7 +52,7 @@ export const UnisolateHost = React.memo( [] ); - const caseCount: number = useMemo(() => caseIds.length, [caseIds]); + const caseCount: number = useMemo(() => casesInfo.length, [casesInfo]); const hostUnisolatedSuccess = useMemo(() => { return ( diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/mock.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/mock.ts index 69358958a395cd..e4bddfba8278bb 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/mock.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/mock.ts @@ -1046,6 +1046,6 @@ export const mockHostIsolation: HostIsolationResponse = { }; export const mockCaseIdsFromAlertId: CasesFromAlertsResponse = [ - '818601a0-b26b-11eb-8759-6b318e8cf4bc', - '8a774850-b26b-11eb-8759-6b318e8cf4bc', + { id: '818601a0-b26b-11eb-8759-6b318e8cf4bc', title: 'Case 1' }, + { id: '8a774850-b26b-11eb-8759-6b318e8cf4bc', title: 'Case 2' }, ]; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/types.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/types.ts index 52b477d95076b6..54d4b6fdcbafdb 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/types.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/types.ts @@ -48,7 +48,7 @@ export interface AlertsIndex { index_mapping_outdated: boolean; } -export type CasesFromAlertsResponse = string[]; +export type CasesFromAlertsResponse = Array<{ id: string; title: string }>; export interface Privilege { username: string; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_cases_from_alerts.test.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_cases_from_alerts.test.tsx index 0867fb001051a1..00aa7c9baa9aca 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_cases_from_alerts.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_cases_from_alerts.test.tsx @@ -35,7 +35,7 @@ describe('useCasesFromAlerts hook', () => { expect(spyOnCases).toHaveBeenCalledTimes(1); expect(result.current).toEqual({ loading: false, - caseIds: mockCaseIdsFromAlertId, + casesInfo: mockCaseIdsFromAlertId, }); }); }); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_cases_from_alerts.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_cases_from_alerts.tsx index 85b80a588e88d2..eeb7968d6b2f27 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_cases_from_alerts.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_cases_from_alerts.tsx @@ -15,7 +15,7 @@ import { CasesFromAlertsResponse } from './types'; interface CasesFromAlertsStatus { loading: boolean; - caseIds: CasesFromAlertsResponse; + casesInfo: CasesFromAlertsResponse; } export const useCasesFromAlerts = ({ alertId }: { alertId: string }): CasesFromAlertsStatus => { @@ -48,5 +48,5 @@ export const useCasesFromAlerts = ({ alertId }: { alertId: string }): CasesFromA isMounted = false; }; }, [alertId, addError]); - return { loading, caseIds: cases }; + return { loading, casesInfo: cases }; }; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts index c54c12981c7710..50fe2ffe2cea99 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts @@ -10,6 +10,7 @@ import { RequestHandler } from 'src/core/server'; import uuid from 'uuid'; import { TypeOf } from '@kbn/config-schema'; import { CommentType } from '../../../../../cases/common'; +import { CasesByAlertId } from '../../../../../cases/common/api/cases/case'; import { HostIsolationRequestSchema } from '../../../../common/endpoint/schema/actions'; import { ISOLATE_HOST_ROUTE, UNISOLATE_HOST_ROUTE } from '../../../../common/endpoint/constants'; import { AGENT_ACTIONS_INDEX } from '../../../../../fleet/common'; @@ -103,12 +104,17 @@ export const isolationRequestHandler = function ( let caseIDs: string[] = req.body.case_ids?.slice() || []; if (req.body.alert_ids && req.body.alert_ids.length > 0) { const newIDs: string[][] = await Promise.all( - req.body.alert_ids.map(async (a: string) => - (await endpointContext.service.getCasesClient(req)).cases.getCaseIDsByAlertID({ + req.body.alert_ids.map(async (a: string) => { + const cases: CasesByAlertId = await ( + await endpointContext.service.getCasesClient(req) + ).cases.getCasesByAlertID({ alertID: a, options: { owner: APP_ID }, - }) - ) + }); + return cases.map((caseInfo): string => { + return caseInfo.id; + }); + }) ); caseIDs = caseIDs.concat(...newIDs); } diff --git a/x-pack/test/case_api_integration/common/lib/utils.ts b/x-pack/test/case_api_integration/common/lib/utils.ts index 63be1736405fc1..921589b2341dd8 100644 --- a/x-pack/test/case_api_integration/common/lib/utils.ts +++ b/x-pack/test/case_api_integration/common/lib/utils.ts @@ -46,6 +46,7 @@ import { CasesConfigurationsResponse, CaseUserActionsResponse, AlertResponse, + CasesByAlertId, } from '../../../../plugins/cases/common/api'; import { getPostCaseRequest, postCollectionReq, postCommentGenAlertReq } from './mock'; import { getCaseUserActionUrl, getSubCasesUrl } from '../../../../plugins/cases/common/api/helpers'; @@ -1017,7 +1018,7 @@ export const findCases = async ({ return res; }; -export const getCaseIDsByAlert = async ({ +export const getCasesByAlert = async ({ supertest, alertID, query = {}, @@ -1029,7 +1030,7 @@ export const getCaseIDsByAlert = async ({ query?: Record; expectedHttpCode?: number; auth?: { user: User; space: string | null }; -}): Promise => { +}): Promise => { const { body: res } = await supertest .get(`${getSpaceUrlPrefix(auth.space)}${CASES_URL}/alerts/${alertID}`) .auth(auth.user.username, auth.user.password) diff --git a/x-pack/test/case_api_integration/common/lib/validation.ts b/x-pack/test/case_api_integration/common/lib/validation.ts new file mode 100644 index 00000000000000..8b1c8ca1241493 --- /dev/null +++ b/x-pack/test/case_api_integration/common/lib/validation.ts @@ -0,0 +1,27 @@ +/* + * 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 expect from '@kbn/expect'; + +import { CaseResponse, CasesByAlertId } from '../../../../plugins/cases/common'; + +/** + * Ensure that the result of the alerts API request matches with the cases created for the test. + */ +export function validateCasesFromAlertIDResponse( + casesFromAPIResponse: CasesByAlertId, + createdCasesForTest: CaseResponse[] +) { + const idToTitle = new Map( + createdCasesForTest.map((caseInfo) => [caseInfo.id, caseInfo.title]) + ); + + for (const apiResCase of casesFromAPIResponse) { + // check that the title in the api response matches the title in the map from the created cases + expect(apiResCase.title).to.be(idToTitle.get(apiResCase.id)); + } +} diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/common/alerts/get_cases.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/common/alerts/get_cases.ts index e34f879e3aff84..136e52d08f46ab 100644 --- a/x-pack/test/case_api_integration/security_and_spaces/tests/common/alerts/get_cases.ts +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/common/alerts/get_cases.ts @@ -13,9 +13,10 @@ import { getPostCaseRequest, postCommentAlertReq } from '../../../../common/lib/ import { createCase, createComment, - getCaseIDsByAlert, + getCasesByAlert, deleteAllCaseItems, } from '../../../../common/lib/utils'; +import { validateCasesFromAlertIDResponse } from '../../../../common/lib/validation'; import { CaseResponse } from '../../../../../../plugins/cases/common'; import { globalRead, @@ -41,9 +42,9 @@ export default ({ getService }: FtrProviderContext): void => { it('should return all cases with the same alert ID attached to them', async () => { const [case1, case2, case3] = await Promise.all([ - createCase(supertest, getPostCaseRequest()), - createCase(supertest, getPostCaseRequest()), - createCase(supertest, getPostCaseRequest()), + createCase(supertest, getPostCaseRequest({ title: 'a' })), + createCase(supertest, getPostCaseRequest({ title: 'b' })), + createCase(supertest, getPostCaseRequest({ title: 'c' })), ]); await Promise.all([ @@ -52,12 +53,10 @@ export default ({ getService }: FtrProviderContext): void => { createComment({ supertest, caseId: case3.id, params: postCommentAlertReq }), ]); - const caseIDsWithAlert = await getCaseIDsByAlert({ supertest, alertID: 'test-id' }); + const caseIDsWithAlert = await getCasesByAlert({ supertest, alertID: 'test-id' }); expect(caseIDsWithAlert.length).to.eql(3); - expect(caseIDsWithAlert).to.contain(case1.id); - expect(caseIDsWithAlert).to.contain(case2.id); - expect(caseIDsWithAlert).to.contain(case3.id); + validateCasesFromAlertIDResponse(caseIDsWithAlert, [case1, case2, case3]); }); it('should return all cases with the same alert ID when more than 100 cases', async () => { @@ -80,13 +79,11 @@ export default ({ getService }: FtrProviderContext): void => { await Promise.all(commentPromises); - const caseIDsWithAlert = await getCaseIDsByAlert({ supertest, alertID: 'test-id' }); + const caseIDsWithAlert = await getCasesByAlert({ supertest, alertID: 'test-id' }); expect(caseIDsWithAlert.length).to.eql(numCases); - for (const caseInfo of cases) { - expect(caseIDsWithAlert).to.contain(caseInfo.id); - } + validateCasesFromAlertIDResponse(caseIDsWithAlert, cases); }); it('should return no cases when the alert ID is not found', async () => { @@ -102,7 +99,7 @@ export default ({ getService }: FtrProviderContext): void => { createComment({ supertest, caseId: case3.id, params: postCommentAlertReq }), ]); - const caseIDsWithAlert = await getCaseIDsByAlert({ supertest, alertID: 'test-id100' }); + const caseIDsWithAlert = await getCasesByAlert({ supertest, alertID: 'test-id100' }); expect(caseIDsWithAlert.length).to.eql(0); }); @@ -120,7 +117,7 @@ export default ({ getService }: FtrProviderContext): void => { createComment({ supertest, caseId: case3.id, params: postCommentAlertReq }), ]); - const caseIDsWithAlert = await getCaseIDsByAlert({ + const caseIDsWithAlert = await getCasesByAlert({ supertest, alertID: 'test-id', query: { owner: 'not-real' }, @@ -137,7 +134,7 @@ export default ({ getService }: FtrProviderContext): void => { describe('rbac', () => { const supertestWithoutAuth = getService('supertestWithoutAuth'); - it('should return the correct case IDs', async () => { + it('should return the correct cases info', async () => { const secOnlyAuth = { user: secOnly, space: 'space1' }; const obsOnlyAuth = { user: obsOnly, space: 'space1' }; @@ -176,20 +173,20 @@ export default ({ getService }: FtrProviderContext): void => { for (const scenario of [ { user: globalRead, - caseIDs: [case1.id, case2.id, case3.id], + cases: [case1, case2, case3], }, { user: superUser, - caseIDs: [case1.id, case2.id, case3.id], + cases: [case1, case2, case3], }, - { user: secOnlyRead, caseIDs: [case1.id, case2.id] }, - { user: obsOnlyRead, caseIDs: [case3.id] }, + { user: secOnlyRead, cases: [case1, case2] }, + { user: obsOnlyRead, cases: [case3] }, { user: obsSecRead, - caseIDs: [case1.id, case2.id, case3.id], + cases: [case1, case2, case3], }, ]) { - const res = await getCaseIDsByAlert({ + const res = await getCasesByAlert({ supertest: supertestWithoutAuth, // cast because the official type is string | string[] but the ids will always be a single value in the tests alertID: postCommentAlertReq.alertId as string, @@ -198,10 +195,9 @@ export default ({ getService }: FtrProviderContext): void => { space: 'space1', }, }); - expect(res.length).to.eql(scenario.caseIDs.length); - for (const caseID of scenario.caseIDs) { - expect(res).to.contain(caseID); - } + expect(res.length).to.eql(scenario.cases.length); + + validateCasesFromAlertIDResponse(res, scenario.cases); } }); @@ -224,7 +220,7 @@ export default ({ getService }: FtrProviderContext): void => { auth: { user: superUser, space: scenario.space }, }); - await getCaseIDsByAlert({ + await getCasesByAlert({ supertest: supertestWithoutAuth, alertID: postCommentAlertReq.alertId as string, auth: { user: scenario.user, space: scenario.space }, @@ -260,17 +256,17 @@ export default ({ getService }: FtrProviderContext): void => { }), ]); - const res = await getCaseIDsByAlert({ + const res = await getCasesByAlert({ supertest: supertestWithoutAuth, alertID: postCommentAlertReq.alertId as string, auth, query: { owner: 'securitySolutionFixture' }, }); - expect(res).to.eql([case1.id]); + expect(res).to.eql([{ id: case1.id, title: case1.title }]); }); - it('should return the correct case IDs when the owner query parameter contains unprivileged values', async () => { + it('should return the correct cases info when the owner query parameter contains unprivileged values', async () => { const auth = { user: obsSec, space: 'space1' }; const [case1, case2] = await Promise.all([ createCase(supertestWithoutAuth, getPostCaseRequest(), 200, auth), @@ -297,7 +293,7 @@ export default ({ getService }: FtrProviderContext): void => { }), ]); - const res = await getCaseIDsByAlert({ + const res = await getCasesByAlert({ supertest: supertestWithoutAuth, alertID: postCommentAlertReq.alertId as string, auth: { user: secOnly, space: 'space1' }, @@ -305,7 +301,7 @@ export default ({ getService }: FtrProviderContext): void => { query: { owner: ['securitySolutionFixture', 'observabilityFixture'] }, }); - expect(res).to.eql([case1.id]); + expect(res).to.eql([{ id: case1.id, title: case1.title }]); }); }); }); diff --git a/x-pack/test/case_api_integration/security_only/tests/common/alerts/get_cases.ts b/x-pack/test/case_api_integration/security_only/tests/common/alerts/get_cases.ts index 9575bd99112f6b..f55427d13b32bf 100644 --- a/x-pack/test/case_api_integration/security_only/tests/common/alerts/get_cases.ts +++ b/x-pack/test/case_api_integration/security_only/tests/common/alerts/get_cases.ts @@ -12,7 +12,7 @@ import { getPostCaseRequest, postCommentAlertReq } from '../../../../common/lib/ import { createCase, createComment, - getCaseIDsByAlert, + getCasesByAlert, deleteAllCaseItems, } from '../../../../common/lib/utils'; import { @@ -30,6 +30,7 @@ import { superUserDefaultSpaceAuth, obsSecDefaultSpaceAuth, } from '../../../utils'; +import { validateCasesFromAlertIDResponse } from '../../../../common/lib/validation'; // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { @@ -43,7 +44,7 @@ export default ({ getService }: FtrProviderContext): void => { const supertestWithoutAuth = getService('supertestWithoutAuth'); - it('should return the correct case IDs', async () => { + it('should return the correct cases info', async () => { const [case1, case2, case3] = await Promise.all([ createCase(supertestWithoutAuth, getPostCaseRequest(), 200, secOnlyDefaultSpaceAuth), createCase(supertestWithoutAuth, getPostCaseRequest(), 200, secOnlyDefaultSpaceAuth), @@ -79,20 +80,20 @@ export default ({ getService }: FtrProviderContext): void => { for (const scenario of [ { user: globalRead, - caseIDs: [case1.id, case2.id, case3.id], + cases: [case1, case2, case3], }, { user: superUser, - caseIDs: [case1.id, case2.id, case3.id], + cases: [case1, case2, case3], }, - { user: secOnlyReadSpacesAll, caseIDs: [case1.id, case2.id] }, - { user: obsOnlyReadSpacesAll, caseIDs: [case3.id] }, + { user: secOnlyReadSpacesAll, cases: [case1, case2] }, + { user: obsOnlyReadSpacesAll, cases: [case3] }, { user: obsSecReadSpacesAll, - caseIDs: [case1.id, case2.id, case3.id], + cases: [case1, case2, case3], }, ]) { - const res = await getCaseIDsByAlert({ + const cases = await getCasesByAlert({ supertest: supertestWithoutAuth, // cast because the official type is string | string[] but the ids will always be a single value in the tests alertID: postCommentAlertReq.alertId as string, @@ -101,10 +102,9 @@ export default ({ getService }: FtrProviderContext): void => { space: null, }, }); - expect(res.length).to.eql(scenario.caseIDs.length); - for (const caseID of scenario.caseIDs) { - expect(res).to.contain(caseID); - } + + expect(cases.length).to.eql(scenario.cases.length); + validateCasesFromAlertIDResponse(cases, scenario.cases); } }); @@ -123,7 +123,7 @@ export default ({ getService }: FtrProviderContext): void => { auth: superUserDefaultSpaceAuth, }); - await getCaseIDsByAlert({ + await getCasesByAlert({ supertest: supertestWithoutAuth, alertID: postCommentAlertReq.alertId as string, auth: { user: noKibanaPrivileges, space: null }, @@ -157,7 +157,7 @@ export default ({ getService }: FtrProviderContext): void => { }), ]); - await getCaseIDsByAlert({ + await getCasesByAlert({ supertest: supertestWithoutAuth, alertID: postCommentAlertReq.alertId as string, auth: { user: obsSecSpacesAll, space: 'space1' }, @@ -192,17 +192,17 @@ export default ({ getService }: FtrProviderContext): void => { }), ]); - const res = await getCaseIDsByAlert({ + const cases = await getCasesByAlert({ supertest: supertestWithoutAuth, alertID: postCommentAlertReq.alertId as string, auth: obsSecDefaultSpaceAuth, query: { owner: 'securitySolutionFixture' }, }); - expect(res).to.eql([case1.id]); + expect(cases).to.eql([{ id: case1.id, title: case1.title }]); }); - it('should return the correct case IDs when the owner query parameter contains unprivileged values', async () => { + it('should return the correct cases info when the owner query parameter contains unprivileged values', async () => { const [case1, case2] = await Promise.all([ createCase(supertestWithoutAuth, getPostCaseRequest(), 200, obsSecDefaultSpaceAuth), createCase( @@ -228,7 +228,7 @@ export default ({ getService }: FtrProviderContext): void => { }), ]); - const res = await getCaseIDsByAlert({ + const cases = await getCasesByAlert({ supertest: supertestWithoutAuth, alertID: postCommentAlertReq.alertId as string, auth: secOnlyDefaultSpaceAuth, @@ -236,7 +236,7 @@ export default ({ getService }: FtrProviderContext): void => { query: { owner: ['securitySolutionFixture', 'observabilityFixture'] }, }); - expect(res).to.eql([case1.id]); + expect(cases).to.eql([{ id: case1.id, title: case1.title }]); }); }); }; diff --git a/x-pack/test/case_api_integration/spaces_only/tests/common/alerts/get_cases.ts b/x-pack/test/case_api_integration/spaces_only/tests/common/alerts/get_cases.ts index 9587502fb642ce..739f8e5ec08926 100644 --- a/x-pack/test/case_api_integration/spaces_only/tests/common/alerts/get_cases.ts +++ b/x-pack/test/case_api_integration/spaces_only/tests/common/alerts/get_cases.ts @@ -12,10 +12,11 @@ import { getPostCaseRequest, postCommentAlertReq } from '../../../../common/lib/ import { createCase, createComment, - getCaseIDsByAlert, + getCasesByAlert, deleteAllCaseItems, getAuthWithSuperUser, } from '../../../../common/lib/utils'; +import { validateCasesFromAlertIDResponse } from '../../../../common/lib/validation'; // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { @@ -57,16 +58,14 @@ export default ({ getService }: FtrProviderContext): void => { }), ]); - const caseIDsWithAlert = await getCaseIDsByAlert({ + const cases = await getCasesByAlert({ supertest, alertID: 'test-id', auth: authSpace1, }); - expect(caseIDsWithAlert.length).to.eql(3); - expect(caseIDsWithAlert).to.contain(case1.id); - expect(caseIDsWithAlert).to.contain(case2.id); - expect(caseIDsWithAlert).to.contain(case3.id); + expect(cases.length).to.eql(3); + validateCasesFromAlertIDResponse(cases, [case1, case2, case3]); }); it('should return 1 case in space2 when 2 cases were created in space1 and 1 in space2', async () => { @@ -97,14 +96,14 @@ export default ({ getService }: FtrProviderContext): void => { }), ]); - const caseIDsWithAlert = await getCaseIDsByAlert({ + const casesByAlert = await getCasesByAlert({ supertest, alertID: 'test-id', auth: authSpace2, }); - expect(caseIDsWithAlert.length).to.eql(1); - expect(caseIDsWithAlert).to.eql([case3.id]); + expect(casesByAlert.length).to.eql(1); + expect(casesByAlert).to.eql([{ id: case3.id, title: case3.title }]); }); }); }; From c33138e5cb2bec40830537e4009ddf4a75ab8bb1 Mon Sep 17 00:00:00 2001 From: Alison Goryachev Date: Tue, 22 Jun 2021 14:13:48 -0400 Subject: [PATCH 06/17] [Rollups] Migrate to new page layout (#102268) --- ...-plugin-core-public.doclinksstart.links.md | 1 + ...kibana-plugin-core-public.doclinksstart.md | 2 +- .../public/doc_links/doc_links_service.ts | 2 + src/core/public/public.api.md | 1 + .../rollup/public/crud_app/_crud_app.scss | 8 - .../sections/job_create/job_create.js | 57 ++--- .../job_list/detail_panel/detail_panel.js | 2 +- .../detail_panel/detail_panel.test.js | 2 +- .../crud_app/sections/job_list/job_list.js | 214 +++++++++--------- .../sections/job_list/job_list.test.js | 26 ++- .../sections/job_list/job_table/job_table.js | 23 +- .../job_list/job_table/job_table.test.js | 8 + .../crud_app/store/actions/load_jobs.js | 13 +- .../plugins/rollup/public/shared_imports.ts | 6 +- .../test/client_integration/job_list.test.js | 5 +- .../client_integration/job_list_clone.test.js | 9 + .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 18 files changed, 198 insertions(+), 183 deletions(-) diff --git a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md index d3d76079cdc2a1..ae433e3db14c68 100644 --- a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md +++ b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md @@ -116,6 +116,7 @@ readonly links: { readonly addData: string; readonly kibana: string; readonly upgradeAssistant: string; + readonly rollupJobs: string; readonly elasticsearch: Record; readonly siem: { readonly guide: string; diff --git a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md index 34279cef198bfb..b0800c7dfc65ea 100644 --- a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md +++ b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md @@ -17,5 +17,5 @@ export interface DocLinksStart | --- | --- | --- | | [DOC\_LINK\_VERSION](./kibana-plugin-core-public.doclinksstart.doc_link_version.md) | string | | | [ELASTIC\_WEBSITE\_URL](./kibana-plugin-core-public.doclinksstart.elastic_website_url.md) | string | | -| [links](./kibana-plugin-core-public.doclinksstart.links.md) | {
    readonly canvas: {
    readonly guide: string;
    };
    readonly dashboard: {
    readonly guide: string;
    readonly drilldowns: string;
    readonly drilldownsTriggerPicker: string;
    readonly urlDrilldownTemplateSyntax: string;
    readonly urlDrilldownVariables: string;
    };
    readonly discover: Record<string, string>;
    readonly filebeat: {
    readonly base: string;
    readonly installation: string;
    readonly configuration: string;
    readonly elasticsearchOutput: string;
    readonly elasticsearchModule: string;
    readonly startup: string;
    readonly exportedFields: string;
    };
    readonly auditbeat: {
    readonly base: string;
    };
    readonly metricbeat: {
    readonly base: string;
    readonly configure: string;
    readonly httpEndpoint: string;
    readonly install: string;
    readonly start: string;
    };
    readonly enterpriseSearch: {
    readonly base: string;
    readonly appSearchBase: string;
    readonly workplaceSearchBase: string;
    };
    readonly heartbeat: {
    readonly base: string;
    };
    readonly logstash: {
    readonly base: string;
    };
    readonly functionbeat: {
    readonly base: string;
    };
    readonly winlogbeat: {
    readonly base: string;
    };
    readonly aggs: {
    readonly composite: string;
    readonly composite_missing_bucket: string;
    readonly date_histogram: string;
    readonly date_range: string;
    readonly date_format_pattern: string;
    readonly filter: string;
    readonly filters: string;
    readonly geohash_grid: string;
    readonly histogram: string;
    readonly ip_range: string;
    readonly range: string;
    readonly significant_terms: string;
    readonly terms: string;
    readonly avg: string;
    readonly avg_bucket: string;
    readonly max_bucket: string;
    readonly min_bucket: string;
    readonly sum_bucket: string;
    readonly cardinality: string;
    readonly count: string;
    readonly cumulative_sum: string;
    readonly derivative: string;
    readonly geo_bounds: string;
    readonly geo_centroid: string;
    readonly max: string;
    readonly median: string;
    readonly min: string;
    readonly moving_avg: string;
    readonly percentile_ranks: string;
    readonly serial_diff: string;
    readonly std_dev: string;
    readonly sum: string;
    readonly top_hits: string;
    };
    readonly runtimeFields: {
    readonly overview: string;
    readonly mapping: string;
    };
    readonly scriptedFields: {
    readonly scriptFields: string;
    readonly scriptAggs: string;
    readonly painless: string;
    readonly painlessApi: string;
    readonly painlessLangSpec: string;
    readonly painlessSyntax: string;
    readonly painlessWalkthrough: string;
    readonly luceneExpressions: string;
    };
    readonly search: {
    readonly sessions: string;
    };
    readonly indexPatterns: {
    readonly introduction: string;
    readonly fieldFormattersNumber: string;
    readonly fieldFormattersString: string;
    readonly runtimeFields: string;
    };
    readonly addData: string;
    readonly kibana: string;
    readonly upgradeAssistant: string;
    readonly elasticsearch: Record<string, string>;
    readonly siem: {
    readonly guide: string;
    readonly gettingStarted: string;
    };
    readonly query: {
    readonly eql: string;
    readonly kueryQuerySyntax: string;
    readonly luceneQuerySyntax: string;
    readonly percolate: string;
    readonly queryDsl: string;
    };
    readonly date: {
    readonly dateMath: string;
    readonly dateMathIndexNames: string;
    };
    readonly management: Record<string, string>;
    readonly ml: Record<string, string>;
    readonly transforms: Record<string, string>;
    readonly visualize: Record<string, string>;
    readonly apis: Readonly<{
    bulkIndexAlias: string;
    byteSizeUnits: string;
    createAutoFollowPattern: string;
    createFollower: string;
    createIndex: string;
    createSnapshotLifecyclePolicy: string;
    createRoleMapping: string;
    createRoleMappingTemplates: string;
    createRollupJobsRequest: string;
    createApiKey: string;
    createPipeline: string;
    createTransformRequest: string;
    cronExpressions: string;
    executeWatchActionModes: string;
    indexExists: string;
    openIndex: string;
    putComponentTemplate: string;
    painlessExecute: string;
    painlessExecuteAPIContexts: string;
    putComponentTemplateMetadata: string;
    putSnapshotLifecyclePolicy: string;
    putIndexTemplateV1: string;
    putWatch: string;
    simulatePipeline: string;
    timeUnits: string;
    updateTransform: string;
    }>;
    readonly observability: Record<string, string>;
    readonly alerting: Record<string, string>;
    readonly maps: Record<string, string>;
    readonly monitoring: Record<string, string>;
    readonly security: Readonly<{
    apiKeyServiceSettings: string;
    clusterPrivileges: string;
    elasticsearchSettings: string;
    elasticsearchEnableSecurity: string;
    indicesPrivileges: string;
    kibanaTLS: string;
    kibanaPrivileges: string;
    mappingRoles: string;
    mappingRolesFieldRules: string;
    runAsPrivilege: string;
    }>;
    readonly watcher: Record<string, string>;
    readonly ccs: Record<string, string>;
    readonly plugins: Record<string, string>;
    readonly snapshotRestore: Record<string, string>;
    readonly ingest: Record<string, string>;
    readonly fleet: Readonly<{
    guide: string;
    fleetServer: string;
    fleetServerAddFleetServer: string;
    settings: string;
    settingsFleetServerHostSettings: string;
    troubleshooting: string;
    elasticAgent: string;
    datastreams: string;
    datastreamsNamingScheme: string;
    upgradeElasticAgent: string;
    upgradeElasticAgent712lower: string;
    }>;
    } | | +| [links](./kibana-plugin-core-public.doclinksstart.links.md) | {
    readonly canvas: {
    readonly guide: string;
    };
    readonly dashboard: {
    readonly guide: string;
    readonly drilldowns: string;
    readonly drilldownsTriggerPicker: string;
    readonly urlDrilldownTemplateSyntax: string;
    readonly urlDrilldownVariables: string;
    };
    readonly discover: Record<string, string>;
    readonly filebeat: {
    readonly base: string;
    readonly installation: string;
    readonly configuration: string;
    readonly elasticsearchOutput: string;
    readonly elasticsearchModule: string;
    readonly startup: string;
    readonly exportedFields: string;
    };
    readonly auditbeat: {
    readonly base: string;
    };
    readonly metricbeat: {
    readonly base: string;
    readonly configure: string;
    readonly httpEndpoint: string;
    readonly install: string;
    readonly start: string;
    };
    readonly enterpriseSearch: {
    readonly base: string;
    readonly appSearchBase: string;
    readonly workplaceSearchBase: string;
    };
    readonly heartbeat: {
    readonly base: string;
    };
    readonly logstash: {
    readonly base: string;
    };
    readonly functionbeat: {
    readonly base: string;
    };
    readonly winlogbeat: {
    readonly base: string;
    };
    readonly aggs: {
    readonly composite: string;
    readonly composite_missing_bucket: string;
    readonly date_histogram: string;
    readonly date_range: string;
    readonly date_format_pattern: string;
    readonly filter: string;
    readonly filters: string;
    readonly geohash_grid: string;
    readonly histogram: string;
    readonly ip_range: string;
    readonly range: string;
    readonly significant_terms: string;
    readonly terms: string;
    readonly avg: string;
    readonly avg_bucket: string;
    readonly max_bucket: string;
    readonly min_bucket: string;
    readonly sum_bucket: string;
    readonly cardinality: string;
    readonly count: string;
    readonly cumulative_sum: string;
    readonly derivative: string;
    readonly geo_bounds: string;
    readonly geo_centroid: string;
    readonly max: string;
    readonly median: string;
    readonly min: string;
    readonly moving_avg: string;
    readonly percentile_ranks: string;
    readonly serial_diff: string;
    readonly std_dev: string;
    readonly sum: string;
    readonly top_hits: string;
    };
    readonly runtimeFields: {
    readonly overview: string;
    readonly mapping: string;
    };
    readonly scriptedFields: {
    readonly scriptFields: string;
    readonly scriptAggs: string;
    readonly painless: string;
    readonly painlessApi: string;
    readonly painlessLangSpec: string;
    readonly painlessSyntax: string;
    readonly painlessWalkthrough: string;
    readonly luceneExpressions: string;
    };
    readonly search: {
    readonly sessions: string;
    };
    readonly indexPatterns: {
    readonly introduction: string;
    readonly fieldFormattersNumber: string;
    readonly fieldFormattersString: string;
    readonly runtimeFields: string;
    };
    readonly addData: string;
    readonly kibana: string;
    readonly upgradeAssistant: string;
    readonly rollupJobs: string;
    readonly elasticsearch: Record<string, string>;
    readonly siem: {
    readonly guide: string;
    readonly gettingStarted: string;
    };
    readonly query: {
    readonly eql: string;
    readonly kueryQuerySyntax: string;
    readonly luceneQuerySyntax: string;
    readonly percolate: string;
    readonly queryDsl: string;
    };
    readonly date: {
    readonly dateMath: string;
    readonly dateMathIndexNames: string;
    };
    readonly management: Record<string, string>;
    readonly ml: Record<string, string>;
    readonly transforms: Record<string, string>;
    readonly visualize: Record<string, string>;
    readonly apis: Readonly<{
    bulkIndexAlias: string;
    byteSizeUnits: string;
    createAutoFollowPattern: string;
    createFollower: string;
    createIndex: string;
    createSnapshotLifecyclePolicy: string;
    createRoleMapping: string;
    createRoleMappingTemplates: string;
    createRollupJobsRequest: string;
    createApiKey: string;
    createPipeline: string;
    createTransformRequest: string;
    cronExpressions: string;
    executeWatchActionModes: string;
    indexExists: string;
    openIndex: string;
    putComponentTemplate: string;
    painlessExecute: string;
    painlessExecuteAPIContexts: string;
    putComponentTemplateMetadata: string;
    putSnapshotLifecyclePolicy: string;
    putIndexTemplateV1: string;
    putWatch: string;
    simulatePipeline: string;
    timeUnits: string;
    updateTransform: string;
    }>;
    readonly observability: Record<string, string>;
    readonly alerting: Record<string, string>;
    readonly maps: Record<string, string>;
    readonly monitoring: Record<string, string>;
    readonly security: Readonly<{
    apiKeyServiceSettings: string;
    clusterPrivileges: string;
    elasticsearchSettings: string;
    elasticsearchEnableSecurity: string;
    indicesPrivileges: string;
    kibanaTLS: string;
    kibanaPrivileges: string;
    mappingRoles: string;
    mappingRolesFieldRules: string;
    runAsPrivilege: string;
    }>;
    readonly watcher: Record<string, string>;
    readonly ccs: Record<string, string>;
    readonly plugins: Record<string, string>;
    readonly snapshotRestore: Record<string, string>;
    readonly ingest: Record<string, string>;
    readonly fleet: Readonly<{
    guide: string;
    fleetServer: string;
    fleetServerAddFleetServer: string;
    settings: string;
    settingsFleetServerHostSettings: string;
    troubleshooting: string;
    elasticAgent: string;
    datastreams: string;
    datastreamsNamingScheme: string;
    upgradeElasticAgent: string;
    upgradeElasticAgent712lower: string;
    }>;
    } | | diff --git a/src/core/public/doc_links/doc_links_service.ts b/src/core/public/doc_links/doc_links_service.ts index 95091a761639b6..8c52d09f821595 100644 --- a/src/core/public/doc_links/doc_links_service.ts +++ b/src/core/public/doc_links/doc_links_service.ts @@ -137,6 +137,7 @@ export class DocLinksService { addData: `${KIBANA_DOCS}connect-to-elasticsearch.html`, kibana: `${KIBANA_DOCS}index.html`, upgradeAssistant: `${KIBANA_DOCS}upgrade-assistant.html`, + rollupJobs: `${KIBANA_DOCS}data-rollups.html`, elasticsearch: { docsBase: `${ELASTICSEARCH_DOCS}`, asyncSearch: `${ELASTICSEARCH_DOCS}async-search-intro.html`, @@ -532,6 +533,7 @@ export interface DocLinksStart { readonly addData: string; readonly kibana: string; readonly upgradeAssistant: string; + readonly rollupJobs: string; readonly elasticsearch: Record; readonly siem: { readonly guide: string; diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 6cc2b3f321fb7c..27569935bcc65f 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -595,6 +595,7 @@ export interface DocLinksStart { readonly addData: string; readonly kibana: string; readonly upgradeAssistant: string; + readonly rollupJobs: string; readonly elasticsearch: Record; readonly siem: { readonly guide: string; diff --git a/x-pack/plugins/rollup/public/crud_app/_crud_app.scss b/x-pack/plugins/rollup/public/crud_app/_crud_app.scss index 9e3bd491115ced..ddf69167145f14 100644 --- a/x-pack/plugins/rollup/public/crud_app/_crud_app.scss +++ b/x-pack/plugins/rollup/public/crud_app/_crud_app.scss @@ -4,11 +4,3 @@ .rollupJobWizardStepActions { align-items: flex-end; /* 1 */ } - -/** - * 1. Ensure panel fills width of parent when search input yields no matching rollup jobs. - */ -.rollupJobsListPanel { - // sass-lint:disable-block no-important - flex-grow: 1 !important; /* 1 */ -} diff --git a/x-pack/plugins/rollup/public/crud_app/sections/job_create/job_create.js b/x-pack/plugins/rollup/public/crud_app/sections/job_create/job_create.js index fa3ce260424f27..6f22345dc1cec5 100644 --- a/x-pack/plugins/rollup/public/crud_app/sections/job_create/job_create.js +++ b/x-pack/plugins/rollup/public/crud_app/sections/job_create/job_create.js @@ -5,7 +5,7 @@ * 2.0. */ -import React, { Component, Fragment } from 'react'; +import React, { Component } from 'react'; import PropTypes from 'prop-types'; import { cloneDeep, debounce, first, mapValues } from 'lodash'; @@ -18,11 +18,10 @@ import { EuiCallOut, EuiLoadingKibana, EuiOverlayMask, - EuiPageContent, - EuiPageContentHeader, + EuiPageContentBody, + EuiPageHeader, EuiSpacer, EuiStepsHorizontal, - EuiTitle, } from '@elastic/eui'; import { @@ -522,44 +521,46 @@ export class JobCreateUi extends Component { } saveErrorFeedback = ( - + <> + + {errorBody} - + ); } return ( - - - - -

    - -

    -
    -
    - - {saveErrorFeedback} - - + + + } + /> - + + + + + {saveErrorFeedback} + + + + {this.renderCurrentStep()} - {this.renderCurrentStep()} + - + {this.renderNavigation()} - {this.renderNavigation()} -
    {savingFeedback} -
    + ); } diff --git a/x-pack/plugins/rollup/public/crud_app/sections/job_list/detail_panel/detail_panel.js b/x-pack/plugins/rollup/public/crud_app/sections/job_list/detail_panel/detail_panel.js index 4fe1674e8c6436..5e97ff5e2980d3 100644 --- a/x-pack/plugins/rollup/public/crud_app/sections/job_list/detail_panel/detail_panel.js +++ b/x-pack/plugins/rollup/public/crud_app/sections/job_list/detail_panel/detail_panel.js @@ -195,7 +195,7 @@ export class DetailPanel extends Component {
    diff --git a/x-pack/plugins/rollup/public/crud_app/sections/job_list/detail_panel/detail_panel.test.js b/x-pack/plugins/rollup/public/crud_app/sections/job_list/detail_panel/detail_panel.test.js index 16919b8388e2e4..e1f9ec2b3a315c 100644 --- a/x-pack/plugins/rollup/public/crud_app/sections/job_list/detail_panel/detail_panel.test.js +++ b/x-pack/plugins/rollup/public/crud_app/sections/job_list/detail_panel/detail_panel.test.js @@ -70,7 +70,7 @@ describe('', () => { ({ component, find, exists } = initTestBed({ isLoading: true })); const loading = find('rollupJobDetailLoading'); expect(loading.length).toBeTruthy(); - expect(loading.text()).toEqual('Loading rollup job...'); + expect(loading.text()).toEqual('Loading rollup job…'); // Make sure the title and the tabs are visible expect(exists('detailPanelTabSelected')).toBeTruthy(); diff --git a/x-pack/plugins/rollup/public/crud_app/sections/job_list/job_list.js b/x-pack/plugins/rollup/public/crud_app/sections/job_list/job_list.js index 589546a11ef38e..b2448eb6107742 100644 --- a/x-pack/plugins/rollup/public/crud_app/sections/job_list/job_list.js +++ b/x-pack/plugins/rollup/public/crud_app/sections/job_list/job_list.js @@ -12,24 +12,19 @@ import { i18n } from '@kbn/i18n'; import { EuiButton, + EuiButtonEmpty, EuiEmptyPrompt, - EuiFlexGroup, - EuiFlexItem, - EuiLoadingSpinner, + EuiPageHeader, EuiPageContent, - EuiPageContentHeader, - EuiPageContentHeaderSection, EuiSpacer, - EuiText, - EuiTextColor, - EuiTitle, - EuiCallOut, } from '@elastic/eui'; import { withKibana } from '../../../../../../../src/plugins/kibana_react/public'; -import { extractQueryParams } from '../../../shared_imports'; +import { extractQueryParams, SectionLoading } from '../../../shared_imports'; import { getRouterLinkProps, listBreadcrumb } from '../../services'; +import { documentationLinks } from '../../services/documentation_links'; + import { JobTable } from './job_table'; import { DetailPanel } from './detail_panel'; @@ -87,38 +82,26 @@ export class JobListUi extends Component { this.props.closeDetailPanel(); } - getHeaderSection() { - return ( - - -

    - -

    -
    -
    - ); - } - renderNoPermission() { const title = i18n.translate('xpack.rollupJobs.jobList.noPermissionTitle', { defaultMessage: 'Permission error', }); return ( - - {this.getHeaderSection()} - - + - - - + iconType="alert" + title={

    {title}

    } + body={ +

    + +

    + } + /> + ); } @@ -130,101 +113,110 @@ export class JobListUi extends Component { const title = i18n.translate('xpack.rollupJobs.jobList.loadingErrorTitle', { defaultMessage: 'Error loading rollup jobs', }); + return ( - - {this.getHeaderSection()} - - - {statusCode} {errorString} - - + + {title}} + body={ +

    + {statusCode} {errorString} +

    + } + /> +
    ); } renderEmpty() { return ( - - - - } - body={ - -

    + + + + } + body={ + +

    + +

    +
    + } + actions={ + + -

    - - } - actions={ - - - - } - /> +
    + } + /> + ); } renderLoading() { return ( - - - - - - - - - - - - - + + + + + ); } renderList() { - const { isLoading } = this.props; - return ( - - - {this.getHeaderSection()} - - - + <> + + + + } + rightSideItems={[ + - - - + , + ]} + /> - {isLoading ? this.renderLoading() : } + + + - + ); } @@ -241,15 +233,13 @@ export class JobListUi extends Component { } } else if (!isLoading && !hasJobs) { content = this.renderEmpty(); + } else if (isLoading) { + content = this.renderLoading(); } else { content = this.renderList(); } - return ( - - {content} - - ); + return content; } } diff --git a/x-pack/plugins/rollup/public/crud_app/sections/job_list/job_list.test.js b/x-pack/plugins/rollup/public/crud_app/sections/job_list/job_list.test.js index 3283f4f521fc0e..b2c738a033b3cb 100644 --- a/x-pack/plugins/rollup/public/crud_app/sections/job_list/job_list.test.js +++ b/x-pack/plugins/rollup/public/crud_app/sections/job_list/job_list.test.js @@ -22,6 +22,15 @@ jest.mock('../../services', () => { }; }); +jest.mock('../../services/documentation_links', () => { + const coreMocks = jest.requireActual('../../../../../../../src/core/public/mocks'); + + return { + init: jest.fn(), + documentationLinks: coreMocks.docLinksServiceMock.createStartContract().links, + }; +}); + const defaultProps = { history: { location: {} }, loadJobs: () => {}, @@ -52,14 +61,14 @@ describe('', () => { it('should display a loading message when loading the jobs', () => { const { component, exists } = initTestBed({ isLoading: true }); - expect(exists('jobListLoading')).toBeTruthy(); + expect(exists('sectionLoading')).toBeTruthy(); expect(component.find('JobTable').length).toBeFalsy(); }); it('should display the when there are jobs', () => { const { component, exists } = initTestBed({ hasJobs: true }); - expect(exists('jobListLoading')).toBeFalsy(); + expect(exists('sectionLoading')).toBeFalsy(); expect(component.find('JobTable').length).toBeTruthy(); }); @@ -71,21 +80,20 @@ describe('', () => { }, }); - it('should display a callout with the status and the message', () => { + it('should display an error with the status and the message', () => { expect(exists('jobListError')).toBeTruthy(); expect(find('jobListError').find('EuiText').text()).toEqual('400 Houston we got a problem.'); }); }); describe('when the user does not have the permission to access it', () => { - const { exists } = initTestBed({ jobLoadError: { status: 403 } }); + const { exists, find } = initTestBed({ jobLoadError: { status: 403 } }); - it('should render a callout message', () => { + it('should render an error message', () => { expect(exists('jobListNoPermission')).toBeTruthy(); - }); - - it('should display the page header', () => { - expect(exists('jobListPageHeader')).toBeTruthy(); + expect(find('jobListNoPermission').find('EuiText').text()).toEqual( + 'You do not have permission to view or add rollup jobs.' + ); }); }); }); diff --git a/x-pack/plugins/rollup/public/crud_app/sections/job_list/job_table/job_table.js b/x-pack/plugins/rollup/public/crud_app/sections/job_list/job_table/job_table.js index fe3d2cbd4cbe0d..83135cf219f350 100644 --- a/x-pack/plugins/rollup/public/crud_app/sections/job_list/job_table/job_table.js +++ b/x-pack/plugins/rollup/public/crud_app/sections/job_list/job_table/job_table.js @@ -5,7 +5,7 @@ * 2.0. */ -import React, { Component, Fragment } from 'react'; +import React, { Component } from 'react'; import PropTypes from 'prop-types'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -28,10 +28,11 @@ import { EuiTableRowCellCheckbox, EuiText, EuiToolTip, + EuiButton, } from '@elastic/eui'; import { UIM_SHOW_DETAILS_CLICK } from '../../../../../common'; -import { METRIC_TYPE } from '../../../services'; +import { METRIC_TYPE, getRouterLinkProps } from '../../../services'; import { trackUiMetric } from '../../../../kibana_services'; import { JobActionMenu, JobStatus } from '../../components'; @@ -346,9 +347,9 @@ export class JobTable extends Component { const atLeastOneItemSelected = Object.keys(idToSelectedJobMap).length > 0; return ( - - - {atLeastOneItemSelected ? ( +
    + + {atLeastOneItemSelected && ( - ) : null} + )} + + + + + @@ -409,7 +418,7 @@ export class JobTable extends Component { {jobs.length > 0 ? this.renderPager() : null} - +
    ); } } diff --git a/x-pack/plugins/rollup/public/crud_app/sections/job_list/job_table/job_table.test.js b/x-pack/plugins/rollup/public/crud_app/sections/job_list/job_table/job_table.test.js index 3fa879923c40ab..d52f3fa35a5441 100644 --- a/x-pack/plugins/rollup/public/crud_app/sections/job_list/job_table/job_table.test.js +++ b/x-pack/plugins/rollup/public/crud_app/sections/job_list/job_table/job_table.test.js @@ -20,6 +20,14 @@ jest.mock('../../../../kibana_services', () => { }; }); +jest.mock('../../../services', () => { + const services = jest.requireActual('../../../services'); + return { + ...services, + getRouterLinkProps: (link) => ({ href: link }), + }; +}); + const defaultProps = { jobs: [], pager: new Pager(20, 10, 1), diff --git a/x-pack/plugins/rollup/public/crud_app/store/actions/load_jobs.js b/x-pack/plugins/rollup/public/crud_app/store/actions/load_jobs.js index 0dc3a02d3c0779..c63d01f3c200d5 100644 --- a/x-pack/plugins/rollup/public/crud_app/store/actions/load_jobs.js +++ b/x-pack/plugins/rollup/public/crud_app/store/actions/load_jobs.js @@ -5,9 +5,7 @@ * 2.0. */ -import { i18n } from '@kbn/i18n'; - -import { loadJobs as sendLoadJobsRequest, deserializeJobs, showApiError } from '../../services'; +import { loadJobs as sendLoadJobsRequest, deserializeJobs } from '../../services'; import { LOAD_JOBS_START, LOAD_JOBS_SUCCESS, LOAD_JOBS_FAILURE } from '../action_types'; export const loadJobs = () => async (dispatch) => { @@ -19,17 +17,10 @@ export const loadJobs = () => async (dispatch) => { try { jobs = await sendLoadJobsRequest(); } catch (error) { - dispatch({ + return dispatch({ type: LOAD_JOBS_FAILURE, payload: { error }, }); - - return showApiError( - error, - i18n.translate('xpack.rollupJobs.loadAction.errorTitle', { - defaultMessage: 'Error loading rollup jobs', - }) - ); } dispatch({ diff --git a/x-pack/plugins/rollup/public/shared_imports.ts b/x-pack/plugins/rollup/public/shared_imports.ts index fd281753186665..c8d7f1d9f13f3d 100644 --- a/x-pack/plugins/rollup/public/shared_imports.ts +++ b/x-pack/plugins/rollup/public/shared_imports.ts @@ -5,4 +5,8 @@ * 2.0. */ -export { extractQueryParams, indices } from '../../../../src/plugins/es_ui_shared/public'; +export { + extractQueryParams, + indices, + SectionLoading, +} from '../../../../src/plugins/es_ui_shared/public'; diff --git a/x-pack/plugins/rollup/public/test/client_integration/job_list.test.js b/x-pack/plugins/rollup/public/test/client_integration/job_list.test.js index fa1a786bc8a71d..46ddfbcfc2de55 100644 --- a/x-pack/plugins/rollup/public/test/client_integration/job_list.test.js +++ b/x-pack/plugins/rollup/public/test/client_integration/job_list.test.js @@ -5,10 +5,10 @@ * 2.0. */ -import { getRouter, setHttp } from '../../crud_app/services'; +import { getRouter, setHttp, init as initDocumentation } from '../../crud_app/services'; import { mockHttpRequest, pageHelpers, nextTick } from './helpers'; import { JOBS } from './helpers/constants'; -import { coreMock } from '../../../../../../src/core/public/mocks'; +import { coreMock, docLinksServiceMock } from '../../../../../../src/core/public/mocks'; jest.mock('../../crud_app/services', () => { const services = jest.requireActual('../../crud_app/services'); @@ -38,6 +38,7 @@ describe('', () => { beforeAll(() => { startMock = coreMock.createStart(); setHttp(startMock.http); + initDocumentation(docLinksServiceMock.createStartContract()); }); beforeEach(async () => { diff --git a/x-pack/plugins/rollup/public/test/client_integration/job_list_clone.test.js b/x-pack/plugins/rollup/public/test/client_integration/job_list_clone.test.js index cfb63893ee423a..3987e18538e577 100644 --- a/x-pack/plugins/rollup/public/test/client_integration/job_list_clone.test.js +++ b/x-pack/plugins/rollup/public/test/client_integration/job_list_clone.test.js @@ -24,6 +24,15 @@ jest.mock('../../kibana_services', () => { }; }); +jest.mock('../../crud_app/services/documentation_links', () => { + const coreMocks = jest.requireActual('../../../../../../src/core/public/mocks'); + + return { + init: jest.fn(), + documentationLinks: coreMocks.docLinksServiceMock.createStartContract().links, + }; +}); + const { setup } = pageHelpers.jobList; describe('Smoke test cloning an existing rollup job from job list', () => { diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 9520c1ad0d9c1d..91277403d9e058 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -18006,7 +18006,6 @@ "xpack.rollupJobs.jobTable.selectRow": "この行 {id} を選択", "xpack.rollupJobs.licenseCheckErrorMessage": "ライセンス確認失敗", "xpack.rollupJobs.listBreadcrumbTitle": "ロールアップジョブ", - "xpack.rollupJobs.loadAction.errorTitle": "ロールアップジョブを読み込み中にエラーが発生", "xpack.rollupJobs.refreshAction.errorTitle": "ロールアップジョブの更新中にエラーが発生", "xpack.rollupJobs.rollupIndexPatternsDescription": "ロールアップインデックスを捕捉するインデックスパターンの作成を有効にします。\n それによりロールアップデータに基づくビジュアライゼーションが可能になります。", "xpack.rollupJobs.rollupIndexPatternsTitle": "ロールアップインデックスパターンを有効にする", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index f74d27eb8b2142..632c502d4ef555 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -18250,7 +18250,6 @@ "xpack.rollupJobs.jobTable.selectRow": "选择行 {id}", "xpack.rollupJobs.licenseCheckErrorMessage": "许可证检查失败", "xpack.rollupJobs.listBreadcrumbTitle": "汇总/打包作业", - "xpack.rollupJobs.loadAction.errorTitle": "加载汇总/打包作业时出错", "xpack.rollupJobs.refreshAction.errorTitle": "刷新汇总/打包作业时出错", "xpack.rollupJobs.rollupIndexPatternsDescription": "启用用于捕获汇总/打包索引的索引模式的创建,\n 汇总/打包索引反过来基于汇总/打包数据启用可视化。", "xpack.rollupJobs.rollupIndexPatternsTitle": "启用汇总索引模式", From 953a464e94ad1791c228fc1705430d11964da909 Mon Sep 17 00:00:00 2001 From: Lisa Cawley Date: Tue, 22 Jun 2021 11:21:19 -0700 Subject: [PATCH 07/17] [Monitoring] Update Kibana rules/alerts language in setup mode (#102441) --- x-pack/plugins/monitoring/public/alerts/badge.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/monitoring/public/alerts/badge.tsx b/x-pack/plugins/monitoring/public/alerts/badge.tsx index 8b4075ba67cdc7..44af8b33279753 100644 --- a/x-pack/plugins/monitoring/public/alerts/badge.tsx +++ b/x-pack/plugins/monitoring/public/alerts/badge.tsx @@ -19,13 +19,18 @@ import { getAlertPanelsByCategory } from './lib/get_alert_panels_by_category'; import { getAlertPanelsByNode } from './lib/get_alert_panels_by_node'; export const numberOfAlertsLabel = (count: number) => `${count} alert${count > 1 ? 's' : ''}`; +export const numberOfRulesLabel = (count: number) => `${count} rule${count > 1 ? 's' : ''}`; const MAX_TO_SHOW_BY_CATEGORY = 8; -const PANEL_TITLE = i18n.translate('xpack.monitoring.alerts.badge.panelTitle', { +const PANEL_TITLE_ALERTS = i18n.translate('xpack.monitoring.alerts.badge.panelTitle', { defaultMessage: 'Alerts', }); +const PANEL_TITLE_RULES = i18n.translate('xpack.monitoring.rules.badge.panelTitle', { + defaultMessage: 'Rules', +}); + const GROUP_BY_NODE = i18n.translate('xpack.monitoring.alerts.badge.groupByNode', { defaultMessage: 'Group by node', }); @@ -54,6 +59,7 @@ export const AlertsBadge: React.FC = (props: Props) => { const [showByNode, setShowByNode] = React.useState( !inSetupMode && alertCount > MAX_TO_SHOW_BY_CATEGORY ); + const PANEL_TITLE = inSetupMode ? PANEL_TITLE_RULES : PANEL_TITLE_ALERTS; React.useEffect(() => { if (inSetupMode && showByNode) { @@ -93,10 +99,12 @@ export const AlertsBadge: React.FC = (props: Props) => { setShowPopover(true)} > - {numberOfAlertsLabel(alertCount)} + {inSetupMode ? numberOfRulesLabel(alertCount) : numberOfAlertsLabel(alertCount)} ); From 00a6bdd4010b2419229e3b9e98c738b10659df52 Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Tue, 22 Jun 2021 14:36:04 -0400 Subject: [PATCH 08/17] Allow initialNamespaces to be used for isolated types (#102585) --- docs/api/saved-objects/bulk_create.asciidoc | 5 + docs/api/saved-objects/create.asciidoc | 5 + ...jectsbulkcreateobject.initialnamespaces.md | 2 +- ...ore-server.savedobjectsbulkcreateobject.md | 2 +- ...dobjectscreateoptions.initialnamespaces.md | 2 +- ...n-core-server.savedobjectscreateoptions.md | 2 +- .../service/lib/repository.test.js | 145 +++++++++++++----- .../saved_objects/service/lib/repository.ts | 74 +++++---- .../service/saved_objects_client.ts | 12 +- src/core/server/server.api.md | 2 +- .../common/lib/saved_object_test_utils.ts | 6 +- .../common/suites/bulk_create.ts | 22 ++- .../common/suites/create.ts | 22 ++- .../security_and_spaces/apis/bulk_create.ts | 19 ++- .../security_and_spaces/apis/create.ts | 19 ++- .../security_only/apis/bulk_create.ts | 18 ++- .../security_only/apis/create.ts | 18 ++- .../spaces_only/apis/bulk_create.ts | 18 ++- .../spaces_only/apis/create.ts | 18 ++- 19 files changed, 307 insertions(+), 104 deletions(-) diff --git a/docs/api/saved-objects/bulk_create.asciidoc b/docs/api/saved-objects/bulk_create.asciidoc index 267ab3891d7000..5bd3a7587dde9e 100644 --- a/docs/api/saved-objects/bulk_create.asciidoc +++ b/docs/api/saved-objects/bulk_create.asciidoc @@ -45,6 +45,11 @@ experimental[] Create multiple {kib} saved objects. (Optional, string array) Identifiers for the <> in which this object is created. If this is provided, the object is created only in the explicitly defined spaces. If this is not provided, the object is created in the current space (default behavior). +* For shareable object types (registered with `namespaceType: 'multiple'`): this option can be used to specify one or more spaces, including +the "All spaces" identifier (`'*'`). +* For isolated object types (registered with `namespaceType: 'single'` or `namespaceType: 'multiple-isolated'`): this option can only be +used to specify a single space, and the "All spaces" identifier (`'*'`) is not allowed. +* For global object types (registered with `namespaceType: 'agnostic'`): this option cannot be used. `version`:: (Optional, number) Specifies the version. diff --git a/docs/api/saved-objects/create.asciidoc b/docs/api/saved-objects/create.asciidoc index d7a368034ef07f..e7e25c7d3bba6d 100644 --- a/docs/api/saved-objects/create.asciidoc +++ b/docs/api/saved-objects/create.asciidoc @@ -52,6 +52,11 @@ any data that you send to the API is properly formed. (Optional, string array) Identifiers for the <> in which this object is created. If this is provided, the object is created only in the explicitly defined spaces. If this is not provided, the object is created in the current space (default behavior). +* For shareable object types (registered with `namespaceType: 'multiple'`): this option can be used to specify one or more spaces, including +the "All spaces" identifier (`'*'`). +* For isolated object types (registered with `namespaceType: 'single'` or `namespaceType: 'multiple-isolated'`): this option can only be +used to specify a single space, and the "All spaces" identifier (`'*'`) is not allowed. +* For global object types (registered with `namespaceType: 'agnostic'): this option cannot be used. [[saved-objects-api-create-request-codes]] ==== Response code diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.initialnamespaces.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.initialnamespaces.md index 3db8bbadfbd6bf..4d094ecde7a96a 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.initialnamespaces.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.initialnamespaces.md @@ -6,7 +6,7 @@ Optional initial namespaces for the object to be created in. If this is defined, it will supersede the namespace ID that is in [SavedObjectsCreateOptions](./kibana-plugin-core-server.savedobjectscreateoptions.md). -Note: this can only be used for multi-namespace object types. +\* For shareable object types (registered with `namespaceType: 'multiple'`): this option can be used to specify one or more spaces, including the "All spaces" identifier (`'*'`). \* For isolated object types (registered with `namespaceType: 'single'` or `namespaceType: 'multiple-isolated'`): this option can only be used to specify a single space, and the "All spaces" identifier (`'*'`) is not allowed. \* For global object types (registered with `namespaceType: 'agnostic'`): this option cannot be used. Signature: diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.md index 6fc01212a2e41a..463c3fe81b7029 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.md @@ -18,7 +18,7 @@ export interface SavedObjectsBulkCreateObject | [attributes](./kibana-plugin-core-server.savedobjectsbulkcreateobject.attributes.md) | T | | | [coreMigrationVersion](./kibana-plugin-core-server.savedobjectsbulkcreateobject.coremigrationversion.md) | string | A semver value that is used when upgrading objects between Kibana versions. If undefined, this will be automatically set to the current Kibana version when the object is created. If this is set to a non-semver value, or it is set to a semver value greater than the current Kibana version, it will result in an error. | | [id](./kibana-plugin-core-server.savedobjectsbulkcreateobject.id.md) | string | | -| [initialNamespaces](./kibana-plugin-core-server.savedobjectsbulkcreateobject.initialnamespaces.md) | string[] | Optional initial namespaces for the object to be created in. If this is defined, it will supersede the namespace ID that is in [SavedObjectsCreateOptions](./kibana-plugin-core-server.savedobjectscreateoptions.md).Note: this can only be used for multi-namespace object types. | +| [initialNamespaces](./kibana-plugin-core-server.savedobjectsbulkcreateobject.initialnamespaces.md) | string[] | Optional initial namespaces for the object to be created in. If this is defined, it will supersede the namespace ID that is in [SavedObjectsCreateOptions](./kibana-plugin-core-server.savedobjectscreateoptions.md).\* For shareable object types (registered with namespaceType: 'multiple'): this option can be used to specify one or more spaces, including the "All spaces" identifier ('*'). \* For isolated object types (registered with namespaceType: 'single' or namespaceType: 'multiple-isolated'): this option can only be used to specify a single space, and the "All spaces" identifier ('*') is not allowed. \* For global object types (registered with namespaceType: 'agnostic'): this option cannot be used. | | [migrationVersion](./kibana-plugin-core-server.savedobjectsbulkcreateobject.migrationversion.md) | SavedObjectsMigrationVersion | Information about the migrations that have been applied to this SavedObject. When Kibana starts up, KibanaMigrator detects outdated documents and migrates them based on this value. For each migration that has been applied, the plugin's name is used as a key and the latest migration version as the value. | | [originId](./kibana-plugin-core-server.savedobjectsbulkcreateobject.originid.md) | string | Optional ID of the original saved object, if this object's id was regenerated | | [references](./kibana-plugin-core-server.savedobjectsbulkcreateobject.references.md) | SavedObjectReference[] | | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.initialnamespaces.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.initialnamespaces.md index 262b0997cb9050..43489b8d2e8a27 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.initialnamespaces.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.initialnamespaces.md @@ -6,7 +6,7 @@ Optional initial namespaces for the object to be created in. If this is defined, it will supersede the namespace ID that is in [SavedObjectsCreateOptions](./kibana-plugin-core-server.savedobjectscreateoptions.md). -Note: this can only be used for multi-namespace object types. +\* For shareable object types (registered with `namespaceType: 'multiple'`): this option can be used to specify one or more spaces, including the "All spaces" identifier (`'*'`). \* For isolated object types (registered with `namespaceType: 'single'` or `namespaceType: 'multiple-isolated'`): this option can only be used to specify a single space, and the "All spaces" identifier (`'*'`) is not allowed. \* For global object types (registered with `namespaceType: 'agnostic'`): this option cannot be used. Signature: diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.md index 1805f389d4e7f3..7eaa9c51f5c82c 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.md @@ -17,7 +17,7 @@ export interface SavedObjectsCreateOptions extends SavedObjectsBaseOptions | --- | --- | --- | | [coreMigrationVersion](./kibana-plugin-core-server.savedobjectscreateoptions.coremigrationversion.md) | string | A semver value that is used when upgrading objects between Kibana versions. If undefined, this will be automatically set to the current Kibana version when the object is created. If this is set to a non-semver value, or it is set to a semver value greater than the current Kibana version, it will result in an error. | | [id](./kibana-plugin-core-server.savedobjectscreateoptions.id.md) | string | (not recommended) Specify an id for the document | -| [initialNamespaces](./kibana-plugin-core-server.savedobjectscreateoptions.initialnamespaces.md) | string[] | Optional initial namespaces for the object to be created in. If this is defined, it will supersede the namespace ID that is in [SavedObjectsCreateOptions](./kibana-plugin-core-server.savedobjectscreateoptions.md).Note: this can only be used for multi-namespace object types. | +| [initialNamespaces](./kibana-plugin-core-server.savedobjectscreateoptions.initialnamespaces.md) | string[] | Optional initial namespaces for the object to be created in. If this is defined, it will supersede the namespace ID that is in [SavedObjectsCreateOptions](./kibana-plugin-core-server.savedobjectscreateoptions.md).\* For shareable object types (registered with namespaceType: 'multiple'): this option can be used to specify one or more spaces, including the "All spaces" identifier ('*'). \* For isolated object types (registered with namespaceType: 'single' or namespaceType: 'multiple-isolated'): this option can only be used to specify a single space, and the "All spaces" identifier ('*') is not allowed. \* For global object types (registered with namespaceType: 'agnostic'): this option cannot be used. | | [migrationVersion](./kibana-plugin-core-server.savedobjectscreateoptions.migrationversion.md) | SavedObjectsMigrationVersion | Information about the migrations that have been applied to this SavedObject. When Kibana starts up, KibanaMigrator detects outdated documents and migrates them based on this value. For each migration that has been applied, the plugin's name is used as a key and the latest migration version as the value. | | [originId](./kibana-plugin-core-server.savedobjectscreateoptions.originid.md) | string | Optional ID of the original saved object, if this object's id was regenerated | | [overwrite](./kibana-plugin-core-server.savedobjectscreateoptions.overwrite.md) | boolean | Overwrite existing documents (defaults to false) | diff --git a/src/core/server/saved_objects/service/lib/repository.test.js b/src/core/server/saved_objects/service/lib/repository.test.js index 22c40a547f419a..4456784fdbc0b4 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.js +++ b/src/core/server/saved_objects/service/lib/repository.test.js @@ -525,15 +525,22 @@ describe('SavedObjectsRepository', () => { const ns2 = 'bar-namespace'; const ns3 = 'baz-namespace'; const objects = [ - { ...obj1, type: MULTI_NAMESPACE_TYPE, initialNamespaces: [ns2] }, - { ...obj2, type: MULTI_NAMESPACE_TYPE, initialNamespaces: [ns3] }, + { ...obj1, type: 'dashboard', initialNamespaces: [ns2] }, + { ...obj1, type: MULTI_NAMESPACE_ISOLATED_TYPE, initialNamespaces: [ns2] }, + { ...obj1, type: MULTI_NAMESPACE_TYPE, initialNamespaces: [ns2, ns3] }, ]; await bulkCreateSuccess(objects, { namespace, overwrite: true }); const body = [ - expect.any(Object), + { index: expect.objectContaining({ _id: `${ns2}:dashboard:${obj1.id}` }) }, + expect.objectContaining({ namespace: ns2 }), + { + index: expect.objectContaining({ + _id: `${MULTI_NAMESPACE_ISOLATED_TYPE}:${obj1.id}`, + }), + }, expect.objectContaining({ namespaces: [ns2] }), - expect.any(Object), - expect.objectContaining({ namespaces: [ns3] }), + { index: expect.objectContaining({ _id: `${MULTI_NAMESPACE_TYPE}:${obj1.id}` }) }, + expect.objectContaining({ namespaces: [ns2, ns3] }), ]; expect(client.bulk).toHaveBeenCalledWith( expect.objectContaining({ body }), @@ -649,24 +656,19 @@ describe('SavedObjectsRepository', () => { ).rejects.toThrowError(createBadRequestError('"options.namespace" cannot be "*"')); }); - it(`returns error when initialNamespaces is used with a non-shareable object`, async () => { - const test = async (objType) => { - const obj = { ...obj3, type: objType, initialNamespaces: [] }; - await bulkCreateError( + it(`returns error when initialNamespaces is used with a space-agnostic object`, async () => { + const obj = { ...obj3, type: NAMESPACE_AGNOSTIC_TYPE, initialNamespaces: [] }; + await bulkCreateError( + obj, + undefined, + expectErrorResult( obj, - undefined, - expectErrorResult( - obj, - createBadRequestError('"initialNamespaces" can only be used on multi-namespace types') - ) - ); - }; - await test('dashboard'); - await test(NAMESPACE_AGNOSTIC_TYPE); - await test(MULTI_NAMESPACE_ISOLATED_TYPE); + createBadRequestError('"initialNamespaces" cannot be used on space-agnostic types') + ) + ); }); - it(`throws when options.initialNamespaces is used with a shareable type and is empty`, async () => { + it(`returns error when initialNamespaces is empty`, async () => { const obj = { ...obj3, type: MULTI_NAMESPACE_TYPE, initialNamespaces: [] }; await bulkCreateError( obj, @@ -678,6 +680,26 @@ describe('SavedObjectsRepository', () => { ); }); + it(`returns error when initialNamespaces is used with a space-isolated object and does not specify a single space`, async () => { + const doTest = async (objType, initialNamespaces) => { + const obj = { ...obj3, type: objType, initialNamespaces }; + await bulkCreateError( + obj, + undefined, + expectErrorResult( + obj, + createBadRequestError( + '"initialNamespaces" can only specify a single space when used with space-isolated types' + ) + ) + ); + }; + await doTest('dashboard', ['spacex', 'spacey']); + await doTest('dashboard', ['*']); + await doTest(MULTI_NAMESPACE_ISOLATED_TYPE, ['spacex', 'spacey']); + await doTest(MULTI_NAMESPACE_ISOLATED_TYPE, ['*']); + }); + it(`returns error when type is invalid`, async () => { const obj = { ...obj3, type: 'unknownType' }; await bulkCreateError(obj, undefined, expectErrorInvalidType(obj)); @@ -1865,12 +1887,46 @@ describe('SavedObjectsRepository', () => { }); it(`adds initialNamespaces instead of namespace`, async () => { - const options = { id, namespace, initialNamespaces: ['bar-namespace', 'baz-namespace'] }; - await createSuccess(MULTI_NAMESPACE_TYPE, attributes, options); - expect(client.create).toHaveBeenCalledWith( + const ns2 = 'bar-namespace'; + const ns3 = 'baz-namespace'; + await savedObjectsRepository.create('dashboard', attributes, { + id, + namespace, + initialNamespaces: [ns2], + }); + await savedObjectsRepository.create(MULTI_NAMESPACE_ISOLATED_TYPE, attributes, { + id, + namespace, + initialNamespaces: [ns2], + }); + await savedObjectsRepository.create(MULTI_NAMESPACE_TYPE, attributes, { + id, + namespace, + initialNamespaces: [ns2, ns3], + }); + + expect(client.create).toHaveBeenCalledTimes(3); + expect(client.create).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + id: `${ns2}:dashboard:${id}`, + body: expect.objectContaining({ namespace: ns2 }), + }), + expect.anything() + ); + expect(client.create).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + id: `${MULTI_NAMESPACE_ISOLATED_TYPE}:${id}`, + body: expect.objectContaining({ namespaces: [ns2] }), + }), + expect.anything() + ); + expect(client.create).toHaveBeenNthCalledWith( + 3, expect.objectContaining({ id: `${MULTI_NAMESPACE_TYPE}:${id}`, - body: expect.objectContaining({ namespaces: options.initialNamespaces }), + body: expect.objectContaining({ namespaces: [ns2, ns3] }), }), expect.anything() ); @@ -1892,29 +1948,40 @@ describe('SavedObjectsRepository', () => { }); describe('errors', () => { - it(`throws when options.initialNamespaces is used with a non-shareable object`, async () => { - const test = async (objType) => { - await expect( - savedObjectsRepository.create(objType, attributes, { initialNamespaces: [namespace] }) - ).rejects.toThrowError( - createBadRequestError( - '"options.initialNamespaces" can only be used on multi-namespace types' - ) - ); - }; - await test('dashboard'); - await test(MULTI_NAMESPACE_ISOLATED_TYPE); - await test(NAMESPACE_AGNOSTIC_TYPE); + it(`throws when options.initialNamespaces is used with a space-agnostic object`, async () => { + await expect( + savedObjectsRepository.create(NAMESPACE_AGNOSTIC_TYPE, attributes, { + initialNamespaces: [namespace], + }) + ).rejects.toThrowError( + createBadRequestError('"initialNamespaces" cannot be used on space-agnostic types') + ); }); - it(`throws when options.initialNamespaces is used with a shareable type and is empty`, async () => { + it(`throws when options.initialNamespaces is empty`, async () => { await expect( savedObjectsRepository.create(MULTI_NAMESPACE_TYPE, attributes, { initialNamespaces: [] }) ).rejects.toThrowError( - createBadRequestError('"options.initialNamespaces" must be a non-empty array of strings') + createBadRequestError('"initialNamespaces" must be a non-empty array of strings') ); }); + it(`throws when options.initialNamespaces is used with a space-isolated object and does not specify a single space`, async () => { + const doTest = async (objType, initialNamespaces) => { + await expect( + savedObjectsRepository.create(objType, attributes, { initialNamespaces }) + ).rejects.toThrowError( + createBadRequestError( + '"initialNamespaces" can only specify a single space when used with space-isolated types' + ) + ); + }; + await doTest('dashboard', ['spacex', 'spacey']); + await doTest('dashboard', ['*']); + await doTest(MULTI_NAMESPACE_ISOLATED_TYPE, ['spacex', 'spacey']); + await doTest(MULTI_NAMESPACE_ISOLATED_TYPE, ['*']); + }); + it(`throws when options.namespace is '*'`, async () => { await expect( savedObjectsRepository.create(type, attributes, { namespace: ALL_NAMESPACES_STRING }) diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index 1577f773434b9d..c9fa50da55df10 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -283,28 +283,18 @@ export class SavedObjectsRepository { } = options; const namespace = normalizeNamespace(options.namespace); - if (initialNamespaces) { - if (!this._registry.isShareable(type)) { - throw SavedObjectsErrorHelpers.createBadRequestError( - '"options.initialNamespaces" can only be used on multi-namespace types' - ); - } else if (!initialNamespaces.length) { - throw SavedObjectsErrorHelpers.createBadRequestError( - '"options.initialNamespaces" must be a non-empty array of strings' - ); - } - } + this.validateInitialNamespaces(type, initialNamespaces); if (!this._allowedTypes.includes(type)) { throw SavedObjectsErrorHelpers.createUnsupportedTypeError(type); } const time = this._getCurrentTime(); - let savedObjectNamespace; + let savedObjectNamespace: string | undefined; let savedObjectNamespaces: string[] | undefined; - if (this._registry.isSingleNamespace(type) && namespace) { - savedObjectNamespace = namespace; + if (this._registry.isSingleNamespace(type)) { + savedObjectNamespace = initialNamespaces ? initialNamespaces[0] : namespace; } else if (this._registry.isMultiNamespace(type)) { if (id && overwrite) { // we will overwrite a multi-namespace saved object if it exists; if that happens, ensure we preserve its included namespaces @@ -369,32 +359,29 @@ export class SavedObjectsRepository { let bulkGetRequestIndexCounter = 0; const expectedResults: Either[] = objects.map((object) => { + const { type, id, initialNamespaces } = object; let error: DecoratedError | undefined; - if (!this._allowedTypes.includes(object.type)) { - error = SavedObjectsErrorHelpers.createUnsupportedTypeError(object.type); - } else if (object.initialNamespaces) { - if (!this._registry.isShareable(object.type)) { - error = SavedObjectsErrorHelpers.createBadRequestError( - '"initialNamespaces" can only be used on multi-namespace types' - ); - } else if (!object.initialNamespaces.length) { - error = SavedObjectsErrorHelpers.createBadRequestError( - '"initialNamespaces" must be a non-empty array of strings' - ); + if (!this._allowedTypes.includes(type)) { + error = SavedObjectsErrorHelpers.createUnsupportedTypeError(type); + } else { + try { + this.validateInitialNamespaces(type, initialNamespaces); + } catch (e) { + error = e; } } if (error) { return { tag: 'Left' as 'Left', - error: { id: object.id, type: object.type, error: errorContent(error) }, + error: { id, type, error: errorContent(error) }, }; } - const method = object.id && overwrite ? 'index' : 'create'; - const requiresNamespacesCheck = object.id && this._registry.isMultiNamespace(object.type); + const method = id && overwrite ? 'index' : 'create'; + const requiresNamespacesCheck = id && this._registry.isMultiNamespace(type); - if (object.id == null) { + if (id == null) { object.id = SavedObjectsUtils.generateId(); } @@ -434,8 +421,8 @@ export class SavedObjectsRepository { return expectedBulkGetResult; } - let savedObjectNamespace; - let savedObjectNamespaces; + let savedObjectNamespace: string | undefined; + let savedObjectNamespaces: string[] | undefined; let versionProperties; const { esRequestIndex, @@ -469,7 +456,7 @@ export class SavedObjectsRepository { versionProperties = getExpectedVersionProperties(version, actualResult); } else { if (this._registry.isSingleNamespace(object.type)) { - savedObjectNamespace = namespace; + savedObjectNamespace = initialNamespaces ? initialNamespaces[0] : namespace; } else if (this._registry.isMultiNamespace(object.type)) { savedObjectNamespaces = initialNamespaces || getSavedObjectNamespaces(namespace); } @@ -2080,6 +2067,29 @@ export class SavedObjectsRepository { const object = await this.get(type, id, options); return { saved_object: object, outcome: 'exactMatch' }; } + + private validateInitialNamespaces(type: string, initialNamespaces: string[] | undefined) { + if (!initialNamespaces) { + return; + } + + if (this._registry.isNamespaceAgnostic(type)) { + throw SavedObjectsErrorHelpers.createBadRequestError( + '"initialNamespaces" cannot be used on space-agnostic types' + ); + } else if (!initialNamespaces.length) { + throw SavedObjectsErrorHelpers.createBadRequestError( + '"initialNamespaces" must be a non-empty array of strings' + ); + } else if ( + !this._registry.isShareable(type) && + (initialNamespaces.length > 1 || initialNamespaces.includes(ALL_NAMESPACES_STRING)) + ) { + throw SavedObjectsErrorHelpers.createBadRequestError( + '"initialNamespaces" can only specify a single space when used with space-isolated types' + ); + } + } } /** diff --git a/src/core/server/saved_objects/service/saved_objects_client.ts b/src/core/server/saved_objects/service/saved_objects_client.ts index af682cfb81296e..1423050145695f 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.ts +++ b/src/core/server/saved_objects/service/saved_objects_client.ts @@ -63,7 +63,11 @@ export interface SavedObjectsCreateOptions extends SavedObjectsBaseOptions { * Optional initial namespaces for the object to be created in. If this is defined, it will supersede the namespace ID that is in * {@link SavedObjectsCreateOptions}. * - * Note: this can only be used for multi-namespace object types. + * * For shareable object types (registered with `namespaceType: 'multiple'`): this option can be used to specify one or more spaces, + * including the "All spaces" identifier (`'*'`). + * * For isolated object types (registered with `namespaceType: 'single'` or `namespaceType: 'multiple-isolated'`): this option can only + * be used to specify a single space, and the "All spaces" identifier (`'*'`) is not allowed. + * * For global object types (registered with `namespaceType: 'agnostic'`): this option cannot be used. */ initialNamespaces?: string[]; } @@ -96,7 +100,11 @@ export interface SavedObjectsBulkCreateObject { * Optional initial namespaces for the object to be created in. If this is defined, it will supersede the namespace ID that is in * {@link SavedObjectsCreateOptions}. * - * Note: this can only be used for multi-namespace object types. + * * For shareable object types (registered with `namespaceType: 'multiple'`): this option can be used to specify one or more spaces, + * including the "All spaces" identifier (`'*'`). + * * For isolated object types (registered with `namespaceType: 'single'` or `namespaceType: 'multiple-isolated'`): this option can only + * be used to specify a single space, and the "All spaces" identifier (`'*'`) is not allowed. + * * For global object types (registered with `namespaceType: 'agnostic'`): this option cannot be used. */ initialNamespaces?: string[]; } diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 9e7721fde90e7d..fcecf39f7e53a9 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -2901,7 +2901,7 @@ export class SavedObjectsRepository { resolve(type: string, id: string, options?: SavedObjectsBaseOptions): Promise>; update(type: string, id: string, attributes: Partial, options?: SavedObjectsUpdateOptions): Promise>; updateObjectsSpaces(objects: SavedObjectsUpdateObjectsSpacesObject[], spacesToAdd: string[], spacesToRemove: string[], options?: SavedObjectsUpdateObjectsSpacesOptions): Promise; -} + } // @public export interface SavedObjectsRepositoryFactory { diff --git a/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_utils.ts b/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_utils.ts index b712c2882ee0f9..eb0c161049cf05 100644 --- a/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_utils.ts +++ b/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_utils.ts @@ -154,12 +154,14 @@ export const expectResponses = { // bulk request error expect(object.type).to.eql(type); expect(object.id).to.eql(id); - expect(object.error).to.eql(error.output.payload); + expect(object.error.error).to.eql(error.output.payload.error); + expect(object.error.statusCode).to.eql(error.output.payload.statusCode); + // ignore the error.message, because it can vary for decorated errors } else { // non-bulk request error expect(object.error).to.eql(error.output.payload.error); expect(object.statusCode).to.eql(error.output.payload.statusCode); - // ignore the error.message, because it can vary for decorated non-bulk errors (e.g., conflict) + // ignore the error.message, because it can vary for decorated errors } } else { // fall back to default behavior of testing the success outcome diff --git a/x-pack/test/saved_object_api_integration/common/suites/bulk_create.ts b/x-pack/test/saved_object_api_integration/common/suites/bulk_create.ts index 5860ec1f193b27..06758da1ebad27 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/bulk_create.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/bulk_create.ts @@ -41,13 +41,25 @@ const EACH_SPACE = [DEFAULT_SPACE_ID, SPACE_1_ID, SPACE_2_ID]; const NEW_SINGLE_NAMESPACE_OBJ = Object.freeze({ type: 'dashboard', id: 'new-dashboard-id' }); const NEW_MULTI_NAMESPACE_OBJ = Object.freeze({ type: 'sharedtype', id: 'new-sharedtype-id' }); -const NEW_EACH_SPACE_OBJ = Object.freeze({ +const INITIAL_NS_SINGLE_NAMESPACE_OBJ_OTHER_SPACE = Object.freeze({ + type: 'isolatedtype', + id: 'new-other-space-id', + expectedNamespaces: ['other-space'], // expected namespaces of resulting object + initialNamespaces: ['other-space'], // args passed to the bulkCreate method +}); +const INITIAL_NS_MULTI_NAMESPACE_ISOLATED_OBJ_OTHER_SPACE = Object.freeze({ + type: 'sharecapabletype', + id: 'new-other-space-id', + expectedNamespaces: ['other-space'], // expected namespaces of resulting object + initialNamespaces: ['other-space'], // args passed to the bulkCreate method +}); +const INITIAL_NS_MULTI_NAMESPACE_OBJ_EACH_SPACE = Object.freeze({ type: 'sharedtype', id: 'new-each-space-id', expectedNamespaces: EACH_SPACE, // expected namespaces of resulting object initialNamespaces: EACH_SPACE, // args passed to the bulkCreate method }); -const NEW_ALL_SPACES_OBJ = Object.freeze({ +const INITIAL_NS_MULTI_NAMESPACE_OBJ_ALL_SPACES = Object.freeze({ type: 'sharedtype', id: 'new-all-spaces-id', expectedNamespaces: [ALL_SPACES_ID], // expected namespaces of resulting object @@ -58,8 +70,10 @@ export const TEST_CASES: Record = Object.freeze({ ...CASES, NEW_SINGLE_NAMESPACE_OBJ, NEW_MULTI_NAMESPACE_OBJ, - NEW_EACH_SPACE_OBJ, - NEW_ALL_SPACES_OBJ, + INITIAL_NS_SINGLE_NAMESPACE_OBJ_OTHER_SPACE, + INITIAL_NS_MULTI_NAMESPACE_ISOLATED_OBJ_OTHER_SPACE, + INITIAL_NS_MULTI_NAMESPACE_OBJ_EACH_SPACE, + INITIAL_NS_MULTI_NAMESPACE_OBJ_ALL_SPACES, NEW_NAMESPACE_AGNOSTIC_OBJ, }); diff --git a/x-pack/test/saved_object_api_integration/common/suites/create.ts b/x-pack/test/saved_object_api_integration/common/suites/create.ts index ff2bfdefb4c087..298e1a98071754 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/create.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/create.ts @@ -41,13 +41,25 @@ const EACH_SPACE = [DEFAULT_SPACE_ID, SPACE_1_ID, SPACE_2_ID]; // we could create six separate test cases to test every permutation, but there's no real value in doing so const NEW_SINGLE_NAMESPACE_OBJ = Object.freeze({ type: 'dashboard', id: '' }); const NEW_MULTI_NAMESPACE_OBJ = Object.freeze({ type: 'sharedtype', id: 'new-sharedtype-id' }); -const NEW_EACH_SPACE_OBJ = Object.freeze({ +const INITIAL_NS_SINGLE_NAMESPACE_OBJ_OTHER_SPACE = Object.freeze({ + type: 'isolatedtype', + id: 'new-other-space-id', + expectedNamespaces: ['other-space'], // expected namespaces of resulting object + initialNamespaces: ['other-space'], // args passed to the bulkCreate method +}); +const INITIAL_NS_MULTI_NAMESPACE_ISOLATED_OBJ_OTHER_SPACE = Object.freeze({ + type: 'sharecapabletype', + id: 'new-other-space-id', + expectedNamespaces: ['other-space'], // expected namespaces of resulting object + initialNamespaces: ['other-space'], // args passed to the bulkCreate method +}); +const INITIAL_NS_MULTI_NAMESPACE_OBJ_EACH_SPACE = Object.freeze({ type: 'sharedtype', id: 'new-each-space-id', expectedNamespaces: EACH_SPACE, // expected namespaces of resulting object initialNamespaces: EACH_SPACE, // args passed to the bulkCreate method }); -const NEW_ALL_SPACES_OBJ = Object.freeze({ +const INITIAL_NS_MULTI_NAMESPACE_OBJ_ALL_SPACES = Object.freeze({ type: 'sharedtype', id: 'new-all-spaces-id', expectedNamespaces: [ALL_SPACES_ID], // expected namespaces of resulting object @@ -58,8 +70,10 @@ export const TEST_CASES: Record = Object.freeze({ ...CASES, NEW_SINGLE_NAMESPACE_OBJ, NEW_MULTI_NAMESPACE_OBJ, - NEW_EACH_SPACE_OBJ, - NEW_ALL_SPACES_OBJ, + INITIAL_NS_SINGLE_NAMESPACE_OBJ_OTHER_SPACE, + INITIAL_NS_MULTI_NAMESPACE_ISOLATED_OBJ_OTHER_SPACE, + INITIAL_NS_MULTI_NAMESPACE_OBJ_EACH_SPACE, + INITIAL_NS_MULTI_NAMESPACE_OBJ_ALL_SPACES, NEW_NAMESPACE_AGNOSTIC_OBJ, }); diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_create.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_create.ts index 1fa24c6d6e2d6f..e048a4abc8ccc5 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_create.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_create.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { SPACES } from '../../common/lib/spaces'; +import { SPACES, ALL_SPACES_ID } from '../../common/lib/spaces'; import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; import { TestUser } from '../../common/lib/types'; import { FtrProviderContext } from '../../common/ftr_provider_context'; @@ -75,7 +75,22 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { { ...CASES.NEW_MULTI_NAMESPACE_OBJ, expectedNamespaces }, CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, ]; - const crossNamespace = [CASES.NEW_EACH_SPACE_OBJ, CASES.NEW_ALL_SPACES_OBJ]; + const crossNamespace = [ + { + ...CASES.INITIAL_NS_SINGLE_NAMESPACE_OBJ_OTHER_SPACE, + initialNamespaces: ['x', 'y'], + ...fail400(), // cannot be created in multiple spaces + }, + CASES.INITIAL_NS_SINGLE_NAMESPACE_OBJ_OTHER_SPACE, // second try creates it in a single other space, which is valid + { + ...CASES.INITIAL_NS_MULTI_NAMESPACE_ISOLATED_OBJ_OTHER_SPACE, + initialNamespaces: [ALL_SPACES_ID], + ...fail400(), // cannot be created in multiple spaces + }, + CASES.INITIAL_NS_MULTI_NAMESPACE_ISOLATED_OBJ_OTHER_SPACE, // second try creates it in a single other space, which is valid + CASES.INITIAL_NS_MULTI_NAMESPACE_OBJ_EACH_SPACE, + CASES.INITIAL_NS_MULTI_NAMESPACE_OBJ_ALL_SPACES, + ]; const hiddenType = [{ ...CASES.HIDDEN, ...fail400() }]; const allTypes = normalTypes.concat(hiddenType); return { normalTypes, crossNamespace, hiddenType, allTypes }; diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/create.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/create.ts index 3553ae0e5b5387..8215c991a9287d 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/create.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/create.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { SPACES } from '../../common/lib/spaces'; +import { SPACES, ALL_SPACES_ID } from '../../common/lib/spaces'; import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; import { TestUser } from '../../common/lib/types'; import { FtrProviderContext } from '../../common/ftr_provider_context'; @@ -62,7 +62,22 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { { ...CASES.NEW_MULTI_NAMESPACE_OBJ, expectedNamespaces }, CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, ]; - const crossNamespace = [CASES.NEW_EACH_SPACE_OBJ, CASES.NEW_ALL_SPACES_OBJ]; + const crossNamespace = [ + { + ...CASES.INITIAL_NS_SINGLE_NAMESPACE_OBJ_OTHER_SPACE, + initialNamespaces: ['x', 'y'], + ...fail400(), // cannot be created in multiple spaces + }, + CASES.INITIAL_NS_SINGLE_NAMESPACE_OBJ_OTHER_SPACE, // second try creates it in a single other space, which is valid + { + ...CASES.INITIAL_NS_MULTI_NAMESPACE_ISOLATED_OBJ_OTHER_SPACE, + initialNamespaces: [ALL_SPACES_ID], + ...fail400(), // cannot be created in multiple spaces + }, + CASES.INITIAL_NS_MULTI_NAMESPACE_ISOLATED_OBJ_OTHER_SPACE, // second try creates it in a single other space, which is valid + CASES.INITIAL_NS_MULTI_NAMESPACE_OBJ_EACH_SPACE, + CASES.INITIAL_NS_MULTI_NAMESPACE_OBJ_ALL_SPACES, + ]; const hiddenType = [{ ...CASES.HIDDEN, ...fail400() }]; const allTypes = normalTypes.concat(crossNamespace, hiddenType); return { normalTypes, crossNamespace, hiddenType, allTypes }; diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/bulk_create.ts b/x-pack/test/saved_object_api_integration/security_only/apis/bulk_create.ts index 7487466f4b38c0..f9423d77c5bb56 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/bulk_create.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/bulk_create.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { SPACES } from '../../common/lib/spaces'; +import { SPACES, ALL_SPACES_ID } from '../../common/lib/spaces'; import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; import { TestUser } from '../../common/lib/types'; import { FtrProviderContext } from '../../common/ftr_provider_context'; @@ -39,8 +39,20 @@ const createTestCases = (overwrite: boolean) => { { ...CASES.NEW_SINGLE_NAMESPACE_OBJ, expectedNamespaces }, { ...CASES.NEW_MULTI_NAMESPACE_OBJ, expectedNamespaces }, CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, - CASES.NEW_EACH_SPACE_OBJ, - CASES.NEW_ALL_SPACES_OBJ, + { + ...CASES.INITIAL_NS_SINGLE_NAMESPACE_OBJ_OTHER_SPACE, + initialNamespaces: ['x', 'y'], + ...fail400(), // cannot be created in multiple spaces + }, + CASES.INITIAL_NS_SINGLE_NAMESPACE_OBJ_OTHER_SPACE, // second try creates it in a single other space, which is valid + { + ...CASES.INITIAL_NS_MULTI_NAMESPACE_ISOLATED_OBJ_OTHER_SPACE, + initialNamespaces: [ALL_SPACES_ID], + ...fail400(), // cannot be created in multiple spaces + }, + CASES.INITIAL_NS_MULTI_NAMESPACE_ISOLATED_OBJ_OTHER_SPACE, // second try creates it in a single other space, which is valid + CASES.INITIAL_NS_MULTI_NAMESPACE_OBJ_EACH_SPACE, + CASES.INITIAL_NS_MULTI_NAMESPACE_OBJ_ALL_SPACES, ]; const hiddenType = [{ ...CASES.HIDDEN, ...fail400() }]; const allTypes = normalTypes.concat(hiddenType); diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/create.ts b/x-pack/test/saved_object_api_integration/security_only/apis/create.ts index 7eda7f52834480..67195637f0c0ac 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/create.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/create.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { SPACES } from '../../common/lib/spaces'; +import { SPACES, ALL_SPACES_ID } from '../../common/lib/spaces'; import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; import { TestUser } from '../../common/lib/types'; import { FtrProviderContext } from '../../common/ftr_provider_context'; @@ -38,8 +38,20 @@ const createTestCases = (overwrite: boolean) => { { ...CASES.NEW_SINGLE_NAMESPACE_OBJ, expectedNamespaces }, { ...CASES.NEW_MULTI_NAMESPACE_OBJ, expectedNamespaces }, CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, - CASES.NEW_EACH_SPACE_OBJ, - CASES.NEW_ALL_SPACES_OBJ, + { + ...CASES.INITIAL_NS_SINGLE_NAMESPACE_OBJ_OTHER_SPACE, + initialNamespaces: ['x', 'y'], + ...fail400(), // cannot be created in multiple spaces + }, + CASES.INITIAL_NS_SINGLE_NAMESPACE_OBJ_OTHER_SPACE, // second try creates it in a single other space, which is valid + { + ...CASES.INITIAL_NS_MULTI_NAMESPACE_ISOLATED_OBJ_OTHER_SPACE, + initialNamespaces: [ALL_SPACES_ID], + ...fail400(), // cannot be created in multiple spaces + }, + CASES.INITIAL_NS_MULTI_NAMESPACE_ISOLATED_OBJ_OTHER_SPACE, // second try creates it in a single other space, which is valid + CASES.INITIAL_NS_MULTI_NAMESPACE_OBJ_EACH_SPACE, + CASES.INITIAL_NS_MULTI_NAMESPACE_OBJ_ALL_SPACES, ]; const hiddenType = [{ ...CASES.HIDDEN, ...fail400() }]; const allTypes = normalTypes.concat(hiddenType); diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_create.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_create.ts index 5812aaf43060d3..c448d73ce7bf83 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_create.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_create.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { SPACES } from '../../common/lib/spaces'; +import { SPACES, ALL_SPACES_ID } from '../../common/lib/spaces'; import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; import { FtrProviderContext } from '../../common/ftr_provider_context'; import { bulkCreateTestSuiteFactory, TEST_CASES as CASES } from '../../common/suites/bulk_create'; @@ -70,8 +70,20 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { { ...CASES.NEW_SINGLE_NAMESPACE_OBJ, expectedNamespaces }, { ...CASES.NEW_MULTI_NAMESPACE_OBJ, expectedNamespaces }, CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, - CASES.NEW_EACH_SPACE_OBJ, - CASES.NEW_ALL_SPACES_OBJ, + { + ...CASES.INITIAL_NS_SINGLE_NAMESPACE_OBJ_OTHER_SPACE, + initialNamespaces: ['x', 'y'], + ...fail400(), // cannot be created in multiple spaces + }, + CASES.INITIAL_NS_SINGLE_NAMESPACE_OBJ_OTHER_SPACE, // second try creates it in a single other space, which is valid + { + ...CASES.INITIAL_NS_MULTI_NAMESPACE_ISOLATED_OBJ_OTHER_SPACE, + initialNamespaces: [ALL_SPACES_ID], + ...fail400(), // cannot be created in multiple spaces + }, + CASES.INITIAL_NS_MULTI_NAMESPACE_ISOLATED_OBJ_OTHER_SPACE, // second try creates it in a single other space, which is valid + CASES.INITIAL_NS_MULTI_NAMESPACE_OBJ_EACH_SPACE, + CASES.INITIAL_NS_MULTI_NAMESPACE_OBJ_ALL_SPACES, ]; }; diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/create.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/create.ts index 4c91781b6ab2c0..7c8726896c18a1 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/create.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/create.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { SPACES } from '../../common/lib/spaces'; +import { SPACES, ALL_SPACES_ID } from '../../common/lib/spaces'; import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; import { FtrProviderContext } from '../../common/ftr_provider_context'; import { createTestSuiteFactory, TEST_CASES as CASES } from '../../common/suites/create'; @@ -57,8 +57,20 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { { ...CASES.NEW_SINGLE_NAMESPACE_OBJ, expectedNamespaces }, { ...CASES.NEW_MULTI_NAMESPACE_OBJ, expectedNamespaces }, CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, - CASES.NEW_EACH_SPACE_OBJ, - CASES.NEW_ALL_SPACES_OBJ, + { + ...CASES.INITIAL_NS_SINGLE_NAMESPACE_OBJ_OTHER_SPACE, + initialNamespaces: ['x', 'y'], + ...fail400(), // cannot be created in multiple spaces + }, + CASES.INITIAL_NS_SINGLE_NAMESPACE_OBJ_OTHER_SPACE, // second try creates it in a single other space, which is valid + { + ...CASES.INITIAL_NS_MULTI_NAMESPACE_ISOLATED_OBJ_OTHER_SPACE, + initialNamespaces: [ALL_SPACES_ID], + ...fail400(), // cannot be created in multiple spaces + }, + CASES.INITIAL_NS_MULTI_NAMESPACE_ISOLATED_OBJ_OTHER_SPACE, // second try creates it in a single other space, which is valid + CASES.INITIAL_NS_MULTI_NAMESPACE_OBJ_EACH_SPACE, + CASES.INITIAL_NS_MULTI_NAMESPACE_OBJ_ALL_SPACES, ]; }; From f422cbdcf17f5e598e715e88bd9bdd52d2f1d72b Mon Sep 17 00:00:00 2001 From: Constance Date: Tue, 22 Jun 2021 11:40:10 -0700 Subject: [PATCH 09/17] [App Search] Convert API Logs page to new page template + empty state polish (#102820) * Convert API Logs noItemsMessage to its own empty state prompt - Will be used by new page template * Convert API Logs view to new page template + use new empty state + add tests clarifying loading UX * Update router * Fix i18n ID --- .../components/api_logs/api_logs.test.tsx | 24 +++--- .../components/api_logs/api_logs.tsx | 73 ++++++++----------- .../components/api_logs_table.test.tsx | 10 +-- .../api_logs/components/api_logs_table.tsx | 20 ----- .../api_logs/components/empty_state.test.tsx | 27 +++++++ .../api_logs/components/empty_state.tsx | 45 ++++++++++++ .../components/api_logs/components/index.ts | 1 + .../components/engine/engine_router.tsx | 10 +-- 8 files changed, 124 insertions(+), 86 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/components/empty_state.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/components/empty_state.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/api_logs.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/api_logs.test.tsx index c2a11ec06fa6a4..5b082ce8d26ba5 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/api_logs.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/api_logs.test.tsx @@ -13,10 +13,7 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { EuiPageHeader } from '@elastic/eui'; - -import { Loading } from '../../../shared/loading'; -import { rerender } from '../../../test_helpers'; +import { rerender, getPageTitle } from '../../../test_helpers'; import { LogRetentionCallout, LogRetentionTooltip } from '../log_retention'; import { ApiLogsTable, NewApiEventsPrompt } from './components'; @@ -42,7 +39,7 @@ describe('ApiLogs', () => { it('renders', () => { const wrapper = shallow(); - expect(wrapper.find(EuiPageHeader).prop('pageTitle')).toEqual('API Logs'); + expect(getPageTitle(wrapper)).toEqual('API Logs'); expect(wrapper.find(ApiLogsTable)).toHaveLength(1); expect(wrapper.find(NewApiEventsPrompt)).toHaveLength(1); @@ -50,11 +47,20 @@ describe('ApiLogs', () => { expect(wrapper.find(LogRetentionTooltip).prop('type')).toEqual('api'); }); - it('renders a loading screen', () => { - setMockValues({ ...values, dataLoading: true, apiLogs: [] }); - const wrapper = shallow(); + describe('loading state', () => { + it('renders a full-page loading state on initial page load (no logs exist yet)', () => { + setMockValues({ ...values, dataLoading: true, apiLogs: [] }); + const wrapper = shallow(); + + expect(wrapper.prop('isLoading')).toEqual(true); + }); + + it('does not re-render a full-page loading state after initial page load (uses component-level loading state instead)', () => { + setMockValues({ ...values, dataLoading: true, apiLogs: [{}] }); + const wrapper = shallow(); - expect(wrapper.find(Loading)).toHaveLength(1); + expect(wrapper.prop('isLoading')).toEqual(false); + }); }); describe('effects', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/api_logs.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/api_logs.tsx index b8179163c93f94..d3eef77db21f04 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/api_logs.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/api_logs.tsx @@ -9,25 +9,14 @@ import React, { useEffect } from 'react'; import { useValues, useActions } from 'kea'; -import { - EuiPageHeader, - EuiTitle, - EuiPageContent, - EuiPageContentBody, - EuiFlexGroup, - EuiFlexItem, - EuiSpacer, -} from '@elastic/eui'; - -import { FlashMessages } from '../../../shared/flash_messages'; -import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; -import { Loading } from '../../../shared/loading'; +import { EuiTitle, EuiFlexGroup, EuiFlexItem, EuiPanel, EuiSpacer } from '@elastic/eui'; import { getEngineBreadcrumbs } from '../engine'; +import { AppSearchPageTemplate } from '../layout'; import { LogRetentionCallout, LogRetentionTooltip, LogRetentionOptions } from '../log_retention'; import { ApiLogFlyout } from './api_log'; -import { ApiLogsTable, NewApiEventsPrompt } from './components'; +import { ApiLogsTable, NewApiEventsPrompt, EmptyState } from './components'; import { API_LOGS_TITLE, RECENT_API_EVENTS } from './constants'; import { ApiLogsLogic } from './'; @@ -44,38 +33,36 @@ export const ApiLogs: React.FC = () => { pollForApiLogs(); }, []); - if (dataLoading && !apiLogs.length) return ; - return ( - <> - - - - + } + > - - - - - -

    {RECENT_API_EVENTS}

    -
    -
    - - - - - - - -
    - + + + + +

    {RECENT_API_EVENTS}

    +
    +
    + + + + + + + +
    + - - -
    -
    - + + + +
    ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/components/api_logs_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/components/api_logs_table.test.tsx index 2a00cc6eb42bb3..82d3d4715cbc56 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/components/api_logs_table.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/components/api_logs_table.test.tsx @@ -18,7 +18,7 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { EuiBasicTable, EuiBadge, EuiHealth, EuiButtonEmpty, EuiEmptyPrompt } from '@elastic/eui'; +import { EuiBasicTable, EuiBadge, EuiHealth, EuiButtonEmpty } from '@elastic/eui'; import { DEFAULT_META } from '../../../../shared/constants'; import { mountWithIntl } from '../../../../test_helpers'; @@ -91,14 +91,6 @@ describe('ApiLogsTable', () => { expect(actions.openFlyout).toHaveBeenCalled(); }); - it('renders an empty prompt if no items are passed', () => { - setMockValues({ ...values, apiLogs: [] }); - const wrapper = mountWithIntl(); - const promptContent = wrapper.find(EuiEmptyPrompt).text(); - - expect(promptContent).toContain('Perform your first API call'); - }); - describe('hasPagination', () => { it('does not render with pagination by default', () => { const wrapper = shallow(); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/components/api_logs_table.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/components/api_logs_table.tsx index bb1327ce2da30b..1b5a8084f5b594 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/components/api_logs_table.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/components/api_logs_table.tsx @@ -15,7 +15,6 @@ import { EuiBadge, EuiHealth, EuiButtonEmpty, - EuiEmptyPrompt, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedRelative } from '@kbn/i18n/react'; @@ -109,25 +108,6 @@ export const ApiLogsTable: React.FC = ({ hasPagination }) => { items={apiLogs} responsive loading={dataLoading} - noItemsMessage={ - - {i18n.translate('xpack.enterpriseSearch.appSearch.engine.apiLogs.emptyTitle', { - defaultMessage: 'Perform your first API call', - })} - - } - body={ -

    - {i18n.translate('xpack.enterpriseSearch.appSearch.engine.apiLogs.emptyDescription', { - defaultMessage: "Check back after you've performed some API calls.", - })} -

    - } - /> - } {...paginationProps} /> ); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/components/empty_state.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/components/empty_state.test.tsx new file mode 100644 index 00000000000000..3ad22ceac5840d --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/components/empty_state.test.tsx @@ -0,0 +1,27 @@ +/* + * 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 { shallow } from 'enzyme'; + +import { EuiEmptyPrompt, EuiButton } from '@elastic/eui'; + +import { EmptyState } from './'; + +describe('EmptyState', () => { + it('renders', () => { + const wrapper = shallow() + .find(EuiEmptyPrompt) + .dive(); + + expect(wrapper.find('h2').text()).toEqual('Perform your first API call'); + expect(wrapper.find(EuiButton).prop('href')).toEqual( + expect.stringContaining('/api-reference.html') + ); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/components/empty_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/components/empty_state.tsx new file mode 100644 index 00000000000000..3f6f44adefc71d --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/components/empty_state.tsx @@ -0,0 +1,45 @@ +/* + * 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 { EuiButton, EuiEmptyPrompt } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { DOCS_PREFIX } from '../../../routes'; + +export const EmptyState: React.FC = () => ( + + {i18n.translate('xpack.enterpriseSearch.appSearch.engine.apiLogs.emptyTitle', { + defaultMessage: 'Perform your first API call', + })} + + } + body={ +

    + {i18n.translate('xpack.enterpriseSearch.appSearch.engine.apiLogs.emptyDescription', { + defaultMessage: "Check back after you've performed some API calls.", + })} +

    + } + actions={ + + {i18n.translate('xpack.enterpriseSearch.appSearch.engine.apiLogs.empty.buttonLabel', { + defaultMessage: 'View the API reference', + })} + + } + /> +); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/components/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/components/index.ts index c0edc51d062283..863216554a540b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/components/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/components/index.ts @@ -7,3 +7,4 @@ export { ApiLogsTable } from './api_logs_table'; export { NewApiEventsPrompt } from './new_api_events_prompt'; +export { EmptyState } from './empty_state'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx index 3e18c9e680de22..fc057858426d2f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx @@ -114,6 +114,11 @@ export const EngineRouter: React.FC = () => { )} + {canViewEngineApiLogs && ( + + + + )} {/* TODO: Remove layout once page template migration is over */} }> {canViewEngineSchema && ( @@ -141,11 +146,6 @@ export const EngineRouter: React.FC = () => { )} - {canViewEngineApiLogs && ( - - - - )} {canViewMetaEngineSourceEngines && ( From 2b0f1256ddd2e65db8f29c198000db4d7cb3af61 Mon Sep 17 00:00:00 2001 From: Clint Andrew Hall Date: Tue, 22 Jun 2021 14:11:15 -0500 Subject: [PATCH 10/17] [canvas] New Home Page (#102446) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- x-pack/plugins/canvas/i18n/components.ts | 221 ------- x-pack/plugins/canvas/i18n/errors.ts | 53 +- .../public/components/home/home.component.tsx | 67 +++ .../public/components/home/home.stories.tsx | 30 + .../canvas/public/components/home/home.tsx | 33 + .../public/components/home/hooks/index.ts | 15 + .../home/hooks/use_clone_workpad.ts | 60 ++ .../home/hooks/use_create_from_template.ts | 32 + .../home/hooks/use_create_workpad.ts | 46 ++ .../home/hooks/use_delete_workpad.ts | 63 ++ .../home/hooks/use_download_workpad.ts | 12 + .../home/hooks/use_find_templates.ts | 38 ++ .../components/home/hooks/use_find_workpad.ts | 57 ++ .../home/hooks/use_upload_workpad.ts | 100 ++++ .../index.js => home/index.ts} | 2 +- .../home/my_workpads/empty_prompt.stories.tsx | 19 + .../home/my_workpads/empty_prompt.tsx | 65 ++ .../components/home/my_workpads/index.ts | 10 + .../components/home/my_workpads/loading.tsx | 17 + .../my_workpads/my_workpads.component.tsx | 38 ++ .../home/my_workpads/my_workpads.stories.tsx | 56 ++ .../home/my_workpads/my_workpads.tsx | 42 ++ .../my_workpads/upload_dropzone.component.tsx | 30 + .../home/my_workpads/upload_dropzone.scss | 8 + .../home/my_workpads/upload_dropzone.tsx | 55 ++ .../my_workpads/workpad_import.component.tsx | 40 ++ .../home/my_workpads/workpad_import.tsx | 35 ++ .../my_workpads/workpad_table.component.tsx | 203 +++++++ .../my_workpads/workpad_table.stories.tsx | 83 +++ .../home/my_workpads/workpad_table.tsx | 38 ++ .../workpad_table_tools.component.tsx | 160 +++++ .../home/my_workpads/workpad_table_tools.tsx | 51 ++ .../home/workpad_create.component.tsx | 37 ++ .../public/components/home/workpad_create.tsx | 31 + .../home/workpad_templates/index.ts | 10 + .../workpad_templates.component.tsx | 157 +++++ .../workpad_templates.stories.tsx | 62 ++ .../workpad_templates/workpad_templates.tsx | 35 ++ .../home_app/home_app.component.tsx | 18 +- .../toolbar/__stories__/toolbar.stories.tsx | 2 - .../components/toolbar/toolbar.component.tsx | 36 +- .../components/workpad_loader/index.tsx | 173 ------ .../workpad_loader/upload_workpad.js | 52 -- .../workpad_loader/workpad_create.js | 31 - .../workpad_loader/workpad_dropzone/index.js | 31 - .../workpad_dropzone/workpad_dropzone.js | 31 - .../workpad_dropzone/workpad_dropzone.scss | 22 - .../workpad_loader/workpad_loader.js | 426 ------------- .../workpad_loader/workpad_loader.scss | 25 - .../workpad_loader/workpad_search.js | 44 -- .../workpad_manager/workpad_manager.js | 69 --- .../workpad_templates.stories.storyshot | 564 ------------------ .../examples/workpad_templates.stories.tsx | 45 -- .../components/workpad_templates/index.tsx | 86 --- .../workpad_templates/workpad_templates.tsx | 215 ------- .../canvas/public/lib/get_tags_filter.tsx | 39 -- .../plugins/canvas/public/services/index.ts | 2 +- .../canvas/public/services/stubs/platform.ts | 8 +- .../canvas/public/services/stubs/workpad.ts | 96 ++- .../plugins/canvas/public/services/workpad.ts | 21 +- x-pack/plugins/canvas/public/style/index.scss | 2 - .../canvas/storybook/decorators/index.ts | 3 +- .../storybook/decorators/redux_decorator.tsx | 2 +- .../decorators/services_decorator.tsx | 40 +- x-pack/plugins/canvas/storybook/index.ts | 5 + x-pack/plugins/canvas/storybook/main.ts | 5 + .../empty_prompt.stories.storyshot | 65 ++ .../canvas/storybook/storyshots.test.tsx | 7 +- .../translations/translations/ja-JP.json | 81 ++- .../translations/translations/zh-CN.json | 81 ++- x-pack/test/accessibility/apps/canvas.ts | 2 +- .../test/functional/apps/canvas/smoke_test.js | 2 +- .../functional/page_objects/canvas_page.ts | 2 +- 73 files changed, 2156 insertions(+), 2288 deletions(-) create mode 100644 x-pack/plugins/canvas/public/components/home/home.component.tsx create mode 100644 x-pack/plugins/canvas/public/components/home/home.stories.tsx create mode 100644 x-pack/plugins/canvas/public/components/home/home.tsx create mode 100644 x-pack/plugins/canvas/public/components/home/hooks/index.ts create mode 100644 x-pack/plugins/canvas/public/components/home/hooks/use_clone_workpad.ts create mode 100644 x-pack/plugins/canvas/public/components/home/hooks/use_create_from_template.ts create mode 100644 x-pack/plugins/canvas/public/components/home/hooks/use_create_workpad.ts create mode 100644 x-pack/plugins/canvas/public/components/home/hooks/use_delete_workpad.ts create mode 100644 x-pack/plugins/canvas/public/components/home/hooks/use_download_workpad.ts create mode 100644 x-pack/plugins/canvas/public/components/home/hooks/use_find_templates.ts create mode 100644 x-pack/plugins/canvas/public/components/home/hooks/use_find_workpad.ts create mode 100644 x-pack/plugins/canvas/public/components/home/hooks/use_upload_workpad.ts rename x-pack/plugins/canvas/public/components/{workpad_manager/index.js => home/index.ts} (83%) create mode 100644 x-pack/plugins/canvas/public/components/home/my_workpads/empty_prompt.stories.tsx create mode 100644 x-pack/plugins/canvas/public/components/home/my_workpads/empty_prompt.tsx create mode 100644 x-pack/plugins/canvas/public/components/home/my_workpads/index.ts create mode 100644 x-pack/plugins/canvas/public/components/home/my_workpads/loading.tsx create mode 100644 x-pack/plugins/canvas/public/components/home/my_workpads/my_workpads.component.tsx create mode 100644 x-pack/plugins/canvas/public/components/home/my_workpads/my_workpads.stories.tsx create mode 100644 x-pack/plugins/canvas/public/components/home/my_workpads/my_workpads.tsx create mode 100644 x-pack/plugins/canvas/public/components/home/my_workpads/upload_dropzone.component.tsx create mode 100644 x-pack/plugins/canvas/public/components/home/my_workpads/upload_dropzone.scss create mode 100644 x-pack/plugins/canvas/public/components/home/my_workpads/upload_dropzone.tsx create mode 100644 x-pack/plugins/canvas/public/components/home/my_workpads/workpad_import.component.tsx create mode 100644 x-pack/plugins/canvas/public/components/home/my_workpads/workpad_import.tsx create mode 100644 x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table.component.tsx create mode 100644 x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table.stories.tsx create mode 100644 x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table.tsx create mode 100644 x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table_tools.component.tsx create mode 100644 x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table_tools.tsx create mode 100644 x-pack/plugins/canvas/public/components/home/workpad_create.component.tsx create mode 100644 x-pack/plugins/canvas/public/components/home/workpad_create.tsx create mode 100644 x-pack/plugins/canvas/public/components/home/workpad_templates/index.ts create mode 100644 x-pack/plugins/canvas/public/components/home/workpad_templates/workpad_templates.component.tsx create mode 100644 x-pack/plugins/canvas/public/components/home/workpad_templates/workpad_templates.stories.tsx create mode 100644 x-pack/plugins/canvas/public/components/home/workpad_templates/workpad_templates.tsx delete mode 100644 x-pack/plugins/canvas/public/components/workpad_loader/index.tsx delete mode 100644 x-pack/plugins/canvas/public/components/workpad_loader/upload_workpad.js delete mode 100644 x-pack/plugins/canvas/public/components/workpad_loader/workpad_create.js delete mode 100644 x-pack/plugins/canvas/public/components/workpad_loader/workpad_dropzone/index.js delete mode 100644 x-pack/plugins/canvas/public/components/workpad_loader/workpad_dropzone/workpad_dropzone.js delete mode 100644 x-pack/plugins/canvas/public/components/workpad_loader/workpad_dropzone/workpad_dropzone.scss delete mode 100644 x-pack/plugins/canvas/public/components/workpad_loader/workpad_loader.js delete mode 100644 x-pack/plugins/canvas/public/components/workpad_loader/workpad_loader.scss delete mode 100644 x-pack/plugins/canvas/public/components/workpad_loader/workpad_search.js delete mode 100644 x-pack/plugins/canvas/public/components/workpad_manager/workpad_manager.js delete mode 100644 x-pack/plugins/canvas/public/components/workpad_templates/examples/__snapshots__/workpad_templates.stories.storyshot delete mode 100644 x-pack/plugins/canvas/public/components/workpad_templates/examples/workpad_templates.stories.tsx delete mode 100644 x-pack/plugins/canvas/public/components/workpad_templates/index.tsx delete mode 100644 x-pack/plugins/canvas/public/components/workpad_templates/workpad_templates.tsx delete mode 100644 x-pack/plugins/canvas/public/lib/get_tags_filter.tsx create mode 100644 x-pack/plugins/canvas/storybook/public/components/home/my_workpads/__snapshots__/empty_prompt.stories.storyshot diff --git a/x-pack/plugins/canvas/i18n/components.ts b/x-pack/plugins/canvas/i18n/components.ts index 7a23137e7ef60e..6f011bb73e3b0d 100644 --- a/x-pack/plugins/canvas/i18n/components.ts +++ b/x-pack/plugins/canvas/i18n/components.ts @@ -1166,12 +1166,6 @@ export const ComponentStrings = { description: 'This is referring to the dimensions of U.S. standard letter paper.', }), }, - WorkpadCreate: { - getWorkpadCreateButtonLabel: () => - i18n.translate('xpack.canvas.workpadCreate.createButtonLabel', { - defaultMessage: 'Create workpad', - }), - }, WorkpadHeader: { getAddElementButtonLabel: () => i18n.translate('xpack.canvas.workpadHeader.addElementButtonLabel', { @@ -1546,219 +1540,4 @@ export const ComponentStrings = { defaultMessage: 'Reset', }), }, - WorkpadLoader: { - getClonedWorkpadName: (workpadName: string) => - i18n.translate('xpack.canvas.workpadLoader.clonedWorkpadName', { - defaultMessage: 'Copy of {workpadName}', - values: { - workpadName, - }, - description: - 'This suffix is added to the end of the name of a cloned workpad to indicate that this ' + - 'new workpad is a copy of the original workpad. Example: "Copy of Sales Pitch"', - }), - getCloneToolTip: () => - i18n.translate('xpack.canvas.workpadLoader.cloneTooltip', { - defaultMessage: 'Clone workpad', - }), - getCreateWorkpadLoadingDescription: () => - i18n.translate('xpack.canvas.workpadLoader.createWorkpadLoadingDescription', { - defaultMessage: 'Creating workpad...', - description: - 'This message appears while the user is waiting for a new workpad to be created', - }), - getDeleteButtonAriaLabel: (numberOfWorkpads: number) => - i18n.translate('xpack.canvas.workpadLoader.deleteButtonAriaLabel', { - defaultMessage: 'Delete {numberOfWorkpads} workpads', - values: { - numberOfWorkpads, - }, - }), - getDeleteButtonLabel: (numberOfWorkpads: number) => - i18n.translate('xpack.canvas.workpadLoader.deleteButtonLabel', { - defaultMessage: 'Delete ({numberOfWorkpads})', - values: { - numberOfWorkpads, - }, - }), - getDeleteModalConfirmButtonLabel: () => - i18n.translate('xpack.canvas.workpadLoader.deleteModalConfirmButtonLabel', { - defaultMessage: 'Delete', - }), - getDeleteModalDescription: () => - i18n.translate('xpack.canvas.workpadLoader.deleteModalDescription', { - defaultMessage: `You can't recover deleted workpads.`, - }), - getDeleteMultipleWorkpadModalTitle: (numberOfWorkpads: string) => - i18n.translate('xpack.canvas.workpadLoader.deleteMultipleWorkpadsModalTitle', { - defaultMessage: 'Delete {numberOfWorkpads} workpads?', - values: { - numberOfWorkpads, - }, - }), - getDeleteSingleWorkpadModalTitle: (workpadName: string) => - i18n.translate('xpack.canvas.workpadLoader.deleteSingleWorkpadModalTitle', { - defaultMessage: `Delete workpad '{workpadName}'?`, - values: { - workpadName, - }, - }), - getEmptyPromptGettingStartedDescription: () => - i18n.translate('xpack.canvas.workpadLoader.emptyPromptGettingStartedDescription', { - defaultMessage: - 'Create a new workpad, start from a template, or import a workpad {JSON} file by dropping it here.', - values: { - JSON, - }, - }), - getEmptyPromptNewUserDescription: () => - i18n.translate('xpack.canvas.workpadLoader.emptyPromptNewUserDescription', { - defaultMessage: 'New to {CANVAS}?', - values: { - CANVAS, - }, - }), - getEmptyPromptTitle: () => - i18n.translate('xpack.canvas.workpadLoader.emptyPromptTitle', { - defaultMessage: 'Add your first workpad', - }), - getExportButtonAriaLabel: (numberOfWorkpads: number) => - i18n.translate('xpack.canvas.workpadLoader.exportButtonAriaLabel', { - defaultMessage: 'Export {numberOfWorkpads} workpads', - values: { - numberOfWorkpads, - }, - }), - getExportButtonLabel: (numberOfWorkpads: number) => - i18n.translate('xpack.canvas.workpadLoader.exportButtonLabel', { - defaultMessage: 'Export ({numberOfWorkpads})', - values: { - numberOfWorkpads, - }, - }), - getExportToolTip: () => - i18n.translate('xpack.canvas.workpadLoader.exportTooltip', { - defaultMessage: 'Export workpad', - }), - getFetchLoadingDescription: () => - i18n.translate('xpack.canvas.workpadLoader.fetchLoadingDescription', { - defaultMessage: 'Fetching workpads...', - description: - 'This message appears while the user is waiting for their list of workpads to load', - }), - getFilePickerPlaceholder: () => - i18n.translate('xpack.canvas.workpadLoader.filePickerPlaceholder', { - defaultMessage: 'Import workpad {JSON} file', - values: { - JSON, - }, - }), - getLoadWorkpadArialLabel: (workpadName: string) => - i18n.translate('xpack.canvas.workpadLoader.loadWorkpadArialLabel', { - defaultMessage: `Load workpad '{workpadName}'`, - values: { - workpadName, - }, - }), - getNoPermissionToCloneToolTip: () => - i18n.translate('xpack.canvas.workpadLoader.noPermissionToCloneToolTip', { - defaultMessage: `You don't have permission to clone workpads`, - }), - getNoPermissionToCreateToolTip: () => - i18n.translate('xpack.canvas.workpadLoader.noPermissionToCreateToolTip', { - defaultMessage: `You don't have permission to create workpads`, - }), - getNoPermissionToDeleteToolTip: () => - i18n.translate('xpack.canvas.workpadLoader.noPermissionToDeleteToolTip', { - defaultMessage: `You don't have permission to delete workpads`, - }), - getNoPermissionToUploadToolTip: () => - i18n.translate('xpack.canvas.workpadLoader.noPermissionToUploadToolTip', { - defaultMessage: `You don't have permission to upload workpads`, - }), - getSampleDataLinkLabel: () => - i18n.translate('xpack.canvas.workpadLoader.sampleDataLinkLabel', { - defaultMessage: 'Add your first workpad', - }), - getTableCreatedColumnTitle: () => - i18n.translate('xpack.canvas.workpadLoader.table.createdColumnTitle', { - defaultMessage: 'Created', - description: 'This column in the table contains the date/time the workpad was created.', - }), - getTableNameColumnTitle: () => - i18n.translate('xpack.canvas.workpadLoader.table.nameColumnTitle', { - defaultMessage: 'Workpad name', - }), - getTableUpdatedColumnTitle: () => - i18n.translate('xpack.canvas.workpadLoader.table.updatedColumnTitle', { - defaultMessage: 'Updated', - description: - 'This column in the table contains the date/time the workpad was last updated.', - }), - getTableActionsColumnTitle: () => - i18n.translate('xpack.canvas.workpadLoader.table.actionsColumnTitle', { - defaultMessage: 'Actions', - description: - 'This column in the table contains the actions that can be taken on a workpad.', - }), - }, - WorkpadManager: { - getModalTitle: () => - i18n.translate('xpack.canvas.workpadManager.modalTitle', { - defaultMessage: '{CANVAS} workpads', - values: { - CANVAS, - }, - }), - getMyWorkpadsTabLabel: () => - i18n.translate('xpack.canvas.workpadManager.myWorkpadsTabLabel', { - defaultMessage: 'My workpads', - }), - getWorkpadTemplatesTabLabel: () => - i18n.translate('xpack.canvas.workpadManager.workpadTemplatesTabLabel', { - defaultMessage: 'Templates', - description: 'The label for the tab that displays a list of designed workpad templates.', - }), - }, - WorkpadSearch: { - getWorkpadSearchPlaceholder: () => - i18n.translate('xpack.canvas.workpadSearch.searchPlaceholder', { - defaultMessage: 'Find workpad', - }), - }, - WorkpadTemplates: { - getCloneTemplateLinkAriaLabel: (templateName: string) => - i18n.translate('xpack.canvas.workpadTemplate.cloneTemplateLinkAriaLabel', { - defaultMessage: `Clone workpad template '{templateName}'`, - values: { - templateName, - }, - }), - getTableDescriptionColumnTitle: () => - i18n.translate('xpack.canvas.workpadTemplates.table.descriptionColumnTitle', { - defaultMessage: 'Description', - }), - getTableNameColumnTitle: () => - i18n.translate('xpack.canvas.workpadTemplates.table.nameColumnTitle', { - defaultMessage: 'Template name', - }), - getTableTagsColumnTitle: () => - i18n.translate('xpack.canvas.workpadTemplates.table.tagsColumnTitle', { - defaultMessage: 'Tags', - description: - 'This column contains relevant tags that indicate what type of template ' + - 'is displayed. For example: "report", "presentation", etc.', - }), - getTemplateSearchPlaceholder: () => - i18n.translate('xpack.canvas.workpadTemplate.searchPlaceholder', { - defaultMessage: 'Find template', - }), - getCreatingTemplateLabel: (templateName: string) => - i18n.translate('xpack.canvas.workpadTemplate.creatingTemplateLabel', { - defaultMessage: `Creating from template '{templateName}'`, - values: { - templateName, - }, - }), - }, }; diff --git a/x-pack/plugins/canvas/i18n/errors.ts b/x-pack/plugins/canvas/i18n/errors.ts index 09280451192345..a55762dce2d204 100644 --- a/x-pack/plugins/canvas/i18n/errors.ts +++ b/x-pack/plugins/canvas/i18n/errors.ts @@ -6,7 +6,6 @@ */ import { i18n } from '@kbn/i18n'; -import { CANVAS, JSON } from './constants'; export const ErrorStrings = { actionsElements: { @@ -93,54 +92,10 @@ export const ErrorStrings = { }, }), }, - WorkpadFileUpload: { - getAcceptJSONOnlyErrorMessage: () => - i18n.translate('xpack.canvas.error.workpadUpload.acceptJSONOnlyErrorMessage', { - defaultMessage: 'Only {JSON} files are accepted', - values: { - JSON, - }, - }), - getFileUploadFailureWithFileNameErrorMessage: (fileName: string) => - i18n.translate('xpack.canvas.errors.workpadUpload.fileUploadFileWithFileNameErrorMessage', { - defaultMessage: `Couldn't upload '{fileName}'`, - values: { - fileName, - }, - }), - getFileUploadFailureWithoutFileNameErrorMessage: () => - i18n.translate( - 'xpack.canvas.error.workpadUpload.fileUploadFailureWithoutFileNameErrorMessage', - { - defaultMessage: `Couldn't upload file`, - } - ), - getMissingPropertiesErrorMessage: () => - i18n.translate('xpack.canvas.error.workpadUpload.missingPropertiesErrorMessage', { - defaultMessage: - 'Some properties required for a {CANVAS} workpad are missing. Edit your {JSON} file to provide the correct property values, and try again.', - values: { - CANVAS, - JSON, - }, - }), - }, - WorkpadLoader: { - getCloneFailureErrorMessage: () => - i18n.translate('xpack.canvas.error.workpadLoader.cloneFailureErrorMessage', { - defaultMessage: `Couldn't clone workpad`, - }), - getDeleteFailureErrorMessage: () => - i18n.translate('xpack.canvas.error.workpadLoader.deleteFailureErrorMessage', { - defaultMessage: `Couldn't delete all workpads`, - }), - getFindFailureErrorMessage: () => - i18n.translate('xpack.canvas.error.workpadLoader.findFailureErrorMessage', { - defaultMessage: `Couldn't find workpad`, - }), - getUploadFailureErrorMessage: () => - i18n.translate('xpack.canvas.error.workpadLoader.uploadFailureErrorMessage', { - defaultMessage: `Couldn't upload workpad`, + WorkpadDropzone: { + getTooManyFilesErrorMessage: () => + i18n.translate('xpack.canvas.error.workpadDropzone.tooManyFilesErrorMessage', { + defaultMessage: 'One one file can be uploaded at a time', }), }, workpadRoutes: { diff --git a/x-pack/plugins/canvas/public/components/home/home.component.tsx b/x-pack/plugins/canvas/public/components/home/home.component.tsx new file mode 100644 index 00000000000000..96a773186da2b9 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/home.component.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 React, { useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { KibanaPageTemplate } from '../../../../../../src/plugins/kibana_react/public'; +import { withSuspense } from '../../../../../../src/plugins/presentation_util/public'; + +import { WorkpadCreate } from './workpad_create'; +import { LazyWorkpadTemplates } from './workpad_templates'; +import { LazyMyWorkpads } from './my_workpads'; + +export type HomePageTab = 'workpads' | 'templates'; + +export interface Props { + activeTab?: HomePageTab; +} + +const WorkpadTemplates = withSuspense(LazyWorkpadTemplates); +const MyWorkpads = withSuspense(LazyMyWorkpads); + +export const Home = ({ activeTab = 'workpads' }: Props) => { + const [tab, setTab] = useState(activeTab); + + return ( + ], + bottomBorder: true, + tabs: [ + { + label: strings.getMyWorkpadsTabLabel(), + id: 'myWorkpads', + isSelected: tab === 'workpads', + onClick: () => setTab('workpads'), + }, + { + label: strings.getWorkpadTemplatesTabLabel(), + id: 'workpadTemplates', + 'data-test-subj': 'workpadTemplates', + isSelected: tab === 'templates', + onClick: () => setTab('templates'), + }, + ], + }} + > + {tab === 'workpads' ? : } + + ); +}; + +const strings = { + getMyWorkpadsTabLabel: () => + i18n.translate('xpack.canvas.home.myWorkpadsTabLabel', { + defaultMessage: 'My workpads', + }), + getWorkpadTemplatesTabLabel: () => + i18n.translate('xpack.canvas.home.workpadTemplatesTabLabel', { + defaultMessage: 'Templates', + description: 'The label for the tab that displays a list of designed workpad templates.', + }), +}; diff --git a/x-pack/plugins/canvas/public/components/home/home.stories.tsx b/x-pack/plugins/canvas/public/components/home/home.stories.tsx new file mode 100644 index 00000000000000..186b916afa0032 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/home.stories.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 React from 'react'; + +import { + reduxDecorator, + getAddonPanelParameters, + servicesContextDecorator, + getDisableStoryshotsParameter, +} from '../../../storybook'; + +import { Home } from './home.component'; + +export default { + title: 'Home/Home Page', + argTypes: {}, + decorators: [reduxDecorator()], + parameters: { ...getAddonPanelParameters(), ...getDisableStoryshotsParameter() }, +}; + +export const NoContent = () => ; +export const HasContent = () => ; + +NoContent.decorators = [servicesContextDecorator()]; +HasContent.decorators = [servicesContextDecorator({ findWorkpads: 5, findTemplates: true })]; diff --git a/x-pack/plugins/canvas/public/components/home/home.tsx b/x-pack/plugins/canvas/public/components/home/home.tsx new file mode 100644 index 00000000000000..6b356ada8681ec --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/home.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, { useEffect, useState } from 'react'; +import { useDispatch } from 'react-redux'; + +import { getBaseBreadcrumb } from '../../lib/breadcrumbs'; +import { resetWorkpad } from '../../state/actions/workpad'; +import { Home as Component } from './home.component'; +import { usePlatformService } from '../../services'; + +export const Home = () => { + const { setBreadcrumbs } = usePlatformService(); + const [isMounted, setIsMounted] = useState(false); + const dispatch = useDispatch(); + + useEffect(() => { + if (!isMounted) { + dispatch(resetWorkpad()); + setIsMounted(true); + } + }, [dispatch, isMounted, setIsMounted]); + + useEffect(() => { + setBreadcrumbs([getBaseBreadcrumb()]); + }, [setBreadcrumbs]); + + return ; +}; diff --git a/x-pack/plugins/canvas/public/components/home/hooks/index.ts b/x-pack/plugins/canvas/public/components/home/hooks/index.ts new file mode 100644 index 00000000000000..91e52948a7ba6b --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/hooks/index.ts @@ -0,0 +1,15 @@ +/* + * 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 { useCloneWorkpad } from './use_clone_workpad'; +export { useCreateWorkpad } from './use_create_workpad'; +export { useDeleteWorkpads } from './use_delete_workpad'; +export { useDownloadWorkpad } from './use_download_workpad'; +export { useFindTemplates, useFindTemplatesOnMount } from './use_find_templates'; +export { useFindWorkpads, useFindWorkpadsOnMount } from './use_find_workpad'; +export { useImportWorkpad } from './use_upload_workpad'; +export { useCreateFromTemplate } from './use_create_from_template'; diff --git a/x-pack/plugins/canvas/public/components/home/hooks/use_clone_workpad.ts b/x-pack/plugins/canvas/public/components/home/hooks/use_clone_workpad.ts new file mode 100644 index 00000000000000..001a711a58a726 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/hooks/use_clone_workpad.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 { useCallback } from 'react'; +import { useHistory } from 'react-router-dom'; +import { i18n } from '@kbn/i18n'; + +import { useNotifyService, useWorkpadService } from '../../../services'; +import { getId } from '../../../lib/get_id'; + +export const useCloneWorkpad = () => { + const workpadService = useWorkpadService(); + const notifyService = useNotifyService(); + const history = useHistory(); + + return useCallback( + async (workpadId: string) => { + try { + let workpad = await workpadService.get(workpadId); + + workpad = { + ...workpad, + name: strings.getClonedWorkpadName(workpad.name), + id: getId('workpad'), + }; + + await workpadService.create(workpad); + + history.push(`/workpad/${workpad.id}/page/1`); + } catch (err) { + notifyService.error(err, { title: errors.getCloneFailureErrorMessage() }); + } + }, + [notifyService, workpadService, history] + ); +}; + +const strings = { + getClonedWorkpadName: (workpadName: string) => + i18n.translate('xpack.canvas.useCloneWorkpad.clonedWorkpadName', { + defaultMessage: 'Copy of {workpadName}', + values: { + workpadName, + }, + description: + 'This suffix is added to the end of the name of a cloned workpad to indicate that this ' + + 'new workpad is a copy of the original workpad. Example: "Copy of Sales Pitch"', + }), +}; + +const errors = { + getCloneFailureErrorMessage: () => + i18n.translate('xpack.canvas.error.useCloneWorkpad.cloneFailureErrorMessage', { + defaultMessage: `Couldn't clone workpad`, + }), +}; diff --git a/x-pack/plugins/canvas/public/components/home/hooks/use_create_from_template.ts b/x-pack/plugins/canvas/public/components/home/hooks/use_create_from_template.ts new file mode 100644 index 00000000000000..968f9398ba8577 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/hooks/use_create_from_template.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useCallback } from 'react'; +import { useHistory } from 'react-router-dom'; + +import { CanvasTemplate } from '../../../../types'; +import { useNotifyService, useWorkpadService } from '../../../services'; + +export const useCreateFromTemplate = () => { + const workpadService = useWorkpadService(); + const notifyService = useNotifyService(); + const history = useHistory(); + + return useCallback( + async (template: CanvasTemplate) => { + try { + const result = await workpadService.createFromTemplate(template.id); + history.push(`/workpad/${result.id}/page/1`); + } catch (e) { + notifyService.error(e, { + title: `Couldn't create workpad from template`, + }); + } + }, + [workpadService, notifyService, history] + ); +}; diff --git a/x-pack/plugins/canvas/public/components/home/hooks/use_create_workpad.ts b/x-pack/plugins/canvas/public/components/home/hooks/use_create_workpad.ts new file mode 100644 index 00000000000000..eb87f4720deec9 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/hooks/use_create_workpad.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useCallback } from 'react'; +import { useHistory } from 'react-router-dom'; +import { i18n } from '@kbn/i18n'; + +// @ts-expect-error +import { getDefaultWorkpad } from '../../../state/defaults'; +import { useNotifyService, useWorkpadService } from '../../../services'; + +import type { CanvasWorkpad } from '../../../../types'; + +export const useCreateWorkpad = () => { + const workpadService = useWorkpadService(); + const notifyService = useNotifyService(); + const history = useHistory(); + + return useCallback( + async (_workpad?: CanvasWorkpad | null) => { + const workpad = _workpad || (getDefaultWorkpad() as CanvasWorkpad); + + try { + await workpadService.create(workpad); + history.push(`/workpad/${workpad.id}/page/1`); + } catch (err) { + notifyService.error(err, { + title: errors.getUploadFailureErrorMessage(), + }); + } + return; + }, + [notifyService, history, workpadService] + ); +}; + +const errors = { + getUploadFailureErrorMessage: () => + i18n.translate('xpack.canvas.error.useCreateWorkpad.uploadFailureErrorMessage', { + defaultMessage: `Couldn't upload workpad`, + }), +}; diff --git a/x-pack/plugins/canvas/public/components/home/hooks/use_delete_workpad.ts b/x-pack/plugins/canvas/public/components/home/hooks/use_delete_workpad.ts new file mode 100644 index 00000000000000..722ddae7411c92 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/hooks/use_delete_workpad.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useCallback } from 'react'; +import { i18n } from '@kbn/i18n'; + +import { useNotifyService, useWorkpadService } from '../../../services'; + +export const useDeleteWorkpads = () => { + const workpadService = useWorkpadService(); + const notifyService = useNotifyService(); + + return useCallback( + async (workpadIds: string[]) => { + const removedWorkpads = workpadIds.map(async (id) => { + try { + await workpadService.remove(id); + return { id, err: null }; + } catch (err) { + return { id, err }; + } + }); + + return Promise.all(removedWorkpads).then((results) => { + const [passes, errored] = results.reduce<[string[], string[]]>( + ([passesArr, errorsArr], result) => { + if (result.err) { + errorsArr.push(result.id); + } else { + passesArr.push(result.id); + } + + return [passesArr, errorsArr]; + }, + [[], []] + ); + + const removedIds = workpadIds.filter((id) => passes.includes(id)); + + if (errored.length > 0) { + notifyService.error(errors.getDeleteFailureErrorMessage()); + } + + return { + removedIds, + errored, + }; + }); + }, + [workpadService, notifyService] + ); +}; + +const errors = { + getDeleteFailureErrorMessage: () => + i18n.translate('xpack.canvas.error.useDeleteWorkpads.deleteFailureErrorMessage', { + defaultMessage: `Couldn't delete all workpads`, + }), +}; diff --git a/x-pack/plugins/canvas/public/components/home/hooks/use_download_workpad.ts b/x-pack/plugins/canvas/public/components/home/hooks/use_download_workpad.ts new file mode 100644 index 00000000000000..b875e08c2a230e --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/hooks/use_download_workpad.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useCallback } from 'react'; +import { downloadWorkpad as downloadWorkpadFn } from '../../../lib/download_workpad'; + +export const useDownloadWorkpad = () => + useCallback((workpadId: string) => downloadWorkpadFn(workpadId), []); diff --git a/x-pack/plugins/canvas/public/components/home/hooks/use_find_templates.ts b/x-pack/plugins/canvas/public/components/home/hooks/use_find_templates.ts new file mode 100644 index 00000000000000..13ee289fe98676 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/hooks/use_find_templates.ts @@ -0,0 +1,38 @@ +/* + * 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 { useState, useCallback } from 'react'; +import useMount from 'react-use/lib/useMount'; + +import { useWorkpadService } from '../../../services'; +import { TemplateFindResponse } from '../../../services/workpad'; + +const emptyResponse = { templates: [] }; + +export const useFindTemplates = () => { + const workpadService = useWorkpadService(); + return useCallback(async () => await workpadService.findTemplates(), [workpadService]); +}; + +export const useFindTemplatesOnMount = (): [boolean, TemplateFindResponse] => { + const [isMounted, setIsMounted] = useState(false); + const findTemplates = useFindTemplates(); + const [templateResponse, setTemplateResponse] = useState(emptyResponse); + + const fetchTemplates = useCallback(async () => { + const foundTemplates = await findTemplates(); + setTemplateResponse(foundTemplates || emptyResponse); + setIsMounted(true); + }, [findTemplates]); + + useMount(() => { + fetchTemplates(); + return () => setIsMounted(false); + }); + + return [isMounted, templateResponse]; +}; diff --git a/x-pack/plugins/canvas/public/components/home/hooks/use_find_workpad.ts b/x-pack/plugins/canvas/public/components/home/hooks/use_find_workpad.ts new file mode 100644 index 00000000000000..3f8b0e6f630f5a --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/hooks/use_find_workpad.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useState, useCallback } from 'react'; +import useMount from 'react-use/lib/useMount'; +import { i18n } from '@kbn/i18n'; + +import { WorkpadFindResponse } from '../../../services/workpad'; + +import { useNotifyService, useWorkpadService } from '../../../services'; +const emptyResponse = { total: 0, workpads: [] }; + +export const useFindWorkpads = () => { + const workpadService = useWorkpadService(); + const notifyService = useNotifyService(); + + return useCallback( + async (text = '') => { + try { + return await workpadService.find(text); + } catch (err) { + notifyService.error(err, { title: errors.getFindFailureErrorMessage() }); + } + }, + [notifyService, workpadService] + ); +}; + +export const useFindWorkpadsOnMount = (): [boolean, WorkpadFindResponse] => { + const [isMounted, setIsMounted] = useState(false); + const findWorkpads = useFindWorkpads(); + const [workpadResponse, setWorkpadResponse] = useState(emptyResponse); + + const fetchWorkpads = useCallback(async () => { + const foundWorkpads = await findWorkpads(); + setWorkpadResponse(foundWorkpads || emptyResponse); + setIsMounted(true); + }, [findWorkpads]); + + useMount(() => { + fetchWorkpads(); + return () => setIsMounted(false); + }); + + return [isMounted, workpadResponse]; +}; + +const errors = { + getFindFailureErrorMessage: () => + i18n.translate('xpack.canvas.error.useFindWorkpads.findFailureErrorMessage', { + defaultMessage: `Couldn't find workpad`, + }), +}; diff --git a/x-pack/plugins/canvas/public/components/home/hooks/use_upload_workpad.ts b/x-pack/plugins/canvas/public/components/home/hooks/use_upload_workpad.ts new file mode 100644 index 00000000000000..7934a469bb7a2c --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/hooks/use_upload_workpad.ts @@ -0,0 +1,100 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useCallback } from 'react'; +import { get } from 'lodash'; +import { i18n } from '@kbn/i18n'; + +import { CANVAS, JSON as JSONString } from '../../../../i18n/constants'; +import { useNotifyService } from '../../../services'; +import { getId } from '../../../lib/get_id'; +import type { CanvasWorkpad } from '../../../../types'; + +export const useImportWorkpad = () => { + const notifyService = useNotifyService(); + + return useCallback( + (file?: File, onComplete: (workpad?: CanvasWorkpad) => void = () => {}) => { + if (!file) { + onComplete(); + return; + } + + if (get(file, 'type') !== 'application/json') { + notifyService.warning(errors.getAcceptJSONOnlyErrorMessage(), { + title: file.name + ? errors.getFileUploadFailureWithFileNameErrorMessage(file.name) + : errors.getFileUploadFailureWithoutFileNameErrorMessage(), + }); + onComplete(); + } + + // TODO: Clean up this file, this loading stuff can, and should be, abstracted + const reader = new FileReader(); + + // handle reading the uploaded file + reader.onload = () => { + try { + const workpad = JSON.parse(reader.result as string); // Type-casting because we catch below. + workpad.id = getId('workpad'); + + // sanity check for workpad object + if (!Array.isArray(workpad.pages) || workpad.pages.length === 0 || !workpad.assets) { + onComplete(); + throw new Error(errors.getMissingPropertiesErrorMessage()); + } + + onComplete(workpad); + } catch (e) { + notifyService.error(e, { + title: file.name + ? errors.getFileUploadFailureWithFileNameErrorMessage(file.name) + : errors.getFileUploadFailureWithoutFileNameErrorMessage(), + }); + onComplete(); + } + }; + + // read the uploaded file + reader.readAsText(file); + }, + [notifyService] + ); +}; + +const errors = { + getFileUploadFailureWithoutFileNameErrorMessage: () => + i18n.translate( + 'xpack.canvas.error.useImportWorkpad.fileUploadFailureWithoutFileNameErrorMessage', + { + defaultMessage: `Couldn't upload file`, + } + ), + getFileUploadFailureWithFileNameErrorMessage: (fileName: string) => + i18n.translate('xpack.canvas.errors.useImportWorkpad.fileUploadFileWithFileNameErrorMessage', { + defaultMessage: `Couldn't upload '{fileName}'`, + values: { + fileName, + }, + }), + getMissingPropertiesErrorMessage: () => + i18n.translate('xpack.canvas.error.useImportWorkpad.missingPropertiesErrorMessage', { + defaultMessage: + 'Some properties required for a {CANVAS} workpad are missing. Edit your {JSON} file to provide the correct property values, and try again.', + values: { + CANVAS, + JSON: JSONString, + }, + }), + getAcceptJSONOnlyErrorMessage: () => + i18n.translate('xpack.canvas.error.useImportWorkpad.acceptJSONOnlyErrorMessage', { + defaultMessage: 'Only {JSON} files are accepted', + values: { + JSON: JSONString, + }, + }), +}; diff --git a/x-pack/plugins/canvas/public/components/workpad_manager/index.js b/x-pack/plugins/canvas/public/components/home/index.ts similarity index 83% rename from x-pack/plugins/canvas/public/components/workpad_manager/index.js rename to x-pack/plugins/canvas/public/components/home/index.ts index e1f5855e762af1..aeb62c3a8de78a 100644 --- a/x-pack/plugins/canvas/public/components/workpad_manager/index.js +++ b/x-pack/plugins/canvas/public/components/home/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export { WorkpadManager } from './workpad_manager'; +export { Home } from './home'; diff --git a/x-pack/plugins/canvas/public/components/home/my_workpads/empty_prompt.stories.tsx b/x-pack/plugins/canvas/public/components/home/my_workpads/empty_prompt.stories.tsx new file mode 100644 index 00000000000000..aef1b0625b5858 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/my_workpads/empty_prompt.stories.tsx @@ -0,0 +1,19 @@ +/* + * 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 { HomeEmptyPrompt } from './empty_prompt'; +import { getDisableStoryshotsParameter } from '../../../../storybook'; + +export default { + title: 'Home/Empty Prompt', + argTypes: {}, + parameters: { ...getDisableStoryshotsParameter() }, +}; + +export const EmptyPrompt = () => ; diff --git a/x-pack/plugins/canvas/public/components/home/my_workpads/empty_prompt.tsx b/x-pack/plugins/canvas/public/components/home/my_workpads/empty_prompt.tsx new file mode 100644 index 00000000000000..797f50ac112d0c --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/my_workpads/empty_prompt.tsx @@ -0,0 +1,65 @@ +/* + * 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, { Fragment } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiEmptyPrompt, EuiLink, EuiPanel, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + +import { CANVAS, JSON } from '../../../../i18n/constants'; + +export const HomeEmptyPrompt = () => ( + + + + {strings.getEmptyPromptTitle()}} + titleSize="m" + body={ + +

    {strings.getEmptyPromptGettingStartedDescription()}

    +

    + {strings.getEmptyPromptNewUserDescription()}{' '} + + {strings.getSampleDataLinkLabel()} + + . +

    +
    + } + /> +
    +
    +
    +); + +const strings = { + getEmptyPromptGettingStartedDescription: () => + i18n.translate('xpack.canvas.homeEmptyPrompt.emptyPromptGettingStartedDescription', { + defaultMessage: + 'Create a new workpad, start from a template, or import a workpad {JSON} file by dropping it here.', + values: { + JSON, + }, + }), + getEmptyPromptNewUserDescription: () => + i18n.translate('xpack.canvas.homeEmptyPrompt.emptyPromptNewUserDescription', { + defaultMessage: 'New to {CANVAS}?', + values: { + CANVAS, + }, + }), + getEmptyPromptTitle: () => + i18n.translate('xpack.canvas.homeEmptyPrompt.emptyPromptTitle', { + defaultMessage: 'Add your first workpad', + }), + getSampleDataLinkLabel: () => + i18n.translate('xpack.canvas.homeEmptyPrompt.sampleDataLinkLabel', { + defaultMessage: 'Add your first workpad', + }), +}; diff --git a/x-pack/plugins/canvas/public/components/home/my_workpads/index.ts b/x-pack/plugins/canvas/public/components/home/my_workpads/index.ts new file mode 100644 index 00000000000000..79b1519df90fe2 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/my_workpads/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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +export const LazyMyWorkpads = React.lazy(() => import('./my_workpads')); diff --git a/x-pack/plugins/canvas/public/components/home/my_workpads/loading.tsx b/x-pack/plugins/canvas/public/components/home/my_workpads/loading.tsx new file mode 100644 index 00000000000000..28edfea7c36caf --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/my_workpads/loading.tsx @@ -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 React from 'react'; +import { EuiLoadingSpinner, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + +export const Loading = () => ( + + + + + +); diff --git a/x-pack/plugins/canvas/public/components/home/my_workpads/my_workpads.component.tsx b/x-pack/plugins/canvas/public/components/home/my_workpads/my_workpads.component.tsx new file mode 100644 index 00000000000000..d9e3f0e4e2c999 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/my_workpads/my_workpads.component.tsx @@ -0,0 +1,38 @@ +/* + * 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 { FoundWorkpad } from '../../../services/workpad'; +import { UploadDropzone } from './upload_dropzone'; +import { HomeEmptyPrompt } from './empty_prompt'; +import { WorkpadTable } from './workpad_table'; + +export interface Props { + workpads: FoundWorkpad[]; +} + +export const MyWorkpads = ({ workpads }: Props) => { + if (workpads.length === 0) { + return ( + + + + + + + + ); + } + + return ( + + + + ); +}; diff --git a/x-pack/plugins/canvas/public/components/home/my_workpads/my_workpads.stories.tsx b/x-pack/plugins/canvas/public/components/home/my_workpads/my_workpads.stories.tsx new file mode 100644 index 00000000000000..0d5d6ca16f614d --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/my_workpads/my_workpads.stories.tsx @@ -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 React, { useState } from 'react'; +import { EuiPanel } from '@elastic/eui'; + +import { + reduxDecorator, + getAddonPanelParameters, + servicesContextDecorator, + getDisableStoryshotsParameter, +} from '../../../../storybook'; +import { getSomeWorkpads } from '../../../services/stubs/workpad'; + +import { MyWorkpads, WorkpadsContext } from './my_workpads'; +import { MyWorkpads as MyWorkpadsComponent } from './my_workpads.component'; + +export default { + title: 'Home/My Workpads', + argTypes: {}, + decorators: [reduxDecorator()], + parameters: { ...getAddonPanelParameters(), ...getDisableStoryshotsParameter() }, +}; + +export const NoWorkpads = () => { + return ; +}; + +export const HasWorkpads = () => { + return ( + + + + ); +}; + +NoWorkpads.decorators = [servicesContextDecorator()]; +HasWorkpads.decorators = [servicesContextDecorator({ findWorkpads: 5 })]; + +export const Component = ({ workpadCount }: { workpadCount: number }) => { + const [workpads, setWorkpads] = useState(getSomeWorkpads(workpadCount)); + + return ( + + + + + + ); +}; + +Component.args = { workpadCount: 5 }; diff --git a/x-pack/plugins/canvas/public/components/home/my_workpads/my_workpads.tsx b/x-pack/plugins/canvas/public/components/home/my_workpads/my_workpads.tsx new file mode 100644 index 00000000000000..4242e2e9d130f7 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/my_workpads/my_workpads.tsx @@ -0,0 +1,42 @@ +/* + * 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, useEffect, createContext, Dispatch, SetStateAction } from 'react'; +import { useFindWorkpadsOnMount } from './../hooks'; +import { FoundWorkpad } from '../../../services/workpad'; +import { Loading } from './loading'; +import { MyWorkpads as Component } from './my_workpads.component'; + +interface Context { + workpads: FoundWorkpad[]; + setWorkpads: Dispatch>; +} + +export const WorkpadsContext = createContext(null); + +export const MyWorkpads = () => { + const [isMounted, workpadResponse] = useFindWorkpadsOnMount(); + const [workpads, setWorkpads] = useState(workpadResponse.workpads); + + useEffect(() => { + setWorkpads(workpadResponse.workpads); + }, [workpadResponse]); + + if (!isMounted) { + return ; + } + + return ( + + + + ); +}; + +// required for dynamic import using React.lazy() +// eslint-disable-next-line import/no-default-export +export default MyWorkpads; diff --git a/x-pack/plugins/canvas/public/components/home/my_workpads/upload_dropzone.component.tsx b/x-pack/plugins/canvas/public/components/home/my_workpads/upload_dropzone.component.tsx new file mode 100644 index 00000000000000..603f4679a9e95f --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/my_workpads/upload_dropzone.component.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 React, { FC } from 'react'; +// @ts-expect-error untyped library +import Dropzone from 'react-dropzone'; + +import './upload_dropzone.scss'; + +export interface Props { + disabled?: boolean; + onDrop?: (files: FileList) => void; +} + +export const UploadDropzone: FC = ({ onDrop = () => {}, disabled, children }) => { + return ( + + {children} + + ); +}; diff --git a/x-pack/plugins/canvas/public/components/home/my_workpads/upload_dropzone.scss b/x-pack/plugins/canvas/public/components/home/my_workpads/upload_dropzone.scss new file mode 100644 index 00000000000000..e4ee284c72deeb --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/my_workpads/upload_dropzone.scss @@ -0,0 +1,8 @@ +.canvasWorkpad__dropzone { + border: 2px dashed transparent; +} + +.canvasWorkpad__dropzone--active { + background-color: $euiColorLightestShade; + border-color: $euiColorLightShade; +} diff --git a/x-pack/plugins/canvas/public/components/home/my_workpads/upload_dropzone.tsx b/x-pack/plugins/canvas/public/components/home/my_workpads/upload_dropzone.tsx new file mode 100644 index 00000000000000..8ee0ae108392e2 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/my_workpads/upload_dropzone.tsx @@ -0,0 +1,55 @@ +/* + * 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, { FC, useState } from 'react'; +// @ts-expect-error untyped library +import Dropzone from 'react-dropzone'; + +import { useNotifyService } from '../../../services'; +import { ErrorStrings } from '../../../../i18n'; +import { useImportWorkpad, useCreateWorkpad } from '../hooks'; +import { CanvasWorkpad } from '../../../../types'; + +import { UploadDropzone as Component } from './upload_dropzone.component'; + +const { WorkpadDropzone: errors } = ErrorStrings; + +export const UploadDropzone: FC = ({ children }) => { + const notify = useNotifyService(); + const uploadWorkpad = useImportWorkpad(); + const createWorkpad = useCreateWorkpad(); + const [isDisabled, setIsDisabled] = useState(false); + + const onComplete = async (workpad?: CanvasWorkpad) => { + if (!workpad) { + setIsDisabled(false); + return; + } + + await createWorkpad(workpad); + }; + + const onDrop = (files: FileList) => { + if (!files) { + return; + } + + if (files.length > 1) { + notify.warning(errors.getTooManyFilesErrorMessage()); + return; + } + + setIsDisabled(true); + uploadWorkpad(files[0], onComplete); + }; + + return ( + + {children} + + ); +}; diff --git a/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_import.component.tsx b/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_import.component.tsx new file mode 100644 index 00000000000000..28e2aa0449d46d --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_import.component.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiFilePicker, EuiFilePickerProps } from '@elastic/eui'; + +import { JSON } from '../../../../i18n/constants'; +export interface Props { + canUserWrite: boolean; + onImportWorkpad?: EuiFilePickerProps['onChange']; + uniqueKey?: string | number; +} + +export const WorkpadImport = ({ uniqueKey, canUserWrite, onImportWorkpad = () => {} }: Props) => ( + +); + +const strings = { + getFilePickerPlaceholder: () => + i18n.translate('xpack.canvas.workpadImport.filePickerPlaceholder', { + defaultMessage: 'Import workpad {JSON} file', + values: { + JSON, + }, + }), +}; diff --git a/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_import.tsx b/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_import.tsx new file mode 100644 index 00000000000000..0f1ba621e14d72 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_import.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 React, { useState } from 'react'; +import { useSelector } from 'react-redux'; + +import { canUserWrite as canUserWriteSelector } from '../../../state/selectors/app'; +import type { State } from '../../../../types'; + +import { useImportWorkpad } from '../hooks'; +import { WorkpadImport as Component, Props as ComponentProps } from './workpad_import.component'; + +type Props = Omit; + +export const WorkpadImport = (props: Props) => { + const importWorkpad = useImportWorkpad(); + const [uniqueKey, setUniqueKey] = useState(Date.now()); + + const { canUserWrite } = useSelector((state: State) => ({ + canUserWrite: canUserWriteSelector(state), + })); + + const onImportWorkpad: ComponentProps['onImportWorkpad'] = (files) => { + if (files) { + importWorkpad(files[0]); + } + setUniqueKey(Date.now()); + }; + + return ; +}; diff --git a/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table.component.tsx b/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table.component.tsx new file mode 100644 index 00000000000000..5301a88844369e --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table.component.tsx @@ -0,0 +1,203 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { + EuiInMemoryTable, + EuiInMemoryTableProps, + EuiTableActionsColumnType, + EuiBasicTableColumn, + EuiToolTip, + EuiButtonIcon, + EuiTableSelectionType, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; +import moment from 'moment'; + +import { RoutingLink } from '../../routing'; +import { FoundWorkpad } from '../../../services/workpad'; +import { WorkpadTableTools } from './workpad_table_tools'; +import { WorkpadImport } from './workpad_import'; + +export interface Props { + workpads: FoundWorkpad[]; + canUserWrite: boolean; + dateFormat: string; + onExportWorkpad: (ids: string) => void; + onCloneWorkpad: (id: string) => void; +} + +const getDisplayName = (name: string, workpadId: string, loadedWorkpadId?: string) => { + const workpadName = name.length ? {name} : {workpadId}; + return workpadId === loadedWorkpadId ? {workpadName} : workpadName; +}; + +export const WorkpadTable = ({ + workpads, + canUserWrite, + dateFormat, + onExportWorkpad: onExport, + onCloneWorkpad, +}: Props) => { + const [selectedIds, setSelectedIds] = useState([]); + const formatDate = (date: string) => date && moment(date).format(dateFormat); + + const selection: EuiTableSelectionType = { + onSelectionChange: (selectedWorkpads) => { + setSelectedIds(selectedWorkpads.map((workpad) => workpad.id).filter((id) => !!id)); + }, + }; + + const actions: EuiTableActionsColumnType['actions'] = [ + { + render: (workpad: FoundWorkpad) => ( + + + + onExport(workpad.id)} + aria-label={strings.getExportToolTip()} + /> + + + + + onCloneWorkpad(workpad.id)} + aria-label={strings.getCloneToolTip()} + disabled={!canUserWrite} + /> + + + + ), + }, + ]; + + const search: EuiInMemoryTableProps['search'] = { + toolsLeft: + selectedIds.length > 0 ? : undefined, + toolsRight: , + box: { + schema: true, + incremental: true, + placeholder: strings.getWorkpadSearchPlaceholder(), + 'data-test-subj': 'tableListSearchBox', + }, + }; + + const columns: Array> = [ + { + field: 'name', + name: strings.getTableNameColumnTitle(), + sortable: true, + dataType: 'string', + render: (name, workpad) => ( + + {getDisplayName(name, workpad.id)} + + ), + }, + { + field: '@created', + name: strings.getTableCreatedColumnTitle(), + sortable: true, + dataType: 'date', + width: '20%', + render: (date: string) => formatDate(date), + }, + { + field: '@timestamp', + name: strings.getTableUpdatedColumnTitle(), + sortable: true, + dataType: 'date', + width: '20%', + render: (date: string) => formatDate(date), + }, + { name: strings.getTableActionsColumnTitle(), actions, width: '100px' }, + ]; + + return ( + + ); +}; + +const strings = { + getCloneToolTip: () => + i18n.translate('xpack.canvas.workpadTable.cloneTooltip', { + defaultMessage: 'Clone workpad', + }), + getExportToolTip: () => + i18n.translate('xpack.canvas.workpadTable.exportTooltip', { + defaultMessage: 'Export workpad', + }), + getLoadWorkpadArialLabel: (workpadName: string) => + i18n.translate('xpack.canvas.workpadTable.loadWorkpadArialLabel', { + defaultMessage: `Load workpad '{workpadName}'`, + values: { + workpadName, + }, + }), + getNoPermissionToCloneToolTip: () => + i18n.translate('xpack.canvas.workpadTable.noPermissionToCloneToolTip', { + defaultMessage: `You don't have permission to clone workpads`, + }), + getNoWorkpadsFoundMessage: () => + i18n.translate('xpack.canvas.workpadTable.noWorkpadsFoundMessage', { + defaultMessage: 'No workpads matched your search.', + }), + getWorkpadSearchPlaceholder: () => + i18n.translate('xpack.canvas.workpadTable.searchPlaceholder', { + defaultMessage: 'Find workpad', + }), + getTableCreatedColumnTitle: () => + i18n.translate('xpack.canvas.workpadTable.table.createdColumnTitle', { + defaultMessage: 'Created', + description: 'This column in the table contains the date/time the workpad was created.', + }), + getTableNameColumnTitle: () => + i18n.translate('xpack.canvas.workpadTable.table.nameColumnTitle', { + defaultMessage: 'Workpad name', + }), + getTableUpdatedColumnTitle: () => + i18n.translate('xpack.canvas.workpadTable.table.updatedColumnTitle', { + defaultMessage: 'Updated', + description: 'This column in the table contains the date/time the workpad was last updated.', + }), + getTableActionsColumnTitle: () => + i18n.translate('xpack.canvas.workpadTable.table.actionsColumnTitle', { + defaultMessage: 'Actions', + description: 'This column in the table contains the actions that can be taken on a workpad.', + }), +}; diff --git a/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table.stories.tsx b/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table.stories.tsx new file mode 100644 index 00000000000000..501a0a76a85893 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table.stories.tsx @@ -0,0 +1,83 @@ +/* + * 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, useEffect } from 'react'; +import { EuiPanel } from '@elastic/eui'; + +import { action } from '@storybook/addon-actions'; +import { + reduxDecorator, + getAddonPanelParameters, + getDisableStoryshotsParameter, +} from '../../../../storybook'; +import { getSomeWorkpads } from '../../../services/stubs/workpad'; + +import { WorkpadTable } from './workpad_table'; +import { WorkpadTable as WorkpadTableComponent } from './workpad_table.component'; +import { WorkpadsContext } from './my_workpads'; + +export default { + title: 'Home/Workpad Table', + argTypes: {}, + decorators: [reduxDecorator()], + parameters: { ...getAddonPanelParameters(), ...getDisableStoryshotsParameter() }, +}; + +export const NoWorkpads = () => { + const [workpads, setWorkpads] = useState(getSomeWorkpads(0)); + + return ( + + + + + + ); +}; + +export const HasWorkpads = () => { + const [workpads, setWorkpads] = useState(getSomeWorkpads(5)); + + return ( + + + + + + ); +}; + +export const Component = ({ + workpadCount, + canUserWrite, + dateFormat, +}: { + workpadCount: number; + canUserWrite: boolean; + dateFormat: string; +}) => { + const [workpads, setWorkpads] = useState(getSomeWorkpads(workpadCount)); + + useEffect(() => { + setWorkpads(getSomeWorkpads(workpadCount)); + }, [workpadCount]); + + return ( + + + + + + ); +}; + +Component.args = { workpadCount: 5, canUserWrite: true, dateFormat: 'MMM D, YYYY @ HH:mm:ss.SSS' }; +Component.argTypes = {}; diff --git a/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table.tsx b/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table.tsx new file mode 100644 index 00000000000000..e5d83039a87ebe --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table.tsx @@ -0,0 +1,38 @@ +/* + * 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, { useContext } from 'react'; +import { useSelector } from 'react-redux'; + +import { canUserWrite as canUserWriteSelector } from '../../../state/selectors/app'; +import type { State } from '../../../../types'; +import { usePlatformService } from '../../../services'; +import { useCloneWorkpad, useDownloadWorkpad } from '../hooks'; + +import { WorkpadTable as Component } from './workpad_table.component'; +import { WorkpadsContext } from './my_workpads'; + +export const WorkpadTable = () => { + const platformService = usePlatformService(); + const onCloneWorkpad = useCloneWorkpad(); + const onExportWorkpad = useDownloadWorkpad(); + const context = useContext(WorkpadsContext); + + const { canUserWrite } = useSelector((state: State) => ({ + canUserWrite: canUserWriteSelector(state), + })); + + if (!context) { + return null; + } + + const { workpads } = context; + + const dateFormat = platformService.getUISetting('dateFormat'); + + return ; +}; diff --git a/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table_tools.component.tsx b/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table_tools.component.tsx new file mode 100644 index 00000000000000..ae6ff9c3cc9104 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table_tools.component.tsx @@ -0,0 +1,160 @@ +/* + * 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, Fragment } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiButton, EuiToolTip, EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; + +import { ConfirmModal } from '../../confirm_modal'; +import { FoundWorkpad } from '../../../services/workpad'; + +export interface Props { + workpads: FoundWorkpad[]; + canUserWrite: boolean; + selectedWorkpadIds: string[]; + onDeleteWorkpads: (ids: string[]) => void; + onExportWorkpads: (ids: string[]) => void; +} + +export const WorkpadTableTools = ({ + workpads, + canUserWrite, + selectedWorkpadIds, + onDeleteWorkpads, + onExportWorkpads, +}: Props) => { + const [isDeletePending, setIsDeletePending] = useState(false); + + const openRemoveConfirm = () => setIsDeletePending(true); + const closeRemoveConfirm = () => setIsDeletePending(false); + + let deleteButton = ( + + {strings.getDeleteButtonLabel(selectedWorkpadIds.length)} + + ); + + const downloadButton = ( + onExportWorkpads(selectedWorkpadIds)} + iconType="exportAction" + aria-label={strings.getExportButtonAriaLabel(selectedWorkpadIds.length)} + > + {strings.getExportButtonLabel(selectedWorkpadIds.length)} + + ); + + if (!canUserWrite) { + deleteButton = ( + {deleteButton} + ); + } + + const modalTitle = + selectedWorkpadIds.length === 1 + ? strings.getDeleteSingleWorkpadModalTitle( + workpads.find((workpad) => workpad.id === selectedWorkpadIds[0])?.name || '' + ) + : strings.getDeleteMultipleWorkpadModalTitle(selectedWorkpadIds.length + ''); + + const confirmModal = ( + { + onDeleteWorkpads(selectedWorkpadIds); + closeRemoveConfirm(); + }} + onCancel={closeRemoveConfirm} + /> + ); + + return ( + + + {downloadButton} + {deleteButton} + + {confirmModal} + + ); +}; + +const strings = { + getDeleteButtonAriaLabel: (numberOfWorkpads: number) => + i18n.translate('xpack.canvas.workpadTableTools.deleteButtonAriaLabel', { + defaultMessage: 'Delete {numberOfWorkpads} workpads', + values: { + numberOfWorkpads, + }, + }), + getDeleteButtonLabel: (numberOfWorkpads: number) => + i18n.translate('xpack.canvas.workpadTableTools.deleteButtonLabel', { + defaultMessage: 'Delete ({numberOfWorkpads})', + values: { + numberOfWorkpads, + }, + }), + getDeleteModalConfirmButtonLabel: () => + i18n.translate('xpack.canvas.workpadTableTools.deleteModalConfirmButtonLabel', { + defaultMessage: 'Delete', + }), + getDeleteModalDescription: () => + i18n.translate('xpack.canvas.workpadTableTools.deleteModalDescription', { + defaultMessage: `You can't recover deleted workpads.`, + }), + getDeleteMultipleWorkpadModalTitle: (numberOfWorkpads: string) => + i18n.translate('xpack.canvas.workpadTableTools.deleteMultipleWorkpadsModalTitle', { + defaultMessage: 'Delete {numberOfWorkpads} workpads?', + values: { + numberOfWorkpads, + }, + }), + getDeleteSingleWorkpadModalTitle: (workpadName: string) => + i18n.translate('xpack.canvas.workpadTableTools.deleteSingleWorkpadModalTitle', { + defaultMessage: `Delete workpad '{workpadName}'?`, + values: { + workpadName, + }, + }), + getExportButtonAriaLabel: (numberOfWorkpads: number) => + i18n.translate('xpack.canvas.workpadTableTools.exportButtonAriaLabel', { + defaultMessage: 'Export {numberOfWorkpads} workpads', + values: { + numberOfWorkpads, + }, + }), + getExportButtonLabel: (numberOfWorkpads: number) => + i18n.translate('xpack.canvas.workpadTableTools.exportButtonLabel', { + defaultMessage: 'Export ({numberOfWorkpads})', + values: { + numberOfWorkpads, + }, + }), + getNoPermissionToCreateToolTip: () => + i18n.translate('xpack.canvas.workpadTableTools.noPermissionToCreateToolTip', { + defaultMessage: `You don't have permission to create workpads`, + }), + getNoPermissionToDeleteToolTip: () => + i18n.translate('xpack.canvas.workpadTableTools.noPermissionToDeleteToolTip', { + defaultMessage: `You don't have permission to delete workpads`, + }), + getNoPermissionToUploadToolTip: () => + i18n.translate('xpack.canvas.workpadTableTools.noPermissionToUploadToolTip', { + defaultMessage: `You don't have permission to upload workpads`, + }), +}; diff --git a/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table_tools.tsx b/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table_tools.tsx new file mode 100644 index 00000000000000..62d84adfc2649d --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/my_workpads/workpad_table_tools.tsx @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useContext } from 'react'; +import { useSelector } from 'react-redux'; + +import { canUserWrite as canUserWriteSelector } from '../../../state/selectors/app'; +import type { State } from '../../../../types'; +import { useDeleteWorkpads, useDownloadWorkpad } from '../hooks'; + +import { + WorkpadTableTools as Component, + Props as ComponentProps, +} from './workpad_table_tools.component'; +import { WorkpadsContext } from './my_workpads'; + +export type Props = Pick; + +export const WorkpadTableTools = ({ selectedWorkpadIds }: Props) => { + const deleteWorkpads = useDeleteWorkpads(); + const downloadWorkpad = useDownloadWorkpad(); + const context = useContext(WorkpadsContext); + + const { canUserWrite } = useSelector((state: State) => ({ + canUserWrite: canUserWriteSelector(state), + })); + + if (context === null || selectedWorkpadIds.length <= 0) { + return null; + } + + const { workpads, setWorkpads } = context; + + const onExport = () => selectedWorkpadIds.map((id) => downloadWorkpad(id)); + const onDelete = async () => { + const { removedIds } = await deleteWorkpads(selectedWorkpadIds); + setWorkpads(workpads.filter((workpad) => !removedIds.includes(workpad.id))); + }; + + return ( + + ); +}; diff --git a/x-pack/plugins/canvas/public/components/home/workpad_create.component.tsx b/x-pack/plugins/canvas/public/components/home/workpad_create.component.tsx new file mode 100644 index 00000000000000..18bdb976831948 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/workpad_create.component.tsx @@ -0,0 +1,37 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { EuiButton } from '@elastic/eui'; +import { EuiButtonPropsForButton } from '@elastic/eui/src/components/button/button'; + +export interface Props + extends Omit { + canUserWrite: boolean; +} + +export const WorkpadCreate = ({ canUserWrite, disabled, ...rest }: Props) => { + return ( + + {strings.getWorkpadCreateButtonLabel()} + + ); +}; + +const strings = { + getWorkpadCreateButtonLabel: () => + i18n.translate('xpack.canvas.workpadCreate.createButtonLabel', { + defaultMessage: 'Create workpad', + }), +}; diff --git a/x-pack/plugins/canvas/public/components/home/workpad_create.tsx b/x-pack/plugins/canvas/public/components/home/workpad_create.tsx new file mode 100644 index 00000000000000..adb73a6bb88967 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/workpad_create.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 React from 'react'; +import { useSelector } from 'react-redux'; + +import { canUserWrite as canUserWriteSelector } from '../../state/selectors/app'; +import type { State } from '../../../types'; + +import { useCreateWorkpad } from './hooks'; +import { WorkpadCreate as Component, Props as ComponentProps } from './workpad_create.component'; + +type Props = Omit; + +export const WorkpadCreate = (props: Props) => { + const createWorkpad = useCreateWorkpad(); + + const { canUserWrite } = useSelector((state: State) => ({ + canUserWrite: canUserWriteSelector(state), + })); + + const onClick: ComponentProps['onClick'] = async () => { + await createWorkpad(); + }; + + return ; +}; diff --git a/x-pack/plugins/canvas/public/components/home/workpad_templates/index.ts b/x-pack/plugins/canvas/public/components/home/workpad_templates/index.ts new file mode 100644 index 00000000000000..4c45dbff383778 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/workpad_templates/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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +export const LazyWorkpadTemplates = React.lazy(() => import('./workpad_templates')); diff --git a/x-pack/plugins/canvas/public/components/home/workpad_templates/workpad_templates.component.tsx b/x-pack/plugins/canvas/public/components/home/workpad_templates/workpad_templates.component.tsx new file mode 100644 index 00000000000000..d974c70b05cf21 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/workpad_templates/workpad_templates.component.tsx @@ -0,0 +1,157 @@ +/* + * 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 { uniq } from 'lodash'; +import { i18n } from '@kbn/i18n'; +import { + EuiInMemoryTable, + EuiBasicTableColumn, + EuiButtonEmpty, + EuiSearchBarProps, + SearchFilterConfig, +} from '@elastic/eui'; + +import { CanvasTemplate } from '../../../../types'; +import { tagsRegistry } from '../../../lib/tags_registry'; +import { TagList } from '../../tag_list'; + +export interface Props { + templates: CanvasTemplate[]; + onCreateWorkpad: (template: CanvasTemplate) => void; +} + +export const WorkpadTemplates = ({ templates, onCreateWorkpad }: Props) => { + const columns: Array> = [ + { + field: 'name', + name: strings.getTableNameColumnTitle(), + sortable: true, + width: '30%', + dataType: 'string', + render: (name: string, template) => { + const templateName = name.length ? name : 'Unnamed Template'; + + return ( + onCreateWorkpad(template)} + aria-label={strings.getCloneTemplateLinkAriaLabel(templateName)} + type="button" + > + {templateName} + + ); + }, + }, + { + field: 'help', + name: strings.getTableDescriptionColumnTitle(), + sortable: false, + dataType: 'string', + width: '30%', + }, + { + field: 'tags', + name: strings.getTableTagsColumnTitle(), + sortable: false, + dataType: 'string', + width: '30%', + render: (tags: string[]) => , + }, + ]; + + let uniqueTagNames: string[] = []; + + templates.forEach((template) => { + const { tags } = template; + tags.forEach((tag) => uniqueTagNames.push(tag)); + uniqueTagNames = uniq(uniqueTagNames); + }); + + const uniqueTags = uniqueTagNames.map( + (name) => + tagsRegistry.get(name) || { + color: undefined, + name, + } + ); + + const filters: SearchFilterConfig[] = [ + { + type: 'field_value_selection', + field: 'tags', + name: 'Tags', + multiSelect: true, + options: uniqueTags.map((tag) => ({ + value: tag.name, + name: tag.name, + view: , + })), + }, + ]; + + const search: EuiSearchBarProps = { + box: { + incremental: true, + schema: true, + }, + filters, + }; + + return ( + + ); +}; + +const strings = { + getCloneTemplateLinkAriaLabel: (templateName: string) => + i18n.translate('xpack.canvas.workpadTemplates.cloneTemplateLinkAriaLabel', { + defaultMessage: `Clone workpad template '{templateName}'`, + values: { + templateName, + }, + }), + getTableDescriptionColumnTitle: () => + i18n.translate('xpack.canvas.workpadTemplates.table.descriptionColumnTitle', { + defaultMessage: 'Description', + }), + getTableNameColumnTitle: () => + i18n.translate('xpack.canvas.workpadTemplates.table.nameColumnTitle', { + defaultMessage: 'Template name', + }), + getTableTagsColumnTitle: () => + i18n.translate('xpack.canvas.workpadTemplates.table.tagsColumnTitle', { + defaultMessage: 'Tags', + description: + 'This column contains relevant tags that indicate what type of template ' + + 'is displayed. For example: "report", "presentation", etc.', + }), + getTemplateSearchPlaceholder: () => + i18n.translate('xpack.canvas.workpadTemplates.searchPlaceholder', { + defaultMessage: 'Find template', + }), + getCreatingTemplateLabel: (templateName: string) => + i18n.translate('xpack.canvas.workpadTemplates.creatingTemplateLabel', { + defaultMessage: `Creating from template '{templateName}'`, + values: { + templateName, + }, + }), +}; diff --git a/x-pack/plugins/canvas/public/components/home/workpad_templates/workpad_templates.stories.tsx b/x-pack/plugins/canvas/public/components/home/workpad_templates/workpad_templates.stories.tsx new file mode 100644 index 00000000000000..cb2b872ea15f96 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/workpad_templates/workpad_templates.stories.tsx @@ -0,0 +1,62 @@ +/* + * 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 { EuiPanel } from '@elastic/eui'; +import { action } from '@storybook/addon-actions'; +import React from 'react'; + +import { + reduxDecorator, + getAddonPanelParameters, + servicesContextDecorator, + getDisableStoryshotsParameter, +} from '../../../../storybook'; +import { getSomeTemplates } from '../../../services/stubs/workpad'; + +import { WorkpadTemplates } from './workpad_templates'; +import { WorkpadTemplates as WorkpadTemplatesComponent } from './workpad_templates.component'; + +export default { + title: 'Home/Workpad Templates', + argTypes: {}, + decorators: [reduxDecorator()], + parameters: { ...getAddonPanelParameters(), ...getDisableStoryshotsParameter() }, +}; + +export const NoTemplates = () => { + return ( + + + + ); +}; + +export const HasTemplates = () => { + return ( + + + + ); +}; + +NoTemplates.decorators = [servicesContextDecorator()]; +HasTemplates.decorators = [servicesContextDecorator({ findTemplates: true })]; + +export const Component = ({ hasTemplates }: { hasTemplates: boolean }) => { + return ( + + + + ); +}; + +Component.args = { + hasTemplates: true, +}; diff --git a/x-pack/plugins/canvas/public/components/home/workpad_templates/workpad_templates.tsx b/x-pack/plugins/canvas/public/components/home/workpad_templates/workpad_templates.tsx new file mode 100644 index 00000000000000..352285e66424b7 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/home/workpad_templates/workpad_templates.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 React from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; + +import { useCreateFromTemplate, useFindTemplatesOnMount } from '../hooks'; + +import { WorkpadTemplates as Component } from './workpad_templates.component'; + +export const WorkpadTemplates = () => { + const [isMounted, templateResponse] = useFindTemplatesOnMount(); + const onCreateWorkpad = useCreateFromTemplate(); + + if (!isMounted) { + return ( + + + + + + ); + } + const { templates } = templateResponse; + + return ; +}; + +// required for dynamic import using React.lazy() +// eslint-disable-next-line import/no-default-export +export default WorkpadTemplates; diff --git a/x-pack/plugins/canvas/public/components/home_app/home_app.component.tsx b/x-pack/plugins/canvas/public/components/home_app/home_app.component.tsx index 712b06cb39299d..2e3e826cc32b5d 100644 --- a/x-pack/plugins/canvas/public/components/home_app/home_app.component.tsx +++ b/x-pack/plugins/canvas/public/components/home_app/home_app.component.tsx @@ -6,9 +6,7 @@ */ import React, { FC } from 'react'; -import { EuiPage, EuiPageBody, EuiPageContent } from '@elastic/eui'; -// @ts-expect-error untyped local -import { WorkpadManager } from '../workpad_manager'; +import { Home } from '../home'; // @ts-expect-error untyped local import { setDocTitle } from '../../lib/doc_title'; @@ -19,17 +17,5 @@ export interface Props { export const HomeApp: FC = ({ onLoad = () => {} }) => { onLoad(); setDocTitle('Canvas'); - return ( - - - - {}} /> - - - - ); + return ; }; diff --git a/x-pack/plugins/canvas/public/components/toolbar/__stories__/toolbar.stories.tsx b/x-pack/plugins/canvas/public/components/toolbar/__stories__/toolbar.stories.tsx index e4f297446701c9..bd47bb52e00308 100644 --- a/x-pack/plugins/canvas/public/components/toolbar/__stories__/toolbar.stories.tsx +++ b/x-pack/plugins/canvas/public/components/toolbar/__stories__/toolbar.stories.tsx @@ -18,7 +18,6 @@ storiesOf('components/Toolbar', module) isWriteable={true} selectedPageNumber={1} totalPages={1} - workpadId={'abc'} workpadName={'My Canvas Workpad'} /> )) @@ -28,7 +27,6 @@ storiesOf('components/Toolbar', module) selectedElement={getDefaultElement()} selectedPageNumber={1} totalPages={1} - workpadId={'abc'} workpadName={'My Canvas Workpad'} /> )); diff --git a/x-pack/plugins/canvas/public/components/toolbar/toolbar.component.tsx b/x-pack/plugins/canvas/public/components/toolbar/toolbar.component.tsx index baafbdafcc549c..9e89ad4c4f27b3 100644 --- a/x-pack/plugins/canvas/public/components/toolbar/toolbar.component.tsx +++ b/x-pack/plugins/canvas/public/components/toolbar/toolbar.component.tsx @@ -7,17 +7,8 @@ import React, { FC, useState, useContext, useEffect } from 'react'; import PropTypes from 'prop-types'; -import { - EuiButtonEmpty, - EuiFlexGroup, - EuiFlexItem, - EuiModal, - EuiModalFooter, - EuiButton, -} from '@elastic/eui'; +import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -// @ts-expect-error untyped local -import { WorkpadManager } from '../workpad_manager'; import { PageManager } from '../page_manager'; import { Expression } from '../expression'; import { Tray } from './tray'; @@ -37,7 +28,6 @@ export interface Props { selectedElement?: CanvasElement; selectedPageNumber: number; totalPages: number; - workpadId: string; workpadName: string; } @@ -46,11 +36,9 @@ export const Toolbar: FC = ({ selectedElement, selectedPageNumber, totalPages, - workpadId, workpadName, }) => { const [activeTray, setActiveTray] = useState(null); - const [showWorkpadManager, setShowWorkpadManager] = useState(false); const { getUrl, previousPage } = useContext(WorkpadRoutingContext); // While the tray doesn't get activated if the workpad isn't writeable, @@ -75,20 +63,6 @@ export const Toolbar: FC = ({ } }; - const closeWorkpadManager = () => setShowWorkpadManager(false); - const openWorkpadManager = () => setShowWorkpadManager(true); - - const workpadManager = ( - - - - - {strings.getWorkpadManagerCloseButtonLabel()} - - - - ); - const trays = { pageManager: , expression: !elementIsSelected ? null : setActiveTray(null)} />, @@ -99,12 +73,6 @@ export const Toolbar: FC = ({ {activeTray !== null && setActiveTray(null)}>{trays[activeTray]}}
    - - openWorkpadManager()}> - {workpadName} - - - = ({ )}
    - {showWorkpadManager && workpadManager} ); }; @@ -153,6 +120,5 @@ Toolbar.propTypes = { selectedElement: PropTypes.object, selectedPageNumber: PropTypes.number.isRequired, totalPages: PropTypes.number.isRequired, - workpadId: PropTypes.string.isRequired, workpadName: PropTypes.string.isRequired, }; diff --git a/x-pack/plugins/canvas/public/components/workpad_loader/index.tsx b/x-pack/plugins/canvas/public/components/workpad_loader/index.tsx deleted file mode 100644 index 2afd5fe70abe12..00000000000000 --- a/x-pack/plugins/canvas/public/components/workpad_loader/index.tsx +++ /dev/null @@ -1,173 +0,0 @@ -/* - * 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, { FC, useState, useCallback } from 'react'; -import { useHistory } from 'react-router-dom'; -import { useSelector } from 'react-redux'; -import moment from 'moment'; -// @ts-expect-error -import { getDefaultWorkpad } from '../../state/defaults'; -import { canUserWrite as canUserWriteSelector } from '../../state/selectors/app'; -import { getWorkpad } from '../../state/selectors/workpad'; -import { getId } from '../../lib/get_id'; -import { downloadWorkpad } from '../../lib/download_workpad'; -import { ComponentStrings, ErrorStrings } from '../../../i18n'; -import { State, CanvasWorkpad } from '../../../types'; -import { useNotifyService, useWorkpadService, usePlatformService } from '../../services'; -// @ts-expect-error -import { WorkpadLoader as Component } from './workpad_loader'; - -const { WorkpadLoader: strings } = ComponentStrings; -const { WorkpadLoader: errors } = ErrorStrings; - -type WorkpadStatePromise = ReturnType['find']>; -type WorkpadState = WorkpadStatePromise extends PromiseLike ? U : never; - -export const WorkpadLoader: FC<{ onClose: () => void }> = ({ onClose }) => { - const fromState = useSelector((state: State) => ({ - workpadId: getWorkpad(state).id, - canUserWrite: canUserWriteSelector(state), - })); - - const [workpadsState, setWorkpadsState] = useState(null); - const workpadService = useWorkpadService(); - const notifyService = useNotifyService(); - const platformService = usePlatformService(); - const history = useHistory(); - - const createWorkpad = useCallback( - async (_workpad: CanvasWorkpad | null | undefined) => { - const workpad = _workpad || getDefaultWorkpad(); - if (workpad != null) { - try { - await workpadService.create(workpad); - history.push(`/workpad/${workpad.id}/page/1`); - } catch (err) { - notifyService.error(err, { - title: errors.getUploadFailureErrorMessage(), - }); - } - return; - } - }, - [workpadService, notifyService, history] - ); - - const findWorkpads = useCallback( - async (text) => { - try { - const fetchedWorkpads = await workpadService.find(text); - setWorkpadsState(fetchedWorkpads); - } catch (err) { - notifyService.error(err, { title: errors.getFindFailureErrorMessage() }); - } - }, - [notifyService, workpadService] - ); - - const onDownloadWorkpad = useCallback((workpadId: string) => downloadWorkpad(workpadId), []); - - const cloneWorkpad = useCallback( - async (workpadId: string) => { - try { - const workpad = await workpadService.get(workpadId); - workpad.name = strings.getClonedWorkpadName(workpad.name); - workpad.id = getId('workpad'); - await workpadService.create(workpad); - history.push(`/workpad/${workpad.id}/page/1`); - } catch (err) { - notifyService.error(err, { title: errors.getCloneFailureErrorMessage() }); - } - }, - [notifyService, workpadService, history] - ); - - const removeWorkpads = useCallback( - (workpadIds: string[]) => { - if (workpadsState === null) { - return; - } - - const removedWorkpads = workpadIds.map(async (id) => { - try { - await workpadService.remove(id); - return { id, err: null }; - } catch (err) { - return { id, err }; - } - }); - - return Promise.all(removedWorkpads).then((results) => { - let redirectHome = false; - - const [passes, errored] = results.reduce<[string[], string[]]>( - ([passesArr, errorsArr], result) => { - if (result.id === fromState.workpadId && !result.err) { - redirectHome = true; - } - - if (result.err) { - errorsArr.push(result.id); - } else { - passesArr.push(result.id); - } - - return [passesArr, errorsArr]; - }, - [[], []] - ); - - const remainingWorkpads = workpadsState.workpads.filter(({ id }) => !passes.includes(id)); - - const workpadState = { - total: remainingWorkpads.length, - workpads: remainingWorkpads, - }; - - if (errored.length > 0) { - notifyService.error(errors.getDeleteFailureErrorMessage()); - } - - setWorkpadsState(workpadState); - - if (redirectHome) { - history.push('/'); - } - - return errored; - }); - }, - [history, workpadService, fromState.workpadId, workpadsState, notifyService] - ); - - const formatDate = useCallback( - (date: any) => { - const dateFormat = platformService.getUISetting('dateFormat'); - return date && moment(date).format(dateFormat); - }, - [platformService] - ); - - const { workpadId, canUserWrite } = fromState; - - return ( - - ); -}; diff --git a/x-pack/plugins/canvas/public/components/workpad_loader/upload_workpad.js b/x-pack/plugins/canvas/public/components/workpad_loader/upload_workpad.js deleted file mode 100644 index 24a694268e4eef..00000000000000 --- a/x-pack/plugins/canvas/public/components/workpad_loader/upload_workpad.js +++ /dev/null @@ -1,52 +0,0 @@ -/* - * 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 { get } from 'lodash'; -import { getId } from '../../lib/get_id'; -import { ErrorStrings } from '../../../i18n'; - -const { WorkpadFileUpload: errors } = ErrorStrings; - -export const uploadWorkpad = (file, onUpload, notify) => { - if (!file) { - return; - } - - if (get(file, 'type') !== 'application/json') { - return notify.warning(errors.getAcceptJSONOnlyErrorMessage(), { - title: file.name - ? errors.getFileUploadFailureWithFileNameErrorMessage(file.name) - : errors.getFileUploadFailureWithoutFileNameErrorMessage(), - }); - } - // TODO: Clean up this file, this loading stuff can, and should be, abstracted - const reader = new FileReader(); - - // handle reading the uploaded file - reader.onload = () => { - try { - const workpad = JSON.parse(reader.result); - workpad.id = getId('workpad'); - - // sanity check for workpad object - if (!Array.isArray(workpad.pages) || workpad.pages.length === 0 || !workpad.assets) { - throw new Error(errors.getMissingPropertiesErrorMessage()); - } - - onUpload(workpad); - } catch (e) { - notify.error(e, { - title: file.name - ? errors.getFileUploadFailureWithFileNameErrorMessage(file.name) - : errors.getFileUploadFailureWithoutFileNameErrorMessage(), - }); - } - }; - - // read the uploaded file - reader.readAsText(file); -}; diff --git a/x-pack/plugins/canvas/public/components/workpad_loader/workpad_create.js b/x-pack/plugins/canvas/public/components/workpad_loader/workpad_create.js deleted file mode 100644 index 51733dad5b3773..00000000000000 --- a/x-pack/plugins/canvas/public/components/workpad_loader/workpad_create.js +++ /dev/null @@ -1,31 +0,0 @@ -/* - * 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 PropTypes from 'prop-types'; -import { EuiButton } from '@elastic/eui'; -import { ComponentStrings } from '../../../i18n'; - -const { WorkpadCreate: strings } = ComponentStrings; - -export const WorkpadCreate = ({ createPending, onCreate, ...rest }) => ( - - {strings.getWorkpadCreateButtonLabel()} - -); - -WorkpadCreate.propTypes = { - onCreate: PropTypes.func.isRequired, - createPending: PropTypes.bool, -}; diff --git a/x-pack/plugins/canvas/public/components/workpad_loader/workpad_dropzone/index.js b/x-pack/plugins/canvas/public/components/workpad_loader/workpad_dropzone/index.js deleted file mode 100644 index 7c34837771c6f5..00000000000000 --- a/x-pack/plugins/canvas/public/components/workpad_loader/workpad_dropzone/index.js +++ /dev/null @@ -1,31 +0,0 @@ -/* - * 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 PropTypes from 'prop-types'; -import { compose, withHandlers } from 'recompose'; -import { uploadWorkpad } from '../upload_workpad'; -import { ErrorStrings } from '../../../../i18n'; -import { WorkpadDropzone as Component } from './workpad_dropzone'; - -const { WorkpadFileUpload: errors } = ErrorStrings; - -export const WorkpadDropzone = compose( - withHandlers(({ notify }) => ({ - onDropAccepted: ({ onUpload }) => ([file]) => uploadWorkpad(file, onUpload), - onDropRejected: () => ([file]) => { - notify.warning(errors.getAcceptJSONOnlyErrorMessage(), { - title: file.name - ? errors.getFileUploadFailureWithFileNameErrorMessage(file.name) - : errors.getFileUploadFailureWithoutFileNameErrorMessage(), - }); - }, - })) -)(Component); - -WorkpadDropzone.propTypes = { - onUpload: PropTypes.func.isRequired, -}; diff --git a/x-pack/plugins/canvas/public/components/workpad_loader/workpad_dropzone/workpad_dropzone.js b/x-pack/plugins/canvas/public/components/workpad_loader/workpad_dropzone/workpad_dropzone.js deleted file mode 100644 index f77929e1feb761..00000000000000 --- a/x-pack/plugins/canvas/public/components/workpad_loader/workpad_dropzone/workpad_dropzone.js +++ /dev/null @@ -1,31 +0,0 @@ -/* - * 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 PropTypes from 'prop-types'; -import Dropzone from 'react-dropzone'; - -export const WorkpadDropzone = ({ onDropAccepted, onDropRejected, disabled, children }) => ( - - {children} - -); - -WorkpadDropzone.propTypes = { - onDropAccepted: PropTypes.func.isRequired, - onDropRejected: PropTypes.func.isRequired, - disabled: PropTypes.bool.isRequired, - children: PropTypes.node.isRequired, -}; diff --git a/x-pack/plugins/canvas/public/components/workpad_loader/workpad_dropzone/workpad_dropzone.scss b/x-pack/plugins/canvas/public/components/workpad_loader/workpad_dropzone/workpad_dropzone.scss deleted file mode 100644 index ac6838da97fbd1..00000000000000 --- a/x-pack/plugins/canvas/public/components/workpad_loader/workpad_dropzone/workpad_dropzone.scss +++ /dev/null @@ -1,22 +0,0 @@ -.canvasWorkpad__dropzone { - border: 2px dashed transparent; -} - -.canvasWorkpad__dropzone--active { - background-color: $euiColorLightestShade; - border-color: $euiColorLightShade; -} - -.canvasWorkpad__dropzoneTable .euiTable { - background-color: transparent; -} - -.canvasWorkpad__dropzoneTable--tags { - .euiTableCellContent { - flex-wrap: wrap; - } - - .euiHealth { - width: 100%; - } -} \ No newline at end of file diff --git a/x-pack/plugins/canvas/public/components/workpad_loader/workpad_loader.js b/x-pack/plugins/canvas/public/components/workpad_loader/workpad_loader.js deleted file mode 100644 index 9c232ab43ec8d0..00000000000000 --- a/x-pack/plugins/canvas/public/components/workpad_loader/workpad_loader.js +++ /dev/null @@ -1,426 +0,0 @@ -/* - * 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, { Fragment } from 'react'; -import PropTypes from 'prop-types'; -import { - EuiFlexGroup, - EuiFlexItem, - EuiBasicTable, - EuiButtonIcon, - EuiPagination, - EuiSpacer, - EuiButton, - EuiToolTip, - EuiEmptyPrompt, - EuiFilePicker, - EuiLink, -} from '@elastic/eui'; -import { orderBy } from 'lodash'; -import { ConfirmModal } from '../confirm_modal'; -import { RoutingLink } from '../routing'; -import { Paginate } from '../paginate'; -import { ComponentStrings } from '../../../i18n'; -import { WorkpadDropzone } from './workpad_dropzone'; -import { WorkpadCreate } from './workpad_create'; -import { WorkpadSearch } from './workpad_search'; -import { uploadWorkpad } from './upload_workpad'; - -const { WorkpadLoader: strings } = ComponentStrings; - -const getDisplayName = (name, workpad, loadedWorkpad) => { - const workpadName = name.length ? name : {workpad.id}; - return workpad.id === loadedWorkpad ? {workpadName} : workpadName; -}; - -export class WorkpadLoader extends React.PureComponent { - static propTypes = { - workpadId: PropTypes.string.isRequired, - canUserWrite: PropTypes.bool.isRequired, - createWorkpad: PropTypes.func.isRequired, - findWorkpads: PropTypes.func.isRequired, - downloadWorkpad: PropTypes.func.isRequired, - cloneWorkpad: PropTypes.func.isRequired, - removeWorkpads: PropTypes.func.isRequired, - onClose: PropTypes.func.isRequired, - workpads: PropTypes.object, - formatDate: PropTypes.func.isRequired, - }; - - state = { - createPending: false, - deletingWorkpad: false, - sortField: '@timestamp', - sortDirection: 'desc', - selectedWorkpads: [], - pageSize: 10, - }; - - async componentDidMount() { - // on component load, kick off the workpad search - this.props.findWorkpads(); - - // keep track of whether or not the component is mounted, to prevent rogue setState calls - this._isMounted = true; - } - - UNSAFE_componentWillReceiveProps(newProps) { - // the workpadId prop will change when a is created or loaded, close the toolbar when it does - const { workpadId, onClose } = this.props; - if (workpadId !== newProps.workpadId) { - onClose(); - } - } - - componentWillUnmount() { - this._isMounted = false; - } - - // create new empty workpad - createWorkpad = async () => { - this.setState({ createPending: true }); - await this.props.createWorkpad(); - this._isMounted && this.setState({ createPending: false }); - }; - - // create new workpad from uploaded JSON - onUpload = async (workpad) => { - this.setState({ createPending: true }); - await this.props.createWorkpad(workpad); - this._isMounted && this.setState({ createPending: false }); - }; - - // clone existing workpad - cloneWorkpad = async (workpad) => { - this.setState({ createPending: true }); - await this.props.cloneWorkpad(workpad.id); - this._isMounted && this.setState({ createPending: false }); - }; - - // Workpad remove methods - openRemoveConfirm = () => this.setState({ deletingWorkpad: true }); - - closeRemoveConfirm = () => this.setState({ deletingWorkpad: false }); - - removeWorkpads = () => { - const { selectedWorkpads } = this.state; - - this.props.removeWorkpads(selectedWorkpads.map(({ id }) => id)).then((remainingIds) => { - const remainingWorkpads = - remainingIds.length > 0 - ? selectedWorkpads.filter(({ id }) => remainingIds.includes(id)) - : []; - - this._isMounted && - this.setState({ - deletingWorkpad: false, - selectedWorkpads: remainingWorkpads, - }); - }); - }; - - // downloads selected workpads as JSON files - downloadWorkpads = () => { - this.state.selectedWorkpads.forEach(({ id }) => this.props.downloadWorkpad(id)); - }; - - onSelectionChange = (selectedWorkpads) => { - this.setState({ selectedWorkpads }); - }; - - onTableChange = ({ sort = {} }) => { - const { field: sortField, direction: sortDirection } = sort; - this.setState({ - sortField, - sortDirection, - }); - }; - - renderWorkpadTable = ({ rows, pageNumber, totalPages, setPage }) => { - const { sortField, sortDirection } = this.state; - const { canUserWrite, createPending, workpadId: loadedWorkpad } = this.props; - - const actions = [ - { - render: (workpad) => ( - - - - this.props.downloadWorkpad(workpad.id)} - aria-label={strings.getExportToolTip()} - /> - - - - - this.cloneWorkpad(workpad)} - aria-label={strings.getCloneToolTip()} - disabled={!canUserWrite} - /> - - - - ), - }, - ]; - - const columns = [ - { - field: 'name', - name: strings.getTableNameColumnTitle(), - sortable: true, - dataType: 'string', - render: (name, workpad) => { - const workpadName = getDisplayName(name, workpad, loadedWorkpad); - - return ( - - {workpadName} - - ); - }, - }, - { - field: '@created', - name: strings.getTableCreatedColumnTitle(), - sortable: true, - dataType: 'date', - width: '20%', - render: (date) => this.props.formatDate(date), - }, - { - field: '@timestamp', - name: strings.getTableUpdatedColumnTitle(), - sortable: true, - dataType: 'date', - width: '20%', - render: (date) => this.props.formatDate(date), - }, - { name: strings.getTableActionsColumnTitle(), actions, width: '100px' }, - ]; - - const sorting = { - sort: { - field: sortField, - direction: sortDirection, - }, - }; - - const selection = { - itemId: 'id', - onSelectionChange: this.onSelectionChange, - }; - - const emptyTable = ( - {strings.getEmptyPromptTitle()}} - titleSize="s" - body={ - -

    {strings.getEmptyPromptGettingStartedDescription()}

    -

    - {strings.getEmptyPromptNewUserDescription()}{' '} - - {strings.getSampleDataLinkLabel()} - - . -

    -
    - } - /> - ); - - return ( - - - - - {rows.length > 0 && ( - - - - - - )} - - - ); - }; - - render() { - const { - deletingWorkpad, - createPending, - selectedWorkpads, - sortField, - sortDirection, - } = this.state; - const { canUserWrite } = this.props; - const isLoading = this.props.workpads == null; - - let createButton = ( - - ); - - let deleteButton = ( - - {strings.getDeleteButtonLabel(selectedWorkpads.length)} - - ); - - const downloadButton = ( - - {strings.getExportButtonLabel(selectedWorkpads.length)} - - ); - - let uploadButton = ( - uploadWorkpad(file, this.onUpload, this.props.notify)} - accept="application/json" - disabled={createPending || !canUserWrite} - /> - ); - - if (!canUserWrite) { - createButton = ( - {createButton} - ); - deleteButton = ( - {deleteButton} - ); - uploadButton = ( - {uploadButton} - ); - } - - const modalTitle = - selectedWorkpads.length === 1 - ? strings.getDeleteSingleWorkpadModalTitle(selectedWorkpads[0].name) - : strings.getDeleteMultipleWorkpadModalTitle(selectedWorkpads.length); - - const confirmModal = ( - - ); - - let sortedWorkpads = []; - - if (!createPending && !isLoading) { - const { workpads } = this.props.workpads; - sortedWorkpads = orderBy(workpads, [sortField, '@timestamp'], [sortDirection, 'desc']); - } - - return ( - - {(pagination) => ( - - - - - {selectedWorkpads.length > 0 && ( - - {downloadButton} - {deleteButton} - - )} - - { - pagination.setPage(0); - this.props.findWorkpads(text); - }} - /> - - - - - - {uploadButton} - {createButton} - - - - - - - {createPending && ( -
    {strings.getCreateWorkpadLoadingDescription()}
    - )} - - {!createPending && isLoading && ( -
    {strings.getFetchLoadingDescription()}
    - )} - - {!createPending && !isLoading && this.renderWorkpadTable(pagination)} - - {confirmModal} -
    - )} -
    - ); - } -} diff --git a/x-pack/plugins/canvas/public/components/workpad_loader/workpad_loader.scss b/x-pack/plugins/canvas/public/components/workpad_loader/workpad_loader.scss deleted file mode 100644 index 3b2c8eae9e5420..00000000000000 --- a/x-pack/plugins/canvas/public/components/workpad_loader/workpad_loader.scss +++ /dev/null @@ -1,25 +0,0 @@ -.canvasWorkpad__upload--compressed { - - &.euiFilePicker--compressed.euiFilePicker { - .euiFilePicker__prompt { - height: $euiSizeXXL; - padding: $euiSizeM; - padding-left: $euiSizeXXL; - } - - .euiFilePicker__icon { - top: $euiSizeM; - } - } - - // The file picker input is being used moreso as a button, outside of a form, - // and thus the need to override the default max-width of form inputs. - // An issue has been opened in EUI to consider creating a button - // version of the file picker - https://github.com/elastic/eui/issues/1987 - - .euiFilePicker__wrap { - @include euiBreakpoint('xs', 's') { - max-width: none; - } - } -} \ No newline at end of file diff --git a/x-pack/plugins/canvas/public/components/workpad_loader/workpad_search.js b/x-pack/plugins/canvas/public/components/workpad_loader/workpad_search.js deleted file mode 100644 index 8bf8bbae8ced40..00000000000000 --- a/x-pack/plugins/canvas/public/components/workpad_loader/workpad_search.js +++ /dev/null @@ -1,44 +0,0 @@ -/* - * 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 PropTypes from 'prop-types'; -import { EuiFieldSearch } from '@elastic/eui'; -import { debounce } from 'lodash'; -import { ComponentStrings } from '../../../i18n'; - -const { WorkpadSearch: strings } = ComponentStrings; -export class WorkpadSearch extends React.PureComponent { - static propTypes = { - onChange: PropTypes.func.isRequired, - initialText: PropTypes.string, - }; - - state = { - searchText: this.props.initialText || '', - }; - - triggerChange = debounce(this.props.onChange, 150); - - setSearchText = (ev) => { - const text = ev.target.value; - this.setState({ searchText: text }); - this.triggerChange(text); - }; - - render() { - return ( - - ); - } -} diff --git a/x-pack/plugins/canvas/public/components/workpad_manager/workpad_manager.js b/x-pack/plugins/canvas/public/components/workpad_manager/workpad_manager.js deleted file mode 100644 index 8055be32ac481a..00000000000000 --- a/x-pack/plugins/canvas/public/components/workpad_manager/workpad_manager.js +++ /dev/null @@ -1,69 +0,0 @@ -/* - * 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, { Fragment } from 'react'; -import PropTypes from 'prop-types'; -import { - EuiTabbedContent, - EuiModalHeader, - EuiModalHeaderTitle, - EuiModalBody, - EuiSpacer, - EuiFlexGroup, - EuiFlexItem, -} from '@elastic/eui'; -import { WorkpadLoader } from '../workpad_loader'; -import { WorkpadTemplates } from '../workpad_templates'; -import { ComponentStrings } from '../../../i18n'; - -const { WorkpadManager: strings } = ComponentStrings; - -export const WorkpadManager = ({ onClose }) => { - const tabs = [ - { - id: 'workpadLoader', - name: strings.getMyWorkpadsTabLabel(), - content: ( - - - - - ), - }, - { - id: 'workpadTemplates', - name: strings.getWorkpadTemplatesTabLabel(), - 'data-test-subj': 'workpadTemplates', - content: ( - - - - - ), - }, - ]; - return ( - - - - - -

    {strings.getModalTitle()}

    -
    -
    -
    -
    - - - -
    - ); -}; - -WorkpadManager.propTypes = { - onClose: PropTypes.func, -}; diff --git a/x-pack/plugins/canvas/public/components/workpad_templates/examples/__snapshots__/workpad_templates.stories.storyshot b/x-pack/plugins/canvas/public/components/workpad_templates/examples/__snapshots__/workpad_templates.stories.storyshot deleted file mode 100644 index cab6e8fd9b5f53..00000000000000 --- a/x-pack/plugins/canvas/public/components/workpad_templates/examples/__snapshots__/workpad_templates.stories.storyshot +++ /dev/null @@ -1,564 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Storyshots components/WorkpadTemplates default 1`] = ` -
    -
    -
    -
    -
    - -
    - - -
    -
    -
    -
    -
    -
    -
    -
    - -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    - -
    -
    -
    -
    -
    -
    - - - - - - - - - - - - - - - - - - - - -
    -
    - - - - - Description - - - - - - Tags - - -
    -
    - Template name -
    -
    - -
    -
    -
    - Description -
    -
    - - This is a test template - -
    -
    -
    - Tags -
    -
    -
    -
    -
    - -
    -
    - tag1 -
    -
    -
    -
    -
    -
    - -
    -
    - tag2 -
    -
    -
    -
    -
    -
    - Template name -
    -
    - -
    -
    -
    - Description -
    -
    - - This is a second test template - -
    -
    -
    - Tags -
    -
    -
    -
    -
    - -
    -
    - tag2 -
    -
    -
    -
    -
    -
    - -
    -
    - tag3 -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    - -
    -
    -
    -`; diff --git a/x-pack/plugins/canvas/public/components/workpad_templates/examples/workpad_templates.stories.tsx b/x-pack/plugins/canvas/public/components/workpad_templates/examples/workpad_templates.stories.tsx deleted file mode 100644 index 8e6c055478ca2b..00000000000000 --- a/x-pack/plugins/canvas/public/components/workpad_templates/examples/workpad_templates.stories.tsx +++ /dev/null @@ -1,45 +0,0 @@ -/* - * 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 { storiesOf } from '@storybook/react'; -import { action } from '@storybook/addon-actions'; -import { WorkpadTemplates } from '../workpad_templates'; -import { CanvasTemplate } from '../../../../types'; - -const templates: Record = { - test1: { - id: 'test1-id', - name: 'test1', - help: 'This is a test template', - tags: ['tag1', 'tag2'], - template_key: 'test1-key', - }, - test2: { - id: 'test2-id', - name: 'test2', - help: 'This is a second test template', - tags: ['tag2', 'tag3'], - template_key: 'test2-key', - }, -}; - -storiesOf('components/WorkpadTemplates', module) - .addDecorator((story) =>
    {story()}
    ) - .add('default', () => { - const onCreateFromTemplateAction = action('onCreateFromTemplate'); - return ( - { - onCreateFromTemplateAction(template); - return Promise.resolve(); - }} - /> - ); - }); diff --git a/x-pack/plugins/canvas/public/components/workpad_templates/index.tsx b/x-pack/plugins/canvas/public/components/workpad_templates/index.tsx deleted file mode 100644 index 7e007b1253464e..00000000000000 --- a/x-pack/plugins/canvas/public/components/workpad_templates/index.tsx +++ /dev/null @@ -1,86 +0,0 @@ -/* - * 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, useEffect, FunctionComponent } from 'react'; -import { EuiLoadingSpinner } from '@elastic/eui'; -import { useHistory } from 'react-router-dom'; - -import { ComponentStrings } from '../../../i18n/components'; -// @ts-expect-error -import * as workpadService from '../../lib/workpad_service'; -import { WorkpadTemplates as Component } from './workpad_templates'; -import { CanvasTemplate } from '../../../types'; -import { list } from '../../lib/template_service'; -import { applyTemplateStrings } from '../../../i18n/templates/apply_strings'; -import { useNotifyService, useServices } from '../../services'; - -interface WorkpadTemplatesProps { - onClose: () => void; -} - -const Creating: FunctionComponent<{ name: string }> = ({ name }) => ( -
    - {' '} - {ComponentStrings.WorkpadTemplates.getCreatingTemplateLabel(name)} -
    -); -export const WorkpadTemplates: FunctionComponent = ({ onClose }) => { - const history = useHistory(); - const services = useServices(); - - const [templates, setTemplates] = useState(undefined); - const [creatingFromTemplateName, setCreatingFromTemplateName] = useState( - undefined - ); - const { error } = useNotifyService(); - - useEffect(() => { - if (!templates) { - (async () => { - const fetchedTemplates = await list(); - setTemplates(applyTemplateStrings(fetchedTemplates)); - })(); - } - }, [templates]); - - let templateProp: Record = {}; - - if (templates) { - templateProp = templates.reduce>((reduction, template) => { - reduction[template.name] = template; - return reduction; - }, {}); - } - - const createFromTemplate = useCallback( - async (template: CanvasTemplate) => { - setCreatingFromTemplateName(template.name); - try { - const result = await services.workpad.createFromTemplate(template.id); - history.push(`/workpad/${result.id}/page/1`); - } catch (e) { - setCreatingFromTemplateName(undefined); - error(e, { - title: `Couldn't create workpad from template`, - }); - } - }, - [services.workpad, error, history] - ); - - if (creatingFromTemplateName) { - return ; - } - - return ( - - ); -}; diff --git a/x-pack/plugins/canvas/public/components/workpad_templates/workpad_templates.tsx b/x-pack/plugins/canvas/public/components/workpad_templates/workpad_templates.tsx deleted file mode 100644 index 72871b93c1735b..00000000000000 --- a/x-pack/plugins/canvas/public/components/workpad_templates/workpad_templates.tsx +++ /dev/null @@ -1,215 +0,0 @@ -/* - * 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, { Fragment } from 'react'; -import PropTypes from 'prop-types'; -import { - EuiFlexGroup, - EuiFlexItem, - EuiBasicTable, - EuiPagination, - EuiSpacer, - EuiButtonEmpty, - EuiSearchBar, - EuiTableSortingType, - Direction, - SortDirection, -} from '@elastic/eui'; -import { orderBy } from 'lodash'; -// @ts-ignore untyped local -import { EuiBasicTableColumn } from '@elastic/eui'; -import { Paginate, PaginateChildProps } from '../paginate'; -import { TagList } from '../tag_list'; -import { getTagsFilter } from '../../lib/get_tags_filter'; -// @ts-expect-error -import { extractSearch } from '../../lib/extract_search'; -import { ComponentStrings } from '../../../i18n'; -import { CanvasTemplate } from '../../../types'; - -interface TableChange { - page?: { - index: number; - size: number; - }; - sort?: { - field: keyof T; - direction: Direction; - }; -} - -const { WorkpadTemplates: strings } = ComponentStrings; - -interface WorkpadTemplatesProps { - onCreateFromTemplate: (template: CanvasTemplate) => Promise; - onClose: () => void; - templates: Record; -} - -interface WorkpadTemplatesState { - sortField: string; - sortDirection: Direction; - pageSize: number; - searchTerm: string; - filterTags: string[]; -} - -export class WorkpadTemplates extends React.PureComponent< - WorkpadTemplatesProps, - WorkpadTemplatesState -> { - static propTypes = { - onCreateFromTemplate: PropTypes.func.isRequired, - onClose: PropTypes.func.isRequired, - templates: PropTypes.object, - }; - - state = { - sortField: 'name', - sortDirection: SortDirection.ASC, - pageSize: 10, - searchTerm: '', - filterTags: [], - }; - - tagType: 'health' = 'health'; - - onTableChange = (tableChange: TableChange) => { - if (tableChange.sort) { - const { field: sortField, direction: sortDirection } = tableChange.sort; - this.setState({ - sortField, - sortDirection, - }); - } - }; - - onSearch = ({ queryText = '' }) => this.setState(extractSearch(queryText)); - - cloneTemplate = (template: CanvasTemplate) => - this.props.onCreateFromTemplate(template).then(() => this.props.onClose()); - - renderWorkpadTable = ({ rows, pageNumber, totalPages, setPage }: PaginateChildProps) => { - const { sortField, sortDirection } = this.state; - - const columns: Array> = [ - { - field: 'name', - name: strings.getTableNameColumnTitle(), - sortable: true, - width: '30%', - dataType: 'string', - render: (name: string, template) => { - const templateName = name.length ? name : 'Unnamed Template'; - - return ( - this.cloneTemplate(template)} - aria-label={strings.getCloneTemplateLinkAriaLabel(templateName)} - type="button" - > - {templateName} - - ); - }, - }, - { - field: 'help', - name: strings.getTableDescriptionColumnTitle(), - sortable: false, - dataType: 'string', - width: '30%', - }, - { - field: 'tags', - name: strings.getTableTagsColumnTitle(), - sortable: false, - dataType: 'string', - width: '30%', - render: (tags: string[]) => , - }, - ]; - - const sorting: EuiTableSortingType = { - sort: { - field: sortField, - direction: sortDirection, - }, - }; - - return ( - - - - {rows.length > 0 && ( - - - - - - )} - - ); - }; - - renderSearch = () => { - const { searchTerm } = this.state; - const filters = [getTagsFilter(this.tagType)]; - - return ( - - ); - }; - - render() { - const { templates } = this.props; - const { sortField, sortDirection, searchTerm, filterTags } = this.state; - const sortedTemplates = orderBy(templates, [sortField, 'name'], [sortDirection, 'asc']); - - const filteredTemplates = sortedTemplates.filter(({ name = '', help = '', tags = [] }) => { - const tagMatch = filterTags.length - ? filterTags.every((filterTag) => tags.indexOf(filterTag) > -1) - : true; - - const lowercaseSearch = searchTerm.toLowerCase(); - const textMatch = lowercaseSearch - ? name.toLowerCase().indexOf(lowercaseSearch) > -1 || - help.toLowerCase().indexOf(lowercaseSearch) > -1 - : true; - - return tagMatch && textMatch; - }); - - return ( - - {(pagination: PaginateChildProps) => ( - - {this.renderSearch()} - - {this.renderWorkpadTable(pagination)} - - )} - - ); - } -} diff --git a/x-pack/plugins/canvas/public/lib/get_tags_filter.tsx b/x-pack/plugins/canvas/public/lib/get_tags_filter.tsx deleted file mode 100644 index 12d77c9c7f0c0a..00000000000000 --- a/x-pack/plugins/canvas/public/lib/get_tags_filter.tsx +++ /dev/null @@ -1,39 +0,0 @@ -/* - * 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 { sortBy } from 'lodash'; -import { SearchFilterConfig } from '@elastic/eui'; -import { Tag } from '../components/tag'; -import { getId } from './get_id'; -import { tagsRegistry } from './tags_registry'; -import { ComponentStrings } from '../../i18n'; - -const { WorkpadTemplates: strings } = ComponentStrings; - -// EUI helper function -// generates the FieldValueSelectionFilter object for EuiSearchBar for tag filtering -export const getTagsFilter = (type: 'health' | 'badge'): SearchFilterConfig => { - const uniqueTags = sortBy(Object.values(tagsRegistry.toJS()), 'name'); - const filterType = 'field_value_selection'; - - return { - type: filterType, - field: 'tag', - name: strings.getTableTagsColumnTitle(), - multiSelect: true, - options: uniqueTags.map(({ name, color }) => ({ - value: name, - name, - view: ( -
    - -
    - ), - })), - }; -}; diff --git a/x-pack/plugins/canvas/public/services/index.ts b/x-pack/plugins/canvas/public/services/index.ts index 6c039660c64c7a..3f8f58367171a8 100644 --- a/x-pack/plugins/canvas/public/services/index.ts +++ b/x-pack/plugins/canvas/public/services/index.ts @@ -34,7 +34,7 @@ export type CanvasServiceFactory = ( appUpdater: BehaviorSubject ) => Service | Promise; -class CanvasServiceProvider { +export class CanvasServiceProvider { private factory: CanvasServiceFactory; private service: Service | undefined; diff --git a/x-pack/plugins/canvas/public/services/stubs/platform.ts b/x-pack/plugins/canvas/public/services/stubs/platform.ts index ea80a5a7c26b99..5776a1d0d69834 100644 --- a/x-pack/plugins/canvas/public/services/stubs/platform.ts +++ b/x-pack/plugins/canvas/public/services/stubs/platform.ts @@ -9,13 +9,19 @@ import { PlatformService } from '../platform'; const noop = (..._args: any[]): any => {}; +const uiSettings: Record = { + dateFormat: 'MMM D, YYYY @ HH:mm:ss.SSS', +}; + +const getUISetting = (setting: string) => uiSettings[setting]; + export const platformService: PlatformService = { getBasePath: () => '/base/path', getBasePathInterface: noop, getDocLinkVersion: () => 'dockLinkVersion', getElasticWebsiteUrl: () => 'https://elastic.co', getHasWriteAccess: () => true, - getUISetting: noop, + getUISetting, setBreadcrumbs: noop, setRecentlyAccessed: noop, getSavedObjects: noop, diff --git a/x-pack/plugins/canvas/public/services/stubs/workpad.ts b/x-pack/plugins/canvas/public/services/stubs/workpad.ts index 857831c92a8a61..4e3612feb67c8d 100644 --- a/x-pack/plugins/canvas/public/services/stubs/workpad.ts +++ b/x-pack/plugins/canvas/public/services/stubs/workpad.ts @@ -5,17 +5,95 @@ * 2.0. */ +import moment from 'moment'; + +// @ts-expect-error +import { getDefaultWorkpad } from '../../state/defaults'; import { WorkpadService } from '../workpad'; -import { CanvasWorkpad } from '../../../types'; +import { getId } from '../../lib/get_id'; +import { CanvasTemplate } from '../../../types'; -export const workpadService: WorkpadService = { - get: (id: string) => Promise.resolve({} as CanvasWorkpad), - create: (workpad) => Promise.resolve({} as CanvasWorkpad), - createFromTemplate: (templateId: string) => Promise.resolve({} as CanvasWorkpad), - find: (term: string) => - Promise.resolve({ +const TIMEOUT = 500; + +const promiseTimeout = (time: number) => () => new Promise((resolve) => setTimeout(resolve, time)); +const getName = () => { + const lorem = 'Lorem ipsum dolor sit amet consectetur adipiscing elit Fusce lobortis aliquet arcu ut turpis duis'.split( + ' ' + ); + return [1, 2, 3].map(() => lorem[Math.floor(Math.random() * lorem.length)]).join(' '); +}; + +const randomDate = ( + start: Date = moment().toDate(), + end: Date = moment().subtract(7, 'days').toDate() +) => new Date(start.getTime() + Math.random() * (end.getTime() - start.getTime())).toISOString(); + +const templates: CanvasTemplate[] = [ + { + id: 'test1-id', + name: 'test1', + help: 'This is a test template', + tags: ['tag1', 'tag2'], + template_key: 'test1-key', + }, + { + id: 'test2-id', + name: 'test2', + help: 'This is a second test template', + tags: ['tag2', 'tag3'], + template_key: 'test2-key', + }, +]; + +export const getSomeWorkpads = (count = 3) => + Array.from({ length: count }, () => ({ + '@created': randomDate( + moment().subtract(3, 'days').toDate(), + moment().subtract(10, 'days').toDate() + ), + '@timestamp': randomDate(), + id: getId('workpad'), + name: getName(), + })); + +export const findSomeWorkpads = (count = 3, timeout = TIMEOUT) => (_term: string) => { + return Promise.resolve() + .then(promiseTimeout(timeout)) + .then(() => ({ + total: count, + workpads: getSomeWorkpads(count), + })); +}; + +export const findNoWorkpads = (timeout = TIMEOUT) => (_term: string) => { + return Promise.resolve() + .then(promiseTimeout(timeout)) + .then(() => ({ total: 0, workpads: [], - }), - remove: (id: string) => Promise.resolve(undefined), + })); +}; + +export const findSomeTemplates = (timeout = TIMEOUT) => () => { + return Promise.resolve() + .then(promiseTimeout(timeout)) + .then(() => getSomeTemplates()); +}; + +export const findNoTemplates = (timeout = TIMEOUT) => () => { + return Promise.resolve() + .then(promiseTimeout(timeout)) + .then(() => getNoTemplates()); +}; + +export const getNoTemplates = () => ({ templates: [] }); +export const getSomeTemplates = () => ({ templates }); + +export const workpadService: WorkpadService = { + get: (id: string) => Promise.resolve({ ...getDefaultWorkpad(), id }), + findTemplates: findNoTemplates(), + create: (workpad) => Promise.resolve(workpad), + createFromTemplate: (_templateId: string) => Promise.resolve(getDefaultWorkpad()), + find: findNoWorkpads(), + remove: (id: string) => Promise.resolve(), }; diff --git a/x-pack/plugins/canvas/public/services/workpad.ts b/x-pack/plugins/canvas/public/services/workpad.ts index 11690ca4c0c450..7d2f1550a312fc 100644 --- a/x-pack/plugins/canvas/public/services/workpad.ts +++ b/x-pack/plugins/canvas/public/services/workpad.ts @@ -5,8 +5,12 @@ * 2.0. */ -import { API_ROUTE_WORKPAD, DEFAULT_WORKPAD_CSS } from '../../common/lib/constants'; -import { CanvasWorkpad } from '../../types'; +import { + API_ROUTE_WORKPAD, + DEFAULT_WORKPAD_CSS, + API_ROUTE_TEMPLATES, +} from '../../common/lib/constants'; +import { CanvasWorkpad, CanvasTemplate } from '../../types'; import { CanvasServiceFactory } from './'; /* @@ -40,9 +44,15 @@ const sanitizeWorkpad = function (workpad: CanvasWorkpad) { return workpad; }; -interface WorkpadFindResponse { +export type FoundWorkpads = Array>; +export type FoundWorkpad = FoundWorkpads[number]; +export interface WorkpadFindResponse { total: number; - workpads: Array>; + workpads: FoundWorkpads; +} + +export interface TemplateFindResponse { + templates: CanvasTemplate[]; } export interface WorkpadService { @@ -51,6 +61,7 @@ export interface WorkpadService { createFromTemplate: (templateId: string) => Promise; find: (term: string) => Promise; remove: (id: string) => Promise; + findTemplates: () => Promise; } export const workpadServiceFactory: CanvasServiceFactory = ( @@ -82,7 +93,9 @@ export const workpadServiceFactory: CanvasServiceFactory = ( body: JSON.stringify({ templateId }), }); }, + findTemplates: async () => coreStart.http.get(API_ROUTE_TEMPLATES), find: (searchTerm: string) => { + // TODO: this shouldn't be necessary. Check for usage. const validSearchTerm = typeof searchTerm === 'string' && searchTerm.length > 0; return coreStart.http.get(`${getApiPath()}/find`, { diff --git a/x-pack/plugins/canvas/public/style/index.scss b/x-pack/plugins/canvas/public/style/index.scss index a79e07a7d00168..d9592d5c0be5f7 100644 --- a/x-pack/plugins/canvas/public/style/index.scss +++ b/x-pack/plugins/canvas/public/style/index.scss @@ -40,8 +40,6 @@ @import '../components/workpad_header/element_menu/element_menu'; @import '../components/workpad_header/share_menu/share_menu'; @import '../components/workpad_header/view_menu/view_menu'; -@import '../components/workpad_loader/workpad_loader'; -@import '../components/workpad_loader/workpad_dropzone/workpad_dropzone'; @import '../components/workpad_page/workpad_page'; @import '../components/workpad_page/workpad_interactive_page/workpad_interactive_page'; @import '../components/workpad_page/workpad_static_page/workpad_static_page'; diff --git a/x-pack/plugins/canvas/storybook/decorators/index.ts b/x-pack/plugins/canvas/storybook/decorators/index.ts index a674eaad576a72..598a2333be5541 100644 --- a/x-pack/plugins/canvas/storybook/decorators/index.ts +++ b/x-pack/plugins/canvas/storybook/decorators/index.ts @@ -11,6 +11,7 @@ import { kibanaContextDecorator } from './kibana_decorator'; import { servicesContextDecorator } from './services_decorator'; export { reduxDecorator } from './redux_decorator'; +export { servicesContextDecorator } from './services_decorator'; export const addDecorators = () => { if (process.env.NODE_ENV === 'test') { @@ -20,5 +21,5 @@ export const addDecorators = () => { addDecorator(kibanaContextDecorator); addDecorator(routerContextDecorator); - addDecorator(servicesContextDecorator); + addDecorator(servicesContextDecorator()); }; diff --git a/x-pack/plugins/canvas/storybook/decorators/redux_decorator.tsx b/x-pack/plugins/canvas/storybook/decorators/redux_decorator.tsx index 01d96cb0c70e62..289171f136ab5a 100644 --- a/x-pack/plugins/canvas/storybook/decorators/redux_decorator.tsx +++ b/x-pack/plugins/canvas/storybook/decorators/redux_decorator.tsx @@ -25,7 +25,7 @@ elementsRegistry.register(image); import { getInitialState, getReducer, getMiddleware, patchDispatch } from '../addon/src/state'; export { ADDON_ID, ACTIONS_PANEL_ID } from '../addon/src/constants'; -interface Params { +export interface Params { workpad?: CanvasWorkpad; elements?: CanvasElement[]; assets?: CanvasAsset[]; diff --git a/x-pack/plugins/canvas/storybook/decorators/services_decorator.tsx b/x-pack/plugins/canvas/storybook/decorators/services_decorator.tsx index a11492387ea7fe..def5a5681a8c4e 100644 --- a/x-pack/plugins/canvas/storybook/decorators/services_decorator.tsx +++ b/x-pack/plugins/canvas/storybook/decorators/services_decorator.tsx @@ -7,8 +7,40 @@ import React from 'react'; -import { ServicesProvider } from '../../public/services'; +import { + CanvasServiceFactory, + CanvasServiceProvider, + ServicesProvider, +} from '../../public/services'; +import { + findNoWorkpads, + findSomeWorkpads, + workpadService, + findSomeTemplates, + findNoTemplates, +} from '../../public/services/stubs/workpad'; +import { WorkpadService } from '../../public/services/workpad'; -export const servicesContextDecorator = (story: Function) => ( - {story()} -); +interface Params { + findWorkpads?: number; + findTemplates?: boolean; +} + +export const servicesContextDecorator = ({ + findWorkpads = 0, + findTemplates: findTemplatesOption = false, +}: Params = {}) => { + const workpadServiceFactory: CanvasServiceFactory = (): WorkpadService => ({ + ...workpadService, + find: findWorkpads > 0 ? findSomeWorkpads(findWorkpads) : findNoWorkpads(), + findTemplates: findTemplatesOption ? findSomeTemplates() : findNoTemplates(), + }); + + const workpad = new CanvasServiceProvider(workpadServiceFactory); + // @ts-expect-error This is a hack at the moment, until we can get Canvas moved over to the new services architecture. + workpad.start(); + + return (story: Function) => ( + {story()} + ); +}; diff --git a/x-pack/plugins/canvas/storybook/index.ts b/x-pack/plugins/canvas/storybook/index.ts index 148af337d7720e..ff60b84c88a696 100644 --- a/x-pack/plugins/canvas/storybook/index.ts +++ b/x-pack/plugins/canvas/storybook/index.ts @@ -10,3 +10,8 @@ import { ACTIONS_PANEL_ID } from './addon/src/constants'; export * from './decorators'; export { ACTIONS_PANEL_ID } from './addon/src/constants'; export const getAddonPanelParameters = () => ({ options: { selectedPanel: ACTIONS_PANEL_ID } }); +export const getDisableStoryshotsParameter = () => ({ + storyshots: { + disable: true, + }, +}); diff --git a/x-pack/plugins/canvas/storybook/main.ts b/x-pack/plugins/canvas/storybook/main.ts index 80a8aeb14a804e..69c05322cf3f07 100644 --- a/x-pack/plugins/canvas/storybook/main.ts +++ b/x-pack/plugins/canvas/storybook/main.ts @@ -53,6 +53,11 @@ const canvasWebpack = { }, ], }, + resolve: { + alias: { + 'src/plugins': resolve(KIBANA_ROOT, 'src/plugins'), + }, + }, }; module.exports = { diff --git a/x-pack/plugins/canvas/storybook/public/components/home/my_workpads/__snapshots__/empty_prompt.stories.storyshot b/x-pack/plugins/canvas/storybook/public/components/home/my_workpads/__snapshots__/empty_prompt.stories.storyshot new file mode 100644 index 00000000000000..39ec1e234ead54 --- /dev/null +++ b/x-pack/plugins/canvas/storybook/public/components/home/my_workpads/__snapshots__/empty_prompt.stories.storyshot @@ -0,0 +1,65 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Storyshots Home/Empty Prompt Empty Prompt 1`] = ` +
    +
    +
    +
    + +
    + +

    + Add your first workpad +

    +
    +
    +

    + Create a new workpad, start from a template, or import a workpad JSON file by dropping it here. +

    +

    + New to Canvas? + + + Add your first workpad + + . +

    +
    + +
    +
    +
    +
    +`; diff --git a/x-pack/plugins/canvas/storybook/storyshots.test.tsx b/x-pack/plugins/canvas/storybook/storyshots.test.tsx index 0c3765812066e7..7f0ea077c75698 100644 --- a/x-pack/plugins/canvas/storybook/storyshots.test.tsx +++ b/x-pack/plugins/canvas/storybook/storyshots.test.tsx @@ -90,6 +90,11 @@ import { EuiObserver } from '@elastic/eui/test-env/components/observer/observer' jest.mock('@elastic/eui/test-env/components/observer/observer'); EuiObserver.mockImplementation(() => 'EuiObserver'); +// @ts-expect-error untyped library +import Dropzone from 'react-dropzone'; +jest.mock('react-dropzone'); +Dropzone.mockImplementation(() => 'Dropzone'); + // This element uses a `ref` and cannot be rendered by Jest snapshots. import { RenderedElement } from '../shareable_runtime/components/rendered_element'; jest.mock('../shareable_runtime/components/rendered_element'); @@ -111,7 +116,7 @@ addSerializer(styleSheetSerializer); // Initialize Storyshots and build the Jest Snapshots initStoryshots({ - configPath: path.resolve(__dirname, './../storybook'), + configPath: path.resolve(__dirname), framework: 'react', test: multiSnapshotWithOptions({}), // Don't snapshot tests that start with 'redux' diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 91277403d9e058..bc8318e803c8f5 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -6107,18 +6107,18 @@ "xpack.canvas.error.esService.indicesFetchErrorMessage": "Elasticsearch インデックスを取得できませんでした", "xpack.canvas.error.RenderWithFn.renderErrorMessage": "「{functionName}」のレンダリングが失敗しました", "xpack.canvas.error.repeatImage.missingMaxArgument": "{emptyImageArgument} を指定する場合は、{maxArgument} を設定する必要があります", - "xpack.canvas.error.workpadLoader.cloneFailureErrorMessage": "ワークパッドのクローンを作成できませんでした", - "xpack.canvas.error.workpadLoader.deleteFailureErrorMessage": "すべてのワークパッドを削除できませんでした", - "xpack.canvas.error.workpadLoader.findFailureErrorMessage": "ワークパッドが見つかりませんでした", - "xpack.canvas.error.workpadLoader.uploadFailureErrorMessage": "ワークパッドをアップロードできませんでした", + "xpack.canvas.error.useCloneWorkpad.cloneFailureErrorMessage": "ワークパッドのクローンを作成できませんでした", + "xpack.canvas.error.useCreateWorkpad.uploadFailureErrorMessage": "ワークパッドをアップロードできませんでした", + "xpack.canvas.error.useDeleteWorkpads.deleteFailureErrorMessage": "すべてのワークパッドを削除できませんでした", + "xpack.canvas.error.useFindWorkpads.findFailureErrorMessage": "ワークパッドが見つかりませんでした", + "xpack.canvas.error.useImportWorkpad.acceptJSONOnlyErrorMessage": "{JSON} 個のファイルしか受け付けられませんでした", + "xpack.canvas.error.useImportWorkpad.fileUploadFailureWithoutFileNameErrorMessage": "ファイルをアップロードできませんでした", + "xpack.canvas.error.useImportWorkpad.missingPropertiesErrorMessage": "{CANVAS} ワークパッドに必要なプロパティの一部が欠けています。 {JSON} ファイルを編集して正しいプロパティ値を入力し、再試行してください。", "xpack.canvas.error.workpadRoutes.createFailureErrorMessage": "ワークパッドを作成できませんでした", "xpack.canvas.error.workpadRoutes.loadFailureErrorMessage": "ID でワークパッドを読み込めませんでした", - "xpack.canvas.error.workpadUpload.acceptJSONOnlyErrorMessage": "{JSON} 個のファイルしか受け付けられませんでした", - "xpack.canvas.error.workpadUpload.fileUploadFailureWithoutFileNameErrorMessage": "ファイルをアップロードできませんでした", - "xpack.canvas.error.workpadUpload.missingPropertiesErrorMessage": "{CANVAS} ワークパッドに必要なプロパティの一部が欠けています。 {JSON} ファイルを編集して正しいプロパティ値を入力し、再試行してください。", "xpack.canvas.errorComponent.description": "表現が失敗し次のメッセージが返されました:", "xpack.canvas.errorComponent.title": "おっと!表現が失敗しました", - "xpack.canvas.errors.workpadUpload.fileUploadFileWithFileNameErrorMessage": "「{fileName}」をアップロードできませんでした", + "xpack.canvas.errors.useImportWorkpad.fileUploadFileWithFileNameErrorMessage": "「{fileName}」をアップロードできませんでした", "xpack.canvas.expression.cancelButtonLabel": "キャンセル", "xpack.canvas.expression.closeButtonLabel": "閉じる", "xpack.canvas.expression.learnLinkText": "表現構文の詳細", @@ -6452,6 +6452,12 @@ "xpack.canvas.helpMenu.description": "{CANVAS} に関する情報", "xpack.canvas.helpMenu.documentationLinkLabel": "{CANVAS} ドキュメント", "xpack.canvas.helpMenu.keyboardShortcutsLinkLabel": "キーボードショートカット", + "xpack.canvas.home.myWorkpadsTabLabel": "マイワークパッド", + "xpack.canvas.home.workpadTemplatesTabLabel": "テンプレート", + "xpack.canvas.homeEmptyPrompt.emptyPromptGettingStartedDescription": "新規ワークパッドを作成、テンプレートで開始、またはワークパッド {JSON} ファイルをここにドロップしてインポートします。", + "xpack.canvas.homeEmptyPrompt.emptyPromptNewUserDescription": "{CANVAS} を初めて使用する場合", + "xpack.canvas.homeEmptyPrompt.emptyPromptTitle": "初の’ワークパッドを追加しましょう", + "xpack.canvas.homeEmptyPrompt.sampleDataLinkLabel": "初の’ワークパッドを追加しましょう", "xpack.canvas.keyboardShortcuts.bringFowardShortcutHelpText": "前に移動", "xpack.canvas.keyboardShortcuts.bringToFrontShortcutHelpText": "表面に移動", "xpack.canvas.keyboardShortcuts.cloneShortcutHelpText": "クローンを作成", @@ -6898,6 +6904,7 @@ "xpack.canvas.units.quickRange.last90Days": "過去90日間", "xpack.canvas.units.quickRange.today": "今日", "xpack.canvas.units.quickRange.yesterday": "昨日", + "xpack.canvas.useCloneWorkpad.clonedWorkpadName": "{workpadName} のコピー", "xpack.canvas.varConfig.addButtonLabel": "変数の追加", "xpack.canvas.varConfig.addTooltipLabel": "変数の追加", "xpack.canvas.varConfig.copyActionButtonLabel": "スニペットをコピー", @@ -7024,40 +7031,30 @@ "xpack.canvas.workpadHeaderViewMenu.zoomPanelTitle": "ズーム", "xpack.canvas.workpadHeaderViewMenu.zoomPrecentageValue": "リセット", "xpack.canvas.workpadHeaderViewMenu.zoomResetText": "{scalePercentage}%", - "xpack.canvas.workpadLoader.clonedWorkpadName": "{workpadName} のコピー", - "xpack.canvas.workpadLoader.cloneTooltip": "ワークパッドのクローンを作成します", - "xpack.canvas.workpadLoader.createWorkpadLoadingDescription": "ワークパッドを作成中...", - "xpack.canvas.workpadLoader.deleteButtonAriaLabel": "{numberOfWorkpads} 個のワークパッドを削除", - "xpack.canvas.workpadLoader.deleteButtonLabel": " ({numberOfWorkpads}) ワークパッドを削除", - "xpack.canvas.workpadLoader.deleteModalConfirmButtonLabel": "削除", - "xpack.canvas.workpadLoader.deleteModalDescription": "削除されたワークパッドは復元できません。", - "xpack.canvas.workpadLoader.deleteMultipleWorkpadsModalTitle": "{numberOfWorkpads} 個のワークパッドを削除しますか?", - "xpack.canvas.workpadLoader.deleteSingleWorkpadModalTitle": "ワークパッド「{workpadName}」削除しますか?", - "xpack.canvas.workpadLoader.emptyPromptGettingStartedDescription": "新規ワークパッドを作成、テンプレートで開始、またはワークパッド {JSON} ファイルをここにドロップしてインポートします。", - "xpack.canvas.workpadLoader.emptyPromptNewUserDescription": "{CANVAS} を初めて使用する場合", - "xpack.canvas.workpadLoader.emptyPromptTitle": "初の’ワークパッドを追加しましょう", - "xpack.canvas.workpadLoader.exportButtonAriaLabel": "{numberOfWorkpads} 個のワークパッドをエクスポート", - "xpack.canvas.workpadLoader.exportButtonLabel": "エクスポート ({numberOfWorkpads}) ", - "xpack.canvas.workpadLoader.exportTooltip": "ワークパッドをエクスポート", - "xpack.canvas.workpadLoader.fetchLoadingDescription": "ワークパッドを取得中...", - "xpack.canvas.workpadLoader.filePickerPlaceholder": "ワークパッド {JSON} ファイルをインポート", - "xpack.canvas.workpadLoader.loadWorkpadArialLabel": "ワークパッド「{workpadName}」を読み込む", - "xpack.canvas.workpadLoader.noPermissionToCloneToolTip": "ワークパッドのクローンを作成するパーミッションがありません", - "xpack.canvas.workpadLoader.noPermissionToCreateToolTip": "ワークパッドを作成するパーミッションがありません", - "xpack.canvas.workpadLoader.noPermissionToDeleteToolTip": "ワークパッドを削除するパーミッションがありません", - "xpack.canvas.workpadLoader.noPermissionToUploadToolTip": "ワークパッドを更新するパーミッションがありません", - "xpack.canvas.workpadLoader.sampleDataLinkLabel": "初の’ワークパッドを追加しましょう", - "xpack.canvas.workpadLoader.table.actionsColumnTitle": "アクション", - "xpack.canvas.workpadLoader.table.createdColumnTitle": "作成済み", - "xpack.canvas.workpadLoader.table.nameColumnTitle": "ワークパッド名", - "xpack.canvas.workpadLoader.table.updatedColumnTitle": "更新しました", - "xpack.canvas.workpadManager.modalTitle": "{CANVAS} ワークパッド", - "xpack.canvas.workpadManager.myWorkpadsTabLabel": "マイワークパッド", - "xpack.canvas.workpadManager.workpadTemplatesTabLabel": "テンプレート", - "xpack.canvas.workpadSearch.searchPlaceholder": "ワークパッドを検索", - "xpack.canvas.workpadTemplate.cloneTemplateLinkAriaLabel": "ワークパッドテンプレート「{templateName}」のクローンを作成", - "xpack.canvas.workpadTemplate.creatingTemplateLabel": "テンプレート「{templateName}」から作成しています", - "xpack.canvas.workpadTemplate.searchPlaceholder": "テンプレートを検索", + "xpack.canvas.workpadImport.filePickerPlaceholder": "ワークパッド {JSON} ファイルをインポート", + "xpack.canvas.workpadTable.cloneTooltip": "ワークパッドのクローンを作成します", + "xpack.canvas.workpadTable.exportTooltip": "ワークパッドをエクスポート", + "xpack.canvas.workpadTable.loadWorkpadArialLabel": "ワークパッド「{workpadName}」を読み込む", + "xpack.canvas.workpadTable.noPermissionToCloneToolTip": "ワークパッドのクローンを作成するパーミッションがありません", + "xpack.canvas.workpadTable.searchPlaceholder": "ワークパッドを検索", + "xpack.canvas.workpadTable.table.actionsColumnTitle": "アクション", + "xpack.canvas.workpadTable.table.createdColumnTitle": "作成済み", + "xpack.canvas.workpadTable.table.nameColumnTitle": "ワークパッド名", + "xpack.canvas.workpadTable.table.updatedColumnTitle": "更新しました", + "xpack.canvas.workpadTableTools.deleteButtonAriaLabel": "{numberOfWorkpads} 個のワークパッドを削除", + "xpack.canvas.workpadTableTools.deleteButtonLabel": " ({numberOfWorkpads}) ワークパッドを削除", + "xpack.canvas.workpadTableTools.deleteModalConfirmButtonLabel": "削除", + "xpack.canvas.workpadTableTools.deleteModalDescription": "削除されたワークパッドは復元できません。", + "xpack.canvas.workpadTableTools.deleteMultipleWorkpadsModalTitle": "{numberOfWorkpads} 個のワークパッドを削除しますか?", + "xpack.canvas.workpadTableTools.deleteSingleWorkpadModalTitle": "ワークパッド「{workpadName}」削除しますか?", + "xpack.canvas.workpadTableTools.exportButtonAriaLabel": "{numberOfWorkpads} 個のワークパッドをエクスポート", + "xpack.canvas.workpadTableTools.exportButtonLabel": "エクスポート ({numberOfWorkpads}) ", + "xpack.canvas.workpadTableTools.noPermissionToCreateToolTip": "ワークパッドを作成するパーミッションがありません", + "xpack.canvas.workpadTableTools.noPermissionToDeleteToolTip": "ワークパッドを削除するパーミッションがありません", + "xpack.canvas.workpadTableTools.noPermissionToUploadToolTip": "ワークパッドを更新するパーミッションがありません", + "xpack.canvas.workpadTemplates.cloneTemplateLinkAriaLabel": "ワークパッドテンプレート「{templateName}」のクローンを作成", + "xpack.canvas.workpadTemplates.creatingTemplateLabel": "テンプレート「{templateName}」から作成しています", + "xpack.canvas.workpadTemplates.searchPlaceholder": "テンプレートを検索", "xpack.canvas.workpadTemplates.table.descriptionColumnTitle": "説明", "xpack.canvas.workpadTemplates.table.nameColumnTitle": "テンプレート名", "xpack.canvas.workpadTemplates.table.tagsColumnTitle": "タグ", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 632c502d4ef555..f867407ff2d9b8 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -6146,18 +6146,18 @@ "xpack.canvas.error.esService.indicesFetchErrorMessage": "无法提取 Elasticsearch 索引", "xpack.canvas.error.RenderWithFn.renderErrorMessage": "呈现“{functionName}”失败。", "xpack.canvas.error.repeatImage.missingMaxArgument": "如果提供 {emptyImageArgument},则必须设置 {maxArgument}", - "xpack.canvas.error.workpadLoader.cloneFailureErrorMessage": "无法克隆 Workpad", - "xpack.canvas.error.workpadLoader.deleteFailureErrorMessage": "无法删除所有 Workpad", - "xpack.canvas.error.workpadLoader.findFailureErrorMessage": "无法查找 Workpad", - "xpack.canvas.error.workpadLoader.uploadFailureErrorMessage": "无法上传 Workpad", + "xpack.canvas.error.useCloneWorkpad.cloneFailureErrorMessage": "无法克隆 Workpad", + "xpack.canvas.error.useCreateWorkpad.uploadFailureErrorMessage": "无法上传 Workpad", + "xpack.canvas.error.useDeleteWorkpads.deleteFailureErrorMessage": "无法删除所有 Workpad", + "xpack.canvas.error.useFindWorkpads.findFailureErrorMessage": "无法查找 Workpad", + "xpack.canvas.error.useImportWorkpad.acceptJSONOnlyErrorMessage": "仅接受 {JSON} 文件", + "xpack.canvas.error.useImportWorkpad.fileUploadFailureWithoutFileNameErrorMessage": "无法上传文件", + "xpack.canvas.error.useImportWorkpad.missingPropertiesErrorMessage": "{CANVAS} Workpad 所需的某些属性缺失。 编辑 {JSON} 文件以提供正确的属性值,然后重试。", "xpack.canvas.error.workpadRoutes.createFailureErrorMessage": "无法创建 Workpad", "xpack.canvas.error.workpadRoutes.loadFailureErrorMessage": "无法加载具有以下 ID 的 Workpad", - "xpack.canvas.error.workpadUpload.acceptJSONOnlyErrorMessage": "仅接受 {JSON} 文件", - "xpack.canvas.error.workpadUpload.fileUploadFailureWithoutFileNameErrorMessage": "无法上传文件", - "xpack.canvas.error.workpadUpload.missingPropertiesErrorMessage": "{CANVAS} Workpad 所需的某些属性缺失。 编辑 {JSON} 文件以提供正确的属性值,然后重试。", "xpack.canvas.errorComponent.description": "表达式失败,并显示消息:", "xpack.canvas.errorComponent.title": "哎哟!表达式失败", - "xpack.canvas.errors.workpadUpload.fileUploadFileWithFileNameErrorMessage": "无法上传“{fileName}”", + "xpack.canvas.errors.useImportWorkpad.fileUploadFileWithFileNameErrorMessage": "无法上传“{fileName}”", "xpack.canvas.expression.cancelButtonLabel": "取消", "xpack.canvas.expression.closeButtonLabel": "关闭", "xpack.canvas.expression.learnLinkText": "学习表达式语法", @@ -6492,6 +6492,12 @@ "xpack.canvas.helpMenu.description": "有关 {CANVAS} 特定信息", "xpack.canvas.helpMenu.documentationLinkLabel": "{CANVAS} 文档", "xpack.canvas.helpMenu.keyboardShortcutsLinkLabel": "快捷键", + "xpack.canvas.home.myWorkpadsTabLabel": "我的 Workpad", + "xpack.canvas.home.workpadTemplatesTabLabel": "模板", + "xpack.canvas.homeEmptyPrompt.emptyPromptGettingStartedDescription": "创建新的 Workpad、从模板入手或通过将 Workpad {JSON} 文件拖放到此处来导入。", + "xpack.canvas.homeEmptyPrompt.emptyPromptNewUserDescription": "{CANVAS} 新手?", + "xpack.canvas.homeEmptyPrompt.emptyPromptTitle": "添加您的首个 Workpad", + "xpack.canvas.homeEmptyPrompt.sampleDataLinkLabel": "添加您的首个 Workpad", "xpack.canvas.keyboardShortcuts.bringFowardShortcutHelpText": "前移", "xpack.canvas.keyboardShortcuts.bringToFrontShortcutHelpText": "置前", "xpack.canvas.keyboardShortcuts.cloneShortcutHelpText": "克隆", @@ -6942,6 +6948,7 @@ "xpack.canvas.units.time.hours": "{hours, plural, other {# 小时}}", "xpack.canvas.units.time.minutes": "{minutes, plural, other {# 分钟}}", "xpack.canvas.units.time.seconds": "{seconds, plural, other {# 秒}}", + "xpack.canvas.useCloneWorkpad.clonedWorkpadName": "{workpadName} 副本", "xpack.canvas.varConfig.addButtonLabel": "添加变量", "xpack.canvas.varConfig.addTooltipLabel": "添加变量", "xpack.canvas.varConfig.copyActionButtonLabel": "复制代码片段", @@ -7072,40 +7079,30 @@ "xpack.canvas.workpadHeaderViewMenu.zoomPanelTitle": "缩放", "xpack.canvas.workpadHeaderViewMenu.zoomPrecentageValue": "重置", "xpack.canvas.workpadHeaderViewMenu.zoomResetText": "{scalePercentage}%", - "xpack.canvas.workpadLoader.clonedWorkpadName": "{workpadName} 副本", - "xpack.canvas.workpadLoader.cloneTooltip": "克隆 Workpad", - "xpack.canvas.workpadLoader.createWorkpadLoadingDescription": "正在创建 Workpad......", - "xpack.canvas.workpadLoader.deleteButtonAriaLabel": "删除 {numberOfWorkpads} 个 Workpad", - "xpack.canvas.workpadLoader.deleteButtonLabel": "删除 ({numberOfWorkpads})", - "xpack.canvas.workpadLoader.deleteModalConfirmButtonLabel": "删除", - "xpack.canvas.workpadLoader.deleteModalDescription": "您无法恢复删除的 Workpad。", - "xpack.canvas.workpadLoader.deleteMultipleWorkpadsModalTitle": "删除 {numberOfWorkpads} 个 Workpad?", - "xpack.canvas.workpadLoader.deleteSingleWorkpadModalTitle": "删除 Workpad“{workpadName}”?", - "xpack.canvas.workpadLoader.emptyPromptGettingStartedDescription": "创建新的 Workpad、从模板入手或通过将 Workpad {JSON} 文件拖放到此处来导入。", - "xpack.canvas.workpadLoader.emptyPromptNewUserDescription": "{CANVAS} 新手?", - "xpack.canvas.workpadLoader.emptyPromptTitle": "添加您的首个 Workpad", - "xpack.canvas.workpadLoader.exportButtonAriaLabel": "导出 {numberOfWorkpads} 个 Workpad", - "xpack.canvas.workpadLoader.exportButtonLabel": "导出 ({numberOfWorkpads})", - "xpack.canvas.workpadLoader.exportTooltip": "导出 Workpad", - "xpack.canvas.workpadLoader.fetchLoadingDescription": "正在获取 Workpad......", - "xpack.canvas.workpadLoader.filePickerPlaceholder": "导入 Workpad {JSON} 文件", - "xpack.canvas.workpadLoader.loadWorkpadArialLabel": "加载 Workpad“{workpadName}”", - "xpack.canvas.workpadLoader.noPermissionToCloneToolTip": "您无权克隆 Workpad", - "xpack.canvas.workpadLoader.noPermissionToCreateToolTip": "您无权创建 Workpad", - "xpack.canvas.workpadLoader.noPermissionToDeleteToolTip": "您无权删除 Workpad", - "xpack.canvas.workpadLoader.noPermissionToUploadToolTip": "您无权上传 Workpad", - "xpack.canvas.workpadLoader.sampleDataLinkLabel": "添加您的首个 Workpad", - "xpack.canvas.workpadLoader.table.actionsColumnTitle": "操作", - "xpack.canvas.workpadLoader.table.createdColumnTitle": "创建时间", - "xpack.canvas.workpadLoader.table.nameColumnTitle": "Workpad 名称", - "xpack.canvas.workpadLoader.table.updatedColumnTitle": "更新时间", - "xpack.canvas.workpadManager.modalTitle": "{CANVAS} Workpad", - "xpack.canvas.workpadManager.myWorkpadsTabLabel": "我的 Workpad", - "xpack.canvas.workpadManager.workpadTemplatesTabLabel": "模板", - "xpack.canvas.workpadSearch.searchPlaceholder": "查找 Workpad", - "xpack.canvas.workpadTemplate.cloneTemplateLinkAriaLabel": "克隆 Workpad 模板“{templateName}”", - "xpack.canvas.workpadTemplate.creatingTemplateLabel": "正在从模板“{templateName}”创建", - "xpack.canvas.workpadTemplate.searchPlaceholder": "查找模板", + "xpack.canvas.workpadImport.filePickerPlaceholder": "导入 Workpad {JSON} 文件", + "xpack.canvas.workpadTable.searchPlaceholder": "查找 Workpad", + "xpack.canvas.workpadTable.cloneTooltip": "克隆 Workpad", + "xpack.canvas.workpadTable.exportTooltip": "导出 Workpad", + "xpack.canvas.workpadTable.loadWorkpadArialLabel": "加载 Workpad“{workpadName}”", + "xpack.canvas.workpadTable.noPermissionToCloneToolTip": "您无权克隆 Workpad", + "xpack.canvas.workpadTable.table.actionsColumnTitle": "操作", + "xpack.canvas.workpadTable.table.createdColumnTitle": "创建时间", + "xpack.canvas.workpadTable.table.nameColumnTitle": "Workpad 名称", + "xpack.canvas.workpadTable.table.updatedColumnTitle": "更新时间", + "xpack.canvas.workpadTableTools.deleteButtonAriaLabel": "删除 {numberOfWorkpads} 个 Workpad", + "xpack.canvas.workpadTableTools.deleteButtonLabel": "删除 ({numberOfWorkpads})", + "xpack.canvas.workpadTableTools.deleteModalConfirmButtonLabel": "删除", + "xpack.canvas.workpadTableTools.deleteModalDescription": "您无法恢复删除的 Workpad。", + "xpack.canvas.workpadTableTools.deleteMultipleWorkpadsModalTitle": "删除 {numberOfWorkpads} 个 Workpad?", + "xpack.canvas.workpadTableTools.deleteSingleWorkpadModalTitle": "删除 Workpad“{workpadName}”?", + "xpack.canvas.workpadTableTools.exportButtonAriaLabel": "导出 {numberOfWorkpads} 个 Workpad", + "xpack.canvas.workpadTableTools.exportButtonLabel": "导出 ({numberOfWorkpads})", + "xpack.canvas.workpadTableTools.noPermissionToCreateToolTip": "您无权创建 Workpad", + "xpack.canvas.workpadTableTools.noPermissionToDeleteToolTip": "您无权删除 Workpad", + "xpack.canvas.workpadTableTools.noPermissionToUploadToolTip": "您无权上传 Workpad", + "xpack.canvas.workpadTemplates.cloneTemplateLinkAriaLabel": "克隆 Workpad 模板“{templateName}”", + "xpack.canvas.workpadTemplates.creatingTemplateLabel": "正在从模板“{templateName}”创建", + "xpack.canvas.workpadTemplates.searchPlaceholder": "查找模板", "xpack.canvas.workpadTemplates.table.descriptionColumnTitle": "描述", "xpack.canvas.workpadTemplates.table.nameColumnTitle": "模板名称", "xpack.canvas.workpadTemplates.table.tagsColumnTitle": "标签", diff --git a/x-pack/test/accessibility/apps/canvas.ts b/x-pack/test/accessibility/apps/canvas.ts index a79fb7b60e76a1..609c8bf5bb1ae4 100644 --- a/x-pack/test/accessibility/apps/canvas.ts +++ b/x-pack/test/accessibility/apps/canvas.ts @@ -23,7 +23,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('loads workpads', async function () { await retry.waitFor( 'canvas workpads visible', - async () => await testSubjects.exists('canvasWorkpadLoaderTable') + async () => await testSubjects.exists('canvasWorkpadTable') ); await a11y.testAppSnapshot(); }); diff --git a/x-pack/test/functional/apps/canvas/smoke_test.js b/x-pack/test/functional/apps/canvas/smoke_test.js index 5280ad0118fbac..fcc04aafdbcd85 100644 --- a/x-pack/test/functional/apps/canvas/smoke_test.js +++ b/x-pack/test/functional/apps/canvas/smoke_test.js @@ -17,7 +17,7 @@ export default function canvasSmokeTest({ getService, getPageObjects }) { describe('smoke test', function () { this.tags('includeFirefox'); - const workpadListSelector = 'canvasWorkpadLoaderTable > canvasWorkpadLoaderWorkpad'; + const workpadListSelector = 'canvasWorkpadTable > canvasWorkpadTableWorkpad'; const testWorkpadId = 'workpad-1705f884-6224-47de-ba49-ca224fe6ec31'; before(async () => { diff --git a/x-pack/test/functional/page_objects/canvas_page.ts b/x-pack/test/functional/page_objects/canvas_page.ts index 0e0203046fd16e..df92c1c398d93d 100644 --- a/x-pack/test/functional/page_objects/canvas_page.ts +++ b/x-pack/test/functional/page_objects/canvas_page.ts @@ -39,7 +39,7 @@ export function CanvasPageProvider({ getService, getPageObjects }: FtrProviderCo * to load the workpad. Resolves once the workpad is in the DOM */ async loadFirstWorkpad(workpadName: string) { - const elem = await testSubjects.find('canvasWorkpadLoaderWorkpad'); + const elem = await testSubjects.find('canvasWorkpadTableWorkpad'); const text = await elem.getVisibleText(); expect(text).to.be(workpadName); await elem.click(); From 86fb2cc90e38e5e9c783c94b9904387c6b06dea6 Mon Sep 17 00:00:00 2001 From: Patrick Mueller Date: Tue, 22 Jun 2021 15:18:35 -0400 Subject: [PATCH 11/17] [actions] add rule saved object reference to action execution event log doc (#101526) resolves https://github.com/elastic/kibana/issues/99225 Prior to this PR, when an alerting connection action was executed, the event log document generated did not contain a reference to the originating rule. This makes it difficult to diagnose problems with connector errors, since the error is often in the parameters specified in the actions in the alert. In this PR, a reference to the alerting rule is added to the saved_objects field in the event document for these events. --- .../actions/server/actions_client.test.ts | 64 ++++++++++++++ .../plugins/actions/server/actions_client.ts | 9 +- .../server/create_execute_function.test.ts | 56 ++++++++++++ .../actions/server/create_execute_function.ts | 5 +- .../actions/server/lib/action_executor.ts | 13 +++ .../server/lib/related_saved_objects.test.ts | 86 +++++++++++++++++++ .../server/lib/related_saved_objects.ts | 31 +++++++ .../server/lib/task_runner_factory.test.ts | 76 ++++++++++++++++ .../actions/server/lib/task_runner_factory.ts | 4 +- .../actions/server/routes/execute.test.ts | 2 + .../plugins/actions/server/routes/execute.ts | 1 + .../server/routes/legacy/execute.test.ts | 2 + .../actions/server/routes/legacy/execute.ts | 1 + .../server/saved_objects/mappings.json | 4 + .../create_execution_handler.test.ts | 32 +++++++ .../task_runner/create_execution_handler.ts | 12 ++- .../server/task_runner/task_runner.test.ts | 32 +++++++ x-pack/plugins/event_log/README.md | 29 ++++--- 18 files changed, 442 insertions(+), 17 deletions(-) create mode 100644 x-pack/plugins/actions/server/lib/related_saved_objects.test.ts create mode 100644 x-pack/plugins/actions/server/lib/related_saved_objects.ts diff --git a/x-pack/plugins/actions/server/actions_client.test.ts b/x-pack/plugins/actions/server/actions_client.test.ts index 3b91b07eb30f4e..16388b2faf52e1 100644 --- a/x-pack/plugins/actions/server/actions_client.test.ts +++ b/x-pack/plugins/actions/server/actions_client.test.ts @@ -1676,6 +1676,70 @@ describe('execute()', () => { name: 'my name', }, }); + + await expect( + actionsClient.execute({ + actionId, + params: { + name: 'my name', + }, + relatedSavedObjects: [ + { + id: 'some-id', + typeId: 'some-type-id', + type: 'some-type', + }, + ], + }) + ).resolves.toMatchObject({ status: 'ok', actionId }); + + expect(actionExecutor.execute).toHaveBeenCalledWith({ + actionId, + request, + params: { + name: 'my name', + }, + relatedSavedObjects: [ + { + id: 'some-id', + typeId: 'some-type-id', + type: 'some-type', + }, + ], + }); + + await expect( + actionsClient.execute({ + actionId, + params: { + name: 'my name', + }, + relatedSavedObjects: [ + { + id: 'some-id', + typeId: 'some-type-id', + type: 'some-type', + namespace: 'some-namespace', + }, + ], + }) + ).resolves.toMatchObject({ status: 'ok', actionId }); + + expect(actionExecutor.execute).toHaveBeenCalledWith({ + actionId, + request, + params: { + name: 'my name', + }, + relatedSavedObjects: [ + { + id: 'some-id', + typeId: 'some-type-id', + type: 'some-type', + namespace: 'some-namespace', + }, + ], + }); }); }); diff --git a/x-pack/plugins/actions/server/actions_client.ts b/x-pack/plugins/actions/server/actions_client.ts index 449d218ed5ae04..f8d13cdafa7557 100644 --- a/x-pack/plugins/actions/server/actions_client.ts +++ b/x-pack/plugins/actions/server/actions_client.ts @@ -469,6 +469,7 @@ export class ActionsClient { actionId, params, source, + relatedSavedObjects, }: Omit): Promise> { if ( (await getAuthorizationModeBySource(this.unsecuredSavedObjectsClient, source)) === @@ -476,7 +477,13 @@ export class ActionsClient { ) { await this.authorization.ensureAuthorized('execute'); } - return this.actionExecutor.execute({ actionId, params, source, request: this.request }); + return this.actionExecutor.execute({ + actionId, + params, + source, + request: this.request, + relatedSavedObjects, + }); } public async enqueueExecution(options: EnqueueExecutionOptions): Promise { diff --git a/x-pack/plugins/actions/server/create_execute_function.test.ts b/x-pack/plugins/actions/server/create_execute_function.test.ts index 4cacba6dc880ab..ee8064d2aadc53 100644 --- a/x-pack/plugins/actions/server/create_execute_function.test.ts +++ b/x-pack/plugins/actions/server/create_execute_function.test.ts @@ -83,6 +83,62 @@ describe('execute()', () => { }); }); + test('schedules the action with all given parameters and relatedSavedObjects', async () => { + const actionTypeRegistry = actionTypeRegistryMock.create(); + const executeFn = createExecutionEnqueuerFunction({ + taskManager: mockTaskManager, + actionTypeRegistry, + isESOCanEncrypt: true, + preconfiguredActions: [], + }); + savedObjectsClient.get.mockResolvedValueOnce({ + id: '123', + type: 'action', + attributes: { + actionTypeId: 'mock-action', + }, + references: [], + }); + savedObjectsClient.create.mockResolvedValueOnce({ + id: '234', + type: 'action_task_params', + attributes: {}, + references: [], + }); + await executeFn(savedObjectsClient, { + id: '123', + params: { baz: false }, + spaceId: 'default', + apiKey: Buffer.from('123:abc').toString('base64'), + source: asHttpRequestExecutionSource(request), + relatedSavedObjects: [ + { + id: 'some-id', + namespace: 'some-namespace', + type: 'some-type', + typeId: 'some-typeId', + }, + ], + }); + expect(savedObjectsClient.create).toHaveBeenCalledWith( + 'action_task_params', + { + actionId: '123', + params: { baz: false }, + apiKey: Buffer.from('123:abc').toString('base64'), + relatedSavedObjects: [ + { + id: 'some-id', + namespace: 'some-namespace', + type: 'some-type', + typeId: 'some-typeId', + }, + ], + }, + {} + ); + }); + test('schedules the action with all given parameters with a preconfigured action', async () => { const executeFn = createExecutionEnqueuerFunction({ taskManager: mockTaskManager, diff --git a/x-pack/plugins/actions/server/create_execute_function.ts b/x-pack/plugins/actions/server/create_execute_function.ts index 4f3ffbef36c6e2..7dcd66c711bdde 100644 --- a/x-pack/plugins/actions/server/create_execute_function.ts +++ b/x-pack/plugins/actions/server/create_execute_function.ts @@ -11,6 +11,7 @@ import { RawAction, ActionTypeRegistryContract, PreConfiguredAction } from './ty import { ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE } from './constants/saved_objects'; import { ExecuteOptions as ActionExecutorOptions } from './lib/action_executor'; import { isSavedObjectExecutionSource } from './lib'; +import { RelatedSavedObjects } from './lib/related_saved_objects'; interface CreateExecuteFunctionOptions { taskManager: TaskManagerStartContract; @@ -23,6 +24,7 @@ export interface ExecuteOptions extends Pick { request: KibanaRequest; params: Record; source?: ActionExecutionSource; + relatedSavedObjects?: RelatedSavedObjects; } export type ActionExecutorContract = PublicMethodsOf; @@ -68,6 +70,7 @@ export class ActionExecutor { params, request, source, + relatedSavedObjects, }: ExecuteOptions): Promise> { if (!this.isInitialized) { throw new Error('ActionExecutor not initialized'); @@ -154,6 +157,16 @@ export class ActionExecutor { }, }; + for (const relatedSavedObject of relatedSavedObjects || []) { + event.kibana?.saved_objects?.push({ + rel: SAVED_OBJECT_REL_PRIMARY, + type: relatedSavedObject.type, + id: relatedSavedObject.id, + type_id: relatedSavedObject.typeId, + namespace: relatedSavedObject.namespace, + }); + } + eventLogger.startTiming(event); let rawResult: ActionTypeExecutorResult; try { diff --git a/x-pack/plugins/actions/server/lib/related_saved_objects.test.ts b/x-pack/plugins/actions/server/lib/related_saved_objects.test.ts new file mode 100644 index 00000000000000..8fd13d13756977 --- /dev/null +++ b/x-pack/plugins/actions/server/lib/related_saved_objects.test.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 { validatedRelatedSavedObjects } from './related_saved_objects'; +import { loggingSystemMock } from '../../../../../src/core/server/mocks'; +import { Logger } from '../../../../../src/core/server'; + +const loggerMock = loggingSystemMock.createLogger(); + +describe('related_saved_objects', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('validates valid objects', () => { + ensureValid(loggerMock, undefined); + ensureValid(loggerMock, []); + ensureValid(loggerMock, [ + { + id: 'some-id', + type: 'some-type', + }, + ]); + ensureValid(loggerMock, [ + { + id: 'some-id', + type: 'some-type', + typeId: 'some-type-id', + }, + ]); + ensureValid(loggerMock, [ + { + id: 'some-id', + type: 'some-type', + namespace: 'some-namespace', + }, + ]); + ensureValid(loggerMock, [ + { + id: 'some-id', + type: 'some-type', + typeId: 'some-type-id', + namespace: 'some-namespace', + }, + ]); + ensureValid(loggerMock, [ + { + id: 'some-id', + type: 'some-type', + }, + { + id: 'some-id-2', + type: 'some-type-2', + }, + ]); + }); +}); + +it('handles invalid objects', () => { + ensureInvalid(loggerMock, 42); + ensureInvalid(loggerMock, {}); + ensureInvalid(loggerMock, [{}]); + ensureInvalid(loggerMock, [{ id: 'some-id' }]); + ensureInvalid(loggerMock, [{ id: 42 }]); + ensureInvalid(loggerMock, [{ id: 'some-id', type: 'some-type', x: 42 }]); +}); + +function ensureValid(logger: Logger, savedObjects: unknown) { + const result = validatedRelatedSavedObjects(logger, savedObjects); + expect(result).toEqual(savedObjects === undefined ? [] : savedObjects); + expect(loggerMock.warn).not.toHaveBeenCalled(); +} + +function ensureInvalid(logger: Logger, savedObjects: unknown) { + const result = validatedRelatedSavedObjects(logger, savedObjects); + expect(result).toEqual([]); + + const message = loggerMock.warn.mock.calls[0][0]; + expect(message).toMatch( + /ignoring invalid related saved objects: expected value of type \[array\] but got/ + ); +} diff --git a/x-pack/plugins/actions/server/lib/related_saved_objects.ts b/x-pack/plugins/actions/server/lib/related_saved_objects.ts new file mode 100644 index 00000000000000..160587a3a9a8be --- /dev/null +++ b/x-pack/plugins/actions/server/lib/related_saved_objects.ts @@ -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 { schema, TypeOf } from '@kbn/config-schema'; +import { Logger } from '../../../../../src/core/server'; + +export type RelatedSavedObjects = TypeOf; + +const RelatedSavedObjectsSchema = schema.arrayOf( + schema.object({ + namespace: schema.maybe(schema.string({ minLength: 1 })), + id: schema.string({ minLength: 1 }), + type: schema.string({ minLength: 1 }), + // optional; for SO types like action/alert that have type id's + typeId: schema.maybe(schema.string({ minLength: 1 })), + }), + { defaultValue: [] } +); + +export function validatedRelatedSavedObjects(logger: Logger, data: unknown): RelatedSavedObjects { + try { + return RelatedSavedObjectsSchema.validate(data); + } catch (err) { + logger.warn(`ignoring invalid related saved objects: ${err.message}`); + return []; + } +} diff --git a/x-pack/plugins/actions/server/lib/task_runner_factory.test.ts b/x-pack/plugins/actions/server/lib/task_runner_factory.test.ts index 229324c1f0df38..2292994e3ccfde 100644 --- a/x-pack/plugins/actions/server/lib/task_runner_factory.test.ts +++ b/x-pack/plugins/actions/server/lib/task_runner_factory.test.ts @@ -126,6 +126,7 @@ test('executes the task by calling the executor with proper parameters', async ( expect(mockedActionExecutor.execute).toHaveBeenCalledWith({ actionId: '2', params: { baz: true }, + relatedSavedObjects: [], request: expect.objectContaining({ headers: { // base64 encoded "123:abc" @@ -247,6 +248,7 @@ test('uses API key when provided', async () => { expect(mockedActionExecutor.execute).toHaveBeenCalledWith({ actionId: '2', params: { baz: true }, + relatedSavedObjects: [], request: expect.objectContaining({ headers: { // base64 encoded "123:abc" @@ -262,6 +264,79 @@ test('uses API key when provided', async () => { ); }); +test('uses relatedSavedObjects when provided', async () => { + const taskRunner = taskRunnerFactory.create({ + taskInstance: mockedTaskInstance, + }); + + mockedActionExecutor.execute.mockResolvedValueOnce({ status: 'ok', actionId: '2' }); + spaceIdToNamespace.mockReturnValueOnce('namespace-test'); + mockedEncryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce({ + id: '3', + type: 'action_task_params', + attributes: { + actionId: '2', + params: { baz: true }, + apiKey: Buffer.from('123:abc').toString('base64'), + relatedSavedObjects: [{ id: 'some-id', type: 'some-type' }], + }, + references: [], + }); + + await taskRunner.run(); + + expect(mockedActionExecutor.execute).toHaveBeenCalledWith({ + actionId: '2', + params: { baz: true }, + relatedSavedObjects: [ + { + id: 'some-id', + type: 'some-type', + }, + ], + request: expect.objectContaining({ + headers: { + // base64 encoded "123:abc" + authorization: 'ApiKey MTIzOmFiYw==', + }, + }), + }); +}); + +test('sanitizes invalid relatedSavedObjects when provided', async () => { + const taskRunner = taskRunnerFactory.create({ + taskInstance: mockedTaskInstance, + }); + + mockedActionExecutor.execute.mockResolvedValueOnce({ status: 'ok', actionId: '2' }); + spaceIdToNamespace.mockReturnValueOnce('namespace-test'); + mockedEncryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce({ + id: '3', + type: 'action_task_params', + attributes: { + actionId: '2', + params: { baz: true }, + apiKey: Buffer.from('123:abc').toString('base64'), + relatedSavedObjects: [{ Xid: 'some-id', type: 'some-type' }], + }, + references: [], + }); + + await taskRunner.run(); + + expect(mockedActionExecutor.execute).toHaveBeenCalledWith({ + actionId: '2', + params: { baz: true }, + relatedSavedObjects: [], + request: expect.objectContaining({ + headers: { + // base64 encoded "123:abc" + authorization: 'ApiKey MTIzOmFiYw==', + }, + }), + }); +}); + test(`doesn't use API key when not provided`, async () => { const factory = new TaskRunnerFactory(mockedActionExecutor); factory.initialize(taskRunnerFactoryInitializerParams); @@ -284,6 +359,7 @@ test(`doesn't use API key when not provided`, async () => { expect(mockedActionExecutor.execute).toHaveBeenCalledWith({ actionId: '2', params: { baz: true }, + relatedSavedObjects: [], request: expect.objectContaining({ headers: {}, }), diff --git a/x-pack/plugins/actions/server/lib/task_runner_factory.ts b/x-pack/plugins/actions/server/lib/task_runner_factory.ts index cf4b1576f27786..0515963ab82f4e 100644 --- a/x-pack/plugins/actions/server/lib/task_runner_factory.ts +++ b/x-pack/plugins/actions/server/lib/task_runner_factory.ts @@ -30,6 +30,7 @@ import { } from '../types'; import { ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE } from '../constants/saved_objects'; import { asSavedObjectExecutionSource } from './action_execution_source'; +import { validatedRelatedSavedObjects } from './related_saved_objects'; export interface TaskRunnerContext { logger: Logger; @@ -77,7 +78,7 @@ export class TaskRunnerFactory { const namespace = spaceIdToNamespace(spaceId); const { - attributes: { actionId, params, apiKey }, + attributes: { actionId, params, apiKey, relatedSavedObjects }, references, } = await encryptedSavedObjectsClient.getDecryptedAsInternalUser( ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE, @@ -117,6 +118,7 @@ export class TaskRunnerFactory { actionId, request: fakeRequest, ...getSourceFromReferences(references), + relatedSavedObjects: validatedRelatedSavedObjects(logger, relatedSavedObjects), }); } catch (e) { if (e instanceof ActionTypeDisabledError) { diff --git a/x-pack/plugins/actions/server/routes/execute.test.ts b/x-pack/plugins/actions/server/routes/execute.test.ts index 4b12bf3111c1f5..54e10698e5af96 100644 --- a/x-pack/plugins/actions/server/routes/execute.test.ts +++ b/x-pack/plugins/actions/server/routes/execute.test.ts @@ -65,6 +65,7 @@ describe('executeActionRoute', () => { someData: 'data', }, source: asHttpRequestExecutionSource(req), + relatedSavedObjects: [], }); expect(res.ok).toHaveBeenCalled(); @@ -101,6 +102,7 @@ describe('executeActionRoute', () => { expect(actionsClient.execute).toHaveBeenCalledWith({ actionId: '1', params: {}, + relatedSavedObjects: [], source: asHttpRequestExecutionSource(req), }); diff --git a/x-pack/plugins/actions/server/routes/execute.ts b/x-pack/plugins/actions/server/routes/execute.ts index 377fe1215b3fb0..7e8110365e87a2 100644 --- a/x-pack/plugins/actions/server/routes/execute.ts +++ b/x-pack/plugins/actions/server/routes/execute.ts @@ -53,6 +53,7 @@ export const executeActionRoute = ( params, actionId: id, source: asHttpRequestExecutionSource(req), + relatedSavedObjects: [], }); return body ? res.ok({ diff --git a/x-pack/plugins/actions/server/routes/legacy/execute.test.ts b/x-pack/plugins/actions/server/routes/legacy/execute.test.ts index 2ac53ddaaedf64..05b71819911a3d 100644 --- a/x-pack/plugins/actions/server/routes/legacy/execute.test.ts +++ b/x-pack/plugins/actions/server/routes/legacy/execute.test.ts @@ -63,6 +63,7 @@ describe('executeActionRoute', () => { someData: 'data', }, source: asHttpRequestExecutionSource(req), + relatedSavedObjects: [], }); expect(res.ok).toHaveBeenCalled(); @@ -100,6 +101,7 @@ describe('executeActionRoute', () => { actionId: '1', params: {}, source: asHttpRequestExecutionSource(req), + relatedSavedObjects: [], }); expect(res.ok).not.toHaveBeenCalled(); diff --git a/x-pack/plugins/actions/server/routes/legacy/execute.ts b/x-pack/plugins/actions/server/routes/legacy/execute.ts index f6ddec1d01c200..d7ed8d2e156041 100644 --- a/x-pack/plugins/actions/server/routes/legacy/execute.ts +++ b/x-pack/plugins/actions/server/routes/legacy/execute.ts @@ -48,6 +48,7 @@ export const executeActionRoute = ( params, actionId: id, source: asHttpRequestExecutionSource(req), + relatedSavedObjects: [], }); return body ? res.ok({ diff --git a/x-pack/plugins/actions/server/saved_objects/mappings.json b/x-pack/plugins/actions/server/saved_objects/mappings.json index c598b96ba24513..57f801ae9a0758 100644 --- a/x-pack/plugins/actions/server/saved_objects/mappings.json +++ b/x-pack/plugins/actions/server/saved_objects/mappings.json @@ -35,6 +35,10 @@ }, "apiKey": { "type": "binary" + }, + "relatedSavedObjects": { + "enabled": false, + "type": "object" } } } diff --git a/x-pack/plugins/alerting/server/task_runner/create_execution_handler.test.ts b/x-pack/plugins/alerting/server/task_runner/create_execution_handler.test.ts index 25f0656163f5d3..033ffcceb6a0ae 100644 --- a/x-pack/plugins/alerting/server/task_runner/create_execution_handler.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/create_execution_handler.test.ts @@ -135,6 +135,14 @@ test('enqueues execution per selected action', async () => { "foo": true, "stateVal": "My goes here", }, + "relatedSavedObjects": Array [ + Object { + "id": "1", + "namespace": "test1", + "type": "alert", + "typeId": "test", + }, + ], "source": Object { "source": Object { "id": "1", @@ -247,6 +255,14 @@ test(`doesn't call actionsPlugin.execute for disabled actionTypes`, async () => id: '1', type: 'alert', }), + relatedSavedObjects: [ + { + id: '1', + namespace: 'test1', + type: 'alert', + typeId: 'test', + }, + ], spaceId: 'test1', apiKey: createExecutionHandlerParams.apiKey, }); @@ -327,6 +343,14 @@ test('context attribute gets parameterized', async () => { "foo": true, "stateVal": "My goes here", }, + "relatedSavedObjects": Array [ + Object { + "id": "1", + "namespace": "test1", + "type": "alert", + "typeId": "test", + }, + ], "source": Object { "source": Object { "id": "1", @@ -360,6 +384,14 @@ test('state attribute gets parameterized', async () => { "foo": true, "stateVal": "My state-val goes here", }, + "relatedSavedObjects": Array [ + Object { + "id": "1", + "namespace": "test1", + "type": "alert", + "typeId": "test", + }, + ], "source": Object { "source": Object { "id": "1", diff --git a/x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts b/x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts index c3a36297c217ac..968fff540dc030 100644 --- a/x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts +++ b/x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts @@ -157,6 +157,8 @@ export function createExecutionHandler< continue; } + const namespace = spaceId === 'default' ? {} : { namespace: spaceId }; + // TODO would be nice to add the action name here, but it's not available const actionLabel = `${action.actionTypeId}:${action.id}`; const actionsClient = await actionsPlugin.getActionsClientWithRequest(request); @@ -169,10 +171,16 @@ export function createExecutionHandler< id: alertId, type: 'alert', }), + relatedSavedObjects: [ + { + id: alertId, + type: 'alert', + namespace: namespace.namespace, + typeId: alertType.id, + }, + ], }); - const namespace = spaceId === 'default' ? {} : { namespace: spaceId }; - const event: IEvent = { event: { action: EVENT_LOG_ACTIONS.executeAction, diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts index 39a45584631d23..8ab267a5610d3b 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts @@ -352,6 +352,14 @@ describe('Task Runner', () => { "params": Object { "foo": true, }, + "relatedSavedObjects": Array [ + Object { + "id": "1", + "namespace": undefined, + "type": "alert", + "typeId": "test", + }, + ], "source": Object { "source": Object { "id": "1", @@ -1098,6 +1106,14 @@ describe('Task Runner', () => { "params": Object { "foo": true, }, + "relatedSavedObjects": Array [ + Object { + "id": "1", + "namespace": undefined, + "type": "alert", + "typeId": "test", + }, + ], "source": Object { "source": Object { "id": "1", @@ -1634,6 +1650,14 @@ describe('Task Runner', () => { "params": Object { "isResolved": true, }, + "relatedSavedObjects": Array [ + Object { + "id": "1", + "namespace": undefined, + "type": "alert", + "typeId": "test", + }, + ], "source": Object { "source": Object { "id": "1", @@ -1826,6 +1850,14 @@ describe('Task Runner', () => { "params": Object { "isResolved": true, }, + "relatedSavedObjects": Array [ + Object { + "id": "1", + "namespace": undefined, + "type": "alert", + "typeId": "test", + }, + ], "source": Object { "source": Object { "id": "1", diff --git a/x-pack/plugins/event_log/README.md b/x-pack/plugins/event_log/README.md index 032f77543acb97..ffbd20dd6f2bec 100644 --- a/x-pack/plugins/event_log/README.md +++ b/x-pack/plugins/event_log/README.md @@ -131,7 +131,7 @@ Below is a document in the expected structure, with descriptions of the fields: instance_id: "alert instance id, for relevant documents", action_group_id: "alert action group, for relevant documents", action_subgroup: "alert action subgroup, for relevant documents", - status: "overall alert status, after alert execution", + status: "overall alert status, after rule execution", }, saved_objects: [ { @@ -160,21 +160,26 @@ plugins: - `action: execute-via-http` - generated when an action is executed via HTTP request - `provider: alerting` - - `action: execute` - generated when an alert executor runs - - `action: execute-action` - generated when an alert schedules an action to run - - `action: new-instance` - generated when an alert has a new instance id that is active - - `action: recovered-instance` - generated when an alert has a previously active instance id that is no longer active - - `action: active-instance` - generated when an alert determines an instance id is active + - `action: execute` - generated when a rule executor runs + - `action: execute-action` - generated when a rule schedules an action to run + - `action: new-instance` - generated when a rule has a new instance id that is active + - `action: recovered-instance` - generated when a rule has a previously active instance id that is no longer active + - `action: active-instance` - generated when a rule determines an instance id is active For the `saved_objects` array elements, these are references to saved objects -associated with the event. For the `alerting` provider, those are alert saved -ojects and for the `actions` provider those are action saved objects. The -`alerts:execute-action` event includes both the alert and action saved object -references. For that event, only the alert reference has the optional `rel` +associated with the event. For the `alerting` provider, those are rule saved +ojects and for the `actions` provider those are connector saved objects. The +`alerts:execute-action` event includes both the rule and connector saved object +references. For that event, only the rule reference has the optional `rel` property with a `primary` value. This property is used when searching the event log to indicate which saved objects should be directly searchable via -saved object references. For the `alerts:execute-action` event, searching -only via the alert saved object reference will return the event. +saved object references. For the `alerts:execute-action` event, only searching +via the rule saved object reference will return the event; searching via the +connector save object reference will **NOT** return the event. The +`actions:execute` event also includes both the rule and connector saved object +references, and both of them have the `rel` property with a `primary` value, +allowing those events to be returned in searches of either the rule or +connector. ## Event Log index - associated resources From b386ce149a8d2175005fbe5045ee48bf8c56f977 Mon Sep 17 00:00:00 2001 From: Constance Date: Tue, 22 Jun 2021 12:27:27 -0700 Subject: [PATCH 12/17] [App Search] Convert Schema pages to new page template (#102846) * Convert Schema page to new page template + update empty state - remove panel wrapper, add create schema field modal * Convert ReindexJob view to new page template + remove breadcrumb prop * Convert Meta Engine Schema view to new page template * Update routers * [Polish] Misc Davey Schema UI tweaks - see https://github.com/elastic/kibana/pull/101958/files + change color away from secondary, since that's going away in EUI at some point * [UX] Fix SchemaAddFieldModal stuttering on first new schema field add - With the new template, transitioning from the empty state to the filled schema state causes the modal to stutter due to the component rerender - Changing the page to not instantly react/update `hasSchema` when local schema state changes but instead to wait for the server call to finish and for cachedSchema to update fixes the UX problem * [UI polish] Revert button color change per Davey's feedback --- .../components/engine/engine_router.tsx | 10 +- .../schema/components/empty_state.test.tsx | 11 ++ .../schema/components/empty_state.tsx | 16 ++- .../schema/reindex_job/reindex_job.test.tsx | 17 +-- .../schema/reindex_job/reindex_job.tsx | 61 ++++---- .../components/schema/schema_logic.test.ts | 8 +- .../components/schema/schema_logic.ts | 5 +- .../components/schema/schema_router.tsx | 12 +- .../schema/views/meta_engine_schema.test.tsx | 10 +- .../schema/views/meta_engine_schema.tsx | 131 +++++++++--------- .../components/schema/views/schema.test.tsx | 28 +--- .../components/schema/views/schema.tsx | 50 +++---- .../shared/schema/add_field_modal/index.tsx | 10 +- 13 files changed, 172 insertions(+), 197 deletions(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx index fc057858426d2f..91a21847107a95 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx @@ -109,6 +109,11 @@ export const EngineRouter: React.FC = () => { )} + {canViewEngineSchema && ( + + + + )} {canManageEngineSearchUi && ( @@ -121,11 +126,6 @@ export const EngineRouter: React.FC = () => { )} {/* TODO: Remove layout once page template migration is over */} }> - {canViewEngineSchema && ( - - - - )} {canManageEngineCurations && ( diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/components/empty_state.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/components/empty_state.test.tsx index ea658c741b8a0d..1b353f17855d2a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/components/empty_state.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/components/empty_state.test.tsx @@ -5,12 +5,16 @@ * 2.0. */ +import { setMockValues } from '../../../../__mocks__/kea_logic'; + import React from 'react'; import { shallow } from 'enzyme'; import { EuiEmptyPrompt, EuiButton } from '@elastic/eui'; +import { SchemaAddFieldModal } from '../../../../shared/schema'; + import { EmptyState } from './'; describe('EmptyState', () => { @@ -24,4 +28,11 @@ describe('EmptyState', () => { expect.stringContaining('#indexing-documents-guide-schema') ); }); + + it('renders a modal that lets a user add a new schema field', () => { + setMockValues({ isModalOpen: true }); + const wrapper = shallow(); + + expect(wrapper.find(SchemaAddFieldModal)).toHaveLength(1); + }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/components/empty_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/components/empty_state.tsx index 6d7dd198d5eef6..ad9285c7b8fefb 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/components/empty_state.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/components/empty_state.tsx @@ -7,14 +7,21 @@ import React from 'react'; -import { EuiPanel, EuiEmptyPrompt, EuiButton } from '@elastic/eui'; +import { useValues, useActions } from 'kea'; + +import { EuiEmptyPrompt, EuiButton } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { SchemaAddFieldModal } from '../../../../shared/schema'; import { DOCS_PREFIX } from '../../../routes'; +import { SchemaLogic } from '../schema_logic'; export const EmptyState: React.FC = () => { + const { isModalOpen } = useValues(SchemaLogic); + const { addSchemaField, closeModal } = useActions(SchemaLogic); + return ( - + <> { } /> - + {isModalOpen && ( + + )} + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/reindex_job/reindex_job.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/reindex_job/reindex_job.test.tsx index e76ab60005231d..4dd7a869ca27ef 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/reindex_job/reindex_job.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/reindex_job/reindex_job.test.tsx @@ -14,15 +14,11 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { Loading } from '../../../../shared/loading'; import { SchemaErrorsAccordion } from '../../../../shared/schema'; import { ReindexJob } from './'; describe('ReindexJob', () => { - const props = { - schemaBreadcrumb: ['Engines', 'some-engine', 'Schema'], - }; const values = { dataLoading: false, fieldCoercionErrors: {}, @@ -43,27 +39,20 @@ describe('ReindexJob', () => { }); it('renders', () => { - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(SchemaErrorsAccordion)).toHaveLength(1); expect(wrapper.find(SchemaErrorsAccordion).prop('generateViewPath')).toHaveLength(1); }); it('calls loadReindexJob on page load', () => { - shallow(); + shallow(); expect(actions.loadReindexJob).toHaveBeenCalledWith('abc1234567890'); }); - it('renders a loading state', () => { - setMockValues({ ...values, dataLoading: true }); - const wrapper = shallow(); - - expect(wrapper.find(Loading)).toHaveLength(1); - }); - it('renders schema errors with links to document pages', () => { - const wrapper = shallow(); + const wrapper = shallow(); const generateViewPath = wrapper .find(SchemaErrorsAccordion) .prop('generateViewPath') as Function; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/reindex_job/reindex_job.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/reindex_job/reindex_job.tsx index 576b4ae11603be..b0a8cbd25f8b04 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/reindex_job/reindex_job.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/reindex_job/reindex_job.tsx @@ -10,25 +10,17 @@ import { useParams } from 'react-router-dom'; import { useActions, useValues } from 'kea'; -import { EuiPageHeader, EuiPageContentBody } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { FlashMessages } from '../../../../shared/flash_messages'; -import { SetAppSearchChrome as SetPageChrome } from '../../../../shared/kibana_chrome'; -import { BreadcrumbTrail } from '../../../../shared/kibana_chrome/generate_breadcrumbs'; -import { Loading } from '../../../../shared/loading'; import { SchemaErrorsAccordion } from '../../../../shared/schema'; - import { ENGINE_DOCUMENT_DETAIL_PATH } from '../../../routes'; -import { EngineLogic, generateEnginePath } from '../../engine'; +import { EngineLogic, generateEnginePath, getEngineBreadcrumbs } from '../../engine'; +import { AppSearchPageTemplate } from '../../layout'; +import { SCHEMA_TITLE } from '../constants'; import { ReindexJobLogic } from './reindex_job_logic'; -interface Props { - schemaBreadcrumb: BreadcrumbTrail; -} - -export const ReindexJob: React.FC = ({ schemaBreadcrumb }) => { +export const ReindexJob: React.FC = () => { const { reindexJobId } = useParams() as { reindexJobId: string }; const { loadReindexJob } = useActions(ReindexJobLogic); const { dataLoading, fieldCoercionErrors } = useValues(ReindexJobLogic); @@ -40,34 +32,29 @@ export const ReindexJob: React.FC = ({ schemaBreadcrumb }) => { loadReindexJob(reindexJobId); }, [reindexJobId]); - if (dataLoading) return ; - return ( - <> - - + + generateEnginePath(ENGINE_DOCUMENT_DETAIL_PATH, { documentId }) + } /> - - - - generateEnginePath(ENGINE_DOCUMENT_DETAIL_PATH, { documentId }) - } - /> - - + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/schema_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/schema_logic.test.ts index 7687296cf9f830..dcc5747b0d32f3 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/schema_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/schema_logic.test.ts @@ -140,13 +140,13 @@ describe('SchemaLogic', () => { describe('selectors', () => { describe('hasSchema', () => { - it('returns true when the schema obj has items', () => { - mountAndSetSchema({ schema: { test: SchemaType.Text } }); + it('returns true when the cached server schema obj has items', () => { + mount({ cachedSchema: { test: SchemaType.Text } }); expect(SchemaLogic.values.hasSchema).toEqual(true); }); - it('returns false when the schema obj is empty', () => { - mountAndSetSchema({ schema: {} }); + it('returns false when the cached server schema obj is empty', () => { + mount({ schema: {} }); expect(SchemaLogic.values.hasSchema).toEqual(false); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/schema_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/schema_logic.ts index 3215a46c8e2998..3dcafd6782afd7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/schema_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/schema_logic.ts @@ -108,7 +108,10 @@ export const SchemaLogic = kea>({ ], }, selectors: { - hasSchema: [(selectors) => [selectors.schema], (schema) => Object.keys(schema).length > 0], + hasSchema: [ + (selectors) => [selectors.cachedSchema], + (cachedSchema) => Object.keys(cachedSchema).length > 0, + ], hasSchemaChanged: [ (selectors) => [selectors.schema, selectors.cachedSchema], (schema, cachedSchema) => !isEqual(schema, cachedSchema), diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/schema_router.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/schema_router.tsx index bfa346fee468bb..d358c489593c5a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/schema_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/schema_router.tsx @@ -10,27 +10,21 @@ import { Route, Switch } from 'react-router-dom'; import { useValues } from 'kea'; -import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; import { ENGINE_REINDEX_JOB_PATH } from '../../routes'; -import { EngineLogic, getEngineBreadcrumbs } from '../engine'; +import { EngineLogic } from '../engine'; -import { SCHEMA_TITLE } from './constants'; import { ReindexJob } from './reindex_job'; import { Schema, MetaEngineSchema } from './views'; export const SchemaRouter: React.FC = () => { const { isMetaEngine } = useValues(EngineLogic); - const schemaBreadcrumb = getEngineBreadcrumbs([SCHEMA_TITLE]); return ( - - - - - {isMetaEngine ? : } + + {isMetaEngine ? : } ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/views/meta_engine_schema.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/views/meta_engine_schema.test.tsx index 1d677ad08db436..60a0513b774fdd 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/views/meta_engine_schema.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/views/meta_engine_schema.test.tsx @@ -7,6 +7,7 @@ import { setMockValues, setMockActions } from '../../../../__mocks__/kea_logic'; import '../../../../__mocks__/shallow_useeffect.mock'; +import '../../../__mocks__/engine_logic.mock'; import React from 'react'; @@ -14,8 +15,6 @@ import { shallow } from 'enzyme'; import { EuiCallOut } from '@elastic/eui'; -import { Loading } from '../../../../shared/loading'; - import { MetaEnginesSchemaTable, MetaEnginesConflictsTable } from '../components'; import { MetaEngineSchema } from './'; @@ -46,13 +45,6 @@ describe('MetaEngineSchema', () => { expect(actions.loadSchema).toHaveBeenCalled(); }); - it('renders a loading state', () => { - setMockValues({ ...values, dataLoading: true }); - const wrapper = shallow(); - - expect(wrapper.find(Loading)).toHaveLength(1); - }); - it('renders an inactive fields callout & table when source engines have schema conflicts', () => { setMockValues({ ...values, hasConflicts: true, conflictingFieldsCount: 5 }); const wrapper = shallow(); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/views/meta_engine_schema.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/views/meta_engine_schema.tsx index 4c0235cf81129b..2eb8bac00a040d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/views/meta_engine_schema.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/views/meta_engine_schema.tsx @@ -9,14 +9,15 @@ import React, { useEffect } from 'react'; import { useValues, useActions } from 'kea'; -import { EuiPageHeader, EuiPageContentBody, EuiCallOut, EuiSpacer } from '@elastic/eui'; +import { EuiCallOut, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { FlashMessages } from '../../../../shared/flash_messages'; -import { Loading } from '../../../../shared/loading'; import { DataPanel } from '../../data_panel'; +import { getEngineBreadcrumbs } from '../../engine'; +import { AppSearchPageTemplate } from '../../layout'; import { MetaEnginesSchemaTable, MetaEnginesConflictsTable } from '../components'; +import { SCHEMA_TITLE } from '../constants'; import { MetaEngineSchemaLogic } from '../schema_meta_engine_logic'; export const MetaEngineSchema: React.FC = () => { @@ -27,90 +28,88 @@ export const MetaEngineSchema: React.FC = () => { loadSchema(); }, []); - if (dataLoading) return ; - return ( - <> - - - - {hasConflicts && ( - <> - + {hasConflicts && ( + <> + +

    + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.schema.metaEngine.conflictsCalloutDescription', { defaultMessage: - '{conflictingFieldsCount, plural, one {# field is} other {# fields are}} not searchable', - values: { conflictingFieldsCount }, + 'The field(s) have an inconsistent field-type across the source engines that make up this meta engine. Apply a consistent field-type from the source engines to make these fields searchable.', } )} - > -

    - {i18n.translate( - 'xpack.enterpriseSearch.appSearch.engine.schema.metaEngine.conflictsCalloutDescription', - { - defaultMessage: - 'The field(s) have an inconsistent field-type across the source engines that make up this meta engine. Apply a consistent field-type from the source engines to make these fields searchable.', - } - )} -

    -
    - - +

    +
    + + + )} + + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.schema.metaEngine.activeFieldsTitle', + { defaultMessage: 'Active fields' } + )} + + } + subtitle={i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.schema.metaEngine.activeFieldsDescription', + { defaultMessage: 'Fields which belong to one or more engine.' } )} + > + + + + {hasConflicts && ( {i18n.translate( - 'xpack.enterpriseSearch.appSearch.engine.schema.metaEngine.activeFieldsTitle', - { defaultMessage: 'Active fields' } + 'xpack.enterpriseSearch.appSearch.engine.schema.metaEngine.inactiveFieldsTitle', + { defaultMessage: 'Inactive fields' } )} } subtitle={i18n.translate( - 'xpack.enterpriseSearch.appSearch.engine.schema.metaEngine.activeFieldsDescription', - { defaultMessage: 'Fields which belong to one or more engine.' } + 'xpack.enterpriseSearch.appSearch.engine.schema.metaEngine.inactiveFieldsDescription', + { + defaultMessage: + 'These fields have type conflicts. To activate these fields, change types in the source engines to match.', + } )} > - + - - {hasConflicts && ( - - {i18n.translate( - 'xpack.enterpriseSearch.appSearch.engine.schema.metaEngine.inactiveFieldsTitle', - { defaultMessage: 'Inactive fields' } - )} - - } - subtitle={i18n.translate( - 'xpack.enterpriseSearch.appSearch.engine.schema.metaEngine.inactiveFieldsDescription', - { - defaultMessage: - 'These fields have type conflicts. To activate these fields, change types in the source engines to match.', - } - )} - > - - - )} -
    - + )} + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/views/schema.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/views/schema.test.tsx index 91ec8eda55fc36..cae16d70592faf 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/views/schema.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/views/schema.test.tsx @@ -7,17 +7,18 @@ import { setMockValues, setMockActions } from '../../../../__mocks__/kea_logic'; import '../../../../__mocks__/shallow_useeffect.mock'; +import '../../../__mocks__/engine_logic.mock'; import React from 'react'; import { shallow } from 'enzyme'; -import { EuiPageHeader, EuiButton } from '@elastic/eui'; +import { EuiButton } from '@elastic/eui'; -import { Loading } from '../../../../shared/loading'; import { SchemaAddFieldModal } from '../../../../shared/schema'; +import { getPageHeaderActions } from '../../../../test_helpers'; -import { SchemaCallouts, SchemaTable, EmptyState } from '../components'; +import { SchemaCallouts, SchemaTable } from '../components'; import { Schema } from './'; @@ -56,27 +57,8 @@ describe('Schema', () => { expect(actions.loadSchema).toHaveBeenCalled(); }); - it('renders a loading state', () => { - setMockValues({ ...values, dataLoading: true }); - const wrapper = shallow(); - - expect(wrapper.find(Loading)).toHaveLength(1); - }); - - it('renders an empty state', () => { - setMockValues({ ...values, hasSchema: false }); - const wrapper = shallow(); - - expect(wrapper.find(EmptyState)).toHaveLength(1); - }); - describe('page action buttons', () => { - const subject = () => - shallow() - .find(EuiPageHeader) - .dive() - .children() - .dive(); + const subject = () => getPageHeaderActions(shallow()); it('renders', () => { const wrapper = subject(); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/views/schema.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/views/schema.tsx index 7bc995b16468aa..d2a760e8accff3 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/views/schema.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/views/schema.tsx @@ -9,14 +9,15 @@ import React, { useEffect } from 'react'; import { useValues, useActions } from 'kea'; -import { EuiPageHeader, EuiButton, EuiPageContentBody } from '@elastic/eui'; +import { EuiButton } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { FlashMessages } from '../../../../shared/flash_messages'; -import { Loading } from '../../../../shared/loading'; import { SchemaAddFieldModal } from '../../../../shared/schema'; +import { getEngineBreadcrumbs } from '../../engine'; +import { AppSearchPageTemplate } from '../../layout'; import { SchemaCallouts, SchemaTable, EmptyState } from '../components'; +import { SCHEMA_TITLE } from '../constants'; import { SchemaLogic } from '../schema_logic'; export const Schema: React.FC = () => { @@ -31,19 +32,18 @@ export const Schema: React.FC = () => { loadSchema(); }, []); - if (dataLoading) return ; - return ( - <> - { > {i18n.translate( 'xpack.enterpriseSearch.appSearch.engine.schema.updateSchemaButtonLabel', - { defaultMessage: 'Update types' } + { defaultMessage: 'Save changes' } )} , { { defaultMessage: 'Create a schema field' } )} , - ]} - /> - - - - {hasSchema ? : } - {isModalOpen && ( - - )} - - + ], + }} + isLoading={dataLoading} + isEmptyState={!hasSchema} + emptyState={} + > + + + {isModalOpen && ( + + )} + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/schema/add_field_modal/index.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/schema/add_field_modal/index.tsx index 902417d02665e6..ba9da900c01456 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/schema/add_field_modal/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/schema/add_field_modal/index.tsx @@ -10,6 +10,7 @@ import React, { ChangeEvent, FormEvent, useEffect, useState } from 'react'; import { EuiButton, EuiButtonEmpty, + EuiCallOut, EuiFieldText, EuiFlexGroup, EuiFlexItem, @@ -83,8 +84,13 @@ export const SchemaAddFieldModal: React.FC = ({ {ADD_FIELD_MODAL_TITLE} -

    {ADD_FIELD_MODAL_DESCRIPTION}

    - + {ADD_FIELD_MODAL_DESCRIPTION}

    } + /> + From dec77cfafb0cd557eaf7a6d2ab72280b9801fb6d Mon Sep 17 00:00:00 2001 From: Chris Roberson Date: Tue, 22 Jun 2021 16:01:43 -0400 Subject: [PATCH 13/17] [Alerting] Add event log entry when an action starts executing (#102370) * First steps for adding action execution to event log * Fix tests * Move the event to the actions plugin * Update functional tests * Fix tests * Fix types --- .../actions/server/constants/event_log.ts | 1 + .../server/lib/action_executor.test.ts | 47 ++++++++++- .../actions/server/lib/action_executor.ts | 12 +++ .../tests/actions/execute.ts | 78 +++++++++++++++---- .../spaces_only/tests/actions/execute.ts | 50 ++++++++---- 5 files changed, 156 insertions(+), 32 deletions(-) diff --git a/x-pack/plugins/actions/server/constants/event_log.ts b/x-pack/plugins/actions/server/constants/event_log.ts index 508709c8783ab7..9163a0d105ce8a 100644 --- a/x-pack/plugins/actions/server/constants/event_log.ts +++ b/x-pack/plugins/actions/server/constants/event_log.ts @@ -8,5 +8,6 @@ export const EVENT_LOG_PROVIDER = 'actions'; export const EVENT_LOG_ACTIONS = { execute: 'execute', + executeStart: 'execute-start', executeViaHttp: 'execute-via-http', }; diff --git a/x-pack/plugins/actions/server/lib/action_executor.test.ts b/x-pack/plugins/actions/server/lib/action_executor.test.ts index 8ec94c4d4a5521..37d461d6b2a501 100644 --- a/x-pack/plugins/actions/server/lib/action_executor.test.ts +++ b/x-pack/plugins/actions/server/lib/action_executor.test.ts @@ -23,6 +23,7 @@ const services = actionsMock.createServices(); const actionsClient = actionsClientMock.create(); const encryptedSavedObjectsClient = encryptedSavedObjectsMock.createClient(); const actionTypeRegistry = actionTypeRegistryMock.create(); +const eventLogger = eventLoggerMock.create(); const executeParams = { actionId: '1', @@ -42,7 +43,7 @@ actionExecutor.initialize({ getActionsClientWithRequest, actionTypeRegistry, encryptedSavedObjectsClient, - eventLogger: eventLoggerMock.create(), + eventLogger, preconfiguredActions: [], }); @@ -379,6 +380,50 @@ test('logs a warning when alert executor returns invalid status', async () => { ); }); +test('writes to event log for execute and execute start', async () => { + const executorMock = setupActionExecutorMock(); + executorMock.mockResolvedValue({ + actionId: '1', + status: 'ok', + }); + await actionExecutor.execute(executeParams); + expect(eventLogger.logEvent).toHaveBeenCalledTimes(2); + expect(eventLogger.logEvent.mock.calls[0][0]).toMatchObject({ + event: { + action: 'execute-start', + }, + kibana: { + saved_objects: [ + { + rel: 'primary', + type: 'action', + id: '1', + type_id: 'test', + namespace: 'some-namespace', + }, + ], + }, + message: 'action started: test:1: action-1', + }); + expect(eventLogger.logEvent.mock.calls[1][0]).toMatchObject({ + event: { + action: 'execute', + }, + kibana: { + saved_objects: [ + { + rel: 'primary', + type: 'action', + id: '1', + type_id: 'test', + namespace: 'some-namespace', + }, + ], + }, + message: 'action executed: test:1: action-1', + }); +}); + function setupActionExecutorMock() { const actionType: jest.Mocked = { id: 'test', diff --git a/x-pack/plugins/actions/server/lib/action_executor.ts b/x-pack/plugins/actions/server/lib/action_executor.ts index 9d2b937734fb06..e9e7b17288611b 100644 --- a/x-pack/plugins/actions/server/lib/action_executor.ts +++ b/x-pack/plugins/actions/server/lib/action_executor.ts @@ -7,6 +7,7 @@ import type { PublicMethodsOf } from '@kbn/utility-types'; import { Logger, KibanaRequest } from 'src/core/server'; +import { cloneDeep } from 'lodash'; import { withSpan } from '@kbn/apm-utils'; import { validateParams, validateConfig, validateSecrets } from './validate_with_schema'; import { @@ -168,6 +169,17 @@ export class ActionExecutor { } eventLogger.startTiming(event); + + const startEvent = cloneDeep({ + ...event, + event: { + ...event.event, + action: EVENT_LOG_ACTIONS.executeStart, + }, + message: `action started: ${actionLabel}`, + }); + eventLogger.logEvent(startEvent); + let rawResult: ActionTypeExecutorResult; try { rawResult = await actionType.executor({ diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/execute.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/execute.ts index f7d7c1df8fd46b..5c578d2d08daee 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/execute.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/execute.ts @@ -519,47 +519,93 @@ export default function ({ getService }: FtrProviderContext) { type: 'action', id: connectorId, provider: 'actions', - actions: new Map([['execute', { equal: 1 }]]), - filter: 'event.action:(execute)', + actions: new Map([ + ['execute-start', { equal: 1 }], + ['execute', { equal: 1 }], + ]), + // filter: 'event.action:(execute)', }); }); - const event = events[0]; + const startExecuteEvent = events[0]; + const executeEvent = events[1]; - const duration = event?.event?.duration; - const eventStart = Date.parse(event?.event?.start || 'undefined'); - const eventEnd = Date.parse(event?.event?.end || 'undefined'); + const duration = executeEvent?.event?.duration; + const executeEventStart = Date.parse(executeEvent?.event?.start || 'undefined'); + const startExecuteEventStart = Date.parse(startExecuteEvent?.event?.start || 'undefined'); + const executeEventEnd = Date.parse(executeEvent?.event?.end || 'undefined'); const dateNow = Date.now(); expect(typeof duration).to.be('number'); - expect(eventStart).to.be.ok(); - expect(eventEnd).to.be.ok(); + expect(executeEventStart).to.be.ok(); + expect(startExecuteEventStart).to.equal(executeEventStart); + expect(executeEventEnd).to.be.ok(); const durationDiff = Math.abs( - Math.round(duration! / NANOS_IN_MILLIS) - (eventEnd - eventStart) + Math.round(duration! / NANOS_IN_MILLIS) - (executeEventEnd - executeEventStart) ); // account for rounding errors expect(durationDiff < 1).to.equal(true); - expect(eventStart <= eventEnd).to.equal(true); - expect(eventEnd <= dateNow).to.equal(true); + expect(executeEventStart <= executeEventEnd).to.equal(true); + expect(executeEventEnd <= dateNow).to.equal(true); - expect(event?.event?.outcome).to.equal(outcome); + expect(executeEvent?.event?.outcome).to.equal(outcome); - expect(event?.kibana?.saved_objects).to.eql([ + expect(executeEvent?.kibana?.saved_objects).to.eql([ { rel: 'primary', type: 'action', id: connectorId, + namespace: 'space1', type_id: actionTypeId, - namespace: spaceId, }, ]); + expect(startExecuteEvent?.kibana?.saved_objects).to.eql(executeEvent?.kibana?.saved_objects); - expect(event?.message).to.eql(message); + expect(executeEvent?.message).to.eql(message); + expect(startExecuteEvent?.message).to.eql(message.replace('executed', 'started')); if (errorMessage) { - expect(event?.error?.message).to.eql(errorMessage); + expect(executeEvent?.error?.message).to.eql(errorMessage); } + + // const event = events[0]; + + // const duration = event?.event?.duration; + // const eventStart = Date.parse(event?.event?.start || 'undefined'); + // const eventEnd = Date.parse(event?.event?.end || 'undefined'); + // const dateNow = Date.now(); + + // expect(typeof duration).to.be('number'); + // expect(eventStart).to.be.ok(); + // expect(eventEnd).to.be.ok(); + + // const durationDiff = Math.abs( + // Math.round(duration! / NANOS_IN_MILLIS) - (eventEnd - eventStart) + // ); + + // // account for rounding errors + // expect(durationDiff < 1).to.equal(true); + // expect(eventStart <= eventEnd).to.equal(true); + // expect(eventEnd <= dateNow).to.equal(true); + + // expect(event?.event?.outcome).to.equal(outcome); + + // expect(event?.kibana?.saved_objects).to.eql([ + // { + // rel: 'primary', + // type: 'action', + // id: connectorId, + // type_id: actionTypeId, + // namespace: spaceId, + // }, + // ]); + + // expect(event?.message).to.eql(message); + + // if (errorMessage) { + // expect(event?.error?.message).to.eql(errorMessage); + // } } } diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/execute.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/execute.ts index 147b6abfb88d14..d494c99c80e8f9 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/execute.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/execute.ts @@ -100,6 +100,7 @@ export default function ({ getService }: FtrProviderContext) { actionTypeId: 'test.index-record', outcome: 'success', message: `action executed: test.index-record:${createdAction.id}: My action`, + startMessage: `action started: test.index-record:${createdAction.id}: My action`, }); }); @@ -336,10 +337,19 @@ export default function ({ getService }: FtrProviderContext) { outcome: string; message: string; errorMessage?: string; + startMessage?: string; } async function validateEventLog(params: ValidateEventLogParams): Promise { - const { spaceId, actionId, actionTypeId, outcome, message, errorMessage } = params; + const { + spaceId, + actionId, + actionTypeId, + outcome, + message, + startMessage, + errorMessage, + } = params; const events: IValidatedEvent[] = await retry.try(async () => { return await getEventLog({ @@ -348,33 +358,39 @@ export default function ({ getService }: FtrProviderContext) { type: 'action', id: actionId, provider: 'actions', - actions: new Map([['execute', { equal: 1 }]]), + actions: new Map([ + ['execute-start', { equal: 1 }], + ['execute', { equal: 1 }], + ]), }); }); - const event = events[0]; + const startExecuteEvent = events[0]; + const executeEvent = events[1]; - const duration = event?.event?.duration; - const eventStart = Date.parse(event?.event?.start || 'undefined'); - const eventEnd = Date.parse(event?.event?.end || 'undefined'); + const duration = executeEvent?.event?.duration; + const executeEventStart = Date.parse(executeEvent?.event?.start || 'undefined'); + const startExecuteEventStart = Date.parse(startExecuteEvent?.event?.start || 'undefined'); + const executeEventEnd = Date.parse(executeEvent?.event?.end || 'undefined'); const dateNow = Date.now(); expect(typeof duration).to.be('number'); - expect(eventStart).to.be.ok(); - expect(eventEnd).to.be.ok(); + expect(executeEventStart).to.be.ok(); + expect(startExecuteEventStart).to.equal(executeEventStart); + expect(executeEventEnd).to.be.ok(); const durationDiff = Math.abs( - Math.round(duration! / NANOS_IN_MILLIS) - (eventEnd - eventStart) + Math.round(duration! / NANOS_IN_MILLIS) - (executeEventEnd - executeEventStart) ); // account for rounding errors expect(durationDiff < 1).to.equal(true); - expect(eventStart <= eventEnd).to.equal(true); - expect(eventEnd <= dateNow).to.equal(true); + expect(executeEventStart <= executeEventEnd).to.equal(true); + expect(executeEventEnd <= dateNow).to.equal(true); - expect(event?.event?.outcome).to.equal(outcome); + expect(executeEvent?.event?.outcome).to.equal(outcome); - expect(event?.kibana?.saved_objects).to.eql([ + expect(executeEvent?.kibana?.saved_objects).to.eql([ { rel: 'primary', type: 'action', @@ -383,11 +399,15 @@ export default function ({ getService }: FtrProviderContext) { type_id: actionTypeId, }, ]); + expect(startExecuteEvent?.kibana?.saved_objects).to.eql(executeEvent?.kibana?.saved_objects); - expect(event?.message).to.eql(message); + expect(executeEvent?.message).to.eql(message); + if (startMessage) { + expect(startExecuteEvent?.message).to.eql(startMessage); + } if (errorMessage) { - expect(event?.error?.message).to.eql(errorMessage); + expect(executeEvent?.error?.message).to.eql(errorMessage); } } } From b161bf03be07d6bc9fe688c03be8909fc26bae5f Mon Sep 17 00:00:00 2001 From: Melissa Alvarez Date: Tue, 22 Jun 2021 16:58:18 -0400 Subject: [PATCH 14/17] [ML] Anomaly Detection: Visualize delayed - data Part 2 (#102270) * add link in datafeed tab.remove interval * add annotation overlay to chart * adds annotations checkbox * ensure annotation with same start/end time show up in chart * update annotations time format * move time format to client * adds info tooltip to modal title * adds model snapshots to datafeed chart --- x-pack/plugins/ml/common/types/results.ts | 4 + .../annotations_table/annotations_table.js | 10 +- .../components/datafeed_modal/constants.ts | 2 +- .../datafeed_modal/datafeed_modal.tsx | 213 ++++++++++++++---- .../datafeed_modal/get_interval_options.ts | 118 ---------- .../components/job_details/job_details.js | 76 +++++-- .../job_details/job_details_pane.js | 13 +- .../services/ml_api_service/results.ts | 7 +- .../models/results_service/results_service.ts | 66 +++++- 9 files changed, 308 insertions(+), 201 deletions(-) delete mode 100644 x-pack/plugins/ml/public/application/jobs/jobs_list/components/datafeed_modal/get_interval_options.ts diff --git a/x-pack/plugins/ml/common/types/results.ts b/x-pack/plugins/ml/common/types/results.ts index fa40cefcaed48d..74d32864385889 100644 --- a/x-pack/plugins/ml/common/types/results.ts +++ b/x-pack/plugins/ml/common/types/results.ts @@ -6,6 +6,7 @@ */ import { estypes } from '@elastic/elasticsearch'; +import { LineAnnotationDatum, RectAnnotationDatum } from '@elastic/charts'; export interface GetStoppedPartitionResult { jobs: string[] | Record; @@ -13,6 +14,9 @@ export interface GetStoppedPartitionResult { export interface GetDatafeedResultsChartDataResult { bucketResults: number[][]; datafeedResults: number[][]; + annotationResultsRect: RectAnnotationDatum[]; + annotationResultsLine: LineAnnotationDatum[]; + modelSnapshotResultsLine: LineAnnotationDatum[]; } export interface DatafeedResultsChartDataParams { diff --git a/x-pack/plugins/ml/public/application/components/annotations/annotations_table/annotations_table.js b/x-pack/plugins/ml/public/application/components/annotations/annotations_table/annotations_table.js index afed7e79ff757f..b68e64a5d9f6ae 100644 --- a/x-pack/plugins/ml/public/application/components/annotations/annotations_table/annotations_table.js +++ b/x-pack/plugins/ml/public/application/components/annotations/annotations_table/annotations_table.js @@ -494,13 +494,13 @@ class AnnotationsTableUI extends Component { render: (annotation) => { const viewDataFeedText = ( ); const viewDataFeedTooltipAriaLabelText = i18n.translate( - 'xpack.ml.annotationsTable.viewDatafeedTooltipAriaLabel', - { defaultMessage: 'View datafeed' } + 'xpack.ml.annotationsTable.datafeedChartTooltipAriaLabel', + { defaultMessage: 'Datafeed chart' } ); return ( ) : null} diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/datafeed_modal/constants.ts b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/datafeed_modal/constants.ts index 71f3795518bc95..b3b94875231961 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/datafeed_modal/constants.ts +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/datafeed_modal/constants.ts @@ -15,7 +15,7 @@ export const CHART_DIRECTION = { export type ChartDirectionType = typeof CHART_DIRECTION[keyof typeof CHART_DIRECTION]; // [width, height] -export const CHART_SIZE: ChartSizeArray = ['100%', 300]; +export const CHART_SIZE: ChartSizeArray = ['100%', 380]; export const TAB_IDS = { CHART: 'chart', diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/datafeed_modal/datafeed_modal.tsx b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/datafeed_modal/datafeed_modal.tsx index cf547a49cac4c3..2dece82e6f5c73 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/datafeed_modal/datafeed_modal.tsx +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/datafeed_modal/datafeed_modal.tsx @@ -11,25 +11,35 @@ import { i18n } from '@kbn/i18n'; import moment from 'moment'; import { EuiButtonEmpty, + EuiCheckbox, EuiDatePicker, EuiFlexGroup, EuiFlexItem, + EuiIcon, + EuiIconTip, EuiLoadingChart, EuiModal, EuiModalHeader, EuiModalBody, - EuiSelect, EuiSpacer, EuiTabs, EuiTab, + EuiText, + EuiTitle, EuiToolTip, + htmlIdGenerator, } from '@elastic/eui'; import { + AnnotationDomainType, Axis, Chart, CurveType, + LineAnnotation, LineSeries, + LineAnnotationDatum, Position, + RectAnnotation, + RectAnnotationDatum, ScaleType, Settings, timeFormatter, @@ -42,7 +52,6 @@ import { useMlApiContext } from '../../../../contexts/kibana'; import { useCurrentEuiTheme } from '../../../../components/color_range_legend'; import { JobMessagesPane } from '../job_details/job_messages_pane'; import { EditQueryDelay } from './edit_query_delay'; -import { getIntervalOptions } from './get_interval_options'; import { CHART_DIRECTION, ChartDirectionType, @@ -53,12 +62,18 @@ import { } from './constants'; import { loadFullJob } from '../utils'; -const dateFormatter = timeFormatter('MM-DD HH:mm'); +const dateFormatter = timeFormatter('MM-DD HH:mm:ss'); +const MAX_CHART_POINTS = 480; interface DatafeedModalProps { jobId: string; end: number; - onClose: (deletionApproved?: boolean) => void; + onClose: () => void; +} + +function setLineAnnotationHeader(lineDatum: LineAnnotationDatum) { + lineDatum.header = dateFormatter(lineDatum.dataValue); + return lineDatum; } export const DatafeedModal: FC = ({ jobId, end, onClose }) => { @@ -68,11 +83,17 @@ export const DatafeedModal: FC = ({ jobId, end, onClose }) = isInitialized: boolean; }>({ datafeedConfig: undefined, bucketSpan: undefined, isInitialized: false }); const [endDate, setEndDate] = useState(moment(end)); - const [interval, setInterval] = useState(); const [selectedTabId, setSelectedTabId] = useState(TAB_IDS.CHART); const [isLoadingChartData, setIsLoadingChartData] = useState(false); const [bucketData, setBucketData] = useState([]); + const [annotationData, setAnnotationData] = useState<{ + rect: RectAnnotationDatum[]; + line: LineAnnotationDatum[]; + }>({ rect: [], line: [] }); + const [modelSnapshotData, setModelSnapshotData] = useState([]); const [sourceData, setSourceData] = useState([]); + const [showAnnotations, setShowAnnotations] = useState(true); + const [showModelSnapshots, setShowModelSnapshots] = useState(true); const { results: { getDatafeedResultChartData }, @@ -102,25 +123,30 @@ export const DatafeedModal: FC = ({ jobId, end, onClose }) = const handleChange = (date: moment.Moment) => setEndDate(date); const handleEndDateChange = (direction: ChartDirectionType) => { - if (interval === undefined) return; + if (data.bucketSpan === undefined) return; const newEndDate = endDate.clone(); - const [count, type] = interval.split(' '); + const unitMatch = data.bucketSpan.match(/[d | h| m | s]/g)!; + const unit = unitMatch[0]; + const count = Number(data.bucketSpan.replace(/[^0-9]/g, '')); if (direction === CHART_DIRECTION.FORWARD) { - newEndDate.add(Number(count), type); + newEndDate.add(MAX_CHART_POINTS * count, unit); } else { - newEndDate.subtract(Number(count), type); + newEndDate.subtract(MAX_CHART_POINTS * count, unit); } setEndDate(newEndDate); }; const getChartData = useCallback(async () => { - if (interval === undefined) return; + if (data.bucketSpan === undefined) return; const endTimestamp = moment(endDate).valueOf(); - const [count, type] = interval.split(' '); - const startMoment = endDate.clone().subtract(Number(count), type); + const unitMatch = data.bucketSpan.match(/[d | h| m | s]/g)!; + const unit = unitMatch[0]; + const count = Number(data.bucketSpan.replace(/[^0-9]/g, '')); + // STARTTIME = ENDTIME - (BucketSpan * MAX_CHART_POINTS) + const startMoment = endDate.clone().subtract(MAX_CHART_POINTS * count, unit); const startTimestamp = moment(startMoment).valueOf(); try { @@ -128,6 +154,11 @@ export const DatafeedModal: FC = ({ jobId, end, onClose }) = setSourceData(chartData.datafeedResults); setBucketData(chartData.bucketResults); + setAnnotationData({ + rect: chartData.annotationResultsRect, + line: chartData.annotationResultsLine.map(setLineAnnotationHeader), + }); + setModelSnapshotData(chartData.modelSnapshotResultsLine.map(setLineAnnotationHeader)); } catch (error) { const title = i18n.translate('xpack.ml.jobsList.datafeedModal.errorToastTitle', { defaultMessage: 'Error fetching data', @@ -135,7 +166,7 @@ export const DatafeedModal: FC = ({ jobId, end, onClose }) = displayErrorToast(error, title); } setIsLoadingChartData(false); - }, [endDate, interval]); + }, [endDate, data.bucketSpan]); const getJobData = async () => { try { @@ -145,11 +176,6 @@ export const DatafeedModal: FC = ({ jobId, end, onClose }) = bucketSpan: job.analysis_config.bucket_span, isInitialized: true, }); - const intervalOptions = getIntervalOptions(job.analysis_config.bucket_span); - const initialInterval = intervalOptions.length - ? intervalOptions[intervalOptions.length - 1] - : undefined; - setInterval(initialInterval?.value || '72 hours'); } catch (error) { displayErrorToast(error); } @@ -161,20 +187,17 @@ export const DatafeedModal: FC = ({ jobId, end, onClose }) = useEffect( function loadChartData() { - if (interval !== undefined) { + if (data.bucketSpan !== undefined) { setIsLoadingChartData(true); getChartData(); } }, - [endDate, interval] + [endDate, data.bucketSpan] ); const { datafeedConfig, bucketSpan, isInitialized } = data; - - const intervalOptions = useMemo(() => { - if (bucketSpan === undefined) return []; - return getIntervalOptions(bucketSpan); - }, [bucketSpan]); + const checkboxIdAnnotation = useMemo(() => htmlIdGenerator()(), []); + const checkboxIdModelSnapshot = useMemo(() => htmlIdGenerator()(), []); return ( = ({ jobId, end, onClose }) = - + + + + } + /> + + + +

    + +

    +
    +
    +
    = ({ jobId, end, onClose }) = - - setInterval(e.target.value)} - aria-label={i18n.translate( - 'xpack.ml.jobsList.datafeedModal.intervalSelection', - { - defaultMessage: 'Datafeed modal chart interval selection', - } - )} - /> - = ({ jobId, end, onClose }) = isEnabled={datafeedConfig.state === DATAFEED_STATE.STOPPED} /> + + + + + + + } + checked={showAnnotations} + onChange={() => setShowAnnotations(!showAnnotations)} + /> + + + + + + } + checked={showModelSnapshots} + onChange={() => setShowModelSnapshots(!showModelSnapshots)} + /> + + + @@ -298,7 +362,65 @@ export const DatafeedModal: FC = ({ jobId, end, onClose }) = })} position={Position.Left} /> + {showModelSnapshots ? ( + } + markerPosition={Position.Top} + style={{ + line: { + strokeWidth: 3, + stroke: euiTheme.euiColorVis1, + opacity: 0.5, + }, + }} + /> + ) : null} + {showAnnotations ? ( + <> + } + markerPosition={Position.Top} + style={{ + line: { + strokeWidth: 3, + stroke: euiTheme.euiColorDangerText, + opacity: 0.5, + }, + }} + /> + + + ) : null} = ({ jobId, end, onClose }) = curve={CurveType.LINEAR} /> { - const unitMatch = bucketSpan.match(/[d | h| m | s]/g)!; - const unit = unitMatch[0]; - const count = Number(bucketSpan.replace(/[^0-9]/g, '')); - - const intervalOptions = []; - - if (['s', 'ms', 'micros', 'nanos'].includes(unit)) { - intervalOptions.push( - { - value: '1 hour', - text: i18n.translate('xpack.ml.jobsList.datafeedModal.1hourOption', { - defaultMessage: '{count} hour', - values: { count: 1 }, - }), - }, - { - value: '2 hours', - text: i18n.translate('xpack.ml.jobsList.datafeedModal.2hourOption', { - defaultMessage: '{count} hours', - values: { count: 2 }, - }), - } - ); - } - - if ((unit === 'm' && count <= 4) || unit === 'h') { - intervalOptions.push( - { - value: '3 hours', - text: i18n.translate('xpack.ml.jobsList.datafeedModal.3hourOption', { - defaultMessage: '{count} hours', - values: { count: 3 }, - }), - }, - { - value: '8 hours', - text: i18n.translate('xpack.ml.jobsList.datafeedModal.8hourOption', { - defaultMessage: '{count} hours', - values: { count: 8 }, - }), - }, - { - value: '12 hours', - text: i18n.translate('xpack.ml.jobsList.datafeedModal.12hourOption', { - defaultMessage: '{count} hours', - values: { count: 12 }, - }), - }, - { - value: '24 hours', - text: i18n.translate('xpack.ml.jobsList.datafeedModal.24hourOption', { - defaultMessage: '{count} hours', - values: { count: 24 }, - }), - } - ); - } - - if ((unit === 'm' && count >= 5 && count <= 15) || unit === 'h') { - intervalOptions.push( - { - value: '48 hours', - text: i18n.translate('xpack.ml.jobsList.datafeedModal.48hourOption', { - defaultMessage: '{count} hours', - values: { count: 48 }, - }), - }, - { - value: '72 hours', - text: i18n.translate('xpack.ml.jobsList.datafeedModal.72hourOption', { - defaultMessage: '{count} hours', - values: { count: 72 }, - }), - } - ); - } - - if ((unit === 'm' && count >= 10 && count <= 15) || unit === 'h' || unit === 'd') { - intervalOptions.push( - { - value: '5 days', - text: i18n.translate('xpack.ml.jobsList.datafeedModal.5daysOption', { - defaultMessage: '{count} days', - values: { count: 5 }, - }), - }, - { - value: '7 days', - text: i18n.translate('xpack.ml.jobsList.datafeedModal.7daysOption', { - defaultMessage: '{count} days', - values: { count: 7 }, - }), - } - ); - } - - if (unit === 'h' || unit === 'd') { - intervalOptions.push({ - value: '14 days', - text: i18n.translate('xpack.ml.jobsList.datafeedModal.14DaysOption', { - defaultMessage: '{count} days', - values: { count: 14 }, - }), - }); - } - - return intervalOptions; -}; diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/job_details.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/job_details.js index b514c8433daf48..d3856e6afa3982 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/job_details.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/job_details.js @@ -7,26 +7,29 @@ import PropTypes from 'prop-types'; import React, { Component, Fragment } from 'react'; - -import { EuiTabbedContent, EuiLoadingSpinner } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { EuiButtonIcon, EuiTabbedContent, EuiLoadingSpinner, EuiToolTip } from '@elastic/eui'; import { extractJobDetails } from './extract_job_details'; import { JsonPane } from './json_tab'; import { DatafeedPreviewPane } from './datafeed_preview_tab'; import { AnnotationsTable } from '../../../../components/annotations/annotations_table'; +import { DatafeedModal } from '../datafeed_modal'; import { AnnotationFlyout } from '../../../../components/annotations/annotation_flyout'; import { ModelSnapshotTable } from '../../../../components/model_snapshots'; import { ForecastsTable } from './forecasts_table'; import { JobDetailsPane } from './job_details_pane'; import { JobMessagesPane } from './job_messages_pane'; -import { i18n } from '@kbn/i18n'; import { withKibana } from '../../../../../../../../../src/plugins/kibana_react/public'; export class JobDetailsUI extends Component { constructor(props) { super(props); - this.state = {}; + this.state = { + datafeedModalVisible: false, + }; if (this.props.addYourself) { this.props.addYourself(props.jobId, (j) => this.updateJob(j)); } @@ -77,6 +80,30 @@ export class JobDetailsUI extends Component { alertRules, } = extractJobDetails(job, basePath, refreshJobList); + datafeed.titleAction = ( + + } + > + + this.setState({ + datafeedModalVisible: true, + }) + } + /> + + ); + const tabs = [ { id: 'job-settings', @@ -105,6 +132,32 @@ export class JobDetailsUI extends Component { /> ), }, + { + id: 'datafeed', + 'data-test-subj': 'mlJobListTab-datafeed', + name: i18n.translate('xpack.ml.jobsList.jobDetails.tabs.datafeedLabel', { + defaultMessage: 'Datafeed', + }), + content: ( + <> + + {this.props.jobId && this.state.datafeedModalVisible ? ( + { + this.setState({ + datafeedModalVisible: false, + }); + }} + end={job.data_counts.latest_bucket_timestamp} + jobId={this.props.jobId} + /> + ) : null} + + ), + }, { id: 'counts', 'data-test-subj': 'mlJobListTab-counts', @@ -137,21 +190,6 @@ export class JobDetailsUI extends Component { ]; if (showFullDetails && datafeed.items.length) { - // Datafeed should be at index 2 in tabs array for full details - tabs.splice(2, 0, { - id: 'datafeed', - 'data-test-subj': 'mlJobListTab-datafeed', - name: i18n.translate('xpack.ml.jobsList.jobDetails.tabs.datafeedLabel', { - defaultMessage: 'Datafeed', - }), - content: ( - - ), - }); - tabs.push( { id: 'datafeed-preview', diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/job_details_pane.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/job_details_pane.js index 49d9bcde490520..4046f4d5d80712 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/job_details_pane.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/job_details_pane.js @@ -9,6 +9,8 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import { + EuiFlexGroup, + EuiFlexItem, EuiTitle, EuiTable, EuiTableBody, @@ -42,9 +44,14 @@ function Section({ section }) { return ( - -

    {section.title}

    -
    + + + +

    {section.title}

    +
    +
    + {section.titleAction} +
    diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/results.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/results.ts index 19ba5aa304bf04..25ef36782207f1 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/results.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/results.ts @@ -6,7 +6,10 @@ */ // Service for obtaining data for the ML Results dashboards. -import { GetStoppedPartitionResult } from '../../../../common/types/results'; +import { + GetStoppedPartitionResult, + GetDatafeedResultsChartDataResult, +} from '../../../../common/types/results'; import { HttpService } from '../http_service'; import { basePath } from './index'; import { JobId } from '../../../../common/types/anomaly_detection_jobs'; @@ -148,7 +151,7 @@ export const resultsApiProvider = (httpService: HttpService) => ({ start, end, }); - return httpService.http({ + return httpService.http({ path: `${basePath()}/results/datafeed_results_chart`, method: 'POST', body, diff --git a/x-pack/plugins/ml/server/models/results_service/results_service.ts b/x-pack/plugins/ml/server/models/results_service/results_service.ts index 9413ee00184d20..81ee394b997044 100644 --- a/x-pack/plugins/ml/server/models/results_service/results_service.ts +++ b/x-pack/plugins/ml/server/models/results_service/results_service.ts @@ -27,6 +27,7 @@ import { import { MlJobsResponse } from '../../../common/types/job_service'; import type { MlClient } from '../../lib/ml_client'; import { datafeedsProvider } from '../job_service/datafeeds'; +import { annotationServiceProvider } from '../annotation_service'; // Service for carrying out Elasticsearch queries to obtain data for the // ML Results dashboards. @@ -620,13 +621,19 @@ export function resultsServiceProvider(mlClient: MlClient, client?: IScopedClust const finalResults: GetDatafeedResultsChartDataResult = { bucketResults: [], datafeedResults: [], + annotationResultsRect: [], + annotationResultsLine: [], + modelSnapshotResultsLine: [], }; const { getDatafeedByJobId } = datafeedsProvider(client!, mlClient); - const datafeedConfig = await getDatafeedByJobId(jobId); - const { body: jobsResponse } = await mlClient.getJobs({ job_id: jobId }); - if (jobsResponse.count === 0 || jobsResponse.jobs === undefined) { + const [datafeedConfig, { body: jobsResponse }] = await Promise.all([ + getDatafeedByJobId(jobId), + mlClient.getJobs({ job_id: jobId }), + ]); + + if (jobsResponse && (jobsResponse.count === 0 || jobsResponse.jobs === undefined)) { throw Boom.notFound(`Job with the id "${jobId}" not found`); } @@ -696,10 +703,25 @@ export function resultsServiceProvider(mlClient: MlClient, client?: IScopedClust ]) || []; } - const bucketResp = await mlClient.getBuckets({ - job_id: jobId, - body: { desc: true, start: String(start), end: String(end), page: { from: 0, size: 1000 } }, - }); + const { getAnnotations } = annotationServiceProvider(client!); + + const [bucketResp, annotationResp, { body: modelSnapshotsResp }] = await Promise.all([ + mlClient.getBuckets({ + job_id: jobId, + body: { desc: true, start: String(start), end: String(end), page: { from: 0, size: 1000 } }, + }), + getAnnotations({ + jobIds: [jobId], + earliestMs: start, + latestMs: end, + maxAnnotations: 1000, + }), + mlClient.getModelSnapshots({ + job_id: jobId, + start: String(start), + end: String(end), + }), + ]); const bucketResults = bucketResp?.body?.buckets ?? []; bucketResults.forEach((dataForTime) => { @@ -708,6 +730,36 @@ export function resultsServiceProvider(mlClient: MlClient, client?: IScopedClust finalResults.bucketResults.push([timestamp, eventCount]); }); + const annotationResults = annotationResp.annotations[jobId] || []; + annotationResults.forEach((annotation) => { + const timestamp = Number(annotation?.timestamp); + const endTimestamp = Number(annotation?.end_timestamp); + if (timestamp === endTimestamp) { + finalResults.annotationResultsLine.push({ + dataValue: timestamp, + details: annotation.annotation, + }); + } else { + finalResults.annotationResultsRect.push({ + coordinates: { + x0: timestamp, + x1: endTimestamp, + }, + details: annotation.annotation, + }); + } + }); + + const modelSnapshots = modelSnapshotsResp?.model_snapshots ?? []; + modelSnapshots.forEach((modelSnapshot) => { + const timestamp = Number(modelSnapshot?.timestamp); + + finalResults.modelSnapshotResultsLine.push({ + dataValue: timestamp, + details: modelSnapshot.description, + }); + }); + return finalResults; } From e580d5a1e2936bb9357d8ec5f12f7c50653937d8 Mon Sep 17 00:00:00 2001 From: Constance Date: Tue, 22 Jun 2021 14:43:54 -0700 Subject: [PATCH 15/17] [App Search] Convert Result Settings & Relevance Tuning pages to new page template (#102845) * Convert Result Settings page to new page template + remove wrapper around empty state (auto handled by new page template) + update tests w/ new test helpers * Convert Relevance Tuning page to new page template - Remove old relevance_tuning_layout (which handled breadcrumbs, page header, flash messages, and callouts) in favor of simply using the new templtate + callouts (yay DRYing) - Remove panel wrapper around empty state (handled by new page template) * Update router * [Polish] Spacing & icon polish from Davey see https://github.com/elastic/kibana/pull/101958/files --- .../components/engine/engine_router.tsx | 20 ++-- .../components/empty_state.tsx | 62 ++++++----- .../relevance_tuning.test.tsx | 57 +++++----- .../relevance_tuning/relevance_tuning.tsx | 74 +++++++++---- .../relevance_tuning_form.tsx | 2 +- .../relevance_tuning_layout.test.tsx | 64 ----------- .../relevance_tuning_layout.tsx | 73 ------------- .../relevance_tuning_preview.tsx | 1 + .../components/empty_state.tsx | 62 ++++++----- .../result_settings/result_settings.test.tsx | 56 ++++------ .../result_settings/result_settings.tsx | 101 +++++++++--------- 11 files changed, 224 insertions(+), 348 deletions(-) delete mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_layout.test.tsx delete mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_layout.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx index 91a21847107a95..04e252e44270bf 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx @@ -114,6 +114,16 @@ export const EngineRouter: React.FC = () => { )} + {canManageEngineRelevanceTuning && ( + + + + )} + {canManageEngineResultSettings && ( + + + + )} {canManageEngineSearchUi && ( @@ -131,21 +141,11 @@ export const EngineRouter: React.FC = () => { )} - {canManageEngineRelevanceTuning && ( - - - - )} {canManageEngineSynonyms && ( )} - {canManageEngineResultSettings && ( - - - - )} {canViewMetaEngineSourceEngines && ( diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/components/empty_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/components/empty_state.tsx index e6a14d7b5cd725..df29010bd682ff 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/components/empty_state.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/components/empty_state.tsx @@ -7,42 +7,40 @@ import React from 'react'; -import { EuiButton, EuiEmptyPrompt, EuiPanel } from '@elastic/eui'; +import { EuiButton, EuiEmptyPrompt } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { DOCS_PREFIX } from '../../../routes'; export const EmptyState: React.FC = () => ( - - - {i18n.translate('xpack.enterpriseSearch.appSearch.engine.relevanceTuning.empty.title', { - defaultMessage: 'Add documents to tune relevance', - })} - + + {i18n.translate('xpack.enterpriseSearch.appSearch.engine.relevanceTuning.empty.title', { + defaultMessage: 'Add documents to tune relevance', + })} + + } + body={i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.relevanceTuning.empty.description', + { + defaultMessage: + 'A schema will be automatically created for you after you index some documents.', } - body={i18n.translate( - 'xpack.enterpriseSearch.appSearch.engine.relevanceTuning.empty.description', - { - defaultMessage: - 'A schema will be automatically created for you after you index some documents.', - } - )} - actions={ - - {i18n.translate( - 'xpack.enterpriseSearch.appSearch.engine.relevanceTuning.empty.buttonLabel', - { defaultMessage: 'Read the relevance tuning guide' } - )} - - } - /> - + )} + actions={ + + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.relevanceTuning.empty.buttonLabel', + { defaultMessage: 'Read the relevance tuning guide' } + )} + + } + /> ); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning.test.tsx index 092740ac5d3cc6..48b536a954ed59 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning.test.tsx @@ -13,14 +13,14 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { EuiEmptyPrompt } from '@elastic/eui'; - -import { Loading } from '../../../shared/loading'; import { UnsavedChangesPrompt } from '../../../shared/unsaved_changes_prompt'; +import { getPageHeaderActions } from '../../../test_helpers'; -import { EmptyState } from './components'; import { RelevanceTuning } from './relevance_tuning'; + +import { RelevanceTuningCallouts } from './relevance_tuning_callouts'; import { RelevanceTuningForm } from './relevance_tuning_form'; +import { RelevanceTuningPreview } from './relevance_tuning_preview'; describe('RelevanceTuning', () => { const values = { @@ -50,9 +50,9 @@ describe('RelevanceTuning', () => { it('renders', () => { const wrapper = subject(); + expect(wrapper.find(RelevanceTuningCallouts).exists()).toBe(true); expect(wrapper.find(RelevanceTuningForm).exists()).toBe(true); - expect(wrapper.find(Loading).exists()).toBe(false); - expect(wrapper.find(EmptyState).exists()).toBe(false); + expect(wrapper.find(RelevanceTuningPreview).exists()).toBe(true); }); it('initializes relevance tuning data', () => { @@ -60,33 +60,38 @@ describe('RelevanceTuning', () => { expect(actions.initializeRelevanceTuning).toHaveBeenCalled(); }); - it('will render an empty message when the engine has no schema', () => { + it('will prevent user from leaving the page if there are unsaved changes', () => { setMockValues({ ...values, - engineHasSchemaFields: false, + unsavedChanges: true, }); - const wrapper = subject(); - expect(wrapper.find(EmptyState).dive().find(EuiEmptyPrompt).exists()).toBe(true); - expect(wrapper.find(Loading).exists()).toBe(false); - expect(wrapper.find(RelevanceTuningForm).exists()).toBe(false); + expect(subject().find(UnsavedChangesPrompt).prop('hasUnsavedChanges')).toBe(true); }); - it('will show a loading message if data is loading', () => { - setMockValues({ - ...values, - dataLoading: true, + describe('header actions', () => { + it('renders a Save button that will save the current changes', () => { + const buttons = getPageHeaderActions(subject()); + expect(buttons.children().length).toBe(2); + const saveButton = buttons.find('[data-test-subj="SaveRelevanceTuning"]'); + saveButton.simulate('click'); + expect(actions.updateSearchSettings).toHaveBeenCalled(); }); - const wrapper = subject(); - expect(wrapper.find(Loading).exists()).toBe(true); - expect(wrapper.find(EmptyState).exists()).toBe(false); - expect(wrapper.find(RelevanceTuningForm).exists()).toBe(false); - }); - it('will prevent user from leaving the page if there are unsaved changes', () => { - setMockValues({ - ...values, - unsavedChanges: true, + it('renders a Reset button that will remove all weights and boosts', () => { + const buttons = getPageHeaderActions(subject()); + expect(buttons.children().length).toBe(2); + const resetButton = buttons.find('[data-test-subj="ResetRelevanceTuning"]'); + resetButton.simulate('click'); + expect(actions.resetSearchSettings).toHaveBeenCalled(); + }); + + it('will not render buttons if the engine has no schema', () => { + setMockValues({ + ...values, + engineHasSchemaFields: false, + }); + const buttons = getPageHeaderActions(subject()); + expect(buttons.children().length).toBe(0); }); - expect(subject().find(UnsavedChangesPrompt).prop('hasUnsavedChanges')).toBe(true); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning.tsx index b98541a9638901..2e87d6836199bd 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning.tsx @@ -9,43 +9,77 @@ import React, { useEffect } from 'react'; import { useActions, useValues } from 'kea'; -import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiButton } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; -import { Loading } from '../../../shared/loading'; +import { SAVE_BUTTON_LABEL } from '../../../shared/constants'; import { UnsavedChangesPrompt } from '../../../shared/unsaved_changes_prompt'; +import { RESTORE_DEFAULTS_BUTTON_LABEL } from '../../constants'; +import { getEngineBreadcrumbs } from '../engine'; +import { AppSearchPageTemplate } from '../layout'; import { EmptyState } from './components'; +import { RELEVANCE_TUNING_TITLE } from './constants'; +import { RelevanceTuningCallouts } from './relevance_tuning_callouts'; import { RelevanceTuningForm } from './relevance_tuning_form'; -import { RelevanceTuningLayout } from './relevance_tuning_layout'; import { RelevanceTuningPreview } from './relevance_tuning_preview'; import { RelevanceTuningLogic } from '.'; export const RelevanceTuning: React.FC = () => { const { dataLoading, engineHasSchemaFields, unsavedChanges } = useValues(RelevanceTuningLogic); - const { initializeRelevanceTuning } = useActions(RelevanceTuningLogic); + const { initializeRelevanceTuning, resetSearchSettings, updateSearchSettings } = useActions( + RelevanceTuningLogic + ); useEffect(() => { initializeRelevanceTuning(); }, []); - if (dataLoading) return ; - return ( - + + {SAVE_BUTTON_LABEL} + , + + {RESTORE_DEFAULTS_BUTTON_LABEL} + , + ] + : [], + }} + isLoading={dataLoading} + isEmptyState={!engineHasSchemaFields} + emptyState={} + > - {engineHasSchemaFields ? ( - - - - - - - - - ) : ( - - )} - + + + + + + + + + + + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_form/relevance_tuning_form.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_form/relevance_tuning_form.tsx index 5cbd291f85debf..c35cd280c7a058 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_form/relevance_tuning_form.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_form/relevance_tuning_form.tsx @@ -42,7 +42,7 @@ export const RelevanceTuningForm: React.FC = () => { return (
    - +

    {i18n.translate( diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_layout.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_layout.test.tsx deleted file mode 100644 index 20b1a16879234e..00000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_layout.test.tsx +++ /dev/null @@ -1,64 +0,0 @@ -/* - * 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 { setMockActions, setMockValues } from '../../../__mocks__/kea_logic'; -import '../../__mocks__/engine_logic.mock'; - -import React from 'react'; - -import { shallow, ShallowWrapper } from 'enzyme'; - -import { EuiPageHeader } from '@elastic/eui'; - -import { RelevanceTuningLayout } from './relevance_tuning_layout'; - -describe('RelevanceTuningLayout', () => { - const values = { - engineHasSchemaFields: true, - schemaFieldsWithConflicts: [], - }; - - const actions = { - updateSearchSettings: jest.fn(), - resetSearchSettings: jest.fn(), - }; - - beforeEach(() => { - jest.clearAllMocks(); - setMockValues(values); - setMockActions(actions); - }); - - const subject = () => shallow(); - const findButtons = (wrapper: ShallowWrapper) => - wrapper.find(EuiPageHeader).prop('rightSideItems') as React.ReactElement[]; - - it('renders a Save button that will save the current changes', () => { - const buttons = findButtons(subject()); - expect(buttons.length).toBe(2); - const saveButton = shallow(buttons[0]); - saveButton.simulate('click'); - expect(actions.updateSearchSettings).toHaveBeenCalled(); - }); - - it('renders a Reset button that will remove all weights and boosts', () => { - const buttons = findButtons(subject()); - expect(buttons.length).toBe(2); - const resetButton = shallow(buttons[1]); - resetButton.simulate('click'); - expect(actions.resetSearchSettings).toHaveBeenCalled(); - }); - - it('will not render buttons if the engine has no schema', () => { - setMockValues({ - ...values, - engineHasSchemaFields: false, - }); - const buttons = findButtons(subject()); - expect(buttons.length).toBe(0); - }); -}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_layout.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_layout.tsx deleted file mode 100644 index 4fa694300a7795..00000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_layout.tsx +++ /dev/null @@ -1,73 +0,0 @@ -/* - * 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 { useActions, useValues } from 'kea'; - -import { EuiPageHeader, EuiButton } from '@elastic/eui'; - -import { i18n } from '@kbn/i18n'; - -import { SAVE_BUTTON_LABEL } from '../../../shared/constants'; -import { FlashMessages } from '../../../shared/flash_messages'; -import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; -import { RESTORE_DEFAULTS_BUTTON_LABEL } from '../../constants'; -import { getEngineBreadcrumbs } from '../engine'; - -import { RELEVANCE_TUNING_TITLE } from './constants'; -import { RelevanceTuningCallouts } from './relevance_tuning_callouts'; -import { RelevanceTuningLogic } from './relevance_tuning_logic'; - -export const RelevanceTuningLayout: React.FC = ({ children }) => { - const { resetSearchSettings, updateSearchSettings } = useActions(RelevanceTuningLogic); - const { engineHasSchemaFields } = useValues(RelevanceTuningLogic); - - const pageHeader = () => ( - - {SAVE_BUTTON_LABEL} - , - - {RESTORE_DEFAULTS_BUTTON_LABEL} - , - ] - : [] - } - /> - ); - - return ( - <> - - {pageHeader()} - - - {children} - - ); -}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_preview.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_preview.tsx index 911e97de5b53f5..4f3b20b419e802 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_preview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_preview.tsx @@ -21,6 +21,7 @@ import { RelevanceTuningLogic } from '.'; const emptyCallout = ( ( - - - {i18n.translate('xpack.enterpriseSearch.appSearch.engine.resultSettings.empty.title', { - defaultMessage: 'Add documents to adjust settings', - })} -

    + + {i18n.translate('xpack.enterpriseSearch.appSearch.engine.resultSettings.empty.title', { + defaultMessage: 'Add documents to adjust settings', + })} + + } + body={i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.resultSettings.empty.description', + { + defaultMessage: + 'A schema will be automatically created for you after you index some documents.', } - body={i18n.translate( - 'xpack.enterpriseSearch.appSearch.engine.resultSettings.empty.description', - { - defaultMessage: - 'A schema will be automatically created for you after you index some documents.', - } - )} - actions={ - - {i18n.translate( - 'xpack.enterpriseSearch.appSearch.engine.resultSettings.empty.buttonLabel', - { defaultMessage: 'Read the result settings guide' } - )} - - } - /> - + )} + actions={ + + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.resultSettings.empty.buttonLabel', + { defaultMessage: 'Read the result settings guide' } + )} + + } + /> ); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings.test.tsx index ec521b4959535c..440acaf136ddac 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings.test.tsx @@ -13,11 +13,9 @@ import React from 'react'; import { shallow, ShallowWrapper } from 'enzyme'; -import { EuiPageHeader } from '@elastic/eui'; - import { UnsavedChangesPrompt } from '../../../shared/unsaved_changes_prompt'; +import { getPageHeaderActions } from '../../../test_helpers'; -import { EmptyState } from './components'; import { ResultSettings } from './result_settings'; import { ResultSettingsTable } from './result_settings_table'; import { SampleResponse } from './sample_response'; @@ -46,8 +44,6 @@ describe('ResultSettings', () => { }); const subject = () => shallow(); - const findButtons = (wrapper: ShallowWrapper) => - wrapper.find(EuiPageHeader).prop('rightSideItems') as React.ReactElement[]; it('renders', () => { const wrapper = subject(); @@ -60,19 +56,10 @@ describe('ResultSettings', () => { expect(actions.initializeResultSettingsData).toHaveBeenCalled(); }); - it('renders a loading screen if data has not loaded yet', () => { - setMockValues({ - dataLoading: true, - }); - const wrapper = subject(); - expect(wrapper.find(ResultSettingsTable).exists()).toBe(false); - expect(wrapper.find(SampleResponse).exists()).toBe(false); - }); - it('renders a "save" button that will save the current changes', () => { - const buttons = findButtons(subject()); - expect(buttons.length).toBe(3); - const saveButton = shallow(buttons[0]); + const buttons = getPageHeaderActions(subject()); + expect(buttons.children().length).toBe(3); + const saveButton = buttons.find('[data-test-subj="SaveResultSettings"]'); saveButton.simulate('click'); expect(actions.saveResultSettings).toHaveBeenCalled(); }); @@ -82,8 +69,8 @@ describe('ResultSettings', () => { ...values, stagedUpdates: false, }); - const buttons = findButtons(subject()); - const saveButton = shallow(buttons[0]); + const buttons = getPageHeaderActions(subject()); + const saveButton = buttons.find('[data-test-subj="SaveResultSettings"]'); expect(saveButton.prop('disabled')).toBe(true); }); @@ -93,15 +80,15 @@ describe('ResultSettings', () => { stagedUpdates: true, resultFieldsEmpty: true, }); - const buttons = findButtons(subject()); - const saveButton = shallow(buttons[0]); + const buttons = getPageHeaderActions(subject()); + const saveButton = buttons.find('[data-test-subj="SaveResultSettings"]'); expect(saveButton.prop('disabled')).toBe(true); }); it('renders a "restore defaults" button that will reset all values to their defaults', () => { - const buttons = findButtons(subject()); - expect(buttons.length).toBe(3); - const resetButton = shallow(buttons[1]); + const buttons = getPageHeaderActions(subject()); + expect(buttons.children().length).toBe(3); + const resetButton = buttons.find('[data-test-subj="ResetResultSettings"]'); resetButton.simulate('click'); expect(actions.confirmResetAllFields).toHaveBeenCalled(); }); @@ -111,15 +98,15 @@ describe('ResultSettings', () => { ...values, resultFieldsAtDefaultSettings: true, }); - const buttons = findButtons(subject()); - const resetButton = shallow(buttons[1]); + const buttons = getPageHeaderActions(subject()); + const resetButton = buttons.find('[data-test-subj="ResetResultSettings"]'); expect(resetButton.prop('disabled')).toBe(true); }); it('renders a "clear" button that will remove all selected options', () => { - const buttons = findButtons(subject()); - expect(buttons.length).toBe(3); - const clearButton = shallow(buttons[2]); + const buttons = getPageHeaderActions(subject()); + expect(buttons.children().length).toBe(3); + const clearButton = buttons.find('[data-test-subj="ClearResultSettings"]'); clearButton.simulate('click'); expect(actions.clearAllFields).toHaveBeenCalled(); }); @@ -143,17 +130,12 @@ describe('ResultSettings', () => { }); it('will not render action buttons', () => { - const buttons = findButtons(wrapper); - expect(buttons.length).toBe(0); - }); - - it('will not render the main page content', () => { - expect(wrapper.find(ResultSettingsTable).exists()).toBe(false); - expect(wrapper.find(SampleResponse).exists()).toBe(false); + const buttons = getPageHeaderActions(wrapper); + expect(buttons.children().length).toBe(0); }); it('will render an empty state', () => { - expect(wrapper.find(EmptyState).exists()).toBe(true); + expect(wrapper.prop('isEmptyState')).toBe(true); }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings.tsx index 45cb9ea1cfcb4c..c315927433a0a9 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings.tsx @@ -9,17 +9,15 @@ import React, { useEffect } from 'react'; import { useActions, useValues } from 'kea'; -import { EuiPageHeader, EuiFlexGroup, EuiFlexItem, EuiButton, EuiButtonEmpty } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiButton, EuiButtonEmpty } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { SAVE_BUTTON_LABEL } from '../../../shared/constants'; -import { FlashMessages } from '../../../shared/flash_messages'; -import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; -import { Loading } from '../../../shared/loading'; import { UnsavedChangesPrompt } from '../../../shared/unsaved_changes_prompt'; import { RESTORE_DEFAULTS_BUTTON_LABEL } from '../../constants'; import { getEngineBreadcrumbs } from '../engine'; +import { AppSearchPageTemplate } from '../layout'; import { EmptyState } from './components'; import { RESULT_SETTINGS_TITLE } from './constants'; @@ -57,59 +55,56 @@ export const ResultSettings: React.FC = () => { initializeResultSettingsData(); }, []); - if (dataLoading) return ; const hasSchema = Object.keys(schema).length > 0; return ( - <> - - - - {SAVE_BUTTON_LABEL} - , - - {RESTORE_DEFAULTS_BUTTON_LABEL} - , - - {CLEAR_BUTTON_LABEL} - , - ] - : [] - } - /> - - {hasSchema ? ( - - - - - - - - - ) : ( - - )} - + ), + rightSideItems: hasSchema + ? [ + + {SAVE_BUTTON_LABEL} + , + + {RESTORE_DEFAULTS_BUTTON_LABEL} + , + + {CLEAR_BUTTON_LABEL} + , + ] + : [], + }} + isLoading={dataLoading} + isEmptyState={!hasSchema} + emptyState={} + > + + + + + + + + + + + ); }; From 0548f98708e3681ed2aff6044a7e3052a4cd6ac9 Mon Sep 17 00:00:00 2001 From: Constance Date: Tue, 22 Jun 2021 14:46:47 -0700 Subject: [PATCH 16/17] [App Search][Polish] API Logs empty state (#102998) * Re-add noItemsMessage to ApiLogsTable - Primarily for Engine Overview use - totally forgot about this :facepalm: * Tweak API logs empty state copy - after discussing w/ Davey --- .../components/api_logs/components/api_logs_table.tsx | 3 +++ .../components/api_logs/components/empty_state.test.tsx | 2 +- .../app_search/components/api_logs/components/empty_state.tsx | 4 ++-- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/components/api_logs_table.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/components/api_logs_table.tsx index 1b5a8084f5b594..d5bb525cfd3328 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/components/api_logs_table.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/components/api_logs_table.tsx @@ -26,6 +26,8 @@ import { ApiLogsLogic } from '../index'; import { ApiLog } from '../types'; import { getStatusColor } from '../utils'; +import { EmptyState } from './'; + import './api_logs_table.scss'; interface Props { @@ -108,6 +110,7 @@ export const ApiLogsTable: React.FC = ({ hasPagination }) => { items={apiLogs} responsive loading={dataLoading} + noItemsMessage={} {...paginationProps} /> ); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/components/empty_state.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/components/empty_state.test.tsx index 3ad22ceac5840d..19f45ced5dc5dc 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/components/empty_state.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/components/empty_state.test.tsx @@ -19,7 +19,7 @@ describe('EmptyState', () => { .find(EuiEmptyPrompt) .dive(); - expect(wrapper.find('h2').text()).toEqual('Perform your first API call'); + expect(wrapper.find('h2').text()).toEqual('No API events in the last 24 hours'); expect(wrapper.find(EuiButton).prop('href')).toEqual( expect.stringContaining('/api-reference.html') ); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/components/empty_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/components/empty_state.tsx index 3f6f44adefc71d..76bd0cba1731f8 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/components/empty_state.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/components/empty_state.tsx @@ -18,14 +18,14 @@ export const EmptyState: React.FC = () => ( title={

    {i18n.translate('xpack.enterpriseSearch.appSearch.engine.apiLogs.emptyTitle', { - defaultMessage: 'Perform your first API call', + defaultMessage: 'No API events in the last 24 hours', })}

    } body={

    {i18n.translate('xpack.enterpriseSearch.appSearch.engine.apiLogs.emptyDescription', { - defaultMessage: "Check back after you've performed some API calls.", + defaultMessage: 'Logs will update in real-time when an API request occurs.', })}

    } From 369127e8c2697af0d58ac43c8a90717f35a19da0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Louv-Jansen?= Date: Wed, 23 Jun 2021 00:12:05 +0200 Subject: [PATCH 17/17] [APM] Fix bug when error page is empty (#102940) --- .../Distribution/index.stories.tsx | 81 +++++++++++++++++++ .../ErrorGroupDetails/Distribution/index.tsx | 7 +- 2 files changed, 85 insertions(+), 3 deletions(-) create mode 100644 x-pack/plugins/apm/public/components/app/ErrorGroupDetails/Distribution/index.stories.tsx diff --git a/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/Distribution/index.stories.tsx b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/Distribution/index.stories.tsx new file mode 100644 index 00000000000000..8cc16dd801c25d --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/Distribution/index.stories.tsx @@ -0,0 +1,81 @@ +/* + * 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, { ComponentType } from 'react'; +import { KibanaContextProvider } from '../../../../../../../../src/plugins/kibana_react/public'; +import { + ApmPluginContext, + ApmPluginContextValue, +} from '../../../../context/apm_plugin/apm_plugin_context'; +import { EuiThemeProvider } from '../../../../../../../../src/plugins/kibana_react/common'; +import { ErrorDistribution } from './'; + +export default { + title: 'app/ErrorGroupDetails/Distribution', + component: ErrorDistribution, + decorators: [ + (Story: ComponentType) => { + const apmPluginContextMock = ({ + observabilityRuleTypeRegistry: { getFormatter: () => undefined }, + } as unknown) as ApmPluginContextValue; + + const kibanaContextServices = { + uiSettings: { get: () => {} }, + }; + + return ( + + + + + + + + ); + }, + ], +}; + +export function Example() { + const distribution = { + noHits: false, + bucketSize: 62350, + buckets: [ + { key: 1624279912350, count: 6 }, + { key: 1624279974700, count: 1 }, + { key: 1624280037050, count: 2 }, + { key: 1624280099400, count: 3 }, + { key: 1624280161750, count: 13 }, + { key: 1624280224100, count: 1 }, + { key: 1624280286450, count: 2 }, + { key: 1624280348800, count: 0 }, + { key: 1624280411150, count: 4 }, + { key: 1624280473500, count: 4 }, + { key: 1624280535850, count: 1 }, + { key: 1624280598200, count: 4 }, + { key: 1624280660550, count: 0 }, + { key: 1624280722900, count: 2 }, + { key: 1624280785250, count: 3 }, + { key: 1624280847600, count: 0 }, + ], + }; + + return ; +} + +export function EmptyState() { + return ( + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/Distribution/index.tsx b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/Distribution/index.tsx index 643653c24aeb3a..e53aaf97cdf757 100644 --- a/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/Distribution/index.tsx +++ b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/Distribution/index.tsx @@ -67,6 +67,7 @@ export function ErrorDistribution({ distribution, title }: Props) { const xFormatter = niceTimeFormatter([xMin, xMax]); const { observabilityRuleTypeRegistry } = useApmPluginContext(); + const { alerts } = useApmServiceContext(); const { getFormatter } = observabilityRuleTypeRegistry; const [selectedAlertId, setSelectedAlertId] = useState( @@ -84,7 +85,7 @@ export function ErrorDistribution({ distribution, title }: Props) { }; return ( -
    + <> {title} @@ -124,7 +125,7 @@ export function ErrorDistribution({ distribution, title }: Props) { alerts: alerts?.filter( (alert) => alert[RULE_ID]?.[0] === AlertType.ErrorCount ), - chartStartTime: buckets[0].x0, + chartStartTime: buckets[0]?.x0, getFormatter, selectedAlertId, setSelectedAlertId, @@ -143,6 +144,6 @@ export function ErrorDistribution({ distribution, title }: Props) {
    -
    + ); }