diff --git a/cypress/integration/create_index.js b/cypress/integration/create_index.js index 68fed27a5..354340f98 100644 --- a/cypress/integration/create_index.js +++ b/cypress/integration/create_index.js @@ -93,12 +93,7 @@ describe("Create Index", () => { }); it("Update alias successfully", () => { - cy.get(`[data-test-subj="view-index-detail-button-${SAMPLE_INDEX}"]`) - .click() - .get("#index-detail-modal-alias") - .click() - .get('[data-test-subj="detail-modal-edit"]') - .click(); + cy.get(`[data-test-subj="view-index-detail-button-${SAMPLE_INDEX}"]`).click().get("#index-detail-modal-alias").click(); // add a alias and remove the exist alias cy.get('[data-test-subj="comboBoxSearchInput"]') @@ -109,17 +104,10 @@ describe("Create Index", () => { .end() .get('[data-test-subj="createIndexCreateButton"]') .click({ force: true }) - .end() - .get('[data-test-subj="change_diff_confirm-confirm"]') - .click(); + .end(); // check the index - cy.get(`[data-test-subj="view-index-detail-button-${SAMPLE_INDEX}"]`) - .click() - .get("#index-detail-modal-alias") - .click() - .get('[data-test-subj="detail-modal-edit"]') - .click(); + cy.get(`[data-test-subj="view-index-detail-button-${SAMPLE_INDEX}"]`).click().get("#index-detail-modal-alias").click(); cy.get(`[value="${SAMPLE_INDEX}"]`) .should("exist") @@ -133,12 +121,7 @@ describe("Create Index", () => { }); it("Update settings successfully", () => { - cy.get(`[data-test-subj="view-index-detail-button-${SAMPLE_INDEX}"]`) - .click() - .get("#index-detail-modal-settings") - .click() - .get('[data-test-subj="detail-modal-edit"]') - .click(); + cy.get(`[data-test-subj="view-index-detail-button-${SAMPLE_INDEX}"]`).click().get("#index-detail-modal-settings").click(); cy.get('[aria-controls="accordion_for_create_index_settings"]') .click() @@ -172,23 +155,13 @@ describe("Create Index", () => { .get('[data-test-subj="change_diff_confirm-confirm"]') .click(); - cy.get(`[data-test-subj="view-index-detail-button-${SAMPLE_INDEX}"]`) - .click() - .get("#index-detail-modal-settings") - .click() - .get('[data-test-subj="detail-modal-edit"]') - .click(); + cy.get(`[data-test-subj="view-index-detail-button-${SAMPLE_INDEX}"]`).click().get("#index-detail-modal-settings").click(); cy.get('[placeholder="The number of replica shards each primary shard should have."]').should("have.value", 12); }); it("Update mappings successfully", () => { - cy.get(`[data-test-subj="view-index-detail-button-${SAMPLE_INDEX}"]`) - .click() - .get("#index-detail-modal-mappings") - .click() - .get('[data-test-subj="detail-modal-edit"]') - .click(); + cy.get(`[data-test-subj="view-index-detail-button-${SAMPLE_INDEX}"]`).click().get("#index-detail-modal-mappings").click(); cy.get('[data-test-subj="create index add field button"]') .click() @@ -201,12 +174,7 @@ describe("Create Index", () => { .get('[data-test-subj="change_diff_confirm-confirm"]') .click(); - cy.get(`[data-test-subj="view-index-detail-button-${SAMPLE_INDEX}"]`) - .click() - .get("#index-detail-modal-mappings") - .click() - .get('[data-test-subj="detail-modal-edit"]') - .click(); + cy.get(`[data-test-subj="view-index-detail-button-${SAMPLE_INDEX}"]`).click().get("#index-detail-modal-mappings").click(); cy.get('[data-test-subj="mapping-visual-editor-2-field-type"]').should("have.value", "text").end(); }); diff --git a/cypress/integration/split_index.js b/cypress/integration/split_index.js index 8e16d3ee8..2b0bdd6d5 100644 --- a/cypress/integration/split_index.js +++ b/cypress/integration/split_index.js @@ -36,23 +36,14 @@ describe("Split Index", () => { // The index should exist cy.get(`#_selection_column_${SAMPLE_INDEX}-checkbox`).should("have.exist").end(); - cy.get(`[data-test-subj="view-index-detail-button-${SAMPLE_INDEX}"]`) - .click() - .end() - .get("#index-detail-modal-settings") - .click() - .end() - .get('[data-test-subj="detail-modal-edit"]') - .click() - .end(); + cy.get(`[data-test-subj="view-index-detail-button-${SAMPLE_INDEX}"]`).click().end().get("#index-detail-modal-settings").click().end(); cy.get('[placeholder="The number of primary shards in the index. Default is 1."]').then(($shardNumber) => { split_number = $shardNumber.val() * 2; }); // Update Index status to blocks write otherwise we can't apply split operation on it - cy.updateIndexSettings(SAMPLE_INDEX, {"index.blocks.write":"true"}) - .end() + cy.updateIndexSettings(SAMPLE_INDEX, { "index.blocks.write": "true" }).end(); }); it("Split successfully", () => { @@ -89,9 +80,6 @@ describe("Split Index", () => { .end() .get("#index-detail-modal-settings") .click() - .end() - .get('[data-test-subj="detail-modal-edit"]') - .click() .end(); cy.get('[placeholder="The number of primary shards in the index. Default is 1."]').should("have.value", `${split_number}`).end(); @@ -110,6 +98,6 @@ describe("Split Index", () => { .get('[data-test-subj="flyout-footer-action-button"]') .click() .end(); - }) + }); }); }); diff --git a/public/components/EuiToolTipWrapper/EuiToolTipWrapper.test.tsx b/public/components/EuiToolTipWrapper/EuiToolTipWrapper.test.tsx new file mode 100644 index 000000000..2abc9fcb7 --- /dev/null +++ b/public/components/EuiToolTipWrapper/EuiToolTipWrapper.test.tsx @@ -0,0 +1,32 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from "react"; +import { render, waitFor, act } from "@testing-library/react"; +import { fireEvent } from "@testing-library/dom"; +import EuiToolTipWrapper from "./index"; + +const WrappedInput = EuiToolTipWrapper((props: any) => ); + +describe(" spec", () => { + it("render the component", async () => { + render(); + await waitFor(() => {}); + expect(document.body.children).toMatchSnapshot(); + }); + + it("render the error", async () => { + const { queryByText, container } = render(); + const anchorDOM = container.querySelector(".euiToolTipAnchor") as Element; + await act(async () => { + await fireEvent.mouseOver(anchorDOM, { + bubbles: true, + }); + }); + await waitFor(() => { + expect(queryByText("test error")).not.toBeNull(); + }); + }); +}); diff --git a/public/components/EuiToolTipWrapper/__snapshots__/EuiToolTipWrapper.test.tsx.snap b/public/components/EuiToolTipWrapper/__snapshots__/EuiToolTipWrapper.test.tsx.snap new file mode 100644 index 000000000..abfaf69d1 --- /dev/null +++ b/public/components/EuiToolTipWrapper/__snapshots__/EuiToolTipWrapper.test.tsx.snap @@ -0,0 +1,13 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` spec render the component 1`] = ` +HTMLCollection [ +
+ + + +
, +] +`; diff --git a/public/components/EuiToolTipWrapper/index.tsx b/public/components/EuiToolTipWrapper/index.tsx new file mode 100644 index 000000000..cce0f2934 --- /dev/null +++ b/public/components/EuiToolTipWrapper/index.tsx @@ -0,0 +1,79 @@ +import React, { forwardRef } from "react"; +import { EuiToolTip } from "@elastic/eui"; + +interface IEuiToolTipWrapperOptions { + disabledKey?: string; +} + +export interface IEuiToolTipWrapperProps { + disabledReason?: + | string + | { + visible: boolean; + message: string; + }[]; +} + +export default function EuiToolTipWrapper( + Component: React.ComponentType, + options?: IEuiToolTipWrapperOptions +): React.ComponentType { + return forwardRef(({ disabledReason, children, ...others }, ref) => { + const finalOptions: Required = { + ...{ + disabledKey: "disabled", + }, + ...options, + }; + let formattedReason: IEuiToolTipWrapperProps["disabledReason"]; + if (typeof disabledReason === "string") { + formattedReason = [ + { + visible: true, + message: disabledReason, + }, + ]; + } else { + formattedReason = disabledReason; + } + formattedReason = formattedReason?.filter((item) => item.visible); + const propsDisabled = (others as Record)[finalOptions.disabledKey]; + const disabled = propsDisabled === undefined ? !!formattedReason?.length : propsDisabled; + const finalProps: IEuiToolTipWrapperProps = { + ...others, + [finalOptions.disabledKey]: disabled, + }; + return ( + + This field is disabled because: +
    + {formattedReason?.map((item, index) => ( +
  1. + + {index + 1}. + + {item.message} +
  2. + ))} +
+ + ) : undefined + } + display="block" + position="right" + > + <> + + +
+ ); + }) as React.ComponentType; +} diff --git a/public/components/FormGenerator/__snapshots__/FormGenerator.test.tsx.snap b/public/components/FormGenerator/__snapshots__/FormGenerator.test.tsx.snap index 255e2e2f5..a53ffb20b 100644 --- a/public/components/FormGenerator/__snapshots__/FormGenerator.test.tsx.snap +++ b/public/components/FormGenerator/__snapshots__/FormGenerator.test.tsx.snap @@ -25,21 +25,25 @@ HTMLCollection [
-
- +
+ +
-
+
-
- +
+ +
-
+
diff --git a/public/components/FormGenerator/built_in_components/index.tsx b/public/components/FormGenerator/built_in_components/index.tsx index dc3adcdc5..81b83955e 100644 --- a/public/components/FormGenerator/built_in_components/index.tsx +++ b/public/components/FormGenerator/built_in_components/index.tsx @@ -1,20 +1,33 @@ import React, { forwardRef } from "react"; import { EuiFieldNumber, EuiFieldText, EuiSwitch } from "@elastic/eui"; +import EuiToolTipWrapper, { IEuiToolTipWrapperProps } from "../../EuiToolTipWrapper"; type ComponentMapEnum = "Input" | "Number" | "Switch"; -const componentMap: Record void; value?: any }>> = { - Input: forwardRef(({ onChange, value, ...others }, ref: React.Ref) => ( - onChange(e.target.value)} {...others} /> - )), - Number: forwardRef(({ onChange, value, ...others }, ref: React.Ref) => ( - onChange(e.target.value)} value={value || ""} {...others} /> - )), - Switch: forwardRef(({ value, onChange, ...others }, ref: React.Ref) => ( -
- onChange(e.target.checked)} {...others} /> -
- )), +export interface IFieldComponentProps extends IEuiToolTipWrapperProps { + onChange: (val: IFieldComponentProps["value"]) => void; + value?: any; + [key: string]: any; +} + +const componentMap: Record> = { + Input: EuiToolTipWrapper( + forwardRef(({ onChange, value, disabledReason, disabled, ...others }, ref: React.Ref) => ( + onChange(e.target.value)} disabled={disabled} {...others} /> + )) as React.ComponentType + ), + Number: EuiToolTipWrapper( + forwardRef(({ onChange, value, ...others }, ref: React.Ref) => ( + onChange(e.target.value)} value={value || ""} {...others} /> + )) as React.ComponentType + ), + Switch: EuiToolTipWrapper( + forwardRef(({ value, onChange, ...others }, ref: React.Ref) => ( +
+ onChange(e.target.checked)} {...others} /> +
+ )) as React.ComponentType + ), }; export default componentMap; diff --git a/public/containers/IndexDetail/index.tsx b/public/containers/IndexDetail/index.tsx index 727a22ec3..db70c1b8c 100644 --- a/public/containers/IndexDetail/index.tsx +++ b/public/containers/IndexDetail/index.tsx @@ -36,7 +36,7 @@ export default function IndexDetail(props: IIndexDetailProps) { props.onGetIndicesDetail && props.onGetIndicesDetail(finalResponse); setLoading(false); })(); - }, [props.indices, setLoading, setItems, coreServices]); + }, [props.indices.join(","), setLoading, setItems, coreServices]); return ( ; @@ -137,9 +145,17 @@ const IndexDetail = ( }, ], props: { - disabled: - (isEdit && !INDEX_DYNAMIC_SETTINGS.includes("index.number_of_shards")) || templateSimulateLoading || !finalValue.index, placeholder: "The number of primary shards in the index. Default is 1.", + disabledReason: [ + { + visible: !finalValue.index, + message: indexNameEmptyTips, + }, + { + visible: isEdit && !INDEX_DYNAMIC_SETTINGS.includes("index.number_of_shards"), + message: staticSettingsTips, + }, + ], }, }, }, @@ -157,9 +173,17 @@ const IndexDetail = ( }, ], props: { - disabled: - (isEdit && !INDEX_DYNAMIC_SETTINGS.includes("index.number_of_replicas")) || templateSimulateLoading || !finalValue.index, placeholder: "The number of replica shards each primary shard should have.", + disabledReason: [ + { + visible: !finalValue.index, + message: indexNameEmptyTips, + }, + { + visible: isEdit && !INDEX_DYNAMIC_SETTINGS.includes("index.number_of_replicas"), + message: staticSettingsTips, + }, + ], }, }, }, @@ -172,9 +196,17 @@ const IndexDetail = ( type: "Input", options: { props: { - disabled: - (isEdit && !INDEX_DYNAMIC_SETTINGS.includes("index.refresh_interval")) || templateSimulateLoading || !finalValue.index, placeholder: "Can be set to -1 to disable refreshing.", + disabledReason: [ + { + visible: !finalValue.index, + message: indexNameEmptyTips, + }, + { + visible: isEdit && !INDEX_DYNAMIC_SETTINGS.includes("index.refresh_interval"), + message: staticSettingsTips, + }, + ], }, }, }, @@ -204,6 +236,7 @@ const IndexDetail = ( onBlur: onIndexInputBlur, isLoading: templateSimulateLoading, disabled: isEdit || templateSimulateLoading, + disabledReason: "Index name can not be modified", }, rules: [ { @@ -223,9 +256,10 @@ const IndexDetail = ( props: { refreshOptions: refreshOptions, isDisabled: !finalValue.index, + disabledReason: indexNameEmptyTips, }, }, - component: AliasSelect as any, + component: WrappedAliasSelect as React.ComponentType, }, ]} value={{ diff --git a/public/pages/CreateIndex/components/IndexDetail/__snapshots__/IndexDetail.test.tsx.snap b/public/pages/CreateIndex/components/IndexDetail/__snapshots__/IndexDetail.test.tsx.snap index be8ca8444..6ddfc3922 100644 --- a/public/pages/CreateIndex/components/IndexDetail/__snapshots__/IndexDetail.test.tsx.snap +++ b/public/pages/CreateIndex/components/IndexDetail/__snapshots__/IndexDetail.test.tsx.snap @@ -56,22 +56,26 @@ exports[` spec renders the component 1`] = ` > Please enter the name before moving to other fields -
- +
+ +
-
+
spec renders the component 1`] = ` > Select existing aliases or specify a new alias
- + diff --git a/public/pages/CreateIndex/components/IndexMapping/IndexMapping.tsx b/public/pages/CreateIndex/components/IndexMapping/IndexMapping.tsx index 3787374e1..c63024050 100644 --- a/public/pages/CreateIndex/components/IndexMapping/IndexMapping.tsx +++ b/public/pages/CreateIndex/components/IndexMapping/IndexMapping.tsx @@ -24,6 +24,7 @@ import { Modal } from "../../../../components/Modal"; import { MappingsProperties, MappingsPropertiesObject } from "../../../../../models/interfaces"; import { INDEX_MAPPING_TYPES, INDEX_MAPPING_TYPES_WITH_CHILDREN } from "../../../../utils/constants"; import "./IndexMapping.scss"; +import EuiToolTipWrapper from "../../../../components/EuiToolTipWrapper"; export interface IndexMappingProps { value?: MappingsProperties; @@ -52,6 +53,11 @@ interface IMappingLabel { id: string; } +const OLD_VALUE_DISABLED_REASON = "Old mappings can not be modified"; + +const EuiFieldTextWrapped = EuiToolTipWrapper(EuiFieldText); +const EuiSelectWrapped = EuiToolTipWrapper(EuiSelect); + const MappingLabel = forwardRef( ( { value, onChange, onFieldNameCheck, disabled, onAddSubField, onDeleteField, id }: IMappingLabel, @@ -99,28 +105,28 @@ const MappingLabel = forwardRef( e.stopPropagation()}> - <> - setFieldNameState(e.target.value)} - onBlur={async (e) => { - const error = await onValidate(); - if (!error) { - onFieldChange("fieldName", fieldNameState); - } - }} - /> - + setFieldNameState(e.target.value)} + onBlur={async (e) => { + const error = await onValidate(); + if (!error) { + onFieldChange("fieldName", fieldNameState); + } + }} + /> - spec render mappings with object type 1`] = `
-
- +
+ +
-
+
@@ -167,74 +173,79 @@ exports[` spec render mappings with object type 1`] = `
-
-
- + + + + + + + + + + +
- EuiIconMock - + + EuiIconMock + +
-
+
@@ -344,20 +355,26 @@ exports[` spec render mappings with object type 1`] = `
-
- +
+ +
-
+
@@ -381,74 +398,79 @@ exports[` spec render mappings with object type 1`] = `
-
-
- + + + + + + + + + + +
- EuiIconMock - + + EuiIconMock + +
-
+
diff --git a/public/pages/CreateIndex/containers/CreateIndex/CreateIndex.test.tsx b/public/pages/CreateIndex/containers/CreateIndex/CreateIndex.test.tsx index 250543bc3..6f685d4cc 100644 --- a/public/pages/CreateIndex/containers/CreateIndex/CreateIndex.test.tsx +++ b/public/pages/CreateIndex/containers/CreateIndex/CreateIndex.test.tsx @@ -10,100 +10,13 @@ import userEvent from "@testing-library/user-event"; import { CoreStart } from "opensearch-dashboards/public"; import CreateIndex from "./CreateIndex"; import { ServicesConsumer, ServicesContext } from "../../../../services"; -import { browserServicesMock, coreServicesMock } from "../../../../../test/mocks"; +import { browserServicesMock, coreServicesMock, apiCallerMock } from "../../../../../test/mocks"; import { BrowserServices } from "../../../../models/interfaces"; import { ModalProvider, ModalRoot } from "../../../../components/Modal"; -import { IndicesUpdateMode, ROUTES } from "../../../../utils/constants"; +import { ROUTES } from "../../../../utils/constants"; import { CoreServicesConsumer, CoreServicesContext } from "../../../../components/core_services"; -browserServicesMock.commonService.apiCaller = jest.fn( - async (payload): Promise => { - switch (payload.endpoint) { - case "transport.request": { - if (payload.data?.path?.startsWith("/_index_template/_simulate_index/")) { - return { - ok: true, - response: { - template: { - settings: { - index: { - number_of_replicas: "10", - }, - }, - }, - }, - }; - } - } - case "indices.create": - if (payload.data?.index === "bad_index") { - return { - ok: false, - error: "bad_index", - }; - } - - return { - ok: true, - response: {}, - }; - break; - case "cat.aliases": - return { - ok: true, - response: [ - { - alias: ".kibana", - index: ".kibana_1", - filter: "-", - is_write_index: "-", - }, - { - alias: "2", - index: "1234", - filter: "-", - is_write_index: "-", - }, - ], - }; - case "indices.get": - const payloadIndex = payload.data?.index; - if (payloadIndex === "bad_index") { - return { - ok: false, - error: "bad_error", - response: {}, - }; - } - - return { - ok: true, - response: { - [payload.data?.index]: { - aliases: { - update_test_1: {}, - }, - mappings: { - properties: { - test_mapping_1: { - type: "text", - }, - }, - }, - settings: { - "index.number_of_shards": "1", - "index.number_of_replicas": "1", - }, - }, - }, - }; - } - return { - ok: true, - response: {}, - }; - } -); +apiCallerMock(browserServicesMock); function renderCreateIndexWithRouter(initialEntries = [ROUTES.CREATE_INDEX] as string[]) { return { @@ -152,258 +65,28 @@ function renderCreateIndexWithRouter(initialEntries = [ROUTES.CREATE_INDEX] as s } describe(" spec", () => { - it("show a toast if getIndices gracefully fails", async () => { - const { getByText } = renderCreateIndexWithRouter([`${ROUTES.CREATE_INDEX}/bad_index`]); - - await waitFor(() => { - getByText("Update"); - }); - expect(coreServicesMock.notifications.toasts.addDanger).toHaveBeenCalledTimes(1); - expect(coreServicesMock.notifications.toasts.addDanger).toHaveBeenCalledWith("bad_error"); - }); - - it("shows error for index name input when clicking create", async () => { - const { queryByText, getByText } = renderCreateIndexWithRouter(); - - await waitFor(() => getByText("Define index")); - - expect(queryByText("Index name can not be null.")).toBeNull(); - - userEvent.click(getByText("Create")); + it("it goes to indices page when click cancel", async () => { + const { getByText } = renderCreateIndexWithRouter([`${ROUTES.CREATE_INDEX}/good_index`]); + await waitFor(() => {}); + userEvent.click(getByText("Cancel")); await waitFor(() => { - expect(queryByText("Index name can not be null.")).not.toBeNull(); + expect(getByText(`location is: ${ROUTES.INDEX_POLICIES}`)).toBeInTheDocument(); }); }); - it("routes you back to indices and shows a success toast when successfully creating a index", async () => { - const { getByText, getByPlaceholderText, getByTestId } = renderCreateIndexWithRouter(); + it("it goes to indices page when click create successfully", async () => { + const { getByText, getByPlaceholderText } = renderCreateIndexWithRouter([`${ROUTES.CREATE_INDEX}`]); await waitFor(() => { getByText("Define index"); }); - userEvent.type(getByPlaceholderText("Please enter the name for your index"), `some_index`); - userEvent.click(document.body); - await waitFor(() => { - expect(getByTestId("form-name-index.number_of_replicas").querySelector("input")).toHaveAttribute("value", "10"); - }); - userEvent.click(getByText("Create")); - await waitFor(() => { - expect(coreServicesMock.notifications.toasts.addSuccess).toHaveBeenCalledWith("some_index has been successfully created."); - }); - }); - - it("shows a danger toast when getting graceful error from create index", async () => { - const { getByText, getByPlaceholderText } = renderCreateIndexWithRouter(); - - await waitFor(() => getByText("Define index")); + const indexNameInput = getByPlaceholderText("Please enter the name for your index"); - userEvent.type(getByPlaceholderText("Please enter the name for your index"), `bad_index`); + userEvent.type(indexNameInput, `good_index`); + userEvent.click(document.body); userEvent.click(getByText("Create")); - await waitFor(() => { - expect(coreServicesMock.notifications.toasts.addDanger).toHaveBeenCalledWith("bad_index"); - }); - }); - - it("it shows detail and does not call any api when nothing modified", async () => { - const { getByText, getByTestId } = renderCreateIndexWithRouter([`${ROUTES.CREATE_INDEX}/good_index`]); - await waitFor(() => getByText("Define index")); - userEvent.click(getByText("Update")); - await waitFor(() => {}); - userEvent.click(getByTestId("change_diff_confirm-confirm")); - - await waitFor(() => { - // it shows detail and does not call any api when nothing modified - expect(browserServicesMock.commonService.apiCaller).toBeCalledTimes(2); - }); - }); - - it("shows detail info and update others", async () => { - const { getByText, getByTestId, getByTitle } = renderCreateIndexWithRouter([`${ROUTES.CREATE_INDEX}/good_index`]); - - await waitFor(() => getByText("Define index")); - - userEvent.click(getByTitle("update_test_1").querySelector("button") as Element); - userEvent.type(getByTestId("comboBoxSearchInput"), "test_1{enter}"); - userEvent.type(getByTestId("form-name-index.number_of_replicas").querySelector("input") as Element, "2"); - userEvent.click(getByTestId("create index add field button")); - await waitFor(() => {}); - await userEvent.clear(getByTestId("mapping-visual-editor-1-field-name")); - await userEvent.type(getByTestId("mapping-visual-editor-1-field-name"), "test_mapping_2"); - await userEvent.click(document.body); - userEvent.click(getByText("Update")); - await waitFor(() => {}); - userEvent.click(getByTestId("change_diff_confirm-confirm")); - - await waitFor(() => { - expect(coreServicesMock.notifications.toasts.addSuccess).toBeCalledTimes(1); - expect(browserServicesMock.commonService.apiCaller).toBeCalledWith({ - endpoint: "indices.updateAliases", - method: "PUT", - data: { - body: { - actions: [ - { - remove: { - index: "good_index", - alias: "update_test_1", - }, - }, - { - add: { - index: "good_index", - alias: "test_1", - }, - }, - ], - }, - }, - }); - - expect(browserServicesMock.commonService.apiCaller).toBeCalledWith({ - endpoint: "indices.putSettings", - method: "PUT", - data: { - index: "good_index", - flat_settings: true, - body: { - "index.number_of_replicas": "12", - }, - }, - }); - - expect(browserServicesMock.commonService.apiCaller).toBeCalledWith({ - endpoint: "indices.putMapping", - method: "PUT", - data: { - index: "good_index", - body: { - properties: { - test_mapping_2: { - type: "text", - }, - }, - }, - }, - }); - }); - }); - - it("shows detail alias and update alias only", async () => { - const { getByText, getByTestId, getByTitle } = renderCreateIndexWithRouter([ - `${ROUTES.CREATE_INDEX}/good_index/${IndicesUpdateMode.alias}`, - ]); - - await waitFor(() => {}); - - userEvent.click(getByTitle("update_test_1").querySelector("button") as Element); - userEvent.type(getByTestId("comboBoxSearchInput"), "test_1{enter}"); - await waitFor(() => {}); - userEvent.click(getByText("Update")); - await waitFor(() => {}); - userEvent.click(getByTestId("change_diff_confirm-cancel")); - await waitFor(() => {}); - userEvent.click(getByText("Update")); - await waitFor(() => {}); - userEvent.click(getByTestId("change_diff_confirm-confirm")); - - await waitFor(() => { - // shows detail alias and update alias only - expect(coreServicesMock.notifications.toasts.addSuccess).toBeCalledTimes(1); - expect(browserServicesMock.commonService.apiCaller).toBeCalledWith({ - endpoint: "indices.updateAliases", - method: "PUT", - data: { - body: { - actions: [ - { - remove: { - index: "good_index", - alias: "update_test_1", - }, - }, - { - add: { - index: "good_index", - alias: "test_1", - }, - }, - ], - }, - }, - }); - }); - }); - - it("shows detail settings and update settings only", async () => { - const { getByText, getByTestId } = renderCreateIndexWithRouter([`${ROUTES.CREATE_INDEX}/good_index/${IndicesUpdateMode.mappings}`]); - - await waitFor(() => {}); - - userEvent.click(getByTestId("create index add field button")); - await waitFor(() => {}); - await userEvent.clear(getByTestId("mapping-visual-editor-1-field-name")); - await userEvent.type(getByTestId("mapping-visual-editor-1-field-name"), "test_mapping_2"); - await userEvent.click(document.body); - await waitFor(() => {}); - userEvent.click(getByText("Update")); - await waitFor(() => {}); - userEvent.click(getByTestId("change_diff_confirm-confirm")); - - await waitFor(() => { - // shows detail settings and update settings only - expect(coreServicesMock.notifications.toasts.addSuccess).toBeCalledTimes(1); - - expect(browserServicesMock.commonService.apiCaller).toBeCalledWith({ - endpoint: "indices.putMapping", - method: "PUT", - data: { - index: "good_index", - body: { - properties: { - test_mapping_2: { - type: "text", - }, - }, - }, - }, - }); - }); - }); - - it("shows detail mappings and update mappings only", async () => { - const { getByText, getByTestId } = renderCreateIndexWithRouter([`${ROUTES.CREATE_INDEX}/good_index/${IndicesUpdateMode.settings}`]); - - await waitFor(() => {}); - - userEvent.type(getByTestId("form-name-index.number_of_replicas").querySelector("input") as Element, "2"); - userEvent.click(getByText("Update")); - await waitFor(() => {}); - userEvent.click(getByTestId("change_diff_confirm-confirm")); - - await waitFor(() => { - // shows detail mappings and update mappings only - expect(coreServicesMock.notifications.toasts.addSuccess).toBeCalledTimes(1); - - expect(browserServicesMock.commonService.apiCaller).toBeCalledWith({ - endpoint: "indices.putSettings", - method: "PUT", - data: { - index: "good_index", - flat_settings: true, - body: { - "index.number_of_replicas": "12", - }, - }, - }); - }); - }); - - it("it goes to indices page when click cancel", async () => { - const { getByText } = renderCreateIndexWithRouter([`${ROUTES.CREATE_INDEX}/good_index`]); - await waitFor(() => {}); - userEvent.click(getByText("Cancel")); await waitFor(() => { expect(getByText(`location is: ${ROUTES.INDEX_POLICIES}`)).toBeInTheDocument(); }); diff --git a/public/pages/CreateIndex/containers/CreateIndex/CreateIndex.tsx b/public/pages/CreateIndex/containers/CreateIndex/CreateIndex.tsx index 22ee867d1..46c10fb01 100644 --- a/public/pages/CreateIndex/containers/CreateIndex/CreateIndex.tsx +++ b/public/pages/CreateIndex/containers/CreateIndex/CreateIndex.tsx @@ -4,53 +4,27 @@ */ import React, { Component } from "react"; -import { EuiSpacer, EuiTitle, EuiFlexGroup, EuiFlexItem, EuiButton, EuiButtonEmpty } from "@elastic/eui"; +import { EuiSpacer, EuiTitle } from "@elastic/eui"; import { RouteComponentProps } from "react-router-dom"; -import { get, set, differenceWith, isEqual } from "lodash"; -import { diffArrays } from "diff"; -import flattern from "flat"; -// eui depends on react-ace, so we can import react-ace here -import { MonacoEditorDiffReact } from "../../../../components/MonacoEditor"; -import IndexDetail from "../../components/IndexDetail"; -import { IAliasAction, IndexItem, IndexItemRemote, MappingsProperties } from "../../../../../models/interfaces"; +import IndexForm from "../IndexForm"; import { BREADCRUMBS, IndicesUpdateMode, ROUTES } from "../../../../utils/constants"; import { CoreServicesContext } from "../../../../components/core_services"; -import { IIndexDetailRef, IndexDetailProps } from "../../components/IndexDetail/IndexDetail"; -import { transformArrayToObject, transformObjectToArray } from "../../components/IndexMapping/IndexMapping"; import { CommonService } from "../../../../services/index"; -import { ServerResponse } from "../../../../../server/models/types"; -import { Modal } from "../../../../components/Modal"; interface CreateIndexProps extends RouteComponentProps<{ index?: string; mode?: IndicesUpdateMode }> { isEdit?: boolean; commonService: CommonService; } -interface CreateIndexState { - indexDetail: IndexItem; - oldIndexDetail?: IndexItem; - isSubmitting: boolean; -} - -export default class CreateIndex extends Component { +export default class CreateIndex extends Component { static contextType = CoreServicesContext; - state: CreateIndexState = { - isSubmitting: false, - indexDetail: { - index: "", - settings: { - "index.number_of_shards": 1, - "index.number_of_replicas": 1, - "index.refresh_interval": "1s", - }, - mappings: {}, - }, - oldIndexDetail: undefined, - }; - indexDetailRef: IIndexDetailRef | null = null; + + get commonService() { + return this.props.commonService; + } get index() { - return this.props.match.params.index || ""; + return this.props.match.params.index; } get isEdit() { @@ -59,29 +33,6 @@ export default class CreateIndex extends Component => { const isEdit = this.isEdit; - if (isEdit) { - const response: ServerResponse> = await this.props.commonService.apiCaller({ - endpoint: "indices.get", - data: { - index: this.index, - flat_settings: true, - }, - }); - if (response.ok) { - const payload = { - ...response.response[this.index || ""], - index: this.index, - }; - set(payload, "mappings.properties", transformObjectToArray(get(payload, "mappings.properties", {}))); - - this.setState({ - indexDetail: payload, - oldIndexDetail: JSON.parse(JSON.stringify(payload)), - }); - } else { - this.context.notifications.toasts.addDanger(response.error); - } - } this.context.chrome.setBreadcrumbs([ BREADCRUMBS.INDEX_MANAGEMENT, BREADCRUMBS.INDICES, @@ -93,294 +44,8 @@ export default class CreateIndex extends Component { - this.setState({ - indexDetail: { - ...this.state.indexDetail, - ...value, - }, - }); - }; - - updateAlias = async (): Promise> => { - const { indexDetail, oldIndexDetail } = this.state; - const { index } = indexDetail; - // handle the alias here - const diffedAliasArrayes = diffArrays(Object.keys(oldIndexDetail?.aliases || {}), Object.keys(indexDetail.aliases || {})); - const aliasActions: IAliasAction[] = diffedAliasArrayes.reduce((total: IAliasAction[], current) => { - if (current.added) { - return [ - ...total, - ...current.value.map((item) => ({ - add: { - index, - alias: item, - }, - })), - ]; - } else if (current.removed) { - return [ - ...total, - ...current.value.map((item) => ({ - remove: { - index, - alias: item, - }, - })), - ]; - } - - return total; - }, [] as IAliasAction[]); - - // alias may have many unexpected errors, do that before update index settings. - if (aliasActions.length) { - return await this.props.commonService.apiCaller({ - endpoint: "indices.updateAliases", - method: "PUT", - data: { - body: { - actions: aliasActions, - }, - }, - }); - } - - return Promise.resolve({ - ok: true, - response: {}, - }); - }; - updateSettings = async (): Promise> => { - const { indexDetail, oldIndexDetail } = this.state; - const { index } = indexDetail; - - const newSettings = (indexDetail?.settings || {}) as Required["settings"]; - const oldSettings = (oldIndexDetail?.settings || {}) as Required["settings"]; - const differences = differenceWith(Object.entries(newSettings), Object.entries(oldSettings), isEqual); - if (!differences.length) { - return { - ok: true, - response: {}, - }; - } - - const finalSettings = differences.reduce((total, current) => { - if (newSettings[current[0]] !== undefined) { - return { - ...total, - [current[0]]: newSettings[current[0]], - }; - } - - return total; - }, {}); - - return await this.props.commonService.apiCaller({ - endpoint: "indices.putSettings", - method: "PUT", - data: { - index, - flat_settings: true, - // In edit mode, only dynamic settings can be modified - body: finalSettings, - }, - }); - }; - updateMappings = async (): Promise> => { - const { indexDetail, oldIndexDetail } = this.state; - const { index } = indexDetail; - // handle the mappings here - const newMappingProperties = indexDetail?.mappings?.properties || []; - const diffedMappingArrayes = diffArrays( - (oldIndexDetail?.mappings?.properties || []).map((item) => item.fieldName), - newMappingProperties.map((item) => item.fieldName) - ); - const newMappingFields: MappingsProperties = diffedMappingArrayes - .filter((item) => item.added) - .reduce((total, current) => [...total, ...current.value], [] as string[]) - .map((current) => newMappingProperties.find((item) => item.fieldName === current) as MappingsProperties[number]) - .filter((item) => item); - - const newMappingSettings = transformArrayToObject(newMappingFields); - - if (newMappingFields.length) { - return await this.props.commonService.apiCaller({ - endpoint: "indices.putMapping", - method: "PUT", - data: { - index, - body: { - properties: newMappingSettings, - }, - }, - }); - } - - return Promise.resolve({ - ok: true, - response: {}, - }); - }; - - chainPromise = async (promises: Promise>[]): Promise> => { - const newPromises = [...promises]; - while (newPromises.length) { - const result = (await newPromises.shift()) as ServerResponse; - if (!result?.ok) { - return result; - } - } - - return { - ok: true, - response: {}, - }; - }; - - showDiff = async (): Promise> => { - return new Promise((resolve, reject) => { - Modal.show({ - title: "Please confirm the change.", - "data-test-subj": "change_diff_confirm", - type: "confirm", - content: ( - <> -

The following changes will be done once you click the confirm button, Please make sure you want to do all the changes.

- - - - ), - onOk: () => - resolve({ - ok: true, - response: {}, - }), - onCancel: () => { - resolve({ - ok: false, - error: "", - }); - }, - }); - }); - }; - - onSubmit = async (): Promise => { - const { mode } = this.props.match.params; - const { indexDetail } = this.state; - const { index, mappings, ...others } = indexDetail; - if (!(await this.indexDetailRef?.validate())) { - return; - } - this.setState({ isSubmitting: true }); - let result: ServerResponse; - if (this.isEdit) { - const diffConfirm = await this.showDiff(); - if (!diffConfirm.ok) { - this.setState({ isSubmitting: false }); - return; - } - let chainedPromises: Promise>[] = []; - if (!mode) { - chainedPromises.push(...[this.updateMappings(), this.updateAlias(), this.updateSettings()]); - } else { - switch (mode) { - case IndicesUpdateMode.alias: - chainedPromises.push(this.updateAlias()); - break; - case IndicesUpdateMode.settings: - chainedPromises.push(this.updateSettings()); - break; - case IndicesUpdateMode.mappings: - chainedPromises.push(this.updateMappings()); - break; - } - } - result = await this.chainPromise(chainedPromises); - } else { - result = await this.props.commonService.apiCaller({ - endpoint: "indices.create", - method: "PUT", - data: { - index, - body: { - ...others, - mappings: { - properties: transformArrayToObject(mappings?.properties || []), - }, - }, - }, - }); - } - this.setState({ isSubmitting: false }); - - // handle all the response here - if (result && result.ok) { - this.context.notifications.toasts.addSuccess(`${indexDetail.index} has been successfully ${this.isEdit ? "updated" : "created"}.`); - this.props.history.push(ROUTES.INDICES); - } else { - this.context.notifications.toasts.addDanger(result.error); - } - }; - - onSimulateIndexTemplate = (indexName: string): Promise> => { - return this.props.commonService - .apiCaller<{ template: IndexItemRemote }>({ - endpoint: "transport.request", - data: { - path: `/_index_template/_simulate_index/${indexName}`, - method: "POST", - }, - }) - .then((res) => { - if (res.ok && res.response && res.response.template) { - return { - ...res, - response: { - ...res.response.template, - settings: flattern(res.response.template?.settings || {}), - }, - }; - } - - return { - ok: false, - error: "", - } as ServerResponse; - }); - }; - render() { const isEdit = this.isEdit; - const { indexDetail, isSubmitting, oldIndexDetail } = this.state; return (
@@ -388,40 +53,13 @@ export default class CreateIndex extends Component{isEdit ? "Edit" : "Create"} index - (this.indexDetailRef = ref)} - isEdit={this.isEdit} - value={indexDetail} - oldValue={oldIndexDetail} - onChange={this.onDetailChange} - onSimulateIndexTemplate={this.onSimulateIndexTemplate} - refreshOptions={(aliasName) => - this.props.commonService.apiCaller({ - endpoint: "cat.aliases", - method: "GET", - data: { - format: "json", - name: aliasName, - expand_wildcards: "open", - }, - }) - } + onCancel={this.onCancel} + onSubmitSuccess={() => this.props.history.push(ROUTES.INDICES)} /> - - - - - - Cancel - - - - - {isEdit ? "Update" : "Create"} - - -
); } diff --git a/public/pages/CreateIndex/containers/IndexForm/IndexForm.test.tsx b/public/pages/CreateIndex/containers/IndexForm/IndexForm.test.tsx new file mode 100644 index 000000000..ff7615f35 --- /dev/null +++ b/public/pages/CreateIndex/containers/IndexForm/IndexForm.test.tsx @@ -0,0 +1,310 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from "react"; +import { render, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import IndexForm, { IndexFormProps } from "./index"; +import { ServicesConsumer, ServicesContext } from "../../../../services"; +import { browserServicesMock, coreServicesMock, apiCallerMock } from "../../../../../test/mocks"; +import { BrowserServices } from "../../../../models/interfaces"; +import { IndicesUpdateMode } from "../../../../utils/constants"; +import { CoreServicesContext } from "../../../../components/core_services"; + +apiCallerMock(browserServicesMock); + +function renderCreateIndexWithRouter(props: Omit) { + return { + ...render( + + + + {(services: BrowserServices | null) => services && } + + + + ), + }; +} + +describe(" spec", () => { + it("show a toast if getIndices gracefully fails", async () => { + const { getByText } = renderCreateIndexWithRouter({ + index: "bad_index", + }); + + await waitFor(() => { + getByText("Update"); + }); + expect(coreServicesMock.notifications.toasts.addDanger).toHaveBeenCalledTimes(1); + expect(coreServicesMock.notifications.toasts.addDanger).toHaveBeenCalledWith("bad_error"); + }); + + it("shows error for index name input when clicking create", async () => { + const { queryByText, getByText } = renderCreateIndexWithRouter({}); + + await waitFor(() => getByText("Define index")); + + expect(queryByText("Index name can not be null.")).toBeNull(); + + userEvent.click(getByText("Create")); + await waitFor(() => { + expect(queryByText("Index name can not be null.")).not.toBeNull(); + }); + }); + + it("routes you back to indices and shows a success toast when successfully creating a index", async () => { + const { getByText, getByPlaceholderText, getByTestId } = renderCreateIndexWithRouter({}); + + await waitFor(() => { + getByText("Define index"); + }); + + const indexNameInput = getByPlaceholderText("Please enter the name for your index"); + + userEvent.type(indexNameInput, `bad_index`); + userEvent.click(document.body); + await waitFor(() => {}); + userEvent.clear(indexNameInput); + userEvent.type(indexNameInput, `good_index`); + userEvent.click(document.body); + await waitFor(() => { + expect(getByTestId("form-name-index.number_of_replicas").querySelector("input")).toHaveAttribute("value", "10"); + }); + userEvent.click(getByText("Create")); + await waitFor(() => { + expect(coreServicesMock.notifications.toasts.addSuccess).toHaveBeenCalledWith("[good_index] has been successfully created."); + }); + }); + + it("shows a danger toast when getting graceful error from create index", async () => { + const { getByText, getByPlaceholderText } = renderCreateIndexWithRouter({}); + + await waitFor(() => getByText("Define index")); + + userEvent.type(getByPlaceholderText("Please enter the name for your index"), `bad_index`); + userEvent.click(getByText("Create")); + + await waitFor(() => { + expect(coreServicesMock.notifications.toasts.addDanger).toHaveBeenCalledWith("bad_index"); + }); + }); + + it("it shows detail and does not call any api when nothing modified", async () => { + const { getByText, getByTestId } = renderCreateIndexWithRouter({ + index: "good_index", + }); + await waitFor(() => getByText("Define index")); + userEvent.click(getByText("Update")); + await waitFor(() => {}); + userEvent.click(getByTestId("change_diff_confirm-confirm")); + + await waitFor(() => { + // it shows detail and does not call any api when nothing modified + expect(browserServicesMock.commonService.apiCaller).toBeCalledTimes(2); + }); + }); + + it("shows detail info and update others", async () => { + const { getByText, getByTestId, getByTitle } = renderCreateIndexWithRouter({ + index: "good_index", + }); + + await waitFor(() => getByText("Define index")); + + userEvent.click(getByTitle("update_test_1").querySelector("button") as Element); + userEvent.type(getByTestId("comboBoxSearchInput"), "test_1{enter}"); + userEvent.type(getByTestId("form-name-index.number_of_replicas").querySelector("input") as Element, "2"); + userEvent.click(getByTestId("create index add field button")); + await waitFor(() => {}); + await userEvent.clear(getByTestId("mapping-visual-editor-1-field-name")); + await userEvent.type(getByTestId("mapping-visual-editor-1-field-name"), "test_mapping_2"); + await userEvent.click(document.body); + userEvent.click(getByText("Update")); + await waitFor(() => {}); + userEvent.click(getByTestId("change_diff_confirm-confirm")); + + await waitFor(() => { + expect(coreServicesMock.notifications.toasts.addSuccess).toBeCalledTimes(1); + expect(browserServicesMock.commonService.apiCaller).toBeCalledWith({ + endpoint: "indices.updateAliases", + method: "PUT", + data: { + body: { + actions: [ + { + remove: { + index: "good_index", + alias: "update_test_1", + }, + }, + { + add: { + index: "good_index", + alias: "test_1", + }, + }, + ], + }, + }, + }); + + expect(browserServicesMock.commonService.apiCaller).toBeCalledWith({ + endpoint: "indices.putSettings", + method: "PUT", + data: { + index: "good_index", + flat_settings: true, + body: { + "index.number_of_replicas": "12", + }, + }, + }); + + expect(browserServicesMock.commonService.apiCaller).toBeCalledWith({ + endpoint: "indices.putMapping", + method: "PUT", + data: { + index: "good_index", + body: { + properties: { + test_mapping_2: { + type: "text", + }, + }, + }, + }, + }); + }); + }); + + it("shows detail alias and update alias only", async () => { + const { getByText, getByTestId, getByTitle } = renderCreateIndexWithRouter({ + index: "good_index", + mode: IndicesUpdateMode.alias, + }); + + await waitFor(() => {}); + + userEvent.click(getByTitle("update_test_1").querySelector("button") as Element); + userEvent.type(getByTestId("comboBoxSearchInput"), "test_1{enter}"); + await waitFor(() => {}); + userEvent.click(getByText("Update")); + + await waitFor(() => { + // shows detail alias and update alias only + expect(coreServicesMock.notifications.toasts.addSuccess).toBeCalledTimes(1); + expect(browserServicesMock.commonService.apiCaller).toBeCalledWith({ + endpoint: "indices.updateAliases", + method: "PUT", + data: { + body: { + actions: [ + { + remove: { + index: "good_index", + alias: "update_test_1", + }, + }, + { + add: { + index: "good_index", + alias: "test_1", + }, + }, + ], + }, + }, + }); + }); + }); + + it("shows detail mappings and update mappings only", async () => { + const { getByText, getByTestId } = renderCreateIndexWithRouter({ + index: "good_index", + mode: IndicesUpdateMode.mappings, + }); + + await waitFor(() => {}); + + userEvent.click(getByTestId("create index add field button")); + await waitFor(() => {}); + await userEvent.clear(getByTestId("mapping-visual-editor-1-field-name")); + await userEvent.type(getByTestId("mapping-visual-editor-1-field-name"), "test_mapping_2"); + await userEvent.click(document.body); + await waitFor(() => {}); + userEvent.click(getByText("Update")); + await waitFor(() => {}); + userEvent.click(getByTestId("change_diff_confirm-cancel")); + await waitFor(() => {}); + userEvent.click(getByText("Update")); + await waitFor(() => {}); + userEvent.click(getByTestId("change_diff_confirm-confirm")); + + await waitFor(() => { + // shows detail settings and update settings only + expect(coreServicesMock.notifications.toasts.addSuccess).toBeCalledTimes(1); + + expect(browserServicesMock.commonService.apiCaller).toBeCalledWith({ + endpoint: "indices.putMapping", + method: "PUT", + data: { + index: "good_index", + body: { + properties: { + test_mapping_2: { + type: "text", + }, + }, + }, + }, + }); + }); + }); + + it("shows detail settings and update settings only", async () => { + const { getByText, getByTestId } = renderCreateIndexWithRouter({ + index: "good_index", + mode: IndicesUpdateMode.settings, + }); + + await waitFor(() => {}); + + userEvent.type(getByTestId("form-name-index.number_of_replicas").querySelector("input") as Element, "2"); + userEvent.click(getByText("Update")); + await waitFor(() => {}); + userEvent.click(getByTestId("change_diff_confirm-confirm")); + + await waitFor(() => { + expect(coreServicesMock.notifications.toasts.addSuccess).toBeCalledTimes(1); + + expect(browserServicesMock.commonService.apiCaller).toBeCalledWith({ + endpoint: "indices.putSettings", + method: "PUT", + data: { + index: "good_index", + flat_settings: true, + body: { + "index.number_of_replicas": "12", + }, + }, + }); + }); + }); + + it("it triggers onCancel when click cancel", async () => { + const onCancelMock = jest.fn(); + const { getByText } = renderCreateIndexWithRouter({ + index: "good_index", + onCancel: onCancelMock, + }); + await waitFor(() => {}); + userEvent.click(getByText("Cancel")); + await (() => { + expect(onCancelMock).toBeCalledTimes(1); + expect(onCancelMock).toBeCalledWith(undefined); + }); + }); +}); diff --git a/public/pages/CreateIndex/containers/IndexForm/index.tsx b/public/pages/CreateIndex/containers/IndexForm/index.tsx new file mode 100644 index 000000000..f05c480bd --- /dev/null +++ b/public/pages/CreateIndex/containers/IndexForm/index.tsx @@ -0,0 +1,454 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { Component } from "react"; +import { EuiSpacer, EuiFlexGroup, EuiFlexItem, EuiButton, EuiButtonEmpty } from "@elastic/eui"; +import { get, set, differenceWith, isEqual } from "lodash"; +import { diffArrays } from "diff"; +import flattern from "flat"; +// eui depends on react-ace, so we can import react-ace here +import { MonacoEditorDiffReact } from "../../../../components/MonacoEditor"; +import IndexDetail from "../../components/IndexDetail"; +import { IAliasAction, IndexItem, IndexItemRemote, MappingsProperties } from "../../../../../models/interfaces"; +import { BREADCRUMBS, IndicesUpdateMode } from "../../../../utils/constants"; +import { CoreServicesContext } from "../../../../components/core_services"; +import { IIndexDetailRef, IndexDetailProps } from "../../components/IndexDetail/IndexDetail"; +import { transformArrayToObject, transformObjectToArray } from "../../components/IndexMapping/IndexMapping"; +import { CommonService } from "../../../../services/index"; +import { ServerResponse } from "../../../../../server/models/types"; +import { Modal } from "../../../../components/Modal"; + +export interface IndexFormProps { + index?: string; + mode?: IndicesUpdateMode; + commonService: CommonService; + onCancel?: () => void; + onSubmitSuccess?: () => void; +} + +interface CreateIndexState { + indexDetail: IndexItem; + oldIndexDetail?: IndexItem; + isSubmitting: boolean; +} + +export default class CreateIndex extends Component { + static contextType = CoreServicesContext; + state: CreateIndexState = { + isSubmitting: false, + indexDetail: { + index: "", + settings: { + "index.number_of_shards": 1, + "index.number_of_replicas": 1, + "index.refresh_interval": "1s", + }, + mappings: {}, + }, + oldIndexDetail: undefined, + }; + + indexDetailRef: IIndexDetailRef | null = null; + + get commonService() { + return this.props.commonService; + } + + get index() { + return this.props.index; + } + + get isEdit() { + return this.index !== undefined; + } + + get mode() { + return this.props.mode; + } + + componentDidMount = async (): Promise => { + const isEdit = this.isEdit; + if (isEdit) { + const response: ServerResponse> = await this.commonService.apiCaller({ + endpoint: "indices.get", + data: { + index: this.index, + flat_settings: true, + }, + }); + if (response.ok) { + const payload = { + ...response.response[this.index || ""], + index: this.index, + }; + set(payload, "mappings.properties", transformObjectToArray(get(payload, "mappings.properties", {}))); + + this.setState({ + indexDetail: payload as IndexItem, + oldIndexDetail: JSON.parse(JSON.stringify(payload)), + }); + } else { + this.context.notifications.toasts.addDanger(response.error); + } + } + this.context.chrome.setBreadcrumbs([ + BREADCRUMBS.INDEX_MANAGEMENT, + BREADCRUMBS.INDICES, + isEdit ? BREADCRUMBS.EDIT_INDEX : BREADCRUMBS.CREATE_INDEX, + ]); + }; + + onCancel = () => { + this.props.onCancel && this.props.onCancel(); + }; + + onDetailChange: IndexDetailProps["onChange"] = (value) => { + this.setState({ + indexDetail: { + ...this.state.indexDetail, + ...value, + }, + }); + }; + + updateAlias = async (): Promise> => { + const { indexDetail, oldIndexDetail } = this.state; + const { index } = indexDetail; + // handle the alias here + const diffedAliasArrayes = diffArrays(Object.keys(oldIndexDetail?.aliases || {}), Object.keys(indexDetail.aliases || {})); + const aliasActions: IAliasAction[] = diffedAliasArrayes.reduce((total: IAliasAction[], current) => { + if (current.added) { + return [ + ...total, + ...current.value.map((item) => ({ + add: { + index, + alias: item, + }, + })), + ]; + } else if (current.removed) { + return [ + ...total, + ...current.value.map((item) => ({ + remove: { + index, + alias: item, + }, + })), + ]; + } + + return total; + }, [] as IAliasAction[]); + + // alias may have many unexpected errors, do that before update index settings. + if (aliasActions.length) { + return await this.commonService.apiCaller({ + endpoint: "indices.updateAliases", + method: "PUT", + data: { + body: { + actions: aliasActions, + }, + }, + }); + } + + return Promise.resolve({ + ok: true, + response: {}, + }); + }; + updateSettings = async (): Promise> => { + const { indexDetail, oldIndexDetail } = this.state; + const { index } = indexDetail; + + const newSettings = (indexDetail?.settings || {}) as Required["settings"]; + const oldSettings = (oldIndexDetail?.settings || {}) as Required["settings"]; + const differences = differenceWith(Object.entries(newSettings), Object.entries(oldSettings), isEqual); + if (!differences.length) { + return { + ok: true, + response: {}, + }; + } + + const finalSettings = differences.reduce((total, current) => { + if (newSettings[current[0]] !== undefined) { + return { + ...total, + [current[0]]: newSettings[current[0]], + }; + } + + return total; + }, {}); + + return await this.commonService.apiCaller({ + endpoint: "indices.putSettings", + method: "PUT", + data: { + index, + flat_settings: true, + // In edit mode, only dynamic settings can be modified + body: finalSettings, + }, + }); + }; + updateMappings = async (): Promise> => { + const { indexDetail, oldIndexDetail } = this.state; + const { index } = indexDetail; + // handle the mappings here + const newMappingProperties = indexDetail?.mappings?.properties || []; + const diffedMappingArrayes = diffArrays( + (oldIndexDetail?.mappings?.properties || []).map((item) => item.fieldName), + newMappingProperties.map((item) => item.fieldName) + ); + const newMappingFields: MappingsProperties = diffedMappingArrayes + .filter((item) => item.added) + .reduce((total, current) => [...total, ...current.value], [] as string[]) + .map((current) => newMappingProperties.find((item) => item.fieldName === current) as MappingsProperties[number]) + .filter((item) => item); + + const newMappingSettings = transformArrayToObject(newMappingFields); + + if (newMappingFields.length) { + return await this.commonService.apiCaller({ + endpoint: "indices.putMapping", + method: "PUT", + data: { + index, + body: { + properties: newMappingSettings, + }, + }, + }); + } + + return Promise.resolve({ + ok: true, + response: {}, + }); + }; + + chainPromise = async (promises: Promise>[]): Promise> => { + const newPromises = [...promises]; + while (newPromises.length) { + const result = (await newPromises.shift()) as ServerResponse; + if (!result?.ok) { + return result; + } + } + + return { + ok: true, + response: {}, + }; + }; + + getOrderedJson = (json: Record) => { + const entries = Object.entries(json); + entries.sort((a, b) => (a[0] < b[0] ? -1 : 1)); + return entries.reduce((total, [key, value]) => ({ ...total, [key]: value }), {}); + }; + + showDiff = async (): Promise> => { + return new Promise((resolve, reject) => { + if (this.mode === IndicesUpdateMode.alias) { + resolve({ + ok: true, + response: {}, + }); + return; + } + Modal.show({ + title: "Please confirm the change.", + "data-test-subj": "change_diff_confirm", + type: "confirm", + maxWidth: "100%", + style: { + width: "70vw", + }, + content: ( + <> +

The following changes will be done once you click the confirm button, Please make sure you want to do all the changes.

+ + + + ), + onOk: () => + resolve({ + ok: true, + response: {}, + }), + onCancel: () => { + resolve({ + ok: false, + error: "", + }); + }, + }); + }); + }; + + onSubmit = async (): Promise => { + const mode = this.mode; + const { indexDetail } = this.state; + const { index, mappings, ...others } = indexDetail; + if (!(await this.indexDetailRef?.validate())) { + return; + } + this.setState({ isSubmitting: true }); + let result: ServerResponse; + if (this.isEdit) { + const diffConfirm = await this.showDiff(); + if (!diffConfirm.ok) { + this.setState({ isSubmitting: false }); + return; + } + let chainedPromises: Promise>[] = []; + if (!mode) { + chainedPromises.push(...[this.updateMappings(), this.updateAlias(), this.updateSettings()]); + } else { + switch (mode) { + case IndicesUpdateMode.alias: + chainedPromises.push(this.updateAlias()); + break; + case IndicesUpdateMode.settings: + chainedPromises.push(this.updateSettings()); + break; + case IndicesUpdateMode.mappings: + chainedPromises.push(this.updateMappings()); + break; + } + } + result = await this.chainPromise(chainedPromises); + } else { + result = await this.commonService.apiCaller({ + endpoint: "indices.create", + method: "PUT", + data: { + index, + body: { + ...others, + mappings: { + properties: transformArrayToObject(mappings?.properties || []), + }, + }, + }, + }); + } + this.setState({ isSubmitting: false }); + + // handle all the response here + if (result && result.ok) { + this.context.notifications.toasts.addSuccess(`[${indexDetail.index}] has been successfully ${this.isEdit ? "updated" : "created"}.`); + this.props.onSubmitSuccess && this.props.onSubmitSuccess(); + } else { + this.context.notifications.toasts.addDanger(result.error); + } + }; + + onSimulateIndexTemplate = (indexName: string): Promise> => { + return this.commonService + .apiCaller<{ template: IndexItemRemote }>({ + endpoint: "transport.request", + data: { + path: `/_index_template/_simulate_index/${indexName}`, + method: "POST", + }, + }) + .then((res) => { + if (res.ok && res.response && res.response.template) { + return { + ...res, + response: { + ...res.response.template, + settings: flattern(res.response.template?.settings || {}), + }, + }; + } + + return { + ok: false, + error: "", + } as ServerResponse; + }); + }; + + render() { + const isEdit = this.isEdit; + const { indexDetail, isSubmitting, oldIndexDetail } = this.state; + + return ( + <> + (this.indexDetailRef = ref)} + isEdit={this.isEdit} + value={indexDetail} + oldValue={oldIndexDetail} + onChange={this.onDetailChange} + onSimulateIndexTemplate={this.onSimulateIndexTemplate} + refreshOptions={(aliasName) => + this.commonService.apiCaller({ + endpoint: "cat.aliases", + method: "GET", + data: { + format: "json", + name: aliasName, + expand_wildcards: "open", + }, + }) + } + /> + + + + + + Cancel + + + + + {isEdit ? "Update" : "Create"} + + + + + ); + } +} diff --git a/public/pages/Indices/components/ShrinkIndexFlyout/__snapshots__/ShrinkIndexFlyout.test.tsx.snap b/public/pages/Indices/components/ShrinkIndexFlyout/__snapshots__/ShrinkIndexFlyout.test.tsx.snap index 8275aeba4..8bfc7bbd9 100644 --- a/public/pages/Indices/components/ShrinkIndexFlyout/__snapshots__/ShrinkIndexFlyout.test.tsx.snap +++ b/public/pages/Indices/components/ShrinkIndexFlyout/__snapshots__/ShrinkIndexFlyout.test.tsx.snap @@ -233,24 +233,28 @@ HTMLCollection [ > The number of primary shards in the new shrunken index. -
- +
+ +
-
+
-
- +
+ +
-
+
Must be a multi of undefined
-
- +
+ +
-
+
spec", () => { + const onUpdateSuccessMock = jest.fn(); it("render the component", async () => { - browserServicesMock.commonService.apiCaller = jest.fn().mockResolvedValue({ - ok: true, - response: { - test_index: { - aliases: {}, - mappings: {}, - settings: { - index: { - number_of_shards: "1", - number_of_replicas: "1", - provided_name: "test_index", - }, - }, - }, - }, - }); - const { container, getByTestId } = renderWithRouter({ + const { container, getByTestId, getByDisplayValue } = renderWithRouter({ index: "test_index", record: { "docs.count": "5", @@ -64,6 +50,10 @@ describe("container spec", () => { data_stream: "", }, onDelete: () => null, + onUpdateIndex: onUpdateSuccessMock, + onClose: () => null, + onOpen: () => null, + onShrink: () => null, }); await waitFor(() => { @@ -82,5 +72,14 @@ describe("container spec", () => { }, }); }); + + userEvent.click(document.getElementById("index-detail-modal-alias") as Element); + await waitFor(() => { + expect(getByDisplayValue("test_index")).not.toBeNull(); + }); + userEvent.click(getByTestId("createIndexCreateButton")); + await waitFor(() => { + expect(onUpdateSuccessMock).toBeCalledTimes(1); + }); }); }); diff --git a/public/pages/Indices/containers/IndexDetail/index.tsx b/public/pages/Indices/containers/IndexDetail/index.tsx index d2d7cd050..e0e88feb5 100644 --- a/public/pages/Indices/containers/IndexDetail/index.tsx +++ b/public/pages/Indices/containers/IndexDetail/index.tsx @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { useContext, useEffect, useMemo, useState } from "react"; +import React, { useCallback, useContext, useEffect, useMemo, useState } from "react"; import { EuiButtonEmpty, EuiCopy, @@ -11,17 +11,11 @@ import { EuiDescriptionList, EuiDescriptionListTitle, EuiDescriptionListDescription, - EuiFlexGrid, - EuiFlexItem, EuiSpacer, - EuiFlexGroup, - EuiButton, - EuiBasicTable, EuiFlyout, EuiFlyoutHeader, EuiTitle, EuiFlyoutBody, - EuiCodeBlock, } from "@elastic/eui"; import { get } from "lodash"; import { Link } from "react-router-dom"; @@ -31,10 +25,12 @@ import { ManagedCatIndex } from "../../../../../server/models/interfaces"; import { IndicesUpdateMode, ROUTES } from "../../../../utils/constants"; import { ServicesContext } from "../../../../services"; import { BrowserServices } from "../../../../models/interfaces"; +import IndexForm from "../../../CreateIndex/containers/IndexForm"; -export interface IndexDetailModalProps extends Pick { +export interface IndexDetailModalProps extends Omit { index: string; record: ManagedCatIndex; + onUpdateIndex: () => void; } interface IFinalDetail extends ManagedCatIndex, IndexItem {} @@ -57,7 +53,7 @@ const OVERVIEW_DISPLAY_INFO: { }, { label: "Creation date", - value: ({ detail }) => {new Date(parseInt(detail.settings?.index.creation_date || "0")).toLocaleString()}, + value: ({ detail }) => {new Date(parseInt(detail.settings?.index?.creation_date || "0")).toLocaleString()}, }, { label: "Total size", @@ -94,7 +90,7 @@ const OVERVIEW_DISPLAY_INFO: { return (
    {blocks.map(([key, value]) => ( -
  • {key}
  • +
  • {key}
  • ))}
); @@ -111,7 +107,7 @@ const OVERVIEW_DISPLAY_INFO: { ]; export default function IndexDetail(props: IndexDetailModalProps) { - const { index, record, onDelete } = props; + const { index, record, onUpdateIndex, ...others } = props; const [visible, setVisible] = useState(false); const [detail, setDetail] = useState({} as IndexItem); const finalDetail: IFinalDetail = useMemo( @@ -149,6 +145,21 @@ export default function IndexDetail(props: IndexDetailModalProps) { }); } }, [visible]); + + const onCloseFlyout = useCallback(() => { + setVisible(false); + }, [setVisible]); + + const indexFormCommonProps = { + index: props.index, + commonService: services.commonService, + onCancel: onCloseFlyout, + onSubmitSuccess: () => { + onCloseFlyout(); + onUpdateIndex(); + }, + }; + return ( <> @@ -158,14 +169,13 @@ export default function IndexDetail(props: IndexDetailModalProps) { {index} {visible ? ( - setVisible(false)} hideCloseButton> +
{index} - {/* {index} */} - +
@@ -209,22 +219,7 @@ export default function IndexDetail(props: IndexDetailModalProps) { content: ( <> - - -

Advanced index settings

-
- - - - Edit - - - -
- - - {JSON.stringify(finalDetail.settings || {}, null, 2)} - + ), }, @@ -234,22 +229,7 @@ export default function IndexDetail(props: IndexDetailModalProps) { content: ( <> - - -

Index mappings

-
- - - - Edit - - - -
- - - {JSON.stringify(finalDetail.mappings || {}, null, 2)} - + ), }, @@ -259,31 +239,7 @@ export default function IndexDetail(props: IndexDetailModalProps) { content: ( <> - - -

Index alias

-
- - - - Edit - - - -
- - ({ alias: item }))} - columns={[ - { - field: "alias", - name: "Alias name", - render: (val: string, record: { alias: string }) => {val}, - }, - ]} - /> + ), }, diff --git a/public/pages/Indices/containers/Indices/Indices.tsx b/public/pages/Indices/containers/Indices/Indices.tsx index 0571dd8f5..7be690553 100644 --- a/public/pages/Indices/containers/Indices/Indices.tsx +++ b/public/pages/Indices/containers/Indices/Indices.tsx @@ -113,7 +113,14 @@ export default class Indices extends Component { if (getIndicesResponse.ok) { const { indices, totalIndices } = getIndicesResponse.response; - this.setState({ indices, totalIndices }); + const payload = { + indices, + totalIndices, + selectedItems: this.state.selectedItems + .map((item) => indices.find((remoteItem) => remoteItem.index === item.index)) + .filter((item) => item), + } as IndicesState; + this.setState(payload); } else { this.context.notifications.toasts.addDanger(getIndicesResponse.error); } @@ -260,6 +267,7 @@ export default class Indices extends Component { onClose: this.getIndices, onShrink: this.getIndices, onReindex: this.getIndices, + onUpdateIndex: this.getIndices, })} isSelectable={true} itemId="index" diff --git a/public/pages/Indices/containers/IndicesActions/IndicesActions.test.tsx b/public/pages/Indices/containers/IndicesActions/IndicesActions.test.tsx index cfe97a7d3..baa80c3a7 100644 --- a/public/pages/Indices/containers/IndicesActions/IndicesActions.test.tsx +++ b/public/pages/Indices/containers/IndicesActions/IndicesActions.test.tsx @@ -274,7 +274,7 @@ describe(" spec", () => { await waitFor(() => { expect(browserServicesMock.commonService.apiCaller).toHaveBeenCalledTimes(2); expect(coreServicesMock.notifications.toasts.addSuccess).toHaveBeenCalledTimes(1); - expect(coreServicesMock.notifications.toasts.addSuccess).toHaveBeenCalledWith("Delete successfully"); + expect(coreServicesMock.notifications.toasts.addSuccess).toHaveBeenCalledWith("Delete [test_index] successfully"); expect(onDelete).toHaveBeenCalledTimes(1); }); }); diff --git a/public/pages/Indices/containers/IndicesActions/index.tsx b/public/pages/Indices/containers/IndicesActions/index.tsx index 452a35ee8..abdf524a3 100644 --- a/public/pages/Indices/containers/IndicesActions/index.tsx +++ b/public/pages/Indices/containers/IndicesActions/index.tsx @@ -22,8 +22,8 @@ import ShrinkIndexFlyout from "../../components/ShrinkIndexFlyout"; import { getErrorMessage } from "../../../../utils/helpers"; import ReindexFlyout from "../../components/ReindexFlyout"; import SplitIndexFlyout from "../../components/SplitIndexFlyout"; -import {IndexItem} from "../../../../../models/interfaces"; -import {ServerResponse} from "../../../../../server/models/types"; +import { IndexItem } from "../../../../../models/interfaces"; +import { ServerResponse } from "../../../../../server/models/types"; export interface IndicesActionsProps { selectedItems: ManagedCatIndex[]; @@ -50,14 +50,15 @@ export default function IndicesActions(props: IndicesActionsProps) { }; const onDeleteIndexModalConfirm = useCallback(async () => { + const indexPayload = selectedItems.map((item) => item.index).join(","); const result = await services.commonService.apiCaller({ endpoint: "indices.delete", data: { - index: selectedItems.map((item) => item.index).join(","), + index: indexPayload, }, }); if (result && result.ok) { - coreServices.notifications.toasts.addSuccess("Delete successfully"); + coreServices.notifications.toasts.addSuccess(`Delete [${indexPayload}] successfully`); onDeleteIndexModalClose(); onDelete(); } else { @@ -78,7 +79,7 @@ export default function IndicesActions(props: IndicesActionsProps) { target: targetIndex, body: { settings: { - ...settingsPayload + ...settingsPayload, }, }, }, @@ -88,8 +89,9 @@ export default function IndicesActions(props: IndicesActionsProps) { onDelete(); onCloseFlyout(); } else { - coreServices.notifications.toasts.addDanger(result?.error || - "There was a problem submit split index request, please check with admin"); + coreServices.notifications.toasts.addDanger( + result?.error || "There was a problem submit split index request, please check with admin" + ); } }; @@ -175,7 +177,7 @@ export default function IndicesActions(props: IndicesActionsProps) { ); const getIndexSettings = async (indexName: string, flat: boolean): Promise> => { - const result : ServerResponse> = await services.commonService.apiCaller({ + const result: ServerResponse> = await services.commonService.apiCaller({ endpoint: "indices.getSettings", data: { index: indexName, @@ -282,8 +284,7 @@ export default function IndicesActions(props: IndicesActionsProps) { { name: "Split", "data-test-subj": "Split Action", - disabled: !selectedItems.length - || selectedItems.length > 1, + disabled: !selectedItems.length || selectedItems.length > 1, onClick: () => setSplitIndexFlyoutVisible(true), }, { @@ -338,14 +339,15 @@ export default function IndicesActions(props: IndicesActionsProps) { /> )} - {splitIndexFlyoutVisible && + {splitIndexFlyoutVisible && ( } + /> + )} ); } diff --git a/public/pages/Indices/utils/constants.tsx b/public/pages/Indices/utils/constants.tsx index 77eeac269..f21577b73 100644 --- a/public/pages/Indices/utils/constants.tsx +++ b/public/pages/Indices/utils/constants.tsx @@ -5,7 +5,7 @@ import React from "react"; import { EuiHealth, EuiTableFieldDataColumnType } from "@elastic/eui"; -import IndexDetail from "../containers/IndexDetail"; +import IndexDetail, { IndexDetailModalProps } from "../containers/IndexDetail"; import { IndicesActionsProps } from "../containers/IndicesActions"; import { ManagedCatIndex } from "../../../../server/models/interfaces"; import { SortDirection } from "../../../utils/constants"; @@ -32,7 +32,7 @@ const HEALTH_TO_COLOR: { red: "danger", }; -interface IColumnOptions extends Omit {} +interface IColumnOptions extends Omit, Pick {} const getColumns = (props: IColumnOptions): EuiTableFieldDataColumnType[] => { return [ diff --git a/test/mocks/index.ts b/test/mocks/index.ts index df4405a77..47bf18162 100644 --- a/test/mocks/index.ts +++ b/test/mocks/index.ts @@ -9,4 +9,100 @@ import httpClientMock from "./httpClientMock"; import styleMock from "./styleMock"; import coreServicesMock from "./coreServicesMock"; -export { browserServicesMock, historyMock, httpClientMock, styleMock, coreServicesMock }; +const apiCallerMock = (browserServicesMockObject: typeof browserServicesMock) => { + browserServicesMockObject.commonService.apiCaller = jest.fn( + async (payload): Promise => { + switch (payload.endpoint) { + case "transport.request": { + if (payload.data?.path?.startsWith("/_index_template/_simulate_index/bad_index")) { + return { + ok: true, + response: {}, + }; + } else { + return { + ok: true, + response: { + template: { + settings: { + index: { + number_of_replicas: "10", + }, + }, + }, + }, + }; + } + } + case "indices.create": + if (payload.data?.index === "bad_index") { + return { + ok: false, + error: "bad_index", + }; + } + + return { + ok: true, + response: {}, + }; + break; + case "cat.aliases": + return { + ok: true, + response: [ + { + alias: ".kibana", + index: ".kibana_1", + filter: "-", + is_write_index: "-", + }, + { + alias: "2", + index: "1234", + filter: "-", + is_write_index: "-", + }, + ], + }; + case "indices.get": + const payloadIndex = payload.data?.index; + if (payloadIndex === "bad_index") { + return { + ok: false, + error: "bad_error", + response: {}, + }; + } + + return { + ok: true, + response: { + [payload.data?.index]: { + aliases: { + update_test_1: {}, + }, + mappings: { + properties: { + test_mapping_1: { + type: "text", + }, + }, + }, + settings: { + "index.number_of_shards": "1", + "index.number_of_replicas": "1", + }, + }, + }, + }; + } + return { + ok: true, + response: {}, + }; + } + ); +}; + +export { browserServicesMock, historyMock, httpClientMock, styleMock, coreServicesMock, apiCallerMock };