From b253ce4d2d2e52899cb17ace3dd85a3fce13fbb8 Mon Sep 17 00:00:00 2001 From: suzhou Date: Tue, 3 Jan 2023 15:54:31 +0800 Subject: [PATCH] Temp/alias management 2.5 (#524) * Feature/common 2.5 (#506) * feat: split to common change Signed-off-by: suzhou * feat: update Signed-off-by: suzhou Signed-off-by: suzhou * feat: update Signed-off-by: suzhou * feat: update Signed-off-by: suzhou Signed-off-by: suzhou --- cypress/integration/aliases.js | 119 +++++ .../IndexControls/IndexControls.test.tsx | 41 ++ .../IndexControls/IndexControls.tsx | 52 +++ .../__snapshots__/IndexControls.test.tsx.snap | 113 +++++ .../Aliases/components/IndexControls/index.ts | 9 + .../AliasActions/AliasActions.test.tsx | 119 +++++ .../__snapshots__/AliasActions.test.tsx.snap | 55 +++ .../Aliases/containers/AliasActions/index.tsx | 76 ++++ .../containers/Aliases/Aliases.test.tsx | 239 ++++++++++ .../Aliases/containers/Aliases/Aliases.tsx | 418 ++++++++++++++++++ .../__snapshots__/Aliases.test.tsx.snap | 410 +++++++++++++++++ .../pages/Aliases/containers/Aliases/index.ts | 8 + .../CreateAlias/CreateAlias.test.tsx | 29 ++ .../__snapshots__/CreateAlias.test.tsx.snap | 222 ++++++++++ .../Aliases/containers/CreateAlias/index.tsx | 220 +++++++++ .../DeleteAliasModal.test.tsx | 17 + .../DeleteAliasModal/DeleteAliasModal.tsx | 110 +++++ .../DeleteAliasModal.test.tsx.snap | 142 ++++++ .../containers/DeleteAliasModal/index.ts | 8 + public/pages/Aliases/index.ts | 8 + public/pages/Aliases/interface.ts | 9 + public/pages/Aliases/utils/constants.tsx | 17 + .../containers/IndexForm/index.tsx | 2 +- public/pages/Main/Main.tsx | 9 + 24 files changed, 2451 insertions(+), 1 deletion(-) create mode 100644 cypress/integration/aliases.js create mode 100644 public/pages/Aliases/components/IndexControls/IndexControls.test.tsx create mode 100644 public/pages/Aliases/components/IndexControls/IndexControls.tsx create mode 100644 public/pages/Aliases/components/IndexControls/__snapshots__/IndexControls.test.tsx.snap create mode 100644 public/pages/Aliases/components/IndexControls/index.ts create mode 100644 public/pages/Aliases/containers/AliasActions/AliasActions.test.tsx create mode 100644 public/pages/Aliases/containers/AliasActions/__snapshots__/AliasActions.test.tsx.snap create mode 100644 public/pages/Aliases/containers/AliasActions/index.tsx create mode 100644 public/pages/Aliases/containers/Aliases/Aliases.test.tsx create mode 100644 public/pages/Aliases/containers/Aliases/Aliases.tsx create mode 100644 public/pages/Aliases/containers/Aliases/__snapshots__/Aliases.test.tsx.snap create mode 100644 public/pages/Aliases/containers/Aliases/index.ts create mode 100644 public/pages/Aliases/containers/CreateAlias/CreateAlias.test.tsx create mode 100644 public/pages/Aliases/containers/CreateAlias/__snapshots__/CreateAlias.test.tsx.snap create mode 100644 public/pages/Aliases/containers/CreateAlias/index.tsx create mode 100644 public/pages/Aliases/containers/DeleteAliasModal/DeleteAliasModal.test.tsx create mode 100644 public/pages/Aliases/containers/DeleteAliasModal/DeleteAliasModal.tsx create mode 100644 public/pages/Aliases/containers/DeleteAliasModal/__snapshots__/DeleteAliasModal.test.tsx.snap create mode 100644 public/pages/Aliases/containers/DeleteAliasModal/index.ts create mode 100644 public/pages/Aliases/index.ts create mode 100644 public/pages/Aliases/interface.ts create mode 100644 public/pages/Aliases/utils/constants.tsx diff --git a/cypress/integration/aliases.js b/cypress/integration/aliases.js new file mode 100644 index 000000000..7d77a0f06 --- /dev/null +++ b/cypress/integration/aliases.js @@ -0,0 +1,119 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { PLUGIN_NAME } from "../support/constants"; + +const SAMPLE_INDEX_PREFIX = "index-for-alias-test"; +const SAMPLE_ALIAS_PREFIX = "alias-for-test"; +const CREATE_ALIAS = "create-alias"; +const EDIT_INDEX = "index-edit-index-for-alias-test"; + +describe("Aliases", () => { + before(() => { + // Set welcome screen tracking to false + localStorage.setItem("home:welcome:show", "false"); + cy.deleteAllIndices(); + for (let i = 0; i < 11; i++) { + cy.createIndex(`${SAMPLE_INDEX_PREFIX}-${i}`, null); + } + cy.createIndex(EDIT_INDEX, null); + for (let i = 0; i < 30; i++) { + cy.addAlias(`${SAMPLE_ALIAS_PREFIX}-${i}`, `${SAMPLE_INDEX_PREFIX}-${i % 11}`); + } + cy.removeAlias(`${SAMPLE_ALIAS_PREFIX}-0`); + cy.addAlias(`${SAMPLE_ALIAS_PREFIX}-0`, `${SAMPLE_INDEX_PREFIX}-*`); + }); + + beforeEach(() => { + // Visit ISM OSD + cy.visit(`${Cypress.env("opensearch_dashboards")}/app/${PLUGIN_NAME}#/aliases`); + + // Common text to wait for to confirm page loaded, give up to 60 seconds for initial load + cy.contains("Rows per page", { timeout: 60000 }); + }); + + describe("can be searched / sorted / paginated", () => { + it("successfully", () => { + cy.get('[data-test-subj="pagination-button-1"]').should("exist"); + cy.get('[placeholder="Search..."]').type("alias-for-test-0{enter}"); + cy.contains("alias-for-test-0"); + cy.get(".euiTableRow").should("have.length", 1); + cy.get('[data-test-subj="comboBoxSearchInput"]').type("closed{enter}"); + + cy.contains("You have no aliases."); + }); + }); + + describe("shows more flyout", () => { + it("successfully", () => { + cy.get('[placeholder="Search..."]').type("alias-for-test-0{enter}"); + cy.contains("alias-for-test-0"); + cy.get(".euiTableRow").should("have.length", 1); + cy.get('.euiTableRowCell [data-test-subj="8 more"]') + .click() + .get('[data-test-subj="indices-table"] .euiTableRow') + .should("have.length", 10); + }); + }); + + describe("can create a alias with wildcard and specific name", () => { + it("successfully", () => { + cy.get('[data-test-subj="Create AliasButton"]').click(); + cy.get('[data-test-subj="form-name-alias"]').type(CREATE_ALIAS); + cy.get('[data-test-subj="form-name-indexArray"] [data-test-subj="comboBoxSearchInput"]').type( + `${EDIT_INDEX}{enter}${SAMPLE_INDEX_PREFIX}-*{enter}` + ); + cy.get(".euiFlyoutFooter .euiButton--fill").click().get('[data-test-subj="9 more"]').should("exist"); + }); + }); + + describe("can edit / delete a alias", () => { + it("successfully", () => { + cy.get('[placeholder="Search..."]').type(`${SAMPLE_ALIAS_PREFIX}-0{enter}`); + cy.contains(`${SAMPLE_ALIAS_PREFIX}-0`); + cy.get('[data-test-subj="moreAction"] button') + .click() + .get('[data-test-subj="editAction"]') + .should("be.disabled") + .get(`#_selection_column_${SAMPLE_ALIAS_PREFIX}-0-checkbox`) + .click() + .get('[data-test-subj="moreAction"] button') + .click() + .get('[data-test-subj="editAction"]') + .click() + .get('[data-test-subj="form-name-indexArray"] [data-test-subj="comboBoxInput"]') + .click() + .type(`${EDIT_INDEX}{enter}`) + .get(`[title="${SAMPLE_INDEX_PREFIX}-0"] button`) + .click() + .get(`[title="${SAMPLE_INDEX_PREFIX}-1"] button`) + .click() + .get(".euiFlyoutFooter .euiButton--fill") + .click() + .end(); + + cy.get('[data-test-subj="7 more"]').should("exist"); + + cy.get('[data-test-subj="moreAction"] button').click().get('[data-test-subj="deleteAction"]').click(); + // The confirm button should be disabled + cy.get('[data-test-subj="deleteConfirmButton"]').should("be.disabled"); + // type delete + cy.wait(500).get('[data-test-subj="deleteInput"]').type("delete"); + cy.get('[data-test-subj="deleteConfirmButton"]').should("not.be.disabled"); + // click to delete + cy.get('[data-test-subj="deleteConfirmButton"]').click(); + // the alias should not exist + cy.wait(500); + cy.get(`#_selection_column_${SAMPLE_ALIAS_PREFIX}-0-checkbox`).should("not.exist"); + }); + }); + + after(() => { + cy.deleteAllIndices(); + for (let i = 0; i < 30; i++) { + cy.removeAlias(`${SAMPLE_ALIAS_PREFIX}-${i}`); + } + cy.removeAlias(CREATE_ALIAS); + }); +}); diff --git a/public/pages/Aliases/components/IndexControls/IndexControls.test.tsx b/public/pages/Aliases/components/IndexControls/IndexControls.test.tsx new file mode 100644 index 000000000..9ed775959 --- /dev/null +++ b/public/pages/Aliases/components/IndexControls/IndexControls.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, waitFor } from "@testing-library/react"; +// @ts-ignore +import userEvent from "@testing-library/user-event"; +import IndexControls from "./IndexControls"; + +describe(" spec", () => { + it("renders the component", async () => { + const { container } = render( {}} />); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it("onChange with right data", async () => { + const onSearchChangeMock = jest.fn(); + const { getByTestId, getByPlaceholderText } = render( + + ); + + userEvent.type(getByTestId("comboBoxSearchInput"), "closed{enter}"); + expect(onSearchChangeMock).toBeCalledTimes(1); + expect(onSearchChangeMock).toBeCalledWith({ + search: "", + status: "closed", + }); + userEvent.type(getByPlaceholderText("Search..."), "test"); + await waitFor(() => { + expect(onSearchChangeMock).toBeCalledTimes(5); + expect(onSearchChangeMock).toBeCalledWith({ + search: "test", + status: "closed", + }); + }); + }); +}); diff --git a/public/pages/Aliases/components/IndexControls/IndexControls.tsx b/public/pages/Aliases/components/IndexControls/IndexControls.tsx new file mode 100644 index 000000000..0c6baa00a --- /dev/null +++ b/public/pages/Aliases/components/IndexControls/IndexControls.tsx @@ -0,0 +1,52 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useEffect, useState } from "react"; +import { EuiComboBox, EuiFieldSearch, EuiFlexGroup, EuiFlexItem } from "@elastic/eui"; +import { ALIAS_STATUS_OPTIONS } from "../../../../utils/constants"; + +export interface SearchControlsProps { + value: { + search: string; + status: string; + }; + onSearchChange: (args: SearchControlsProps["value"]) => void; +} + +export default function SearchControls(props: SearchControlsProps) { + const [state, setState] = useState(props.value); + const onChange = (field: T, value: SearchControlsProps["value"][T]) => { + const payload = { + ...state, + [field]: value, + }; + setState(payload); + props.onSearchChange(payload); + }; + useEffect(() => { + setState(props.value); + }, [props.value]); + return ( + + + onChange("search", e.target.value)} /> + + + onChange("status", val[0]?.label)} + /> + + + ); +} diff --git a/public/pages/Aliases/components/IndexControls/__snapshots__/IndexControls.test.tsx.snap b/public/pages/Aliases/components/IndexControls/__snapshots__/IndexControls.test.tsx.snap new file mode 100644 index 000000000..334962fa0 --- /dev/null +++ b/public/pages/Aliases/components/IndexControls/__snapshots__/IndexControls.test.tsx.snap @@ -0,0 +1,113 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` spec renders the component 1`] = ` +
+
+
+
+ +
+ + EuiIconMock + +
+
+ +
+
+
+
+
+ +
+`; diff --git a/public/pages/Aliases/components/IndexControls/index.ts b/public/pages/Aliases/components/IndexControls/index.ts new file mode 100644 index 000000000..993ddb52b --- /dev/null +++ b/public/pages/Aliases/components/IndexControls/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import IndexControls from "./IndexControls"; + +export * from "./IndexControls"; +export default IndexControls; diff --git a/public/pages/Aliases/containers/AliasActions/AliasActions.test.tsx b/public/pages/Aliases/containers/AliasActions/AliasActions.test.tsx new file mode 100644 index 000000000..8739999b1 --- /dev/null +++ b/public/pages/Aliases/containers/AliasActions/AliasActions.test.tsx @@ -0,0 +1,119 @@ +/* + * 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"; +// @ts-ignore +import userEvent from "@testing-library/user-event"; +import { browserServicesMock, coreServicesMock } from "../../../../../test/mocks"; +import AliasesActions, { AliasesActionsProps } from "./index"; +import { ModalProvider } from "../../../../components/Modal"; +import { ServicesContext } from "../../../../services"; +import { CoreServicesContext } from "../../../../components/core_services"; + +function renderWithRouter(props: AliasesActionsProps) { + return { + ...render( + + + + + + + + ), + }; +} + +describe(" spec", () => { + it("renders the component and all the actions should be disabled when no items selected", async () => { + const { container, getByTestId } = renderWithRouter({ + selectedItems: [], + onUpdateAlias: () => null, + onDelete: () => null, + }); + + await waitFor(() => { + expect(container.firstChild).toMatchSnapshot(); + }); + + userEvent.click(document.querySelector('[data-test-subj="moreAction"] button') as Element); + await waitFor(() => { + expect(getByTestId("deleteAction")).toBeDisabled(); + }); + }); + + it("delete alias by calling commonService", async () => { + const onDelete = jest.fn(); + let times = 0; + browserServicesMock.commonService.apiCaller = jest.fn( + async (payload): Promise => { + if (payload.endpoint === "indices.deleteAlias") { + if (times >= 1) { + return { + ok: true, + response: {}, + }; + } else { + times++; + return { + ok: false, + error: "test error", + }; + } + } + return { ok: true, response: {} }; + } + ); + const { container, getByTestId, getByPlaceholderText } = renderWithRouter({ + selectedItems: [ + { + index: "test_index", + alias: "1", + filter: "1", + "routing.index": "1", + "routing.search": "1", + is_write_index: "1", + indexArray: ["test_index"], + }, + ], + onUpdateAlias: () => null, + onDelete, + }); + + await waitFor(() => { + expect(container.firstChild).toMatchSnapshot(); + }); + + userEvent.click(document.querySelector('[data-test-subj="moreAction"] button') as Element); + userEvent.click(getByTestId("deleteAction")); + userEvent.type(getByPlaceholderText("delete"), "delete"); + userEvent.click(getByTestId("deleteConfirmButton")); + + await waitFor(() => { + expect(browserServicesMock.commonService.apiCaller).toHaveBeenCalledTimes(1); + expect(browserServicesMock.commonService.apiCaller).toHaveBeenCalledWith({ + endpoint: "indices.deleteAlias", + data: { + index: "_all", + name: ["1"], + }, + }); + expect(coreServicesMock.notifications.toasts.addDanger).toHaveBeenCalledTimes(1); + expect(coreServicesMock.notifications.toasts.addDanger).toHaveBeenCalledWith("test error"); + expect(onDelete).toHaveBeenCalledTimes(0); + }); + + userEvent.click(getByTestId("deleteConfirmButton")); + + await waitFor(() => { + expect(browserServicesMock.commonService.apiCaller).toHaveBeenCalledTimes(2); + expect(coreServicesMock.notifications.toasts.addSuccess).toHaveBeenCalledTimes(1); + expect(coreServicesMock.notifications.toasts.addSuccess).toHaveBeenCalledWith("Delete [1] successfully"); + expect(onDelete).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/public/pages/Aliases/containers/AliasActions/__snapshots__/AliasActions.test.tsx.snap b/public/pages/Aliases/containers/AliasActions/__snapshots__/AliasActions.test.tsx.snap new file mode 100644 index 000000000..4f0d2ca06 --- /dev/null +++ b/public/pages/Aliases/containers/AliasActions/__snapshots__/AliasActions.test.tsx.snap @@ -0,0 +1,55 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` spec delete alias by calling commonService 1`] = ` +
+
+ +
+
+`; + +exports[` spec renders the component and all the actions should be disabled when no items selected 1`] = ` +
+
+ +
+
+`; diff --git a/public/pages/Aliases/containers/AliasActions/index.tsx b/public/pages/Aliases/containers/AliasActions/index.tsx new file mode 100644 index 000000000..5de68f6c0 --- /dev/null +++ b/public/pages/Aliases/containers/AliasActions/index.tsx @@ -0,0 +1,76 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import React, { useMemo, useState } from "react"; +import { EuiButton, EuiContextMenu } from "@elastic/eui"; + +import SimplePopover from "../../../../components/SimplePopover"; +import DeleteIndexModal from "../DeleteAliasModal"; +import { IAlias } from "../../interface"; + +export interface AliasesActionsProps { + selectedItems: IAlias[]; + onDelete: () => void; + onUpdateAlias: () => void; +} + +export default function AliasesActions(props: AliasesActionsProps) { + const { selectedItems, onDelete, onUpdateAlias } = props; + const [deleteIndexModalVisible, setDeleteIndexModalVisible] = useState(false); + + const onDeleteIndexModalClose = () => { + setDeleteIndexModalVisible(false); + }; + + const renderKey = useMemo(() => Date.now(), [selectedItems]); + + return ( + <> + + Actions + + } + > + setDeleteIndexModalVisible(true), + }, + ], + }, + ]} + /> + + item.alias)} + visible={deleteIndexModalVisible} + onClose={onDeleteIndexModalClose} + onDelete={() => { + onDeleteIndexModalClose(); + onDelete(); + }} + /> + + ); +} diff --git a/public/pages/Aliases/containers/Aliases/Aliases.test.tsx b/public/pages/Aliases/containers/Aliases/Aliases.test.tsx new file mode 100644 index 000000000..bb5bc351f --- /dev/null +++ b/public/pages/Aliases/containers/Aliases/Aliases.test.tsx @@ -0,0 +1,239 @@ +/* + * 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 { Redirect, Route, Switch } from "react-router-dom"; +import { HashRouter as Router } from "react-router-dom"; +import { browserServicesMock, coreServicesMock } from "../../../../../test/mocks"; +import Aliases from "./Aliases"; +import { ServicesContext } from "../../../../services"; +import { ROUTES } from "../../../../utils/constants"; +import { CoreServicesContext } from "../../../../components/core_services"; +import userEvent from "@testing-library/user-event"; +import { IAlias } from "../../interface"; + +function renderWithRouter() { + return { + ...render( + + + ( + + + + + + )} + /> + + + + ), + }; +} + +const testAliasId = "test"; +const multiIndexAliasId = "test2"; + +describe(" spec", () => { + beforeEach(() => { + browserServicesMock.commonService.apiCaller = jest.fn(async (payload) => { + if (payload.endpoint === "cat.aliases") { + return { + ok: true, + response: [ + { + alias: testAliasId, + index: "1", + }, + { + alias: multiIndexAliasId, + index: "1", + }, + { + alias: multiIndexAliasId, + index: "2", + }, + { + alias: multiIndexAliasId, + index: "3", + }, + { + alias: multiIndexAliasId, + index: "4", + }, + ] as IAlias[], + }; + } else if (payload.endpoint === "cat.indices") { + return { + ok: true, + response: [ + { + health: "green", + status: "open", + index: "1", + pri: "1", + rep: "0", + "docs.count": "1", + "docs.deleted": "0", + "store.size": "5.2kb", + "pri.store.size": "5.2kb", + }, + ], + }; + } else if (payload?.data?.name === multiIndexAliasId) { + return { + ok: false, + error: "alias exist", + }; + } else if (payload.endpoint === "transport.request" && payload.data?.path === "/_data_stream") { + return { + ok: true, + response: { + data_streams: [ + { + name: "test_data_stream", + indices: [], + }, + ], + }, + }; + } + + return { + ok: true, + response: {}, + }; + }) as any; + window.location.hash = "/"; + }); + it("renders the component", async () => { + const { container, getByTestId, queryByText } = renderWithRouter(); + + expect(container.firstChild).toMatchSnapshot(); + await waitFor(() => { + expect(browserServicesMock.commonService.apiCaller).toBeCalledTimes(1); + }); + userEvent.click(getByTestId("tableHeaderCell_alias_0").querySelector("button") as Element); + await waitFor(() => { + expect(queryByText("1 more")).not.toBeNull(); + expect(browserServicesMock.commonService.apiCaller).toBeCalledTimes(2); + expect(browserServicesMock.commonService.apiCaller).toBeCalledWith({ + data: { format: "json", name: `**`, s: "alias:asc" }, + endpoint: "cat.aliases", + }); + }); + }); + + it("with some actions", async () => { + const { + findByTitle, + findByTestId, + getByTestId, + getByPlaceholderText, + getByTitle, + findByPlaceholderText, + getByText, + } = renderWithRouter(); + expect(browserServicesMock.commonService.apiCaller).toBeCalledTimes(1); + userEvent.type(getByPlaceholderText("Search..."), `${testAliasId}{enter}`); + await waitFor(() => { + expect(browserServicesMock.commonService.apiCaller).toBeCalledTimes(2); + expect(browserServicesMock.commonService.apiCaller).toBeCalledWith({ + data: { format: "json", name: `*${testAliasId}*`, s: "alias:desc" }, + endpoint: "cat.aliases", + }); + }); + userEvent.click(document.getElementById(`_selection_column_${testAliasId}-checkbox`) as Element); + await waitFor(() => {}); + userEvent.click(document.querySelector('[data-test-subj="moreAction"] button') as Element); + userEvent.click(document.querySelector('[data-test-subj="editAction"]') as Element); + userEvent.click(getByTestId("cancelCreateAliasButton")); + userEvent.click(document.querySelector('[data-test-subj="moreAction"] button') as Element); + userEvent.click(document.querySelector('[data-test-subj="editAction"]') as Element); + await findByPlaceholderText("Specify alias name"); + expect(getByPlaceholderText("Specify alias name")).toBeDisabled(); + expect((getByPlaceholderText("Specify alias name") as HTMLInputElement).value).toEqual(testAliasId); + expect(getByTitle("1")).toBeInTheDocument(); + expect(browserServicesMock.commonService.apiCaller).toBeCalledTimes(6); + userEvent.type(getByTestId("form-name-indexArray").querySelector('[data-test-subj="comboBoxSearchInput"]') as Element, "2{enter}"); + userEvent.click(document.querySelector('[title="1"] button') as Element); + userEvent.click(getByText("Save changes")); + await waitFor(() => { + expect(browserServicesMock.commonService.apiCaller).toBeCalledTimes(8); + expect(browserServicesMock.commonService.apiCaller).toBeCalledWith({ + data: { + body: { + actions: [ + { + remove: { + alias: testAliasId, + index: "1", + }, + }, + { + add: { + alias: testAliasId, + index: "2", + }, + }, + ], + }, + }, + endpoint: "indices.updateAliases", + }); + }); + + userEvent.click(getByTestId("Create AliasButton")); + await findByTestId("createAliasButton"); + userEvent.click(getByTestId("cancelCreateAliasButton")); + userEvent.click(getByTestId("Create AliasButton")); + await findByTestId("createAliasButton"); + userEvent.click(getByTestId("createAliasButton")); + await waitFor(() => { + expect(getByText("Alias name is required")).not.toBeNull(); + }); + userEvent.type(getByPlaceholderText("Specify alias name"), multiIndexAliasId); + userEvent.type(getByTestId("form-name-indexArray").querySelector('[data-test-subj="comboBoxSearchInput"]') as Element, "1{enter}"); + await waitFor(() => {}); + userEvent.click(getByTestId("createAliasButton")); + await waitFor(() => { + expect(coreServicesMock.notifications.toasts.addDanger).toBeCalledTimes(1); + expect(coreServicesMock.notifications.toasts.addDanger).toBeCalledWith("alias exist"); + }); + userEvent.clear(getByPlaceholderText("Specify alias name")); + userEvent.type(getByPlaceholderText("Specify alias name"), testAliasId); + userEvent.click(getByTestId("createAliasButton")); + await waitFor(() => { + expect(browserServicesMock.commonService.apiCaller).toBeCalledTimes(17); + expect(browserServicesMock.commonService.apiCaller).toBeCalledWith({ + data: { + index: ["1"], + name: testAliasId, + }, + endpoint: "indices.putAlias", + }); + }); + + userEvent.click(getByText("1 more")); + await findByTitle(`Indices in ${multiIndexAliasId} (4)`); + userEvent.click(getByText("Rows per page: 10")); + userEvent.click(getByTestId("tablePagination-25-rows")); + userEvent.click(getByTestId("euiFlyoutCloseButton")); + }, 50000); + + it("shows detail", async () => { + const { getByTestId, findByTestId, getByText } = renderWithRouter(); + await findByTestId(`aliasDetail-${testAliasId}`); + userEvent.click(getByTestId(`aliasDetail-${testAliasId}`)); + await waitFor(() => expect(getByText("Save changes")).toBeInTheDocument(), { + timeout: 3000, + }); + }); +}); diff --git a/public/pages/Aliases/containers/Aliases/Aliases.tsx b/public/pages/Aliases/containers/Aliases/Aliases.tsx new file mode 100644 index 000000000..2ef84abc3 --- /dev/null +++ b/public/pages/Aliases/containers/Aliases/Aliases.tsx @@ -0,0 +1,418 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { Component, useContext, useState } from "react"; +import _ from "lodash"; +import { RouteComponentProps } from "react-router-dom"; +import queryString from "query-string"; +import { + EuiHorizontalRule, + EuiBasicTable, + Criteria, + EuiTableSortingType, + Direction, + Pagination, + EuiTableSelectionType, + EuiButton, + EuiFlyout, + EuiFlyoutHeader, + EuiFlyoutBody, + EuiText, + EuiLink, + EuiTitle, + EuiFormRow, +} from "@elastic/eui"; +import { ContentPanel, ContentPanelActions } from "../../../../components/ContentPanel"; +import { DEFAULT_PAGE_SIZE_OPTIONS, DEFAULT_QUERY_PARAMS } from "../../utils/constants"; +import CommonService from "../../../../services/CommonService"; +import { IAlias } from "../../interface"; +import { BREADCRUMBS } from "../../../../utils/constants"; +import { CoreServicesContext } from "../../../../components/core_services"; +import { ServicesContext } from "../../../../services"; +import IndexControls, { SearchControlsProps } from "../../components/IndexControls"; +import CreateAlias from "../CreateAlias"; +import AliasesActions from "../AliasActions"; +import { CoreStart } from "opensearch-dashboards/public"; + +interface AliasesProps extends RouteComponentProps { + commonService: CommonService; +} + +interface AliasesState { + totalAliases: number; + from: string; + size: string; + search: string; + status: string; + sortField: keyof IAlias; + sortDirection: Direction; + selectedItems: IAlias[]; + editingItem: IAlias | null; + aliases: IAlias[]; + loading: boolean; + aliasCreateFlyoutVisible: boolean; + aliasEditFlyoutVisible: boolean; +} + +function IndexNameDisplay(props: { indices: string[]; alias: string }) { + const [hide, setHide] = useState(true); + const [tableParams, setTableParams] = useState>({}); + const { index, size } = tableParams.page || { + index: 0, + size: 10, + }; + + return ( + <> + {props.indices.slice(0, 3).join(",")} + {props.indices.length <= 3 ? null : ( + setHide(!hide)}> + {props.indices.length - 3} more + + )} + {hide ? null : ( + setHide(!hide)}> + + +

+ Indices in {props.alias} ({props.indices.length}) +

+
+
+ + ({ index }))} + onChange={setTableParams} + pagination={{ + pageIndex: index, + pageSize: size, + totalItemCount: props.indices.length, + }} + /> + +
+ )} + + ); +} + +class Aliases extends Component { + static contextType = CoreServicesContext; + constructor(props: AliasesProps) { + super(props); + const { + from = DEFAULT_QUERY_PARAMS.from, + size = DEFAULT_QUERY_PARAMS.size, + search = DEFAULT_QUERY_PARAMS.search, + sortField = DEFAULT_QUERY_PARAMS.sortField, + sortDirection = DEFAULT_QUERY_PARAMS.sortDirection, + status = DEFAULT_QUERY_PARAMS.status, + } = queryString.parse(props.history.location.search) as { + from: string; + size: string; + search: string; + sortField: keyof IAlias; + sortDirection: Direction; + status: string; + }; + this.state = { + totalAliases: 0, + from, + size, + status, + search, + sortField, + sortDirection, + selectedItems: [], + aliases: [], + loading: false, + aliasCreateFlyoutVisible: false, + aliasEditFlyoutVisible: false, + editingItem: null, + }; + + this.getAliases = _.debounce(this.getAliases, 500, { leading: true }); + } + + componentDidMount() { + this.context.chrome.setBreadcrumbs([BREADCRUMBS.INDEX_MANAGEMENT, BREADCRUMBS.ALIASES]); + this.getAliases(); + } + + getQueryState = (state: AliasesState) => { + return Object.keys(DEFAULT_QUERY_PARAMS).reduce((total, key) => { + return { + ...total, + [key]: state[key as keyof typeof DEFAULT_QUERY_PARAMS], + }; + }, {} as AliasesState); + }; + + groupResponse = (array: IAlias[]) => { + const groupedMap: Record = {}; + array.forEach((item, index) => { + groupedMap[item.alias] = groupedMap[item.alias] || { + ...item, + order: index, + indexArray: [], + }; + groupedMap[item.alias].indexArray.push(item.index); + }); + const result = Object.values(groupedMap); + result.sort((a, b) => a.order - b.order); + return Object.values(groupedMap).sort(); + }; + + getAliases = async (): Promise => { + this.setState({ loading: true }); + const { from, size, status } = this.state; + const fromNumber = Number(from); + const sizeNumber = Number(size); + const { history, commonService } = this.props; + const queryObject = this.getQueryState(this.state); + const queryParamsString = queryString.stringify(queryObject); + history.replace({ ...this.props.location, search: queryParamsString }); + + const payload: any = { + format: "json", + name: `*${queryObject.search}*`, + s: `${queryObject.sortField}:${queryObject.sortDirection}`, + expand_wildcards: status, + }; + if (!status) { + delete payload.expand_wildcards; + } + + const getAliasesResponse = await commonService.apiCaller({ + endpoint: "cat.aliases", + data: payload, + }); + + if (getAliasesResponse.ok) { + // group by alias name + const responseGroupByAliasName: IAlias[] = this.groupResponse(getAliasesResponse.response); + const totalAliases = responseGroupByAliasName.length; + const payload = { + aliases: responseGroupByAliasName.slice(fromNumber * sizeNumber, (fromNumber + 1) * sizeNumber), + totalAliases, + selectedItems: this.state.selectedItems + .map((item) => responseGroupByAliasName.find((remoteItem) => remoteItem.alias === item.alias)) + .filter((item) => item), + } as AliasesState; + this.setState(payload); + } else { + this.context.notifications.toasts.addDanger(getAliasesResponse.error); + } + + // Avoiding flicker by showing/hiding the "Data stream" column only after the results are loaded. + this.setState({ loading: false }); + }; + + onTableChange = ({ page: tablePage, sort }: Criteria): void => { + const { index: page, size } = tablePage || {}; + const { field: sortField, direction: sortDirection } = sort || {}; + this.setState( + { + from: "" + page, + size: "" + size, + sortField: sortField || DEFAULT_QUERY_PARAMS.sortField, + sortDirection: sortDirection as Direction, + }, + () => this.getAliases() + ); + }; + + onSelectionChange = (selectedItems: IAlias[]): void => { + this.setState({ selectedItems }); + }; + + onSearchChange = (params: Parameters[0]): void => { + this.setState({ from: "0", ...params }, () => this.getAliases()); + }; + + render() { + const { totalAliases, from, size, sortField, sortDirection, aliases } = this.state; + + const pagination: Pagination = { + pageIndex: Number(from), + pageSize: Number(size), + pageSizeOptions: DEFAULT_PAGE_SIZE_OPTIONS, + totalItemCount: Number(totalAliases), + }; + + const sorting: EuiTableSortingType = { + sort: { + direction: sortDirection, + field: sortField, + }, + }; + + const selection: EuiTableSelectionType = { + onSelectionChange: this.onSelectionChange, + }; + return ( + { + this.setState({ aliasEditFlyoutVisible: true }); + }} + selectedItems={this.state.selectedItems} + onDelete={this.getAliases} + /> + ), + }, + { + text: "Create Alias", + buttonProps: { + fill: true, + onClick: () => { + this.setState({ + aliasCreateFlyoutVisible: true, + }); + }, + }, + }, + ]} + /> + } + bodyStyles={{ padding: "initial" }} + title={ + <> + + Aliases + + + An alias is a virtual index name that can point to one or more indexes. If your data is spread across multiple indexes, + rather than keeping track of which indexes to query, you can create an alias and query it instead. + + Learn more. + +
+ } + > + <> + + + } + > + + + + { + return ( + + this.setState({ + editingItem: record, + aliasEditFlyoutVisible: true, + }) + } + > + {value} + + ); + }, + }, + { + field: "indexArray", + name: "Index name", + render: (value: string[], record) => { + return ; + }, + }, + ]} + isSelectable={true} + itemId="alias" + items={aliases} + onChange={this.onTableChange} + pagination={pagination} + selection={selection} + sorting={sorting} + noItemsMessage={ +
+

You have no aliases.

+ { + this.setState({ + aliasCreateFlyoutVisible: true, + }); + }} + > + Create alias + +
+ } + /> + { + this.getAliases(); + this.setState({ aliasCreateFlyoutVisible: false }); + }} + onClose={() => this.setState({ aliasCreateFlyoutVisible: false })} + /> + { + this.getAliases(); + this.setState({ editingItem: null, aliasEditFlyoutVisible: false }); + }} + onClose={() => + this.setState({ + editingItem: null, + aliasEditFlyoutVisible: false, + }) + } + alias={this.state.editingItem || this.state.selectedItems[0]} + /> + + ); + } +} + +export default function AliasContainer(props: Omit) { + const context = useContext(ServicesContext); + return ; +} diff --git a/public/pages/Aliases/containers/Aliases/__snapshots__/Aliases.test.tsx.snap b/public/pages/Aliases/containers/Aliases/__snapshots__/Aliases.test.tsx.snap new file mode 100644 index 000000000..05865f4d0 --- /dev/null +++ b/public/pages/Aliases/containers/Aliases/__snapshots__/Aliases.test.tsx.snap @@ -0,0 +1,410 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` spec renders the component 1`] = ` +
+
+
+ + Aliases + +
+
+
+
+ An alias is a virtual index name that can point to one or more indexes. If your data is spread across multiple indexes, rather than keeping track of which indexes to query, you can create an alias and query it instead. + + Learn more. + EuiIconMock + + (opens in a new tab or window) + + +
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+ +
+ + EuiIconMock + +
+
+
+
+
+ +
+
+
+
+
+
+
+
+ +
+ +
+
+
+
+
+
+ +
+
+
+
+
+
+ + + + + + + + + + + + + +
+
+
+
+ +
+
+
+
+ + + + + Index name + + +
+
+ +
+

+ You have no aliases. +

+ +
+
+
+
+
+
+
+
+`; diff --git a/public/pages/Aliases/containers/Aliases/index.ts b/public/pages/Aliases/containers/Aliases/index.ts new file mode 100644 index 000000000..1498b3c3e --- /dev/null +++ b/public/pages/Aliases/containers/Aliases/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import Aliases from "./Aliases"; + +export default Aliases; diff --git a/public/pages/Aliases/containers/CreateAlias/CreateAlias.test.tsx b/public/pages/Aliases/containers/CreateAlias/CreateAlias.test.tsx new file mode 100644 index 000000000..cadecca57 --- /dev/null +++ b/public/pages/Aliases/containers/CreateAlias/CreateAlias.test.tsx @@ -0,0 +1,29 @@ +/* + * 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 CreateAlias from "./index"; +import { browserServicesMock } from "../../../../../test/mocks"; +import { ServicesContext } from "../../../../services"; + +describe(" spec", () => { + // the main unit test case is in Aliases.test.tsx + it("renders the component", async () => { + browserServicesMock.commonService.apiCaller = jest.fn(async () => ({ + ok: true, + response: [], + })) as typeof browserServicesMock.commonService.apiCaller; + render( + + {}} onSuccess={() => {}} /> + + ); + await waitFor(() => { + expect(document.body.children).toMatchSnapshot(); + }); + }); +}); diff --git a/public/pages/Aliases/containers/CreateAlias/__snapshots__/CreateAlias.test.tsx.snap b/public/pages/Aliases/containers/CreateAlias/__snapshots__/CreateAlias.test.tsx.snap new file mode 100644 index 000000000..fd9ea12f6 --- /dev/null +++ b/public/pages/Aliases/containers/CreateAlias/__snapshots__/CreateAlias.test.tsx.snap @@ -0,0 +1,222 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` spec renders the component 1`] = ` +HTMLCollection [ +