From 5f5a172beaab96183c611558fc9deecad2038421 Mon Sep 17 00:00:00 2001 From: AWSHurneyt Date: Tue, 30 May 2023 19:25:42 -0700 Subject: [PATCH] Implemented alias action UX. (#754) * Implemented UX component for configuring and editing alias actions. Signed-off-by: AWSHurneyt * Implemented unit and integration tests for alias action UX. Signed-off-by: AWSHurneyt * Refactored existing test to accommodate addition of alias action UX. Signed-off-by: AWSHurneyt * Fixed a bug preventing validation messages from appearing under both combo boxes at once. Signed-off-by: AWSHurneyt * Removed redundant 'popout' icons from 'Learn more' links. Having 'target="_blank"' in the link attributes provides the icon. Updated snapshots. Signed-off-by: AWSHurneyt * Added unit tests for AliasUiAction. Signed-off-by: AWSHurneyt * Refactored alias action UX to include toggles to hide/display the add/remove combo boxes. Implemented a modal to confirm clearing combo box entries when toggling display. Signed-off-by: AWSHurneyt * Refactored UX to remove the alias combo box clear confirmation modal based on PR feedback. Signed-off-by: AWSHurneyt * Updated the 2.8 release notes. Signed-off-by: AWSHurneyt --------- Signed-off-by: AWSHurneyt --- .../sample_policy_alias_action.json | 41 ++ .../policies_spec.js | 167 +++++ models/interfaces.ts | 19 + .../ErrorNotification/ErrorNotification.tsx | 2 +- .../ErrorNotification.test.tsx.snap | 3 +- .../components/NewPolicy/NewPolicy.tsx | 2 +- .../__snapshots__/NewPolicy.test.tsx.snap | 3 +- .../__snapshots__/ChangePolicy.test.tsx.snap | 3 +- .../components/DefinePolicy/DefinePolicy.tsx | 2 +- .../__snapshots__/DefinePolicy.test.tsx.snap | 3 +- .../containers/CreatePolicy/CreatePolicy.tsx | 2 +- .../__snapshots__/CreatePolicy.test.tsx.snap | 3 +- .../utils/__snapshots__/helpers.test.tsx.snap | 37 + .../pages/CreatePolicy/utils/helpers.test.tsx | 35 + public/pages/CreatePolicy/utils/helpers.tsx | 21 + .../ApplyPolicyModal/ApplyPolicyModal.tsx | 2 +- .../components/ISMTemplates/ISMTemplates.tsx | 2 +- .../__snapshots__/ISMTemplates.test.tsx.snap | 3 +- .../components/States/States.tsx | 2 +- .../States/__snapshots__/States.test.tsx.snap | 3 +- .../components/Transition/Transition.tsx | 2 +- .../AliasUIAction/AliasUIAction.test.tsx | 83 +++ .../UIActions/AliasUIAction/AliasUIAction.tsx | 109 +++ .../AliasUIAction/AliasUIActionComponent.tsx | 172 +++++ .../__snapshots__/AliasUIAction.test.tsx.snap | 665 ++++++++++++++++++ .../components/UIActions/index.ts | 2 + .../containers/CreateAction/CreateAction.tsx | 16 +- .../__snapshots__/CreateAction.test.tsx.snap | 8 +- .../CreateTransition/CreateTransition.tsx | 2 +- .../CreateTransition.test.tsx.snap | 3 +- .../ErrorNotification/ErrorNotification.tsx | 2 +- .../ErrorNotification.test.tsx.snap | 3 +- .../VisualCreatePolicy/VisualCreatePolicy.tsx | 2 +- .../VisualCreatePolicy/utils/constants.ts | 10 +- .../VisualCreatePolicy/utils/helpers.test.ts | 4 +- .../pages/VisualCreatePolicy/utils/helpers.ts | 25 +- public/utils/constants.ts | 9 + ...dashboards-plugin.release-notes-2.8.0.0.md | 1 + 38 files changed, 1426 insertions(+), 47 deletions(-) create mode 100644 cypress/fixtures/plugins/index-management-dashboards-plugin/sample_policy_alias_action.json create mode 100644 public/pages/CreatePolicy/utils/__snapshots__/helpers.test.tsx.snap create mode 100644 public/pages/CreatePolicy/utils/helpers.test.tsx create mode 100644 public/pages/CreatePolicy/utils/helpers.tsx create mode 100644 public/pages/VisualCreatePolicy/components/UIActions/AliasUIAction/AliasUIAction.test.tsx create mode 100644 public/pages/VisualCreatePolicy/components/UIActions/AliasUIAction/AliasUIAction.tsx create mode 100644 public/pages/VisualCreatePolicy/components/UIActions/AliasUIAction/AliasUIActionComponent.tsx create mode 100644 public/pages/VisualCreatePolicy/components/UIActions/AliasUIAction/__snapshots__/AliasUIAction.test.tsx.snap diff --git a/cypress/fixtures/plugins/index-management-dashboards-plugin/sample_policy_alias_action.json b/cypress/fixtures/plugins/index-management-dashboards-plugin/sample_policy_alias_action.json new file mode 100644 index 000000000..6f62047de --- /dev/null +++ b/cypress/fixtures/plugins/index-management-dashboards-plugin/sample_policy_alias_action.json @@ -0,0 +1,41 @@ +{ + "policy": { + "policy_id": "test_alias_policy_id", + "description": "Example policy with an alias action.", + "default_state": "alias-state", + "states": [ + { + "name": "alias-state", + "actions": [ + { + "alias": { + "actions": [ + { + "remove": { + "aliases": ["alias1", "alias2"] + } + }, + { + "remove": { + "alias": "alias3" + } + }, + { + "add": { + "aliases": ["alias4", "alias5"] + } + }, + { + "add": { + "aliases": "alias6" + } + } + ] + } + } + ], + "transitions": [] + } + ] + } +} diff --git a/cypress/integration/plugins/index-management-dashboards-plugin/policies_spec.js b/cypress/integration/plugins/index-management-dashboards-plugin/policies_spec.js index 7f4ed8b88..2a9f6d384 100644 --- a/cypress/integration/plugins/index-management-dashboards-plugin/policies_spec.js +++ b/cypress/integration/plugins/index-management-dashboards-plugin/policies_spec.js @@ -5,6 +5,7 @@ import { BASE_PATH, IM_PLUGIN_NAME } from "../../../utils/constants"; import samplePolicy from "../../../fixtures/plugins/index-management-dashboards-plugin/sample_policy"; +import sampleAliasPolicy from "../../../fixtures/plugins/index-management-dashboards-plugin/sample_policy_alias_action.json"; const POLICY_ID = "test_policy_id"; @@ -64,6 +65,98 @@ describe("Policies", () => { // Confirm we can see the created policy's description in table cy.contains("A simple description"); }); + + it("with an alias action using the visual editor", () => { + /* Create a policy with an alias action */ + const aliasPolicyId = "visual-editor-alias-policy"; + const testInputs = { + add: ["alias1", "alias2"], + remove: ["alias3", "alias5", "alias6"], + }; + + // Route us to create policy page + cy.contains("Create policy").click({ force: true }); + + // Use the visual editor + cy.contains("Visual editor").click({ force: true }); + cy.contains("Continue").click({ force: true }); + + // Wait for input to load and then type in the policy ID + cy.get(`input[placeholder="hot_cold_workflow"]`).type(aliasPolicyId, { + force: true, + }); + + // Type in the policy description + cy.get(`[data-test-subj="create-policy-description"]`).type("{selectall}{backspace}" + sampleAliasPolicy.policy.description); + + // Add a state + cy.get("button").contains("Add state").click({ force: true }); + + // Enter a state name + cy.get(`[data-test-subj="create-state-state-name"]`).type(sampleAliasPolicy.policy.states[0].name); + + // Add a new action + cy.get("button").contains("+ Add action").click({ force: true }); + + // Select 'Alias' type + cy.get(`[data-test-subj="create-state-action-type"]`).select("Add / remove aliases"); + + // Confirm 'Add action' button is disabled + cy.get(`[data-test-subj="flyout-footer-action-button"]`).should("be.disabled"); + + // Toggle the add alias combo box + cy.get(`[data-test-subj="add-alias-toggle"]`).click({ force: true }); + + // Enter aliases to add + cy.get(`[data-test-subj="add-alias-combo-box"]`).click({ force: true }).type(testInputs.add.join("{enter}")); + + // Toggle the add alias combo box + cy.get(`[data-test-subj="remove-alias-toggle"]`).click({ force: true }); + + // Enter aliases to remove + cy.get(`[data-test-subj="remove-alias-combo-box"]`).click({ force: true }).type(testInputs.remove.join("{enter}")); + + // Click the 'Add action' button + cy.get(`[data-test-subj="flyout-footer-action-button"]`).click({ force: true }); + + // Click the 'Save action' button + cy.get("button").contains("Save state").click({ force: true }); + + // Click the 'Create' button + cy.get("button").contains("Create").click({ force: true }); + + /* Confirm policy was created as expected */ + + // Wait for the 'State management' dashboard to load + cy.contains("State management policies (", { timeout: 60000 }); + + // Click on the test alias to navigate to the details page + cy.contains(aliasPolicyId).click({ force: true }); + + // Wait for the details page to load, and click the 'Edit' button + cy.url({ timeout: 60000 }).should("include", "policy-details"); + cy.contains("Edit").click({ force: true }); + + // Use the visual editor + cy.contains("Visual editor").click({ force: true }); + cy.contains("Continue").click({ force: true }); + + // Click the state edit icon + cy.get(`[aria-label="Edit"]`).click({ force: true }); + cy.get(`[data-test-subj="draggable"]`).within(() => { + cy.get(`[aria-label="Edit"]`).click({ force: true }); + }); + + // Confirm all of the expected inputs are in the 'Add' combo box + testInputs.add.forEach((alias) => { + cy.get(`[data-test-subj="add-alias-combo-box"]`).contains(alias); + }); + + // Confirm all of the expected inputs are in the 'Remove' combo box + testInputs.remove.forEach((alias) => { + cy.get(`[data-test-subj="remove-alias-combo-box"]`).contains(alias); + }); + }); }); describe("can be edited", () => { @@ -71,6 +164,7 @@ describe("Policies", () => { cy.deleteAllIndices(); cy.deleteIMJobs(); cy.createPolicy(POLICY_ID, samplePolicy); + cy.createPolicy(sampleAliasPolicy.policy.policy_id, sampleAliasPolicy); }); it("successfully", () => { @@ -116,6 +210,79 @@ describe("Policies", () => { // Confirm new description shows in table cy.contains("A new description"); }); + + it("with more aliases", () => { + // Click on the test alias to navigate to the details page + const testInputs = { + add: ["alias4", "alias6"], + remove: ["alias1", "alias2", "alias3", "alias7"], + }; + /* Edit the policy */ + + // Click on the test alias to navigate to the details page + cy.contains(sampleAliasPolicy.policy.policy_id).click({ force: true }); + + // Wait for the details page to load, and click the 'Edit' button + cy.url({ timeout: 60000 }).should("include", "policy-details"); + cy.contains("Edit").click({ force: true }); + + // Use the visual editor + cy.contains("Visual editor").click({ force: true }); + cy.contains("Continue").click({ force: true }); + + // Click the 'Edit state' icon + cy.get(`[aria-label="Edit"]`).click({ force: true }); + + // Click the 'Edit action' icon + cy.get(`[data-test-subj="draggable"]`).within(() => { + cy.get(`[aria-label="Edit"]`).click({ force: true }); + }); + + // Remove an alias from the 'Add' combo box + cy.get(`[aria-label="Remove alias5 from selection in this group"]`).click({ force: true }); + + // Add a new alias to the 'Remove' combo box + cy.get(`[data-test-subj="remove-alias-combo-box"]`).click({ force: true }).type("alias7{enter}"); + + // Save the edits + cy.get(`[data-test-subj="flyout-footer-action-button"]`).click({ force: true }); + cy.get("button").contains("Update state").click({ force: true }); + cy.get("button").contains("Update").click({ force: true }); + + /* Confirm policy was edited as expected */ + + // Wait for the 'State management' dashboard to load + cy.contains("State management policies (", { timeout: 60000 }); + + // Click on the test alias to navigate to the details page + cy.contains(sampleAliasPolicy.policy.policy_id).click({ force: true }); + + // Wait for the details page to load, and click the 'Edit' button + cy.url({ timeout: 60000 }).should("include", "policy-details"); + cy.contains("Edit").click({ force: true }); + + // Use the visual editor + cy.contains("Visual editor").click({ force: true }); + cy.contains("Continue").click({ force: true }); + + // Click the 'Edit state' icon + cy.get(`[aria-label="Edit"]`).click({ force: true }); + + // Click the 'Edit action' icon + cy.get(`[data-test-subj="draggable"]`).within(() => { + cy.get(`[aria-label="Edit"]`).click({ force: true }); + }); + + // Confirm all of the expected inputs are in the 'Add' combo box + testInputs.add.forEach((alias) => { + cy.get(`[data-test-subj="add-alias-combo-box"]`).contains(alias); + }); + + // Confirm all of the expected inputs are in the 'Remove' combo box + testInputs.remove.forEach((alias) => { + cy.get(`[data-test-subj="remove-alias-combo-box"]`).contains(alias); + }); + }); }); describe("can be deleted", () => { diff --git a/models/interfaces.ts b/models/interfaces.ts index 9cfac1414..062dd49e8 100644 --- a/models/interfaces.ts +++ b/models/interfaces.ts @@ -325,6 +325,7 @@ export interface UIAction { clone: (action: Data) => UIAction; content: () => JSX.Element | string | null; toAction: () => Action; + customDisplayText?: string; } export interface ForceMergeAction extends Action { @@ -433,6 +434,24 @@ export interface IndexPriorityAction extends Action { }; } +export enum AliasActions { + ADD = "add", + REMOVE = "remove", +} + +export type AliasActionItem = { + [key in AliasActions]: { + alias?: string; + aliases?: string[]; + }; +}; + +export interface AliasAction extends Action { + alias: { + actions: AliasActionItem[]; + }; +} + export interface AllocationAction extends Action { allocation: { // TODO: These require a complete UI and we are only supporting JSON editor for allocation for now diff --git a/public/containers/ErrorNotification/ErrorNotification.tsx b/public/containers/ErrorNotification/ErrorNotification.tsx index e68d6f40f..6d7333a39 100644 --- a/public/containers/ErrorNotification/ErrorNotification.tsx +++ b/public/containers/ErrorNotification/ErrorNotification.tsx @@ -143,7 +143,7 @@ class ErrorNotification extends Component You can set up an error notification for when a policy execution fails.{" "} - Learn more + Learn more

diff --git a/public/containers/ErrorNotification/__snapshots__/ErrorNotification.test.tsx.snap b/public/containers/ErrorNotification/__snapshots__/ErrorNotification.test.tsx.snap index 06e91c673..c7c4d12f9 100644 --- a/public/containers/ErrorNotification/__snapshots__/ErrorNotification.test.tsx.snap +++ b/public/containers/ErrorNotification/__snapshots__/ErrorNotification.test.tsx.snap @@ -60,8 +60,7 @@ exports[` spec renders the component 1`] = ` rel="noopener noreferrer" target="_blank" > - Learn more - EuiIconMock + Learn more EuiIconMock When the new policy will take effect depends on the current state of indices and the states of the new policy.{" "} - Learn more + Learn more

diff --git a/public/pages/ChangePolicy/components/NewPolicy/__snapshots__/NewPolicy.test.tsx.snap b/public/pages/ChangePolicy/components/NewPolicy/__snapshots__/NewPolicy.test.tsx.snap index 94100bb2f..58003e5d6 100644 --- a/public/pages/ChangePolicy/components/NewPolicy/__snapshots__/NewPolicy.test.tsx.snap +++ b/public/pages/ChangePolicy/components/NewPolicy/__snapshots__/NewPolicy.test.tsx.snap @@ -46,8 +46,7 @@ exports[` spec renders the component 1`] = ` rel="noopener noreferrer" target="_blank" > - Learn more - EuiIconMock + Learn more EuiIconMock spec renders the component 1`] = ` rel="noopener noreferrer" target="_blank" > - Learn more - EuiIconMock + Learn more EuiIconMock - Learn more + Learn more

diff --git a/public/pages/CreatePolicy/components/DefinePolicy/__snapshots__/DefinePolicy.test.tsx.snap b/public/pages/CreatePolicy/components/DefinePolicy/__snapshots__/DefinePolicy.test.tsx.snap index 02aca8681..2475ca9dc 100644 --- a/public/pages/CreatePolicy/components/DefinePolicy/__snapshots__/DefinePolicy.test.tsx.snap +++ b/public/pages/CreatePolicy/components/DefinePolicy/__snapshots__/DefinePolicy.test.tsx.snap @@ -96,8 +96,7 @@ exports[` spec renders the component 1`] = ` rel="noopener noreferrer" target="_blank" > - Learn more - EuiIconMock + Learn more EuiIconMock - Learn more + Learn more

diff --git a/public/pages/CreatePolicy/containers/CreatePolicy/__snapshots__/CreatePolicy.test.tsx.snap b/public/pages/CreatePolicy/containers/CreatePolicy/__snapshots__/CreatePolicy.test.tsx.snap index 42fd980de..082b6e881 100644 --- a/public/pages/CreatePolicy/containers/CreatePolicy/__snapshots__/CreatePolicy.test.tsx.snap +++ b/public/pages/CreatePolicy/containers/CreatePolicy/__snapshots__/CreatePolicy.test.tsx.snap @@ -341,8 +341,7 @@ exports[` spec renders the edit component 1`] = ` rel="noopener noreferrer" target="_blank" > - Learn more - EuiIconMock + Learn more EuiIconMock +
+ You can add up to 10 more aliases. +
+ +`; + +exports[`inputLimitText renders the component with 0 inputs remaining 1`] = ` +
+
+ You have reached the limit of 10 aliases. +
+
+`; + +exports[`inputLimitText renders the component with 1 input remaining 1`] = ` +
+
+ You can add up to 1 more alias. +
+
+`; diff --git a/public/pages/CreatePolicy/utils/helpers.test.tsx b/public/pages/CreatePolicy/utils/helpers.test.tsx new file mode 100644 index 000000000..3a6c774b2 --- /dev/null +++ b/public/pages/CreatePolicy/utils/helpers.test.tsx @@ -0,0 +1,35 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { inputLimitText } from "./helpers"; + +function renderInputLimitText(currCount?, limit?, singularKeyword?, pluralKeyword?, styleProps?) { + return { ...render(inputLimitText(currCount, limit, singularKeyword, pluralKeyword, styleProps)) }; +} + +describe("inputLimitText", () => { + it("renders the component with 0 inputs", () => { + const expected = `You can add up to 10 more aliases.`; + const { container } = renderInputLimitText(0, 10, "alias", "aliases"); + expect(screen.getByText(expected)).toBeInTheDocument(); + expect(container.firstChild).toMatchSnapshot(); + }); + + it("renders the component with 1 input remaining", () => { + const expected = "You can add up to 1 more alias."; + const { container } = renderInputLimitText(9, 10, "alias", "aliases"); + expect(screen.getByText(expected)).toBeInTheDocument(); + expect(container.firstChild).toMatchSnapshot(); + }); + + it("renders the component with 0 inputs remaining", () => { + const expected = "You have reached the limit of 10 aliases."; + const { container } = renderInputLimitText(10, 10, "alias", "aliases"); + expect(screen.getByText(expected)).toBeInTheDocument(); + expect(container.firstChild).toMatchSnapshot(); + }); +}); diff --git a/public/pages/CreatePolicy/utils/helpers.tsx b/public/pages/CreatePolicy/utils/helpers.tsx new file mode 100644 index 000000000..75380ba05 --- /dev/null +++ b/public/pages/CreatePolicy/utils/helpers.tsx @@ -0,0 +1,21 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from "react"; +import { EuiText } from "@elastic/eui"; + +// A helper function to generate a simple string explaining how many elements a user can add to a list. +export const inputLimitText = (currCount = 0, limit = 0, singularKeyword = "", pluralKeyword = "", styleProps = {}) => { + const difference = limit - currCount; + const remainingLimit = `You can add up to ${difference} ${limit === 1 ? "" : "more"} ${ + difference === 1 ? singularKeyword : pluralKeyword + }.`; + const reachedLimit = `You have reached the limit of ${limit} ${limit === 1 ? singularKeyword : pluralKeyword}.`; + return ( + + {difference > 0 ? remainingLimit : reachedLimit} + + ); +}; diff --git a/public/pages/Indices/components/ApplyPolicyModal/ApplyPolicyModal.tsx b/public/pages/Indices/components/ApplyPolicyModal/ApplyPolicyModal.tsx index 0b9072112..1c4106669 100644 --- a/public/pages/Indices/components/ApplyPolicyModal/ApplyPolicyModal.tsx +++ b/public/pages/Indices/components/ApplyPolicyModal/ApplyPolicyModal.tsx @@ -207,7 +207,7 @@ export default class ApplyPolicyModal extends Component This policy includes a rollover action. Specify a rollover alias.{" "} - Learn more + Learn more

diff --git a/public/pages/VisualCreatePolicy/components/ISMTemplates/ISMTemplates.tsx b/public/pages/VisualCreatePolicy/components/ISMTemplates/ISMTemplates.tsx index f3bbd53d3..223ea6e63 100644 --- a/public/pages/VisualCreatePolicy/components/ISMTemplates/ISMTemplates.tsx +++ b/public/pages/VisualCreatePolicy/components/ISMTemplates/ISMTemplates.tsx @@ -55,7 +55,7 @@ const ISMTemplates = ({ policy, onChangePolicy }: ISMTemplatesProps) => {

Specify ISM template patterns that match the index to apply the policy.{" "} - Learn more + Learn more

diff --git a/public/pages/VisualCreatePolicy/components/ISMTemplates/__snapshots__/ISMTemplates.test.tsx.snap b/public/pages/VisualCreatePolicy/components/ISMTemplates/__snapshots__/ISMTemplates.test.tsx.snap index 1929feb30..9b109b00b 100644 --- a/public/pages/VisualCreatePolicy/components/ISMTemplates/__snapshots__/ISMTemplates.test.tsx.snap +++ b/public/pages/VisualCreatePolicy/components/ISMTemplates/__snapshots__/ISMTemplates.test.tsx.snap @@ -60,8 +60,7 @@ exports[` spec renders the component 1`] = ` rel="noopener noreferrer" target="_blank" > - Learn more - EuiIconMock + Learn more EuiIconMock "Transitions" define when to move from one state to another.{" "} - Learn more + Learn more

diff --git a/public/pages/VisualCreatePolicy/components/States/__snapshots__/States.test.tsx.snap b/public/pages/VisualCreatePolicy/components/States/__snapshots__/States.test.tsx.snap index 27704ea90..2df7c2b78 100644 --- a/public/pages/VisualCreatePolicy/components/States/__snapshots__/States.test.tsx.snap +++ b/public/pages/VisualCreatePolicy/components/States/__snapshots__/States.test.tsx.snap @@ -43,8 +43,7 @@ exports[` spec renders the component 1`] = ` rel="noopener noreferrer" target="_blank" > - Learn more - EuiIconMock + Learn more EuiIconMock { helpText="The matching cron expression required to transition to the next state." learnMore={ - Learn more + Learn more } /> diff --git a/public/pages/VisualCreatePolicy/components/UIActions/AliasUIAction/AliasUIAction.test.tsx b/public/pages/VisualCreatePolicy/components/UIActions/AliasUIAction/AliasUIAction.test.tsx new file mode 100644 index 000000000..0198771f1 --- /dev/null +++ b/public/pages/VisualCreatePolicy/components/UIActions/AliasUIAction/AliasUIAction.test.tsx @@ -0,0 +1,83 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from "react"; +import "@testing-library/jest-dom"; +import { render, screen, cleanup } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { DEFAULT_ALIAS } from "../../../utils/constants"; +import { AliasAction, AliasActions, UIAction } from "../../../../../../models/interfaces"; +import { actionRepoSingleton } from "../../../utils/helpers"; + +const TEST_PROPS: UIAction = { action: DEFAULT_ALIAS } as UIAction; + +const renderComponent = (uiAction: UIAction = TEST_PROPS) => { + render(actionRepoSingleton.getUIAction("alias").render(uiAction, mockOnChangeAction)); +}; +const mockOnChangeAction = (uiAction: UIAction = TEST_PROPS) => { + cleanup(); + renderComponent(uiAction); +}; + +afterEach(() => cleanup()); + +describe("AliasUIAction component", () => { + it("renders with blank action", () => { + const { container } = render(actionRepoSingleton.getUIAction("alias").render(TEST_PROPS, mockOnChangeAction)); + + userEvent.click(screen.getByTestId("add-alias-toggle")); + userEvent.click(screen.getByTestId("remove-alias-toggle")); + + const addAliasRow = screen.getByTestId("add-alias-row"); + const removeAliasRow = screen.getByTestId("remove-alias-row"); + + expect(addAliasRow).toHaveTextContent("Select aliases to add to indexes"); + expect(addAliasRow).toHaveTextContent("You can add up to 10 more aliases."); + + expect(removeAliasRow).toHaveTextContent("Select aliases to remove from indexes"); + expect(removeAliasRow).toHaveTextContent("You can add up to 10 more aliases."); + + expect(container).toMatchSnapshot(); + }); + + it("renders with pre-defined actions", () => { + const actions = [ + { [AliasActions.ADD]: { alias: "alias1" } }, + { [AliasActions.ADD]: { alias: "alias3" } }, + { [AliasActions.REMOVE]: { alias: "alias2" } }, + { [AliasActions.ADD]: { aliases: ["alias5", "alias7", "alias9"] } }, + { [AliasActions.REMOVE]: { aliases: ["alias4", "alias6", "alias8"] } }, + ]; + const testProps = { ...TEST_PROPS }; + testProps.action.alias.actions = actions; + + const { container } = render(actionRepoSingleton.getUIAction("alias").render(testProps, mockOnChangeAction)); + expect(screen.getByTestId("add-alias-row")).toHaveTextContent("You can add up to 5 more aliases."); + expect(screen.getByTestId("remove-alias-row")).toHaveTextContent("You can add up to 6 more aliases."); + + const addAliasComboBox = screen.getByTestId("add-alias-combo-box"); + const removeAliasComboBox = screen.getByTestId("remove-alias-combo-box"); + actions.forEach((action) => { + switch (Object.keys(action)[0]) { + case AliasActions.ADD: + if (action[AliasActions.ADD].alias) expect(addAliasComboBox).toHaveTextContent(action[AliasActions.ADD].alias); + if (action[AliasActions.ADD].aliases) + action[AliasActions.ADD].aliases.forEach((alias) => { + expect(addAliasComboBox).toHaveTextContent(alias); + }); + break; + case AliasActions.REMOVE: + if (action[AliasActions.REMOVE].alias) expect(removeAliasComboBox).toHaveTextContent(action[AliasActions.REMOVE].alias); + if (action[AliasActions.REMOVE].aliases) + action[AliasActions.REMOVE].aliases.forEach((alias) => { + expect(removeAliasComboBox).toHaveTextContent(alias); + }); + break; + } + }); + + expect(container).toMatchSnapshot(); + }); +}); diff --git a/public/pages/VisualCreatePolicy/components/UIActions/AliasUIAction/AliasUIAction.tsx b/public/pages/VisualCreatePolicy/components/UIActions/AliasUIAction/AliasUIAction.tsx new file mode 100644 index 000000000..a6dc67750 --- /dev/null +++ b/public/pages/VisualCreatePolicy/components/UIActions/AliasUIAction/AliasUIAction.tsx @@ -0,0 +1,109 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from "react"; +import { EuiComboBoxOptionOption } from "@elastic/eui"; +import { AliasAction, AliasActionItem, AliasActions, UIAction } from "../../../../../../models/interfaces"; +import { ActionType } from "../../../utils/constants"; +import { makeId } from "../../../../../utils/helpers"; +import { ALIAS_NAMING_MESSAGE, ALIAS_NAMING_PATTERN } from "../../../../../utils/constants"; +import AliasUIActionComponent from "./AliasUIActionComponent"; + +export const MAX_ALIAS_ACTIONS = 10; +export const DUPLICATED_ALIAS_TEXT = "An alias cannot be added and removed in the same action."; + +export default class AliasUIAction implements UIAction { + customDisplayText = "Add / remove aliases"; + id: string; + action: AliasAction; + type = ActionType.Alias; + errors: { [key in AliasActions]: string | undefined } = {}; + selectedItems: { [key in AliasActions]: EuiComboBoxOptionOption[] } = {}; + + constructor(action: AliasAction, id: string = makeId()) { + this.action = action; + this.id = id; + this.selectedItems = this.parseToComboBoxOptions(action); + this.errors = this.getAliasActionErrorText(this.selectedItems); + } + + content = () => this.customDisplayText; + + clone = (action: AliasAction = this.action) => new AliasUIAction(action, this.id); + + isValid = () => { + // Either add/remove has at least 1 action + if (this.action.alias.actions.length === 0) return false; + + // No errors for any alias actions + return !Object.entries(this.errors).some(([_, error]) => error); + }; + + getAliasActionErrorText = (selectedItems: { [key in AliasActions]: EuiComboBoxOptionOption[] }) => { + const errors: { [key in AliasActions]: string | undefined } = {}; + + // Each alias is valid + let aliasError: string | undefined; + this.action.alias.actions.forEach((action) => { + const aliasActionType = Object.keys(action)[0]; + aliasError = this.getAliasErrorText(action, selectedItems); + errors[aliasActionType] = aliasError; + }); + return errors; + }; + + getAliasErrorText = ( + action: AliasActionItem, + selectedItems: { [key in AliasActions]: EuiComboBoxOptionOption[] } + ): string | undefined => { + const aliasActionType = Object.keys(action)[0] as AliasActions; + + // Validate alias string. + const alias = action[aliasActionType].alias; + if (alias && !ALIAS_NAMING_PATTERN.test(alias)) return ALIAS_NAMING_MESSAGE; + + // No duplicate aliases between add and remove actions + switch (aliasActionType) { + case AliasActions.ADD: + if ((selectedItems[AliasActions.REMOVE] || []).some((option) => option?.label === alias)) return DUPLICATED_ALIAS_TEXT; + break; + case AliasActions.REMOVE: + if ((selectedItems[AliasActions.ADD] || []).some((option) => option?.label === alias)) return DUPLICATED_ALIAS_TEXT; + break; + } + }; + + parseToComboBoxOptions = (aliasAction: AliasAction) => { + const allOptions: { [key in AliasActions]: EuiComboBoxOptionOption[] } = {}; + aliasAction.alias.actions?.forEach((action) => { + const aliasActionType = Object.keys(action)[0] as AliasActions; + if (!allOptions[aliasActionType]) allOptions[aliasActionType] = []; + if (action[aliasActionType].alias) allOptions[aliasActionType].push({ label: action[aliasActionType]?.alias }); + + // When retrieving an existing policy from the backend, the GetPolicy API returns an AliasActionItem with a string[] + // called "aliases" for each of the aliases configured in the action. Each string[] contains 1 alias. + // This IF block checks for that as it indicates a policy is being edited. + if (action[aliasActionType].aliases) + allOptions[aliasActionType] = allOptions[aliasActionType].concat( + action[aliasActionType].aliases.map((alias) => ({ label: alias })) + ); + }); + return allOptions; + }; + + render = (uiAction: UIAction, onChangeAction: (uiAction: UIAction) => void) => { + return ( + + ); + }; + + toAction = () => this.action; +} diff --git a/public/pages/VisualCreatePolicy/components/UIActions/AliasUIAction/AliasUIActionComponent.tsx b/public/pages/VisualCreatePolicy/components/UIActions/AliasUIAction/AliasUIActionComponent.tsx new file mode 100644 index 000000000..11bfde084 --- /dev/null +++ b/public/pages/VisualCreatePolicy/components/UIActions/AliasUIAction/AliasUIActionComponent.tsx @@ -0,0 +1,172 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { Component } from "react"; +import { EuiComboBox, EuiComboBoxOptionOption, EuiFormRow, EuiSpacer, EuiSwitch } from "@elastic/eui"; +import { AliasAction, AliasActionItem, AliasActions, UIAction } from "../../../../../../models/interfaces"; +import AliasUIAction, { MAX_ALIAS_ACTIONS } from "./AliasUIAction"; +import { inputLimitText } from "../../../../CreatePolicy/utils/helpers"; + +export interface AliasUIActionComponentProps { + action: AliasAction; + clone: (action: AliasAction) => AliasUIAction; + errors: { [key in AliasActions]: string | undefined }; + onChangeAction: (uiAction: UIAction) => void; + selectedItems: { [key in AliasActions]: EuiComboBoxOptionOption[] }; +} + +export interface AliasUIActionComponentState { + addAliasToggle: boolean; + removeAliasToggle: boolean; +} + +export default class AliasUIActionComponent extends Component { + constructor(props: AliasUIActionComponentProps) { + super(props); + + const { selectedItems } = props; + this.state = { + addAliasToggle: selectedItems.add?.length > 0, + removeAliasToggle: selectedItems.remove?.length > 0, + }; + } + + componentDidMount() { + // TODO: Implement functionality to retrieve, and populate the combo boxes with any pre-existing aliases. + } + + onCreateOption = (value: string, options: EuiComboBoxOptionOption[], aliasAction: AliasActions) => { + const { action, clone, onChangeAction } = this.props; + options.push({ label: value }); + const aliasActions = action.alias.actions.concat(this.parseToAliasActionItems(options, aliasAction)); + onChangeAction(clone({ ...action, alias: { actions: aliasActions } })); + }; + + parseToAliasActionItems = (options: EuiComboBoxOptionOption[], aliasActionType = AliasActions.ADD) => { + return options.map((option) => ({ [aliasActionType]: { alias: option.label } })) as AliasActionItem[]; + }; + + onAddAliasChange = (options = []) => { + const { action, clone, selectedItems, onChangeAction } = this.props; + const parsedOptions = this.parseToAliasActionItems(options, AliasActions.ADD); + const parseSelectedItems = this.parseToAliasActionItems(selectedItems.remove || [], AliasActions.REMOVE); + onChangeAction( + clone({ + ...action, + alias: { + // Consolidating the changed options with the existing options in the other combo box + actions: parsedOptions.concat(parseSelectedItems), + }, + }) + ); + }; + + onRemoveAliasChange = (options) => { + const { action, clone, selectedItems, onChangeAction } = this.props; + const parsedOptions = this.parseToAliasActionItems(options, AliasActions.REMOVE); + const parseSelectedItems = this.parseToAliasActionItems(selectedItems.add || [], AliasActions.ADD); + onChangeAction( + clone({ + ...action, + alias: { + // Consolidating the changed options with the existing options in the other combo box + actions: parsedOptions.concat(parseSelectedItems), + }, + }) + ); + }; + + render() { + const { errors, selectedItems } = this.props; + const { addAliasToggle, removeAliasToggle } = this.state; + return ( + <> + { + // If the user disables the combo box while there are entries in it, clear the inputs + if (addAliasToggle && selectedItems.add?.length > 0) this.onAddAliasChange([]); + this.setState({ ...this.state, addAliasToggle: e.target.checked }); + }} + data-test-subj={"add-alias-toggle"} + /> + {addAliasToggle && ( + <> + + + <> + this.onCreateOption(searchValue, options, AliasActions.ADD)) + } + onChange={(options) => this.onAddAliasChange(options)} + isInvalid={errors.add !== undefined} + data-test-subj={"add-alias-combo-box"} + /> + {inputLimitText(selectedItems.add?.length, MAX_ALIAS_ACTIONS, "alias", "aliases")} + + + + )} + + + + { + // If the user disables the combo box while there are entries in it, clear the inputs + if (removeAliasToggle && selectedItems.remove?.length > 0) this.onRemoveAliasChange([]); + this.setState({ ...this.state, removeAliasToggle: e.target.checked }); + }} + data-test-subj={"remove-alias-toggle"} + /> + {removeAliasToggle && ( + <> + + + <> + this.onCreateOption(searchValue, options, AliasActions.REMOVE)) + } + onChange={(options) => this.onRemoveAliasChange(options)} + isInvalid={errors.remove !== undefined} + data-test-subj={"remove-alias-combo-box"} + /> + {inputLimitText(selectedItems.remove?.length, MAX_ALIAS_ACTIONS, "alias", "aliases")} + + + + )} + + ); + } +} diff --git a/public/pages/VisualCreatePolicy/components/UIActions/AliasUIAction/__snapshots__/AliasUIAction.test.tsx.snap b/public/pages/VisualCreatePolicy/components/UIActions/AliasUIAction/__snapshots__/AliasUIAction.test.tsx.snap new file mode 100644 index 000000000..64846aae6 --- /dev/null +++ b/public/pages/VisualCreatePolicy/components/UIActions/AliasUIAction/__snapshots__/AliasUIAction.test.tsx.snap @@ -0,0 +1,665 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AliasUIAction component renders with blank action 1`] = ` +
+
+ + + Add aliases + +
+
+
+
+ +
+
+ +
+
+
+ + + Remove aliases + +
+
+
+
+ +
+
+ +
+
+`; + +exports[`AliasUIAction component renders with pre-defined actions 1`] = ` +
+
+ + + Add aliases + +
+
+
+
+ +
+
+ +
+
+
+ + + Remove aliases + +
+
+
+
+ +
+
+ +
+
+`; diff --git a/public/pages/VisualCreatePolicy/components/UIActions/index.ts b/public/pages/VisualCreatePolicy/components/UIActions/index.ts index 8e4da1d42..53db9ec57 100644 --- a/public/pages/VisualCreatePolicy/components/UIActions/index.ts +++ b/public/pages/VisualCreatePolicy/components/UIActions/index.ts @@ -3,6 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import AliasUIAction from "./AliasUIAction/AliasUIAction"; import AllocationUIAction from "./AllocationUIAction"; import CloseUIAction from "./CloseUIAction"; import DeleteUIAction from "./DeleteUIAction"; @@ -19,6 +20,7 @@ import ShrinkUIAction from "./ShrinkUIAction"; import SnapshotUIAction from "./SnapshotUIAction"; export { + AliasUIAction, AllocationUIAction, CloseUIAction, DeleteUIAction, diff --git a/public/pages/VisualCreatePolicy/containers/CreateAction/CreateAction.tsx b/public/pages/VisualCreatePolicy/containers/CreateAction/CreateAction.tsx index bb6ff1ace..3bc647ea1 100644 --- a/public/pages/VisualCreatePolicy/containers/CreateAction/CreateAction.tsx +++ b/public/pages/VisualCreatePolicy/containers/CreateAction/CreateAction.tsx @@ -4,10 +4,10 @@ */ import React, { Component, ChangeEvent } from "react"; -import { EuiText, EuiLink, EuiIcon, EuiFlyoutBody, EuiFlyoutFooter, EuiTitle, EuiFormRow, EuiSelect, EuiSpacer } from "@elastic/eui"; +import { EuiText, EuiLink, EuiFlyoutBody, EuiFlyoutFooter, EuiTitle, EuiFormRow, EuiSelect, EuiSpacer } from "@elastic/eui"; import { UIAction, Action } from "../../../../../models/interfaces"; import TimeoutRetrySettings from "../../components/TimeoutRetrySettings"; -import { actionRepoSingleton, capitalizeFirstLetter } from "../../utils/helpers"; +import { actionRepoSingleton, getActionOptions } from "../../utils/helpers"; import FlyoutFooter from "../../components/FlyoutFooter"; import EuiFormCustomLabel from "../../components/EuiFormCustomLabel"; import { ACTIONS_DOCUMENTATION_URL } from "../../../../utils/constants"; @@ -55,15 +55,7 @@ export default class CreateAction extends Component { - return { - value: key, - text: key - .split("_") - .map((str) => capitalizeFirstLetter(str)) - .join(" "), - }; - }); + const actionOptions = getActionOptions(actionRepoSingleton); let bodyTitle = "Add action"; if (!!editAction) bodyTitle = "Edit action"; @@ -78,7 +70,7 @@ export default class CreateAction extends Component Actions are the operations ISM performs when an index is in a certain state.{" "} - Learn more + Learn more diff --git a/public/pages/VisualCreatePolicy/containers/CreateAction/__snapshots__/CreateAction.test.tsx.snap b/public/pages/VisualCreatePolicy/containers/CreateAction/__snapshots__/CreateAction.test.tsx.snap index c01d72d1f..2af3ddd18 100644 --- a/public/pages/VisualCreatePolicy/containers/CreateAction/__snapshots__/CreateAction.test.tsx.snap +++ b/public/pages/VisualCreatePolicy/containers/CreateAction/__snapshots__/CreateAction.test.tsx.snap @@ -28,8 +28,7 @@ exports[` spec renders the component 1`] = ` rel="noopener noreferrer" target="_blank" > - Learn more - EuiIconMock + Learn more EuiIconMock spec renders the component 1`] = ` >   +