diff --git a/cypress/integration/templates.js b/cypress/integration/templates.js new file mode 100644 index 000000000..c0743a8cf --- /dev/null +++ b/cypress/integration/templates.js @@ -0,0 +1,91 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { PLUGIN_NAME } from "../support/constants"; + +const SAMPLE_TEMPLATE_PREFIX = "index-for-alias-test"; +const MAX_TEMPLATE_NUMBER = 30; + +describe("Templates", () => { + before(() => { + // Set welcome screen tracking to false + localStorage.setItem("home:welcome:show", "false"); + cy.deleteTemplate(`${SAMPLE_TEMPLATE_PREFIX}-${MAX_TEMPLATE_NUMBER}`); + for (let i = 0; i < MAX_TEMPLATE_NUMBER; i++) { + cy.deleteTemplate(`${SAMPLE_TEMPLATE_PREFIX}-${i}`); + cy.createIndexTemplate(`${SAMPLE_TEMPLATE_PREFIX}-${i}`, { + index_patterns: ["template-test-*"], + priority: i, + template: { + aliases: {}, + settings: { + number_of_shards: 2, + number_of_replicas: 1, + }, + }, + }); + } + }); + + beforeEach(() => { + // Visit ISM OSD + cy.visit(`${Cypress.env("opensearch_dashboards")}/app/${PLUGIN_NAME}#/templates`); + + // 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(`${SAMPLE_TEMPLATE_PREFIX}-0`); + cy.contains(`${SAMPLE_TEMPLATE_PREFIX}-0`); + cy.get(".euiTableRow").should("have.length", 1); + }); + }); + + describe("can create a template", () => { + it("successfully", () => { + cy.get('[data-test-subj="Create templateButton"]').click(); + cy.contains("Define template"); + + cy.get('[data-test-subj="form-row-name"] input').type(`${SAMPLE_TEMPLATE_PREFIX}-${MAX_TEMPLATE_NUMBER}`); + cy.get('[data-test-subj="form-row-index_patterns"] [data-test-subj="comboBoxSearchInput"]').type("test{enter}"); + cy.get('[data-test-subj="CreateIndexTemplateCreateButton"]').click(); + + cy.contains(`${SAMPLE_TEMPLATE_PREFIX}-${MAX_TEMPLATE_NUMBER} has been successfully created.`); + + cy.get('[placeholder="Search..."]').type(`${SAMPLE_TEMPLATE_PREFIX}-${MAX_TEMPLATE_NUMBER}`); + cy.contains(`${SAMPLE_TEMPLATE_PREFIX}-${MAX_TEMPLATE_NUMBER}`); + cy.get(".euiTableRow").should("have.length", 1); + }); + }); + + describe("can delete a template", () => { + it("successfully", () => { + cy.get('[placeholder="Search..."]').type(`${SAMPLE_TEMPLATE_PREFIX}-0`); + cy.contains(`${SAMPLE_TEMPLATE_PREFIX}-0`); + cy.get(`#_selection_column_${SAMPLE_TEMPLATE_PREFIX}-0-checkbox`).click(); + + 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_TEMPLATE_PREFIX}-0-checkbox`).should("not.exist"); + }); + }); + + after(() => { + cy.deleteTemplate(`${SAMPLE_TEMPLATE_PREFIX}-${MAX_TEMPLATE_NUMBER}`); + for (let i = 0; i < MAX_TEMPLATE_NUMBER; i++) { + cy.deleteTemplate(`${SAMPLE_TEMPLATE_PREFIX}-${i}`); + } + }); +}); diff --git a/models/interfaces.ts b/models/interfaces.ts index 1caf6843b..f4909d816 100644 --- a/models/interfaces.ts +++ b/models/interfaces.ts @@ -86,9 +86,7 @@ export interface TemplateItemRemote extends ITemplateExtras { /** * ManagedIndex item shown in the Managed Indices table */ -export interface ManagedIndexItem { - index: string; - indexUuid: string; +export interface ManagedIndexItem extends IndexItem { dataStream: string | null; policyId: string; policySeqNo: number; @@ -98,10 +96,6 @@ export interface ManagedIndexItem { managedIndexMetaData: ManagedIndexMetaData | null; } -export interface IndexItem { - index: string; -} - /** * Interface what the Policy Opensearch Document */ @@ -228,7 +222,7 @@ export interface SMDeleteCondition { export interface ErrorNotification { destination?: Destination; channel?: Channel; - message_template: MessageTemplate; + message_template?: MessageTemplate; } export interface Notification { @@ -624,3 +618,25 @@ export enum TRANSFORM_AGG_TYPE { histogram = "histogram", date_histogram = "date_histogram", } +export interface IAPICaller { + endpoint: string; + method?: string; + data?: any; +} + +export interface IRecoveryItem { + index: string; + stage: "done" | "translog"; +} + +export interface ITaskItem { + action: string; + description: string; +} + +export interface IReindexItem extends ITaskItem { + fromIndex: string; + toIndex: string; +} + +export type IAliasAction = Record; diff --git a/public/pages/CreateIndexTemplate/components/TemplateType/TemplateType.tsx b/public/pages/CreateIndexTemplate/components/TemplateType/TemplateType.tsx new file mode 100644 index 000000000..b52d7b8ab --- /dev/null +++ b/public/pages/CreateIndexTemplate/components/TemplateType/TemplateType.tsx @@ -0,0 +1,31 @@ +import { EuiRadio } from "@elastic/eui"; +import { TEMPLATE_TYPE } from "../../../../utils/constants"; +import React from "react"; + +export interface ITemplateTypeProps { + value?: {}; + onChange: (val: ITemplateTypeProps["value"]) => void; +} + +export default function TemplateType(props: ITemplateTypeProps) { + const { value, onChange } = props; + return ( + <> + e.target.checked && onChange(undefined)} + label={TEMPLATE_TYPE.INDEX_TEMPLATE} + checked={value === undefined} + /> + e.target.checked && onChange({})} + label={TEMPLATE_TYPE.DATA_STREAM} + checked={value !== undefined} + /> + + ); +} + +export const TemplateConvert = (props: Pick) => + props.value === undefined ? TEMPLATE_TYPE.INDEX_TEMPLATE : TEMPLATE_TYPE.DATA_STREAM; diff --git a/public/pages/CreateIndexTemplate/components/TemplateType/index.ts b/public/pages/CreateIndexTemplate/components/TemplateType/index.ts new file mode 100644 index 000000000..d8bd5aedd --- /dev/null +++ b/public/pages/CreateIndexTemplate/components/TemplateType/index.ts @@ -0,0 +1,4 @@ +import TemplateType from "./TemplateType"; +export { TemplateConvert } from "./TemplateType"; + +export default TemplateType; diff --git a/public/pages/CreateIndexTemplate/containers/CreateIndexTemplate/CreateIndexTemplate.test.tsx b/public/pages/CreateIndexTemplate/containers/CreateIndexTemplate/CreateIndexTemplate.test.tsx new file mode 100644 index 000000000..625b4cc56 --- /dev/null +++ b/public/pages/CreateIndexTemplate/containers/CreateIndexTemplate/CreateIndexTemplate.test.tsx @@ -0,0 +1,129 @@ +/* + * 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 CreateIndexTemplate from "./CreateIndexTemplate"; +import { ServicesContext } from "../../../../services"; +import { browserServicesMock, coreServicesMock, apiCallerMock } from "../../../../../test/mocks"; +import { ROUTES } from "../../../../utils/constants"; +import { CoreServicesContext } from "../../../../components/core_services"; + +function renderCreateIndexTemplateWithRouter(initialEntries = [ROUTES.CREATE_TEMPLATE] as string[]) { + return { + ...render( + + + + + } + /> + } + /> + } /> +

location is: {ROUTES.TEMPLATES}

} /> +
+
+
+
+ ), + }; +} + +describe(" spec", () => { + beforeEach(() => { + apiCallerMock(browserServicesMock); + }); + it("it goes to templates page when click cancel", async () => { + const { getByTestId, getByText, findByTitle, container } = renderCreateIndexTemplateWithRouter([ + `${ROUTES.CREATE_TEMPLATE}/good_template/readonly`, + ]); + await findByTitle("good_template"); + expect(container).toMatchSnapshot(); + userEvent.click(getByText("Edit")); + await waitFor(() => expect(document.querySelector('[data-test-subj="form-row-name"] [title="good_template"]')).toBeInTheDocument(), { + timeout: 3000, + }); + userEvent.click(getByTestId("CreateIndexTemplateCancelButton")); + await waitFor(() => { + expect(getByText(`location is: ${ROUTES.TEMPLATES}`)).toBeInTheDocument(); + }); + }); + + it("it goes to indices page when click create successfully in happy path", async () => { + const { getByText, getByTestId } = renderCreateIndexTemplateWithRouter([`${ROUTES.CREATE_TEMPLATE}`]); + + const templateNameInput = getByTestId("form-row-name").querySelector("input") as HTMLInputElement; + const submitButton = getByTestId("CreateIndexTemplateCreateButton"); + const shardsInput = getByTestId("form-row-template.settings.index.number_of_shards").querySelector("input") as HTMLInputElement; + const replicaInput = getByTestId("form-row-template.settings.index.number_of_replicas").querySelector("input") as HTMLInputElement; + userEvent.type(templateNameInput, `bad_template`); + + userEvent.click(submitButton); + await waitFor(() => expect(getByText("Index patterns must be defined")).not.toBeNull(), { + timeout: 3000, + }); + + const patternInput = getByTestId("form-row-index_patterns").querySelector('[data-test-subj="comboBoxSearchInput"]') as HTMLInputElement; + userEvent.type(patternInput, `test_patterns{enter}`); + + userEvent.click(submitButton); + await waitFor(() => expect(coreServicesMock.notifications.toasts.addDanger).toBeCalledWith("bad template")); + userEvent.clear(templateNameInput); + userEvent.type(templateNameInput, "good_template"); + + userEvent.clear(shardsInput); + userEvent.type(shardsInput, "1.5"); + await waitFor(() => expect(getByText("Number of primary shards must be an integer")).toBeInTheDocument(), { timeout: 3000 }); + userEvent.clear(shardsInput); + userEvent.type(shardsInput, "1"); + + userEvent.clear(replicaInput); + userEvent.type(replicaInput, "1.5"); + await waitFor(() => expect(getByText("Number of replicas must be an integer")).toBeInTheDocument(), { timeout: 3000 }); + userEvent.clear(replicaInput); + userEvent.type(replicaInput, "1"); + + userEvent.click(getByTestId("createIndexAddFieldButton")); + userEvent.click(submitButton); + await waitFor(() => expect(getByText("Field name is required, please input")).not.toBeNull()); + userEvent.click(getByTestId("mapping-visual-editor-0-delete-field")); + + userEvent.click(submitButton); + await waitFor( + () => { + expect(browserServicesMock.commonService.apiCaller).toBeCalledWith({ + endpoint: "transport.request", + data: { + method: "PUT", + path: "_index_template/good_template", + body: { + priority: 0, + template: { + settings: { + "index.number_of_replicas": "1", + "index.number_of_shards": "1", + "index.refresh_interval": "1s", + }, + mappings: { properties: {} }, + }, + index_patterns: ["test_patterns"], + }, + }, + }); + expect(getByText(`location is: ${ROUTES.TEMPLATES}`)).toBeInTheDocument(); + }, + { + timeout: 3000, + } + ); + }); +}); diff --git a/public/pages/CreateIndexTemplate/containers/CreateIndexTemplate/CreateIndexTemplate.tsx b/public/pages/CreateIndexTemplate/containers/CreateIndexTemplate/CreateIndexTemplate.tsx new file mode 100644 index 000000000..916bbc3a4 --- /dev/null +++ b/public/pages/CreateIndexTemplate/containers/CreateIndexTemplate/CreateIndexTemplate.tsx @@ -0,0 +1,73 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { Component } from "react"; +import { RouteComponentProps } from "react-router-dom"; +import TemplateDetail from "../TemplateDetail"; +import { BREADCRUMBS, ROUTES } from "../../../../utils/constants"; +import { CoreServicesContext } from "../../../../components/core_services"; +import { isEqual } from "lodash"; + +interface CreateIndexTemplateProps extends RouteComponentProps<{ template?: string; mode?: string }> {} + +export default class CreateIndexTemplate extends Component { + static contextType = CoreServicesContext; + + get template() { + return this.props.match.params.template; + } + + get readonly() { + return this.props.match.params.mode === "readonly"; + } + + setBreadCrumb() { + const isEdit = this.template; + const readonly = this.readonly; + let lastBread: typeof BREADCRUMBS.TEMPLATES; + if (readonly && this.template) { + lastBread = { + text: this.template, + href: `#${this.props.location.pathname}`, + }; + } else if (isEdit) { + lastBread = { + ...BREADCRUMBS.EDIT_TEMPLATE, + href: `#${this.props.location.pathname}`, + }; + } else { + lastBread = BREADCRUMBS.CREATE_TEMPLATE; + } + this.context.chrome.setBreadcrumbs([BREADCRUMBS.INDEX_MANAGEMENT, BREADCRUMBS.TEMPLATES, lastBread]); + } + + componentDidUpdate(prevProps: Readonly): void { + if (!isEqual(prevProps, this.props)) { + this.setBreadCrumb(); + } + } + + componentDidMount = async (): Promise => { + this.setBreadCrumb(); + }; + + onCancel = (): void => { + this.props.history.push(ROUTES.TEMPLATES); + }; + + render() { + return ( +
+ this.props.history.push(ROUTES.TEMPLATES)} + /> +
+ ); + } +} diff --git a/public/pages/CreateIndexTemplate/containers/CreateIndexTemplate/__snapshots__/CreateIndexTemplate.test.tsx.snap b/public/pages/CreateIndexTemplate/containers/CreateIndexTemplate/__snapshots__/CreateIndexTemplate.test.tsx.snap new file mode 100644 index 000000000..1ee7c1c78 --- /dev/null +++ b/public/pages/CreateIndexTemplate/containers/CreateIndexTemplate/__snapshots__/CreateIndexTemplate.test.tsx.snap @@ -0,0 +1,720 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` spec it goes to templates page when click cancel 1`] = ` +
+
+
+
+

+ good_template +

+
+
+ + + +
+
+
+
+
+
+

+ Template details +

+
+
+
+
+
+
+
+
+
+ Template name +
+
+ good_template +
+
+
+
+
+
+ Template type +
+
+ Indexes +
+
+
+
+
+
+ Index patterns +
+
+ - +
+
+
+
+
+
+ Priority +
+
+ - +
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+ Allow the new indexes to be referenced by existing aliases or specify a new alias. +
+
+
+
+
+
+
+
+
+
+
+
+ Alias names +
+
+ - +
+
+
+
+
+
+
+
+
+
+

+ Index settings +

+
+
+
+
+
+
+
+
+
+ Number of primary shards +
+
+ - +
+
+
+
+
+
+ Number of replicas +
+
+ - +
+
+
+
+
+
+ Refresh interval +
+
+ - +
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+ +
+
+
+

+ Specify a comma-delimited list of settings. + + + View index settings + EuiIconMock + + (opens in a new tab or window) + + +

+

+ All the settings will be handled in flat structure. + + + Learn more. + EuiIconMock + + (opens in a new tab or window) + + +

+
+ +
+ +
+ +
+ +
+