diff --git a/cypress/integration/create_index.js b/cypress/integration/create_index.js new file mode 100644 index 000000000..21720762b --- /dev/null +++ b/cypress/integration/create_index.js @@ -0,0 +1,273 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { PLUGIN_NAME } from "../support/constants"; + +const SAMPLE_INDEX = "index-specific-index"; + +describe("Create Index", () => { + before(() => { + // Set welcome screen tracking to false + localStorage.setItem("home:welcome:show", "false"); + cy.deleteAllIndices(); + cy.deleteTemplate("index-common-template"); + cy.deleteTemplate("index-specific-template"); + cy.createIndexTemplate("index-common-template", { + index_patterns: ["index-*"], + template: { + aliases: { + alias_for_common_1: {}, + alias_for_common_2: {}, + }, + settings: { + number_of_shards: 2, + number_of_replicas: 1, + }, + }, + }); + cy.createIndexTemplate("index-specific-template", { + index_patterns: ["index-specific-*"], + priority: 1, + template: { + aliases: { + alias_for_specific_1: {}, + }, + settings: { + number_of_shards: 3, + number_of_replicas: 2, + }, + mappings: { + properties: { + text: { + type: "text", + }, + }, + }, + }, + }); + }); + + describe("can be created and updated", () => { + beforeEach(() => { + // Visit ISM OSD + cy.visit(`${Cypress.env("opensearch_dashboards")}/app/${PLUGIN_NAME}#/indices`); + cy.contains("Rows per page", { timeout: 60000 }); + }); + + it("Create a index successfully", () => { + // enter create page + cy.get('[data-test-subj="Create IndexButton"]').click(); + cy.contains("Create index"); + + // type field name + cy.get('[placeholder="Specify a name for the new index."]').type(SAMPLE_INDEX).blur(); + + cy.wait(1000); + + cy.get('[data-test-subj="comboBoxSearchInput"]').get('[title="alias_for_specific_1"]').should("exist"); + + cy.get('[data-test-subj="comboBoxSearchInput"]').type("some_test_alias{enter}"); + + cy.get('[data-test-subj="editorTypeJsonEditor"]').click().end(); + + cy.get('[data-test-subj="mappingsJsonEditorFormRow"] [data-test-subj="jsonEditor-valueDisplay"]').should(($editor) => { + expect(JSON.parse($editor.val())).to.deep.equal({ + properties: { + text: { + type: "text", + }, + }, + }); + }); + + cy.get('[data-test-subj="mappingsJsonEditorFormRow"] .ace_text-input') + .focus() + .clear({ force: true }) + .type( + JSON.stringify({ + properties: { + text: { + type: "text", + }, + }, + dynamic: true, + }), + { parseSpecialCharSequences: false, force: true } + ) + .end() + .wait(1000) + .get('[data-test-subj="editorTypeVisualEditor"]') + .click() + .end(); + + // add a field + cy.get('[data-test-subj="createIndexAddFieldButton"]').click().end(); + cy.get('[data-test-subj="mapping-visual-editor-1-field-name"]').type("text_mappings"); + + // click create + cy.get('[data-test-subj="createIndexCreateButton"]').click({ force: true }); + + // The index should exist + cy.get(`#_selection_column_${SAMPLE_INDEX}-checkbox`).should("have.exist"); + + // check the index detail + cy.visit(`${Cypress.env("opensearch_dashboards")}/app/${PLUGIN_NAME}#/create-index/${SAMPLE_INDEX}`); + + // index name and alias should exist + cy.get(`[title="${SAMPLE_INDEX}"]`) + .should("have.exist") + .end() + .get('[title="some_test_alias"]') + .should("have.exist") + .end() + .get('[data-test-subj="mapping-visual-editor-0-field-type"]') + .should("have.attr", "title", "text") + .end() + .get('[data-test-subj="mapping-visual-editor-1-field-name"]') + .should("have.attr", "title", "text_mappings") + .end() + .get('[data-test-subj="editorTypeJsonEditor"]') + .click() + .end() + .get('[data-test-subj="mappingsJsonEditorFormRow"] [data-test-subj="jsonEditor-valueDisplay"]') + .should(($editor) => { + expect(JSON.parse($editor.val())).to.deep.equal({ + dynamic: "true", + properties: {}, + }); + }); + }); + + it("Update alias successfully", () => { + cy.get(`[data-test-subj="viewIndexDetailButton-${SAMPLE_INDEX}"]`) + .click() + .get("#indexDetailModalAlias") + .click() + .get('[data-test-subj="detailModalEdit"]') + .click() + .end(); + + // add a alias and remove the exist alias + cy.get('[data-test-subj="comboBoxSearchInput"]') + .type("some_new_test_alias{enter}") + .end() + .get('[title="some_test_alias"] .euiBadge__iconButton') + .click() + .end() + .get('[data-test-subj="createIndexCreateButton"]') + .click({ force: true }) + .end(); + + cy.get('[title="some_test_alias"]').should("not.exist").end().get('[title="some_new_test_alias"]').should("exist").end(); + }); + + it("Update settings successfully", () => { + cy.get(`[data-test-subj="viewIndexDetailButton-${SAMPLE_INDEX}"]`) + .click() + .get("#indexDetailModalSettings") + .click() + .get('[data-test-subj="detailModalEdit"]') + .click() + .end(); + + cy.get('[data-test-subj="index-form-in-index-detail"] [aria-controls="accordionForCreateIndexSettings"]') + .click() + .end() + .get('[data-test-subj="index-form-in-index-detail"] .ace_text-input') + .focus() + .clear({ force: true }) + .type('{ "index.blocks.write": true, "index.number_of_shards": 2, "index.number_of_replicas": 3 }', { + parseSpecialCharSequences: false, + force: true, + }); + + cy.get('[data-test-subj="createIndexCreateButton"]').click({ force: true }); + + cy.contains(`Can't update non dynamic settings`).should("exist"); + + cy.get('[data-test-subj="index-form-in-index-detail"] .ace_text-input') + .focus() + .clear({ force: true }) + .type('{ "index.blocks.write": true }', { parseSpecialCharSequences: false, force: true }) + .end() + .wait(1000) + .get('[data-test-subj="index-form-in-index-detail"] [placeholder="The number of replica shards each primary shard should have."]') + .clear() + .type(2) + .end(); + + cy.get('[data-test-subj="createIndexCreateButton"]').click({ force: true }); + + cy.wait(1000).get('[data-test-subj="form-name-index.number_of_replicas"] .euiText').should("have.text", "2"); + }); + + it("Update mappings successfully", () => { + cy.get(`[data-test-subj="viewIndexDetailButton-${SAMPLE_INDEX}"]`) + .click() + .get("#indexDetailModalMappings") + .click() + .get('[data-test-subj="detailModalEdit"]') + .click() + .end(); + + cy.get('[data-test-subj="createIndexAddFieldButton"]') + .click() + .end() + .get('[data-test-subj="mapping-visual-editor-2-field-name"]') + .type("text_mappings_2") + .end() + .get('[data-test-subj="createIndexCreateButton"]') + .click({ force: true }); + + cy.get('[data-test-subj="mapping-visual-editor-2-field-type"]').should("have.attr", "title", "text").end(); + + cy.get('[data-test-subj="detailModalEdit"]') + .click() + .end() + .get('[data-test-subj="index-form-in-index-detail"] [data-test-subj="editorTypeJsonEditor"]') + .click() + .end() + .get('[data-test-subj="index-form-in-index-detail"] .ace_text-input') + .focus() + .clear({ force: true }) + .type('{ "dynamic": true }', { parseSpecialCharSequences: false, force: true }) + .end() + .wait(1000) + .get('[data-test-subj="createIndexCreateButton"]') + .click({ force: true }); + + cy.wait(1000) + .get('[data-test-subj="editorTypeJsonEditor"]') + .click() + .end() + .get('[data-test-subj="jsonEditor-valueDisplay"]') + .should( + "have.text", + JSON.stringify( + { + dynamic: "true", + properties: { + text: { + type: "text", + }, + text_mappings: { + type: "text", + }, + text_mappings_2: { + type: "text", + }, + }, + }, + null, + 2 + ) + ); + }); + }); + + after(() => { + cy.deleteTemplate("index-common-template"); + cy.deleteTemplate("index-specific-template"); + }); +}); diff --git a/cypress/integration/indices_spec.js b/cypress/integration/indices_spec.js index e20986940..7962b853b 100644 --- a/cypress/integration/indices_spec.js +++ b/cypress/integration/indices_spec.js @@ -132,12 +132,10 @@ describe("Indices", () => { cy.get(`[data-test-subj="checkboxSelectRow-${SAMPLE_INDEX}"]`).check({ force: true }); // Click apply policy button + cy.get('[data-test-subj="moreAction"]').click(); cy.get(`[data-test-subj="Apply policyButton"]`).click({ force: true }); - cy.get(`input[data-test-subj="comboBoxSearchInput"]`).focus().type(POLICY_ID, { - parseSpecialCharSequences: false, - delay: 1, - }); + cy.get(`input[data-test-subj="comboBoxSearchInput"]`).click().type(POLICY_ID); // Click the policy option cy.get(`button[role="option"]`).first().click({ force: true }); @@ -153,6 +151,257 @@ describe("Indices", () => { // Confirm our index is now being managed cy.get(`tbody > tr:contains("${SAMPLE_INDEX}") > td`).filter(`:nth-child(4)`).contains("Yes"); + + // Confirm the information shows in detail modal + cy.get(`[data-test-subj="viewIndexDetailButton-${SAMPLE_INDEX}"]`).click(); + cy.get(`[data-test-subj="index-detail-overview-item-Managed by policy"] .euiDescriptionList__description a`).contains(POLICY_ID); + }); + }); + + describe("can make indices deleted", () => { + before(() => { + cy.deleteAllIndices(); + cy.createIndex(SAMPLE_INDEX); + }); + + it("successfully", () => { + // Confirm we have our initial index + cy.contains(SAMPLE_INDEX); + + // Click actions button + cy.get('[data-test-subj="moreAction"]').click(); + + // Delete btn should be disabled if no items selected + cy.get('[data-test-subj="deleteAction"]').should("have.class", "euiContextMenuItem-isDisabled"); + + // click any where to hide actions + cy.get("#_selection_column_sample_index-checkbox").click(); + cy.get('[data-test-subj="deleteAction"]').should("not.exist"); + + // Click actions button + cy.get('[data-test-subj="moreAction"]').click(); + // Delete btn should be enabled + cy.get('[data-test-subj="deleteAction"]').should("exist").should("not.have.class", "euiContextMenuItem-isDisabled").click(); + // The confirm button should be disabled + cy.get('[data-test-subj="Delete Confirm button"]').should("have.class", "euiButton-isDisabled"); + // type delete + cy.get('[placeholder="delete"]').type("delete"); + cy.get('[data-test-subj="Delete Confirm button"]').should("not.have.class", "euiContextMenuItem-isDisabled"); + // click to delete + cy.get('[data-test-subj="Delete Confirm button"]').click(); + // the sample_index should not exist + cy.wait(500); + cy.get("#_selection_column_sample_index-checkbox").should("not.exist"); + }); + }); + + describe("shows detail of a index when click the item", () => { + before(() => { + cy.deleteAllIndices(); + cy.createIndex(SAMPLE_INDEX); + }); + + it("successfully", () => { + cy.get(`[data-test-subj="viewIndexDetailButton-${SAMPLE_INDEX}"]`).click(); + cy.get(`[data-test-subj="index-detail-overview-item-Index name"] .euiDescriptionList__description > span`).should( + "have.text", + SAMPLE_INDEX + ); + }); + }); + + describe("can search with reindex & recovery status", () => { + const reindexedIndex = "reindex_opensearch_dashboards_sample_data_ecommerce"; + const splittedIndex = "split_opensearch_dashboards_sample_data_logs"; + before(() => { + cy.deleteAllIndices(); + // Visit ISM OSD + cy.visit(`${Cypress.env("opensearch_dashboards")}/app/${PLUGIN_NAME}#/indices`); + + // Common text to wait for to confirm page loaded, give up to 60 seconds for initial load + cy.contains("Rows per page", { timeout: 60000 }); + + cy.request({ + method: "POST", + url: `${Cypress.env("opensearch_dashboards")}/api/sample_data/ecommerce`, + headers: { + "osd-xsrf": true, + }, + }).then((response) => { + expect(response.status).equal(200); + }); + + cy.request({ + method: "POST", + url: `${Cypress.env("opensearch_dashboards")}/api/sample_data/logs`, + headers: { + "osd-xsrf": true, + }, + }).then((response) => { + expect(response.status).equal(200); + }); + + cy.request({ + method: "PUT", + url: `${Cypress.env("opensearch")}/${splittedIndex}/_settings`, + body: { + "index.blocks.read_only": false, + }, + failOnStatusCode: false, + }); + cy.request({ + method: "DELETE", + url: `${Cypress.env("opensearch")}/${reindexedIndex}`, + failOnStatusCode: false, + }); + cy.request({ + method: "DELETE", + url: `${Cypress.env("opensearch")}/${splittedIndex}`, + failOnStatusCode: false, + }); + }); + + it("Successfully", () => { + cy.request({ + method: "PUT", + url: `${Cypress.env("opensearch")}/${reindexedIndex}`, + body: { + settings: { + index: { + number_of_shards: 1, + number_of_replicas: "0", + }, + }, + }, + }); + // do a simple reindex + cy.request("POST", `${Cypress.env("opensearch")}/_reindex?wait_for_completion=false`, { + source: { + index: "opensearch_dashboards_sample_data_ecommerce", + }, + dest: { + index: reindexedIndex, + }, + }); + + cy.get('[placeholder="Search"]').type("o"); + + // do a simple split + cy.request("PUT", `${Cypress.env("opensearch")}/opensearch_dashboards_sample_data_logs/_settings`, { + "index.blocks.write": true, + }); + + cy.request({ + method: "POST", + url: `${Cypress.env("opensearch_dashboards")}/api/ism/apiCaller`, + headers: { + "osd-xsrf": true, + }, + body: { + endpoint: "indices.split", + data: { + index: "opensearch_dashboards_sample_data_logs", + target: splittedIndex, + body: { + settings: { + index: { + number_of_shards: 2, + }, + }, + }, + }, + }, + }); + + cy.get('[placeholder="Search"]').type("p"); + }); + + after(() => { + cy.request({ + method: "DELETE", + url: `${Cypress.env("opensearch")}/${reindexedIndex}`, + failOnStatusCode: false, + }); + cy.request({ + method: "DELETE", + url: `${Cypress.env("opensearch")}/${splittedIndex}`, + failOnStatusCode: false, + }); + }); + }); + + describe("can close and open an index", () => { + before(() => { + cy.deleteAllIndices(); + cy.createIndex(SAMPLE_INDEX); + }); + + it("successfully close an index", () => { + cy.contains(SAMPLE_INDEX); + + cy.get('[data-test-subj="moreAction"]').click(); + // Close btn should be disabled if no items selected + cy.get('[data-test-subj="Close Action"]').should("have.class", "euiContextMenuItem-isDisabled"); + + // Select an index + cy.get(`[data-test-subj="checkboxSelectRow-${SAMPLE_INDEX}"]`).check({ force: true }); + + cy.get('[data-test-subj="moreAction"]').click(); + // Close btn should be enabled + cy.get('[data-test-subj="Close Action"]').should("exist").should("not.have.class", "euiContextMenuItem-isDisabled").click(); + + // Check for close index modal + cy.contains("Close indices"); + + // Close confirm button should be disabled + cy.get('[data-test-subj="Close Confirm button"]').should("have.class", "euiButton-isDisabled"); + // type close + cy.get('[placeholder="close"]').type("close"); + cy.get('[data-test-subj="Close Confirm button"]').should("not.have.class", "euiContextMenuItem-isDisabled"); + + // Click close confirm button + cy.get('[data-test-subj="Close Confirm button"]').click(); + + // Check for success toast + cy.contains("Close [sample_index] successfully"); + + // Confirm the index is closed + cy.get(`input[type="search"]`).focus().type(SAMPLE_INDEX); + cy.get("tbody > tr").should(($tr) => { + expect($tr, "1 row").to.have.length(1); + expect($tr, "item").to.contain("close"); + }); + }); + + it("successfully open an index", () => { + // Confirm we have our initial index + cy.contains(SAMPLE_INDEX); + + cy.get('[data-test-subj="moreAction"]').click(); + // Open btn should be disabled if no items selected + cy.get('[data-test-subj="Open Action"]').should("have.class", "euiContextMenuItem-isDisabled"); + + // Select an index + cy.get(`[data-test-subj="checkboxSelectRow-${SAMPLE_INDEX}"]`).check({ force: true }); + + cy.get('[data-test-subj="moreAction"]').click(); + // Open btn should be enabled + cy.get('[data-test-subj="Open Action"]').should("exist").should("not.have.class", "euiContextMenuItem-isDisabled").click(); + + // Check for open index modal + cy.contains("Open indices"); + + cy.get('[data-test-subj="Open Confirm button"]').click(); + + // Check for success toast + cy.contains("Open [sample_index] successfully"); + + // Confirm the index is open + cy.get(`input[type="search"]`).focus().type(SAMPLE_INDEX); + cy.get("tbody > tr").should(($tr) => { + expect($tr, "1 row").to.have.length(1); + expect($tr, "item").to.contain("open"); + }); }); }); }); diff --git a/models/interfaces.ts b/models/interfaces.ts index 136a900a2..1caf6843b 100644 --- a/models/interfaces.ts +++ b/models/interfaces.ts @@ -23,6 +23,66 @@ export interface ManagedIndexMetaData { info?: object; } +export type MappingsPropertiesObject = Record< + string, + { + type: string; + properties?: MappingsPropertiesObject; + } +>; + +export type MappingsProperties = { + fieldName: string; + type: string; + path?: string; + analyzer?: string; + properties?: MappingsProperties; +}[]; + +export interface IndexItem { + index: string; + indexUuid?: string; + data_stream: string | null; + settings?: { + index?: { + number_of_shards?: number; + number_of_replicas?: number; + creation_date?: string; + [key: string]: any; + }; + "index.number_of_shards"?: number; + "index.number_of_replicas"?: number; + "index.refresh_interval"?: string; + [key: string]: any; + }; + aliases?: Record; + mappings?: { + properties?: MappingsProperties; + [key: string]: any; + }; +} + +export interface IndexItemRemote extends Omit { + mappings?: { + properties?: MappingsPropertiesObject; + }; +} + +interface ITemplateExtras { + name: string; + data_stream?: {}; + version: number; + priority: number; + index_patterns: string[]; +} + +export interface TemplateItem extends ITemplateExtras { + template: Pick; +} +export interface TemplateItemRemote extends ITemplateExtras { + template: Pick; +} + /** * ManagedIndex item shown in the Managed Indices table */ diff --git a/public/pages/CreateIndex/components/AliasSelect/AliasSelect.test.tsx b/public/pages/CreateIndex/components/AliasSelect/AliasSelect.test.tsx new file mode 100644 index 000000000..b78e65f70 --- /dev/null +++ b/public/pages/CreateIndex/components/AliasSelect/AliasSelect.test.tsx @@ -0,0 +1,117 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState } from "react"; +import { render, waitFor } from "@testing-library/react"; +import AliasSelect, { AliasSelectProps } from "./index"; +import userEvent from "@testing-library/user-event"; + +const onChangeMock = jest.fn(); + +const AliasSelectWithOnchange = (props: AliasSelectProps) => { + const [tempValue, setTempValue] = useState(props.value); + return ( + { + onChangeMock(val); + setTempValue(val); + }} + /> + ); +}; + +describe(" spec", () => { + it("renders the component and remove duplicate aliases", async () => { + const onOptionsChange = jest.fn(); + const { container } = render( + + Promise.resolve({ + ok: true, + response: [ + { + alias: "a", + index: "a", + }, + { + alias: "a", + index: "b", + }, + ], + }) + } + onChange={() => {}} + onOptionsChange={onOptionsChange} + /> + ); + await waitFor( + () => { + expect(onOptionsChange).toBeCalledWith([ + { + label: "a", + }, + ]); + expect(container.firstChild).toMatchSnapshot(); + }, + { + timeout: 3000, + } + ); + }); + + it("renders with error", async () => { + const onOptionsChange = jest.fn(); + const { container } = render( + + Promise.resolve({ + ok: false, + error: "Some error", + }) + } + onChange={() => {}} + onOptionsChange={onOptionsChange} + /> + ); + await waitFor(() => {}); + expect(container).toMatchSnapshot(); + }); + + it("it should choose options or create one", async () => { + const { getByTestId } = render( + Promise.resolve({ ok: true, response: [{ alias: "test", index: "123", query: "test" }] })} + /> + ); + await waitFor(() => { + expect(getByTestId("comboBoxInput")).toBeInTheDocument(); + }); + await userEvent.click(getByTestId("comboBoxInput")); + await waitFor(() => { + expect(document.querySelector('button[title="test"]')).toBeInTheDocument(); + }); + await userEvent.click(document.querySelector('button[title="test"]') as Element); + await waitFor(() => { + expect(onChangeMock).toBeCalledTimes(1); + expect(onChangeMock).toBeCalledWith({ + test: {}, + }); + }); + await userEvent.type(getByTestId("comboBoxInput"), "test2{enter}"); + await waitFor(() => { + expect(onChangeMock).toBeCalledTimes(2); + expect(onChangeMock).toBeCalledWith({ + test: {}, + test2: {}, + }); + }); + await userEvent.type(getByTestId("comboBoxInput"), " {enter}"); + await waitFor(() => { + expect(onChangeMock).toBeCalledTimes(2); + }); + }); +}); diff --git a/public/pages/CreateIndex/components/AliasSelect/__snapshots__/AliasSelect.test.tsx.snap b/public/pages/CreateIndex/components/AliasSelect/__snapshots__/AliasSelect.test.tsx.snap new file mode 100644 index 000000000..fe44d3c78 --- /dev/null +++ b/public/pages/CreateIndex/components/AliasSelect/__snapshots__/AliasSelect.test.tsx.snap @@ -0,0 +1,115 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` spec renders the component and remove duplicate aliases 1`] = ` + + } + > + <> + + + } + titleSize="s" + > + {content} + + ); + })()} + {templateSimulateLoading ? ( + + + We are simulating your template with existing templates, please wait for a second. + + ) : null} + + ); +}; + +// @ts-ignore +export default forwardRef(IndexDetail); diff --git a/public/pages/CreateIndex/components/IndexDetail/__snapshots__/IndexDetail.test.tsx.snap b/public/pages/CreateIndex/components/IndexDetail/__snapshots__/IndexDetail.test.tsx.snap new file mode 100644 index 000000000..351127895 --- /dev/null +++ b/public/pages/CreateIndex/components/IndexDetail/__snapshots__/IndexDetail.test.tsx.snap @@ -0,0 +1,177 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` spec renders the component 1`] = ` +
+
+
+

+ Define index +

+
+
+
+
+
+
+
+ +
+
+ +
+
+ +
+
+
+
+
+ Must be in lowercase letters. Cannot begin with underscores or hyphens. Spaces, commas, and characters :, ", *, +, /, , |, ?, #, > are not allowed. +
+
+
+
+
+
+
+
+
+ +
+
+
+ Allow this index to be referenced by existing aliases or specify a new alias. +
+ + +
+
+
+
+`; diff --git a/public/pages/CreateIndex/components/IndexDetail/index.ts b/public/pages/CreateIndex/components/IndexDetail/index.ts new file mode 100644 index 000000000..f46f9f20e --- /dev/null +++ b/public/pages/CreateIndex/components/IndexDetail/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import IndexDetail from "./IndexDetail"; + +export default IndexDetail; +export * from "./IndexDetail"; diff --git a/public/pages/CreateIndex/components/IndexMapping/IndexMapping.scss b/public/pages/CreateIndex/components/IndexMapping/IndexMapping.scss new file mode 100644 index 000000000..44d2f388a --- /dev/null +++ b/public/pages/CreateIndex/components/IndexMapping/IndexMapping.scss @@ -0,0 +1,19 @@ +.index-mapping-tree { + .euiTreeView__node { + max-height: unset; + line-height: unset; + } + .euiTreeView__nodeInner { + height: auto; + padding: 4px 0; + margin-top: 4px; + margin-bottom: 4px; + } +} + +.ism-index-mappings-field-line { + > * { + margin-right: 8px; + vertical-align: middle; + } +} \ No newline at end of file diff --git a/public/pages/CreateIndex/components/IndexMapping/IndexMapping.test.tsx b/public/pages/CreateIndex/components/IndexMapping/IndexMapping.test.tsx new file mode 100644 index 000000000..7e234d5d2 --- /dev/null +++ b/public/pages/CreateIndex/components/IndexMapping/IndexMapping.test.tsx @@ -0,0 +1,165 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { forwardRef, Ref, useRef, useState } from "react"; +import { render, fireEvent, waitFor, act } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import IndexMapping, { IIndexMappingsRef, IndexMappingProps, transformObjectToArray } from "./IndexMapping"; +import { MappingsProperties } from "../../../../../models/interfaces"; +import { renderHook } from "@testing-library/react-hooks"; + +const IndexMappingOnChangeWrapper = forwardRef((props: Partial, ref: Ref) => { + const [value, setValue] = useState(props.value as any); + return ( + { + setValue(val); + }} + /> + ); +}); + +describe(" spec", () => { + it("renders the component", () => { + const { container } = render( {}} />); + expect(container.firstChild).toMatchSnapshot(); + }); + + it("render mappings with object type", () => { + const { container } = render( + {}} + value={{ + properties: [{ fieldName: "object", type: "object", properties: [{ fieldName: "text", type: "text" }] }], + }} + /> + ); + expect(container).toMatchSnapshot(); + }); + + it("render mappings with oldValue in edit mode and all operation works well", async () => { + const { result } = renderHook(() => { + const ref = useRef(null); + const renderResult = render( + + ); + + return { + renderResult, + ref, + }; + }); + const { renderResult, ref } = result.current; + const { getByTestId, getByText, queryByTestId, queryByText } = renderResult; + + // old field disable check + expect(getByTestId("mapping-visual-editor-0-field-name")).toHaveAttribute("title", "object"); + expect(getByTestId("mapping-visual-editor-0.properties.0-field-name")).toHaveAttribute("title", "text"); + expect(document.querySelector('[data-test-subj="mapping-visual-editor-0-delete-field"]')).toBeNull(); + expect(document.querySelector('[data-test-subj="mapping-visual-editor-0.properties.0-add-sub-field"]')).toBeNull(); + expect(document.querySelector('[data-test-subj="mapping-visual-editor-0.properties.0-delete-field"]')).toBeNull(); + + // add a new field + userEvent.click(getByTestId("createIndexAddFieldButton")); + // new field should be editable + expect(getByTestId("mapping-visual-editor-1-field-name")).not.toHaveAttribute("disabled"); + expect(document.querySelector('[data-test-subj="mapping-visual-editor-1-delete-field"]')).not.toBeNull(); + + // empty and duplicate validation for field name + userEvent.click(document.querySelector('[data-test-subj="mapping-visual-editor-1-field-name"]') as Element); + expect(getByTestId("mapping-visual-editor-1-field-name")).toHaveValue(""); + expect(queryByText("Field name is required, please input")).toBeNull(); + userEvent.type(getByTestId("mapping-visual-editor-1-field-name"), "object"); + await waitFor(() => { + expect(getByText("Duplicate field name [object], please change your field name")).not.toBeNull(); + }); + await act(async () => { + expect(await ref.current?.validate()).toEqual("with error"); + }); + userEvent.clear(getByTestId("mapping-visual-editor-1-field-name")); + userEvent.type(getByTestId("mapping-visual-editor-1-field-name"), "new_object"); + await act(async () => { + expect(await ref.current?.validate()).toEqual(""); + }); + + await waitFor(() => { + expect(queryByText("Duplicate field name [object], please change your field name")).toBeNull(); + }); + + // only show the sub action for type of object + expect(queryByTestId("mapping-visual-editor-1-add-sub-field")).toBeNull(); + + // change type to object + fireEvent.change(getByTestId("mapping-visual-editor-1-field-type"), { + target: { + value: "object", + }, + }); + + // sub action for object + expect(getByTestId("mapping-visual-editor-1-add-sub-field")).not.toBeNull(); + userEvent.click(getByTestId("mapping-visual-editor-1-add-sub-field")); + // new sub field check + expect((getByTestId("mapping-visual-editor-1.properties.0-field-type") as HTMLSelectElement).value).toBe("text"); + await waitFor(() => { + userEvent.click(getByTestId("mapping-visual-editor-1.properties.0-delete-field")); + }); + + // add a new field + userEvent.click(getByTestId("createIndexAddFieldButton")); + // delete the new field + await waitFor(() => {}); + userEvent.click(getByTestId("mapping-visual-editor-2-delete-field")); + expect(queryByTestId("mapping-visual-editor-2-delete-field")).toBeNull(); + + await userEvent.click(getByTestId("editorTypeJsonEditor").querySelector("input") as Element); + await waitFor(() => {}); + userEvent.click(getByTestId("previousMappingsJsonButton")); + await waitFor(() => {}); + expect(queryByTestId("previousMappingsJsonModal-ok")).not.toBeNull(); + userEvent.click(getByTestId("previousMappingsJsonModal-ok")); + await waitFor(() => { + expect(queryByTestId("previousMappingsJsonModal-ok")).toBeNull(); + }); + }); + + it("it transformObjectToArray", () => { + expect( + transformObjectToArray({ + test: { + type: "text", + properties: { + test_children: { + type: "text", + }, + }, + }, + }) + ).toEqual([ + { + fieldName: "test", + type: "text", + properties: [ + { + fieldName: "test_children", + type: "text", + }, + ], + }, + ] as MappingsProperties); + }); +}); diff --git a/public/pages/CreateIndex/components/IndexMapping/IndexMapping.tsx b/public/pages/CreateIndex/components/IndexMapping/IndexMapping.tsx new file mode 100644 index 000000000..07b5445d0 --- /dev/null +++ b/public/pages/CreateIndex/components/IndexMapping/IndexMapping.tsx @@ -0,0 +1,366 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { forwardRef, useCallback, useState, Ref, useRef, useMemo, useImperativeHandle } from "react"; +import { EuiTreeView, EuiIcon, EuiTreeViewProps, EuiButton, EuiSpacer, EuiButtonGroup, EuiLink } from "@elastic/eui"; +import { set, get, isEmpty } from "lodash"; +import JSONEditor, { IJSONEditorRef } from "../../../../components/JSONEditor"; +import { Modal } from "../../../../components/Modal"; +import { MappingsProperties, MappingsPropertiesObject } from "../../../../../models/interfaces"; +import CustomFormRow from "../../../../components/CustomFormRow"; +import MappingLabel, { IMappingLabelRef } from "../MappingLabel"; +import "./IndexMapping.scss"; + +export const transformObjectToArray = (obj: MappingsPropertiesObject): MappingsProperties => { + return Object.entries(obj).map(([fieldName, fieldSettings]) => { + const { properties, ...others } = fieldSettings; + const payload: MappingsProperties[number] = { + ...others, + fieldName, + }; + if (properties) { + payload.properties = transformObjectToArray(properties); + } + return payload; + }); +}; + +export const transformArrayToObject = (array: MappingsProperties): MappingsPropertiesObject => { + return array.reduce((total, current) => { + const { fieldName, properties, ...others } = current; + const payload: MappingsPropertiesObject[string] = { + ...others, + }; + if (properties) { + payload.properties = transformArrayToObject(properties); + } + return { + ...total, + [current.fieldName]: payload, + }; + }, {} as MappingsPropertiesObject); +}; + +const countNodesInTree = (array: MappingsProperties) => { + return array.reduce((total, current) => { + total = total + 1; + const { properties } = current; + if (properties) { + total = total + countNodesInTree(properties); + } + return total; + }, 0); +}; + +export type IndexMappingsAll = { + properties?: MappingsProperties; + [key: string]: any; +}; + +export type IndexMappingsObjectAll = { + properties?: MappingsPropertiesObject; + [key: string]: any; +}; + +export interface IndexMappingProps { + value?: IndexMappingsAll; + oldValue?: IndexMappingsAll; + originalValue?: IndexMappingsAll; + onChange: (value: IndexMappingProps["value"]) => void; + isEdit?: boolean; + oldMappingsEditable?: boolean; // in template edit case, existing mappings is editable + readonly?: boolean; +} + +export enum EDITOR_MODE { + JSON = "JSON", + VISUAL = "VISUAL", +} + +export interface IIndexMappingsRef { + validate: () => Promise; +} + +const IndexMapping = ( + { value: propsValue, onChange: propsOnChange, isEdit, oldValue, readonly, oldMappingsEditable }: IndexMappingProps, + ref: Ref +) => { + const value = propsValue?.properties || []; + const onChange = (val: MappingsProperties) => { + propsOnChange({ + ...propsValue, + properties: val, + }); + }; + const allFieldsRef = useRef>({}); + const JSONEditorRef = useRef(null); + useImperativeHandle(ref, () => ({ + validate: async () => { + const values = await Promise.all(Object.values(allFieldsRef.current).map((item) => item.validate())); + const JSONEditorValidateResult = await JSONEditorRef.current?.validate(); + return values.some((item) => item) || JSONEditorValidateResult ? "with error" : ""; + }, + })); + const [editorMode, setEditorMode] = useState(EDITOR_MODE.VISUAL); + const addField = useCallback( + (pos, fieldSettings?: Partial) => { + const newValue = [...(value || [])]; + const nowProperties = ((pos ? get(newValue, pos) : (newValue as MappingsProperties)) || []) as MappingsProperties; + nowProperties.push({ + fieldName: fieldSettings?.fieldName || "", + type: "text", + ...fieldSettings, + }); + if (pos) { + set(newValue, pos, nowProperties); + } + onChange(newValue); + }, + [onChange, value] + ); + const deleteField = useCallback( + (pos) => { + const newValue = [...(value || [])]; + const splittedArray = pos.split("."); + const index = splittedArray[splittedArray.length - 1]; + const prefix = splittedArray.slice(0, -1); + const prefixPos = prefix.join("."); + const nowProperties = ((prefixPos ? get(newValue, prefixPos) : (newValue as MappingsProperties)) || []) as MappingsProperties; + nowProperties.splice(index, 1); + + if (prefixPos) { + set(newValue, prefixPos, nowProperties); + } + + onChange(newValue); + }, + [onChange, value] + ); + const transformValueToTreeItems = (formValue: MappingsProperties, pos: string = ""): EuiTreeViewProps["items"] => { + let isFirstEditableField = false; + return (formValue || []).map((item, index) => { + const { fieldName, ...fieldSettings } = item; + const id = [pos, index].filter((item) => item !== "").join(".properties."); + const readonlyFlag = readonly || (isEdit && !!get(oldValue?.properties, id)); + let shouldShowLabel = false; + if (!readonlyFlag && !isFirstEditableField) { + isFirstEditableField = true; + shouldShowLabel = true; + } + const payload: EuiTreeViewProps["items"][number] = { + label: ( + { + if (ref) { + allFieldsRef.current[id] = ref; + } else { + delete allFieldsRef.current[id]; + } + }} + readonly={readonlyFlag} + value={item} + id={`mapping-visual-editor-${id}`} + onFieldNameCheck={(fieldName) => { + const hasDuplicateName = (formValue || []) + .filter((sibItem, sibIndex) => sibIndex < index) + .some((sibItem) => sibItem.fieldName === fieldName); + if (hasDuplicateName) { + return `Duplicate field name [${fieldName}], please change your field name`; + } + + return ""; + }} + onChange={(val, key, v) => { + const newValue = [...(value || [])]; + set(newValue, id, val); + onChange(newValue); + }} + onDeleteField={() => { + deleteField(id); + }} + onAddSubField={() => { + addField(`${id}.properties`); + }} + onAddSubObject={() => { + addField(`${id}.properties`, { + type: "", + }); + }} + /> + ), + id: `mapping-visual-editor-${id}`, + icon: , + iconWhenExpanded: , + }; + if (fieldSettings.properties) { + (payload.icon = ), + (payload.iconWhenExpanded = ), + (payload.children = transformValueToTreeItems(fieldSettings.properties, id)); + } + + return payload; + }); + }; + const transformedTreeItems = useMemo(() => transformValueToTreeItems(value), [value]); + const newValue = useMemo(() => { + const oldValueKeys = (oldValue?.properties || []).map((item) => item.fieldName); + return value?.filter((item) => !oldValueKeys.includes(item.fieldName)) || []; + }, [oldValue?.properties, value]); + const renderKey = useMemo(() => { + return countNodesInTree(value || []); + }, [value]); + return ( + <> + setEditorMode(id as EDITOR_MODE)} + legend="Editor Type" + options={[ + { + label: readonly ? "Tree view" : "Visual Editor", + id: EDITOR_MODE.VISUAL, + "data-test-subj": "editorTypeVisualEditor", + }, + { + label: readonly ? "JSON" : "JSON Editor", + id: EDITOR_MODE.JSON, + "data-test-subj": "editorTypeJsonEditor", + }, + ]} + /> + + {editorMode === EDITOR_MODE.VISUAL ? ( + <> + {transformedTreeItems.length ? ( + + ) : ( +

You have no field mappings.

+ )} + {readonly ? null : ( + <> + + addField("")}> + Add new field + + + addField("", { + type: "object", + }) + } + > + Add new object + + + )} + + ) : ( + <> + {isEdit && !readonly && !isEmpty(oldValue) ? ( + <> + { + Modal.show({ + title: "Previous mappings", + content: ( + + ), + "data-test-subj": "previousMappingsJsonModal", + onOk: () => {}, + }); + }} + > + See previous settings + + + + ) : null} + {readonly ? ( + + ) : ( + +
+ Specify mapping in JSON format.{" "} + + View mapping example. + +
+ {oldMappingsEditable ? null : ( +
+ {isEdit + ? "Mappings and field types cannot be changed once they have been added." + : "The existing mapping properties cannot be changed after the index is created."} +
+ )} +
+ } + fullWidth + > + { + const result: IndexMappingsObjectAll = JSON.parse(val); + propsOnChange({ + ...result, + properties: [...(oldValue?.properties || []), ...transformObjectToArray(result?.properties || {})], + }); + }} + width="100%" + ref={JSONEditorRef} + /> + + )} + + )} + + ); +}; + +// @ts-ignore +export default forwardRef(IndexMapping); diff --git a/public/pages/CreateIndex/components/IndexMapping/__snapshots__/IndexMapping.test.tsx.snap b/public/pages/CreateIndex/components/IndexMapping/__snapshots__/IndexMapping.test.tsx.snap new file mode 100644 index 000000000..95eb79bc3 --- /dev/null +++ b/public/pages/CreateIndex/components/IndexMapping/__snapshots__/IndexMapping.test.tsx.snap @@ -0,0 +1,794 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` spec render mappings with object type 1`] = ` +
+
+ + Editor Type + +
+ + +
+
+
+
+

+ You can quickly navigate this list using arrow keys. +

+
    +
  • + +
    +
    +
      +
    • + +
      +
    • +
    +
    +
    +
  • +
+
+
+ + +
+`; + +exports[` spec renders the component 1`] = ` +
+ + Editor Type + +
+ + +
+
+`; diff --git a/public/pages/CreateIndex/components/IndexMapping/index.ts b/public/pages/CreateIndex/components/IndexMapping/index.ts new file mode 100644 index 000000000..c5249554c --- /dev/null +++ b/public/pages/CreateIndex/components/IndexMapping/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import IndexMapping from "./IndexMapping"; + +export * from "./IndexMapping"; +export default IndexMapping; diff --git a/public/pages/CreateIndex/components/MappingLabel/MappingLabel.test.tsx b/public/pages/CreateIndex/components/MappingLabel/MappingLabel.test.tsx new file mode 100644 index 000000000..93ef6e14e --- /dev/null +++ b/public/pages/CreateIndex/components/MappingLabel/MappingLabel.test.tsx @@ -0,0 +1,39 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from "react"; +import { render } from "@testing-library/react"; +import MappingLabel from "./MappingLabel"; + +describe(" spec", () => { + // main unit test case is in IndexMapping.test.tsx + it("render component", async () => { + const { container } = render( + { + return ""; + }} + onFieldNameCheck={function (val: string): string { + throw new Error("Function not implemented."); + }} + onAddSubField={function (): void { + throw new Error("Function not implemented."); + }} + onAddSubObject={function (): void { + throw new Error("Function not implemented."); + }} + onDeleteField={function (): void { + throw new Error("Function not implemented."); + }} + id={""} + value={{ + fieldName: "text", + type: "text", + }} + /> + ); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/public/pages/CreateIndex/components/MappingLabel/MappingLabel.tsx b/public/pages/CreateIndex/components/MappingLabel/MappingLabel.tsx new file mode 100644 index 000000000..bfdb5ab61 --- /dev/null +++ b/public/pages/CreateIndex/components/MappingLabel/MappingLabel.tsx @@ -0,0 +1,268 @@ +import React, { forwardRef, useCallback, useRef, useImperativeHandle } from "react"; +import { + EuiIcon, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiToolTip, + EuiCode, + EuiBadge, + EuiText, + EuiContextMenu, + EuiFormRowProps, +} from "@elastic/eui"; +import { set, pick } from "lodash"; +import { MappingsProperties } from "../../../../../models/interfaces"; +import { AllBuiltInComponents } from "../../../../components/FormGenerator"; +import useField, { transformNameToString } from "../../../../lib/field"; +import { INDEX_MAPPING_TYPES, INDEX_MAPPING_TYPES_WITH_CHILDREN } from "../../../../utils/constants"; +import SimplePopover from "../../../../components/SimplePopover"; + +const OLD_VALUE_DISABLED_REASON = "Old mappings can not be modified"; + +interface IMappingLabel { + value: MappingsProperties[number]; + onChange: (val: IMappingLabel["value"], key: string, value: string) => void | string; + onFieldNameCheck: (val: string) => string; + onAddSubField: () => void; + onAddSubObject: () => void; + onDeleteField: () => void; + readonly?: boolean; + id: string; + shouldShowLabel?: boolean; +} + +export interface IMappingLabelRef { + validate: () => Promise; +} + +const FormRow = (props: EuiFormRowProps & Pick) => { + const { shouldShowLabel, label, ...others } = props; + return ; +}; + +export const MappingLabel = forwardRef((props: IMappingLabel, forwardedRef: React.Ref) => { + const { onFieldNameCheck, onAddSubField, onAddSubObject, onDeleteField, id, readonly, shouldShowLabel } = props; + const propsRef = useRef(props); + propsRef.current = props; + const onFieldChange = useCallback( + (k, v) => { + let newValue = { ...propsRef.current.value }; + set(newValue, k, v); + if (k === "type") { + newValue = pick(newValue, ["fieldName", "type"]); + const findItem = INDEX_MAPPING_TYPES.find((item) => item.label === v); + const initValues = (findItem?.options?.fields || []).reduce((total, current) => { + if (current && current.initValue !== undefined) { + return { + ...total, + [transformNameToString(current.name)]: current.initValue, + }; + } + + return total; + }, {}); + newValue = { + ...newValue, + ...initValues, + }; + field.setValues(newValue); + } + return propsRef.current.onChange(newValue, k, v); + }, + [propsRef.current.value, propsRef.current.onChange] + ); + const field = useField({ + values: { + ...propsRef.current.value, + type: propsRef.current.value.type || "object", + }, + onChange: onFieldChange, + unmountComponent: true, + }); + const value = field.getValues(); + const type = value.type; + useImperativeHandle(forwardedRef, () => ({ + validate: async () => { + const { errors } = await field.validatePromise(); + if (errors) { + return "Validate Error"; + } else { + return ""; + } + }, + })); + + const findItem = INDEX_MAPPING_TYPES.find((item) => item.label === type); + const moreFields = findItem?.options?.fields || []; + + if (readonly) { + return ( + +
  • + + + {field.getValue("fieldName")} + + + {type} + + {moreFields.map((extraField) => ( + + {extraField.label}: {field.getValue(extraField.name)} + + ))} +
  • +
    + ); + } + + return ( + e.stopPropagation()}> + + + {readonly ? ( + {field.getValue("fieldName")} + ) : ( + { + const checkResult = onFieldNameCheck(value); + if (checkResult) { + return Promise.reject(checkResult); + } + + return Promise.resolve(""); + }, + }, + ], + })} + disabled={readonly} + disabledReason={readonly ? "" : OLD_VALUE_DISABLED_REASON} + compressed + data-test-subj={`${id}-field-name`} + /> + )} + + + + + {readonly ? ( + {type} + ) : ( + { + const allOptions = INDEX_MAPPING_TYPES.map((item) => ({ text: item.label, value: item.label })); + const typeValue = field.getValue("type"); + if (!allOptions.find((item) => item.value === typeValue)) { + allOptions.push({ + text: typeValue, + value: typeValue, + }); + } + return allOptions; + })()} + /> + )} + + + {moreFields.map((item) => { + const { label, type, ...others } = item; + const RenderComponent = readonly ? AllBuiltInComponents.Text : AllBuiltInComponents[type]; + return ( + + + + + + ); + })} + {readonly ? null : ( + + +
    { + e.stopPropagation(); + }} + > + {INDEX_MAPPING_TYPES_WITH_CHILDREN.includes(type) ? ( + + + + } + > + + + ) : null} + + + + + +
    +
    +
    + )} +
    + ); +}); + +export default MappingLabel; diff --git a/public/pages/CreateIndex/components/MappingLabel/__snapshots__/MappingLabel.test.tsx.snap b/public/pages/CreateIndex/components/MappingLabel/__snapshots__/MappingLabel.test.tsx.snap new file mode 100644 index 000000000..0e672e1fb --- /dev/null +++ b/public/pages/CreateIndex/components/MappingLabel/__snapshots__/MappingLabel.test.tsx.snap @@ -0,0 +1,240 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` spec render component 1`] = ` +
    +
    +
    +
    +
    + +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    + +
    + + EuiIconMock + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + + + EuiIconMock + + +
    +
    +
    +
    +
    +
    +`; diff --git a/public/pages/CreateIndex/components/MappingLabel/index.ts b/public/pages/CreateIndex/components/MappingLabel/index.ts new file mode 100644 index 000000000..c0deb2c16 --- /dev/null +++ b/public/pages/CreateIndex/components/MappingLabel/index.ts @@ -0,0 +1,4 @@ +import MappingLabel from "./MappingLabel"; + +export default MappingLabel; +export * from "./MappingLabel"; diff --git a/public/pages/CreateIndex/containers/CreateIndex/CreateIndex.test.tsx b/public/pages/CreateIndex/containers/CreateIndex/CreateIndex.test.tsx new file mode 100644 index 000000000..eb86d4cae --- /dev/null +++ b/public/pages/CreateIndex/containers/CreateIndex/CreateIndex.test.tsx @@ -0,0 +1,95 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from "react"; +import { MemoryRouter as Router, Route, RouteComponentProps, Switch } from "react-router-dom"; +import { render, waitFor } from "@testing-library/react"; +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, apiCallerMock } from "../../../../../test/mocks"; +import { BrowserServices } from "../../../../models/interfaces"; +import { ModalProvider, ModalRoot } from "../../../../components/Modal"; +import { ROUTES } from "../../../../utils/constants"; +import { CoreServicesConsumer, CoreServicesContext } from "../../../../components/core_services"; + +function renderCreateIndexWithRouter(initialEntries = [ROUTES.CREATE_INDEX] as string[]) { + return { + ...render( + + + + + {(services: BrowserServices | null) => + services && ( + + {(core: CoreStart | null) => + core && ( + + + + } + /> + } + /> + } + /> +

    location is: {ROUTES.INDEX_POLICIES}

    } + /> +
    +
    + ) + } +
    + ) + } +
    +
    +
    +
    + ), + }; +} + +describe(" spec", () => { + beforeEach(() => { + apiCallerMock(browserServicesMock); + }); + 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(); + }); + }); + + it("it goes to indices page when click create successfully", async () => { + const { getByText, getByPlaceholderText } = renderCreateIndexWithRouter([`${ROUTES.CREATE_INDEX}`]); + + await waitFor(() => { + getByText("Define index"); + }); + + const indexNameInput = getByPlaceholderText("Specify a name for the new index."); + + userEvent.type(indexNameInput, `good_index`); + userEvent.click(document.body); + userEvent.click(getByText("Create")); + + 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 new file mode 100644 index 000000000..5da962e8b --- /dev/null +++ b/public/pages/CreateIndex/containers/CreateIndex/CreateIndex.tsx @@ -0,0 +1,61 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { Component } from "react"; +import { EuiSpacer, EuiTitle } from "@elastic/eui"; +import { RouteComponentProps } from "react-router-dom"; +import IndexForm from "../IndexForm"; +import { BREADCRUMBS, IndicesUpdateMode, ROUTES } from "../../../../utils/constants"; +import { CoreServicesContext } from "../../../../components/core_services"; +import { CommonService } from "../../../../services/index"; + +interface CreateIndexProps extends RouteComponentProps<{ index?: string; mode?: IndicesUpdateMode }> { + isEdit?: boolean; + commonService: CommonService; +} + +export default class CreateIndex extends Component { + static contextType = CoreServicesContext; + + get index() { + return this.props.match.params.index; + } + + get isEdit() { + return this.props.match.params.index !== undefined; + } + + componentDidMount = async (): Promise => { + const isEdit = this.isEdit; + this.context.chrome.setBreadcrumbs([ + BREADCRUMBS.INDEX_MANAGEMENT, + BREADCRUMBS.INDICES, + isEdit ? BREADCRUMBS.EDIT_INDEX : BREADCRUMBS.CREATE_INDEX, + ]); + }; + + onCancel = (): void => { + this.props.history.push(ROUTES.INDICES); + }; + + render() { + const isEdit = this.isEdit; + + return ( +
    + +

    {isEdit ? "Edit" : "Create"} index

    +
    + + this.props.history.push(ROUTES.INDICES)} + /> +
    + ); + } +} diff --git a/public/pages/CreateIndex/containers/CreateIndex/index.ts b/public/pages/CreateIndex/containers/CreateIndex/index.ts new file mode 100644 index 000000000..78d14cd03 --- /dev/null +++ b/public/pages/CreateIndex/containers/CreateIndex/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import CreateIndex from "./CreateIndex"; + +export default CreateIndex; 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..a7762d4fb --- /dev/null +++ b/public/pages/CreateIndex/containers/IndexForm/IndexForm.test.tsx @@ -0,0 +1,289 @@ +/* + * 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 { ServicesContext } from "../../../../services"; +import { browserServicesMock, coreServicesMock, apiCallerMock } from "../../../../../test/mocks"; +import { IndicesUpdateMode } from "../../../../utils/constants"; +import { CoreServicesContext } from "../../../../components/core_services"; + +function renderCreateIndexWithRouter(props: IndexFormProps) { + return { + ...render( + + + + + + ), + }; +} + +describe(" spec", () => { + beforeEach(() => { + apiCallerMock(browserServicesMock); + }); + it("show a toast if getIndices gracefully fails", async () => { + const { findByText } = renderCreateIndexWithRouter({ + index: "bad_index", + }); + + await findByText("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("Invalid index name.")).toBeNull(); + + userEvent.click(getByText("Create")); + await waitFor(() => { + expect(queryByText("Invalid index name.")).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("Specify a name for the new 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("Specify a name for the new 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 } = renderCreateIndexWithRouter({ + index: "good_index", + }); + await waitFor(() => getByText("Define index")); + userEvent.click(getByText("Update")); + + 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("createIndexAddFieldButton")); + 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(() => { + 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("createIndexAddFieldButton")); + 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(() => { + 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(() => { + 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..c151456cb --- /dev/null +++ b/public/pages/CreateIndex/containers/IndexForm/index.tsx @@ -0,0 +1,417 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { Component, forwardRef, useContext } from "react"; +import { EuiSpacer, EuiFlexGroup, EuiFlexItem, EuiButton, EuiButtonEmpty, EuiLoadingSpinner } from "@elastic/eui"; +import { get, set, differenceWith, isEqual, merge } from "lodash"; +import { diffArrays } from "diff"; +import flattern from "flat"; +import IndexDetail, { IndexDetailProps, IIndexDetailRef, defaultIndexSettings } from "../../components/IndexDetail"; +import { IAliasAction, IndexItem, IndexItemRemote, MappingsProperties } from "../../../../../models/interfaces"; +import { IndicesUpdateMode } from "../../../../utils/constants"; +import { CoreServicesContext } from "../../../../components/core_services"; +import { transformArrayToObject, transformObjectToArray } from "../../components/IndexMapping/IndexMapping"; +import { ServerResponse } from "../../../../../server/models/types"; +import { BrowserServices } from "../../../../models/interfaces"; +import { ServicesContext } from "../../../../services"; + +export const getAliasActionsByDiffArray = ( + oldAliases: string[], + newAliases: string[], + callback: (val: string) => IAliasAction[string] +): IAliasAction[] => { + const diffedAliasArrayes = diffArrays(oldAliases, newAliases); + return diffedAliasArrayes.reduce((total: IAliasAction[], current) => { + if (current.added) { + return [ + ...total, + ...current.value.map((item) => ({ + add: callback(item), + })), + ]; + } else if (current.removed) { + return [ + ...total, + ...current.value.map((item) => ({ + remove: callback(item), + })), + ]; + } + + return total; + }, [] as IAliasAction[]); +}; + +export interface IndexFormProps extends Pick { + index?: string; + mode?: IndicesUpdateMode; + onCancel?: () => void; + onSubmitSuccess?: (indexName: string) => void; + hideButtons?: boolean; +} + +interface CreateIndexState { + indexDetail: IndexItem; + oldIndexDetail?: IndexItem; + isSubmitting: boolean; + loading: boolean; +} + +export class IndexForm extends Component { + static contextType = CoreServicesContext; + constructor(props: IndexFormProps & { services: BrowserServices }) { + super(props); + const isEdit = this.isEdit; + this.state = { + isSubmitting: false, + indexDetail: merge({}, defaultIndexSettings), + oldIndexDetail: undefined, + loading: isEdit, + }; + } + + componentDidMount(): void { + const isEdit = this.isEdit; + if (isEdit) { + this.refreshIndex(); + } + } + + indexDetailRef: IIndexDetailRef | null = null; + + get commonService() { + return this.props.services.commonService; + } + + get index() { + return this.props.index; + } + + get isEdit() { + return this.index !== undefined; + } + + get mode() { + return this.props.mode; + } + + getIndexDetail = async (indexName: string): Promise => { + const response = await this.commonService.apiCaller>({ + endpoint: "indices.get", + data: { + index: indexName, + flat_settings: true, + }, + }); + if (response.ok) { + return response.response[indexName]; + } + + this.context.notifications.toasts.addDanger(response.error); + return Promise.reject(); + }; + + refreshIndex = async () => { + this.setState({ + loading: true, + }); + try { + const indexDetail = await this.getIndexDetail(this.index as string); + const payload = { + ...indexDetail, + index: this.index, + }; + set(payload, "mappings.properties", transformObjectToArray(get(payload, "mappings.properties", {}))); + + this.setState({ + indexDetail: payload as IndexItem, + oldIndexDetail: JSON.parse(JSON.stringify(payload)), + }); + } catch (e) { + // do nothing + } finally { + this.setState({ + loading: false, + }); + } + }; + + 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; + const aliasActions = getAliasActionsByDiffArray( + Object.keys(oldIndexDetail?.aliases || {}), + Object.keys(indexDetail.aliases || {}), + (alias) => ({ + index, + alias, + }) + ); + + 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 (!isEqual(indexDetail.mappings, oldIndexDetail?.mappings)) { + return await this.commonService.apiCaller({ + endpoint: "indices.putMapping", + method: "PUT", + data: { + index, + body: { + ...indexDetail.mappings, + 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 }), {}); + }; + + 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) { + 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: { + ...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(indexDetail.index); + } 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 { hideButtons, readonly } = this.props; + const { indexDetail, isSubmitting, oldIndexDetail, loading } = this.state; + + if (loading) { + return ; + } + + return ( + <> + (this.indexDetailRef = ref)} + isEdit={this.isEdit} + value={indexDetail} + oldValue={oldIndexDetail} + onChange={this.onDetailChange} + onSimulateIndexTemplate={this.onSimulateIndexTemplate} + sourceIndices={this.props.sourceIndices} + onGetIndexDetail={this.getIndexDetail} + refreshOptions={(aliasName) => + this.commonService.apiCaller({ + endpoint: "cat.aliases", + method: "GET", + data: { + format: "json", + name: aliasName, + expand_wildcards: "open", + }, + }) + } + /> + {hideButtons ? null : ( + <> + + + + + + Cancel + + + + + {isEdit ? "Update" : "Create"} + + + + + )} + + ); + } +} + +export default forwardRef(function IndexFormWrapper(props: IndexFormProps, ref: React.Ref) { + const services = useContext(ServicesContext); + return ; +}); diff --git a/public/pages/CreateIndex/index.ts b/public/pages/CreateIndex/index.ts new file mode 100644 index 000000000..a82e1ca1c --- /dev/null +++ b/public/pages/CreateIndex/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import CreateIndex from "./containers/CreateIndex"; + +export default CreateIndex; diff --git a/public/pages/IndexDetail/containers/IndexDetail/IndexDetail.test.tsx b/public/pages/IndexDetail/containers/IndexDetail/IndexDetail.test.tsx new file mode 100644 index 000000000..ceb1e2207 --- /dev/null +++ b/public/pages/IndexDetail/containers/IndexDetail/IndexDetail.test.tsx @@ -0,0 +1,90 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from "react"; +import "@testing-library/jest-dom/extend-expect"; +import { render, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter as Router } from "react-router"; +import { Route, RouteComponentProps, Switch } from "react-router-dom"; +import { browserServicesMock, coreServicesMock, apiCallerMock } from "../../../../../test/mocks"; +import IndexDetail, { IndexDetailModalProps } from "./IndexDetail"; +import { ModalProvider } from "../../../../components/Modal"; +import { ServicesContext } from "../../../../services"; +import { CoreServicesContext } from "../../../../components/core_services"; + +function renderWithRouter(props: Omit, initialEntries: string[]) { + return { + ...render( + + + + + + } /> + + + + + + ), + }; +} + +describe("container spec", () => { + beforeEach(() => { + apiCallerMock(browserServicesMock); + }); + it("render the component", async () => { + browserServicesMock.indexService.getIndices = jest.fn(() => { + return { + ok: true, + response: { + indices: [ + { + "docs.count": "5", + "docs.deleted": "2", + health: "green", + index: "test_index", + pri: "1", + "pri.store.size": "100KB", + rep: "0", + status: "open", + "store.size": "100KB", + uuid: "some_uuid", + managed: "", + managedPolicy: "", + data_stream: "", + }, + ], + }, + } as any; + }) as typeof browserServicesMock.indexService.getIndices; + const { container, getByTestId, queryByText } = renderWithRouter({}, [`/test_index`]); + + await waitFor(() => { + expect(container.firstChild).toMatchSnapshot(); + expect(document.querySelector("#indexDetailModalOverview")).not.toBeNull(); + expect(browserServicesMock.commonService.apiCaller).toBeCalledTimes(1); + expect(browserServicesMock.commonService.apiCaller).toBeCalledWith({ + endpoint: "indices.get", + data: { + index: "test_index", + }, + }); + }); + + userEvent.click(document.getElementById("indexDetailModalAlias") as Element); + await waitFor(() => { + expect(queryByText("Index alias")).not.toBeNull(); + }); + userEvent.click(getByTestId("detailModalEdit")); + await waitFor(() => {}); + userEvent.click(getByTestId("createIndexCreateButton")); + await waitFor(() => { + expect(browserServicesMock.commonService.apiCaller).toBeCalledTimes(4); + }); + }); +}); diff --git a/public/pages/IndexDetail/containers/IndexDetail/IndexDetail.tsx b/public/pages/IndexDetail/containers/IndexDetail/IndexDetail.tsx new file mode 100644 index 000000000..eccc7bd73 --- /dev/null +++ b/public/pages/IndexDetail/containers/IndexDetail/IndexDetail.tsx @@ -0,0 +1,415 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useContext, useEffect, useMemo, useRef, useState } from "react"; +import { + EuiTabbedContent, + EuiDescriptionList, + EuiDescriptionListTitle, + EuiDescriptionListDescription, + EuiFlexItem, + EuiSpacer, + EuiFlexGroup, + EuiButton, + EuiBasicTable, + EuiFlyout, + EuiFlyoutHeader, + EuiTitle, + EuiFlyoutBody, + EuiIcon, + EuiHealth, + EuiFormRow, + EuiLink, +} from "@elastic/eui"; +import { get } from "lodash"; +import { Link, RouteComponentProps } from "react-router-dom"; +import { IndexItem } from "../../../../../models/interfaces"; +import IndicesActions from "../../../Indices/containers/IndicesActions"; +import { ManagedCatIndex } from "../../../../../server/models/interfaces"; +import { BREADCRUMBS, IndicesUpdateMode, ROUTES } from "../../../../utils/constants"; +import { ServicesContext } from "../../../../services"; +import { BrowserServices } from "../../../../models/interfaces"; +import IndexFormWrapper, { IndexForm } from "../../../CreateIndex/containers/IndexForm"; +import { HEALTH_TO_COLOR } from "../../../Indices/utils/constants"; +import { CoreServicesContext } from "../../../../components/core_services"; +import { ContentPanel } from "../../../../components/ContentPanel"; + +export interface IndexDetailModalProps extends RouteComponentProps<{ index: string }> {} + +interface IFinalDetail extends ManagedCatIndex, IndexItem {} + +const OVERVIEW_DISPLAY_INFO: { + label: string; + value: string | React.FunctionComponent<{ detail: IFinalDetail }>; +}[] = [ + { + label: "Index name", + value: "index", + }, + { + label: "Health", + value: "health", + }, + { + label: "Status", + value: ({ detail }) => { + const health = detail.health; + const color = health ? HEALTH_TO_COLOR[health] : "subdued"; + const text = health || detail.status; + return ( + + {text} + + ); + }, + }, + { + label: "Creation date", + value: ({ detail }) => ( + + {detail.settings?.index?.creation_date ? new Date(parseInt(detail.settings?.index?.creation_date)).toLocaleString() : "-"} + + ), + }, + { + label: "Total size", + value: ({ detail }) => {detail["store.size"]}, + }, + { + label: "Size of primaries", + value: ({ detail }) => {detail["pri.store.size"]}, + }, + { + label: "Total documents", + value: ({ detail }) => {detail["docs.count"]}, + }, + { + label: "Deleted documents", + value: ({ detail }) => {detail["docs.deleted"]}, + }, + { + label: "Primaries", + value: "pri", + }, + { + label: "Replicas", + value: "rep", + }, + { + label: "Index blocks", + value: ({ detail }) => { + const blocks = Object.entries(detail.settings?.index?.blocks || {}).filter(([key, value]) => value === "true"); + if (!blocks.length) { + return -; + } + + return ( +
      + {blocks.map(([key, value]) => ( +
    • {key}
    • + ))} +
    + ); + }, + }, + { + label: "Managed by policy", + value: ({ detail }) => ( + + {detail.managedPolicy ? {detail.managedPolicy} : "-"} + + ), + }, +]; + +export default function IndexDetail(props: IndexDetailModalProps) { + const { index } = props.match.params; + const [record, setRecord] = useState(undefined); + const [editVisible, setEditVisible] = useState(false); + const [editMode, setEditMode] = useState(IndicesUpdateMode.settings); + const [detail, setDetail] = useState({} as IndexItem); + const ref = useRef(null); + const coreService = useContext(CoreServicesContext); + const finalDetail: IFinalDetail | undefined = useMemo(() => { + if (!detail || !record) { + return undefined; + } + + return { + ...record, + ...detail, + }; + }, [record, detail]); + const services = useContext(ServicesContext) as BrowserServices; + + const fetchIndicesDetail = () => + services.commonService + .apiCaller>({ + endpoint: "indices.get", + data: { + index, + }, + }) + .then((res) => { + if (!res.ok) { + return res; + } + + return { + ...res, + response: res.response[index], + }; + }) + .then((res) => { + if (res && res.ok) { + setDetail(res.response); + } else { + coreService?.notifications.toasts.addDanger(res.error || ""); + props.history.replace(ROUTES.INDICES); + } + + return res; + }); + + const fetchCatIndexDetail = async (props: { showDataStreams: "true" | "false" }) => { + const result = await services.indexService.getIndices({ + terms: index, + from: 0, + size: 10, + search: index, + sortField: "index", + sortDirection: "desc", + ...props, + }); + if (result.ok) { + const findItem = result.response.indices.find((item) => item.index === index); + setRecord(findItem); + } else { + coreService?.notifications.toasts.addDanger(result.error || ""); + } + }; + + const refreshDetails = async () => { + const result = await fetchIndicesDetail(); + if (result.ok) { + const { data_stream } = result.response; + const payload: { showDataStreams: "true" | "false"; search?: string; dataStreams?: string } = { + showDataStreams: data_stream ? "true" : "false", + }; + if (data_stream) { + payload.search = `data_streams: (${result.response.data_stream})`; + payload.dataStreams = data_stream; + } + + fetchCatIndexDetail(payload); + } + }; + + useEffect(() => { + refreshDetails(); + coreService?.chrome.setBreadcrumbs([ + BREADCRUMBS.INDEX_MANAGEMENT, + BREADCRUMBS.INDICES, + { + ...BREADCRUMBS.INDEX_DETAIL, + href: `#${props.location.pathname}`, + }, + ]); + }, []); + + const indexFormCommonProps = { + index, + onCancel: () => setEditVisible(false), + onSubmitSuccess: () => { + ref.current?.refreshIndex(); + setEditVisible(false); + fetchIndicesDetail(); + }, + }; + + const indexFormReadonlyCommonProps = { + ...indexFormCommonProps, + hideButtons: true, + readonly: true, + ref, + }; + + const onEdit = (editMode: IndicesUpdateMode) => { + setEditMode(editMode); + setEditVisible(true); + }; + + if (!record || !detail || !finalDetail) { + return null; + } + + return ( + + + {index} + + props.history.replace(ROUTES.INDICES)} + onOpen={refreshDetails} + onClose={refreshDetails} + onShrink={() => props.history.replace(ROUTES.INDICES)} + getIndices={async () => {}} + /> +
    + } + > + + +
    + {OVERVIEW_DISPLAY_INFO.map((item) => { + let valueContent = null; + if (typeof item.value === "string") { + valueContent = {get(finalDetail, item.value)}; + } else { + const ValueComponent = item.value; + valueContent = ; + } + return ( +
    + {item.label} + + {valueContent} + +
    + ); + })} +
    + + ), + }, + { + id: "indexDetailModalSettings", + name: "Settings", + content: ( + <> + + + +

    Advanced index settings

    +
    + + onEdit(IndicesUpdateMode.settings)}> + Edit + + +
    + + + + ), + }, + { + id: "indexDetailModalMappings", + name: "Mappings", + content: ( + <> + + + +

    Index mappings

    + + Define how documents and their fields are stored and indexed.{" "} + + Learn more. + +
    + } + > + <> + + + + onEdit(IndicesUpdateMode.mappings)}> + Add index mappings + + + + + + + ), + }, + { + id: `indexDetailModalAlias`, + name: "Alias", + content: ( + <> + + + +

    Index alias

    +
    + + onEdit(IndicesUpdateMode.alias)}> + Edit + + +
    + + ({ alias: item }))} + columns={[ + { + field: "alias", + name: "Alias name", + render: (val: string, record: { alias: string }) => ( + + {val} + + ), + }, + ]} + /> + + ), + }, + ]} + /> + {editVisible ? ( + null} hideCloseButton> + + + setEditVisible(false)} style={{ cursor: "pointer" }}> + + Edit index {editMode} + + + + + + + + ) : null} + + ); +} diff --git a/public/pages/IndexDetail/containers/IndexDetail/__snapshots__/IndexDetail.test.tsx.snap b/public/pages/IndexDetail/containers/IndexDetail/__snapshots__/IndexDetail.test.tsx.snap new file mode 100644 index 000000000..bf8b6b363 --- /dev/null +++ b/public/pages/IndexDetail/containers/IndexDetail/__snapshots__/IndexDetail.test.tsx.snap @@ -0,0 +1,358 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`container spec render the component 1`] = `null`; + +exports[`container spec render the component 2`] = ` +
    +
    +
    +
    + + test_index + +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    + + + + +
    +
    +
    +
    +
    +
    +
    + Index name +
    +
    + + test_index + +
    +
    +
    +
    + Health +
    +
    + + green + +
    +
    +
    +
    + Status +
    +
    +
    +
    +
    + EuiIconMock +
    +
    + green +
    +
    +
    +
    +
    +
    +
    + Creation date +
    +
    + + - + +
    +
    +
    +
    + Total size +
    +
    + + 100KB + +
    +
    +
    +
    + Size of primaries +
    +
    + + 100KB + +
    +
    +
    +
    + Total documents +
    +
    + + 5 + +
    +
    +
    +
    + Deleted documents +
    +
    + + 2 + +
    +
    +
    +
    + Primaries +
    +
    + + 1 + +
    +
    +
    +
    + Replicas +
    +
    + + 0 + +
    +
    +
    +
    + Index blocks +
    +
    + + - + +
    +
    +
    +
    + Managed by policy +
    +
    + + - + +
    +
    +
    +
    +
    +
    +
    +
    +`; diff --git a/public/pages/IndexDetail/containers/IndexDetail/index.ts b/public/pages/IndexDetail/containers/IndexDetail/index.ts new file mode 100644 index 000000000..622fb5b32 --- /dev/null +++ b/public/pages/IndexDetail/containers/IndexDetail/index.ts @@ -0,0 +1,3 @@ +import IndexDetail from "./IndexDetail"; + +export default IndexDetail; diff --git a/public/pages/IndexDetail/index.ts b/public/pages/IndexDetail/index.ts new file mode 100644 index 000000000..7298d4768 --- /dev/null +++ b/public/pages/IndexDetail/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import IndexDetail from "./containers/IndexDetail"; + +export default IndexDetail; diff --git a/public/pages/Indices/components/CloseIndexModal/CloseIndexModal.test.tsx b/public/pages/Indices/components/CloseIndexModal/CloseIndexModal.test.tsx new file mode 100644 index 000000000..8e9d61158 --- /dev/null +++ b/public/pages/Indices/components/CloseIndexModal/CloseIndexModal.test.tsx @@ -0,0 +1,41 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from "react"; +import "@testing-library/jest-dom/extend-expect"; +import { render, fireEvent } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import CloseIndexModal from "./CloseIndexModal"; + +describe(" spec", () => { + it("renders the component", async () => { + render( {}} onClose={() => {}} />); + expect(document.body.children).toMatchSnapshot(); + }); + + it("calls close when cancel button clicked", () => { + const onClose = jest.fn(); + const { getByTestId } = render( {}} onClose={onClose} />); + fireEvent.click(getByTestId("Close Cancel button")); + expect(onClose).toHaveBeenCalled(); + }); + + it("Close button should be disabled unless a 'close' was input", async () => { + const { getByPlaceholderText } = render( {}} onClose={() => {}} />); + expect(document.querySelector(".euiButton")).toHaveAttribute("disabled"); + userEvent.type(getByPlaceholderText("close"), "close"); + expect(document.querySelector(".euiButton")).not.toHaveAttribute("disabled"); + }); + + it("Show warning when system indices are selected", async () => { + render( {}} onClose={() => {}} />); + expect(document.querySelector(".euiCallOut")).not.toHaveAttribute("hidden"); + }); + + it("No warning if no system indices are selected", async () => { + render( {}} onClose={() => {}} />); + expect(document.querySelector(".euiCallOut")).toHaveAttribute("hidden"); + }); +}); diff --git a/public/pages/Indices/components/CloseIndexModal/CloseIndexModal.tsx b/public/pages/Indices/components/CloseIndexModal/CloseIndexModal.tsx new file mode 100644 index 000000000..ad0c0fdea --- /dev/null +++ b/public/pages/Indices/components/CloseIndexModal/CloseIndexModal.tsx @@ -0,0 +1,82 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useEffect, useState } from "react"; +import { + EuiButton, + EuiButtonEmpty, + EuiFieldText, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiSpacer, + EuiCallOut, + EuiText, +} from "@elastic/eui"; +import { filterByMinimatch } from "../../../../../utils/helper"; +import { SYSTEM_INDEX } from "../../../../../utils/constants"; + +interface CloseIndexModalProps { + selectedItems: string[]; + visible: boolean; + onClose: () => void; + onConfirm: () => void; +} + +export default function CloseIndexModal(props: CloseIndexModalProps) { + const [value, setValue] = useState(""); + const { onClose, onConfirm, visible, selectedItems } = props; + useEffect(() => { + if (visible) { + setValue(""); + } + }, [visible]); + if (!visible) { + return null; + } + + const showWarning = selectedItems.filter((item) => filterByMinimatch(item as string, SYSTEM_INDEX)).length > 0; + + return ( + + + Close indices + + + + <> + + + +
    +

    The following index will be closed. It is not possible to index documents or to search for documents in a closed index.

    +
      + {selectedItems.map((item) => ( +
    • {item}
    • + ))} +
    + + + To confirm your action, type close. + + setValue(e.target.value)} /> +
    +
    + + + + Cancel + + + Close + + +
    + ); +} diff --git a/public/pages/Indices/components/CloseIndexModal/__snapshots__/CloseIndexModal.test.tsx.snap b/public/pages/Indices/components/CloseIndexModal/__snapshots__/CloseIndexModal.test.tsx.snap new file mode 100644 index 000000000..c3a8f86fd --- /dev/null +++ b/public/pages/Indices/components/CloseIndexModal/__snapshots__/CloseIndexModal.test.tsx.snap @@ -0,0 +1,159 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` spec renders the component 1`] = ` +HTMLCollection [ +