diff --git a/cypress/fixtures/sample_transform.json b/cypress/fixtures/sample_transform.json new file mode 100644 index 000000000..85d929468 --- /dev/null +++ b/cypress/fixtures/sample_transform.json @@ -0,0 +1,40 @@ +{ + "transform": { + "enabled": true, + "schedule": { + "interval": { + "period": 1, + "unit": "Minutes", + "start_time": 1602100553 + } + }, + "description": "Test transform", + "source_index": "opensearch_dashboards_sample_data_ecommerce", + "target_index": "test_transform", + "data_selection_query": { + "match_all": {} + }, + "page_size": 1000, + "groups": [ + { + "terms": { + "source_field": "customer_gender", + "target_field": "gender" + } + }, + { + "terms": { + "source_field": "day_of_week", + "target_field": "day" + } + } + ], + "aggregations": { + "quantity": { + "sum": { + "field": "total_quantity" + } + } + } + } +} diff --git a/cypress/integration/transforms_spec.js b/cypress/integration/transforms_spec.js new file mode 100644 index 000000000..39f3675e5 --- /dev/null +++ b/cypress/integration/transforms_spec.js @@ -0,0 +1,257 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +import { PLUGIN_NAME } from "../support/constants"; +import sampleTransform from "../fixtures/sample_transform"; + +const TRANSFORM_ID = "test_transform_id"; + +describe("Transforms", () => { + before(() => { + // Delete all indices + cy.deleteAllIndices(); + + // Load ecommerce data + cy.request({ + method: 'POST', + url:`${Cypress.env("opensearch_dashboards")}/api/sample_data/ecommerce`, + headers: { + 'osd-xsrf': true + } + }).then((response) => { + expect(response.status).equal(200); + }); + }); + + beforeEach(() => { + // delete test transform and index + cy.request("DELETE", `${Cypress.env("opensearch")}/test_transform*`); + cy.request({ + method: 'POST', + url: `${Cypress.env("opensearch")}/_plugins/_transform/${TRANSFORM_ID}/_stop`, + failOnStatusCode: false + }); + cy.request({ + method: 'DELETE', + url: `${Cypress.env("opensearch")}/_plugins/_transform/${TRANSFORM_ID} `, + failOnStatusCode: false + }); + + // Set welcome screen tracking to test_transform_target + localStorage.setItem("home:welcome:show", true); + + // Visit ISM Transforms Dashboard + cy.visit(`${Cypress.env("opensearch_dashboards")}/app/${PLUGIN_NAME}#/transforms`); + + // Common text to wait for to confirm page loaded, give up to 60 seconds for initial load + cy.contains("Create transform", { timeout: 60000 }); + }); + + describe("can be created", () => { + it("successfully", () => { + // Confirm we loaded empty state + cy.contains( + "Transform jobs help you create a materialized view on top of existing data." + ); + + // Route to create transform page + cy.contains("Create transform").click({ force: true }); + + // Type in transform ID + cy.get(`input[placeholder="my-transformjob1"]`).type(TRANSFORM_ID, { force: true }); + + // Get description input box + cy.get(`textarea[data-test-subj="description"]`).focus().type("some description"); + + // Enter source index + cy.get(`div[data-test-subj="sourceIndexCombobox"]`) + .find(`input[data-test-subj="comboBoxSearchInput"]`) + .focus() + .type("opensearch_dashboards_sample_data_ecommerce{enter}"); + + // Enter target index + cy.get(`div[data-test-subj="targetIndexCombobox"]`) + .find(`input[data-test-subj="comboBoxSearchInput"]`) + .focus() + .type("test_transform{enter}"); + + // Click the next button + cy.get("button").contains("Next").click({ force: true }); + + // Confirm that we got to step 2 of creation page + cy.contains("Select fields to transform"); + + cy.get(`button[data-test-subj="category.keywordOptionsPopover"]`) + .click({ force: true }); + + cy.contains("Group by terms").click({ force: true }); + + // Confirm group was added + cy.contains("category.keyword_terms"); + + // Add aggregable field + cy.contains("50 columns hidden").click({ force: true }); + cy.contains("taxless_total_price").click({ force: true }); + // Click out of the window + cy.contains("Select fields to transform").click({ force: true }); + + cy.get(`button[data-test-subj="taxless_total_priceOptionsPopover"]`) + .click({ force: true }); + + cy.contains("Aggregate by avg").click({ force: true }); + + // Confirm agg was added + cy.contains("avg_taxless_total_price"); + + // Click the next button + cy.get("button").contains("Next").click({ force: true }); + + // Confirm that we got to step 3 of creation page + cy.contains("Job enabled by default"); + + // Click the next button + cy.get("button").contains("Next").click({ force: true }); + + // Confirm that we got to step 4 of creation page + cy.contains("Review and create"); + + // Click the create button + cy.get("button").contains("Create").click({ force: true }); + + // Verify that sample data is add by checking toast notification + cy.contains(`Transform job "${TRANSFORM_ID}" successfully created.`); + cy.location('hash').should('contain', 'transforms'); + cy.get(`button[data-test-subj="transformLink_${TRANSFORM_ID}"]`); + }); + }); + + describe("can be edited", () => { + beforeEach(() => { + cy.createTransform(TRANSFORM_ID, sampleTransform); + cy.reload(); + }); + + it("successfully", () => { + // Confirm we have our initial transform + cy.contains(TRANSFORM_ID); + + // Select checkbox for our transform + cy.get(`#_selection_column_${TRANSFORM_ID}-checkbox`) + .check({ force: true }); + + // Click on Actions popover menu + cy.get(`[data-test-subj="actionButton"]`).click({ force: true }); + + // Click Edit button + cy.get(`[data-test-subj="editButton"]`).click({ force: true }); + + // Wait for initial transform job to load + cy.contains("Test transform"); + + cy.get(`textArea[data-test-subj="description"]`).focus().clear().type("A new description"); + + // Click Save changes button + cy.get(`[data-test-subj="editTransformSaveButton"]`).click({ force: true }); + + // Confirm we get toaster saying changes saved + cy.contains(`Changes to transform saved`); + + // Click into transform job details page + cy.get(`[data-test-subj="transformLink_${TRANSFORM_ID}"]`).click({ force: true }); + + // Confirm new description shows in details page + cy.contains("A new description"); + }); + }); + + describe("can be deleted", () => { + beforeEach(() => { + cy.createTransform(TRANSFORM_ID, sampleTransform); + cy.reload(); + }); + + it("successfully", () => { + // Confirm we have our initial transform + cy.contains(TRANSFORM_ID); + + // Disable transform + cy.get(`#_selection_column_${TRANSFORM_ID}-checkbox`).check({ force: true }); + cy.get(`[data-test-subj="disableButton"]`).click({ force: true }); + cy.contains(`"${TRANSFORM_ID}" is disabled`); + + // Select checkbox for our transform job + cy.get(`#_selection_column_${TRANSFORM_ID}-checkbox`).check({ force: true }); + + // Click on Actions popover menu + cy.get(`[data-test-subj="actionButton"]`).click({ force: true }); + + // Click Delete button + cy.get(`[data-test-subj="deleteButton"]`).click({ force: true }); + + // Type "delete" to confirm deletion + cy.get(`input[placeholder="delete"]`).type("delete", { force: true }); + + // Click the delete confirmation button in modal + cy.get(`[data-test-subj="confirmModalConfirmButton"]`).click(); + + // Confirm we got deleted toaster + cy.contains(`"${TRANSFORM_ID}" successfully deleted`); + + // Confirm showing empty loading state + cy.contains( + "Transform jobs help you create a materialized view on top of existing data." + ); + }); + }); + + describe("can be enabled and disabled", () => { + beforeEach(() => { + cy.createTransform(TRANSFORM_ID, sampleTransform); + cy.reload(); + }); + + it("successfully", () => { + // Confirm we have our initial transform + cy.contains(TRANSFORM_ID); + + // Click into transform job details page + cy.get(`[data-test-subj="transformLink_${TRANSFORM_ID}"]`).click({ force: true }); + + cy.contains(`${TRANSFORM_ID}`); + + /* Wait required for page data to load, otherwise "Disable" button will + * appear greyed out and unavailable. Cypress automatically retries, + * but only after menu is open, doesn't re-render. + */ + cy.wait(1000); + + // Click into Actions menu + cy.get(`[data-test-subj="actionButton"]`).click({ force: true }); + + // Click Disable button + cy.get(`[data-test-subj="disableButton"]`).click(); + + // Confirm we get toaster saying transform job is disabled + cy.contains(`"${TRANSFORM_ID}" is disabled`); + + cy.wait(1000); + + // Click into Actions menu + cy.get(`[data-test-subj="actionButton"]`).click({ force: true }); + + // Click Enable button + cy.get(`[data-test-subj="enableButton"]`).click({ force: true }); + + // Confirm we get toaster saying transform job is enabled + cy.contains(`"${TRANSFORM_ID}" is enabled`); + }); + }) +}); diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 830b278cd..6e2e27920 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -157,3 +157,7 @@ Cypress.Commands.add("deleteDataStreams", (names) => { Cypress.Commands.add("rollover", (target) => { cy.request("POST", `${Cypress.env("opensearch")}/${target}/_rollover`); }); + +Cypress.Commands.add("createTransform", (transformId, transformJSON) => { + cy.request("PUT", `${Cypress.env("opensearch")}${API.TRANSFORM_JOBS_BASE}/${transformId}`, transformJSON); +}); diff --git a/cypress/support/constants.js b/cypress/support/constants.js index 41e8d17f0..477d47c5d 100644 --- a/cypress/support/constants.js +++ b/cypress/support/constants.js @@ -26,6 +26,7 @@ export const API_ROUTE_PREFIX = "/_plugins/_ism"; export const API_ROUTE_PREFIX_ROLLUP = "/_plugins/_rollup"; +export const API_ROUTE_PREFIX_TRANSFORM = "/_plugins/_transform"; export const INDEX = { OPENDISTRO_ISM_CONFIG: ".opendistro-ism-config", @@ -41,6 +42,7 @@ export const API = { REMOVE_POLICY_BASE: `${API_ROUTE_PREFIX}/remove`, CHANGE_POLICY_BASE: `${API_ROUTE_PREFIX}/change_policy`, ROLLUP_JOBS_BASE: `${API_ROUTE_PREFIX_ROLLUP}/jobs`, + TRANSFORM_JOBS_BASE: `${API_ROUTE_PREFIX_TRANSFORM}`, }; export const PLUGIN_NAME = "opensearch_index_management_dashboards"; diff --git a/cypress/support/index.d.ts b/cypress/support/index.d.ts index 3d15567d9..97eb6fc2d 100644 --- a/cypress/support/index.d.ts +++ b/cypress/support/index.d.ts @@ -98,5 +98,12 @@ declare namespace Cypress { * cy.rollover("some_rollover_target") */ rollover(target: string): Chainable; + + /** + * Creates a transform + * @example + * cy.createTransform("some_transform", { "transform": { ... } }) + */ + createTransform(transformId: string, transformJSON: object): Chainable; } } diff --git a/public/pages/CreateTransform/components/DefineTransforms/DefineTransforms.tsx b/public/pages/CreateTransform/components/DefineTransforms/DefineTransforms.tsx index 274192bf7..5ed34a234 100644 --- a/public/pages/CreateTransform/components/DefineTransforms/DefineTransforms.tsx +++ b/public/pages/CreateTransform/components/DefineTransforms/DefineTransforms.tsx @@ -115,6 +115,7 @@ export default function DefineTransforms({ React.useEffect(() => { fetchData(); + }, []); const onChangeItemsPerPage = useCallback( @@ -145,6 +146,7 @@ export default function DefineTransforms({ ); const renderCellValue = ({ rowIndex, columnId }) => { + if (!loading && data.hasOwnProperty(rowIndex)) { if (columns?.find((column) => column.id == columnId).schema == "keyword") { // Remove the keyword postfix for getting correct data from array diff --git a/public/pages/CreateTransform/components/TransformOptions/TransformOptions.tsx b/public/pages/CreateTransform/components/TransformOptions/TransformOptions.tsx index 1870d3467..f7c7044ea 100644 --- a/public/pages/CreateTransform/components/TransformOptions/TransformOptions.tsx +++ b/public/pages/CreateTransform/components/TransformOptions/TransformOptions.tsx @@ -373,7 +373,7 @@ export default function TransformOptions({ }, ]; - const button = setIsPopoverOpen(!isPopoverOpen)} />; + const button = setIsPopoverOpen(!isPopoverOpen)} data-test-subj={`${name}OptionsPopover`} />; return (
diff --git a/public/pages/CreateTransform/containers/CreateTransformForm/CreateTransformForm.test.tsx b/public/pages/CreateTransform/containers/CreateTransformForm/CreateTransformForm.test.tsx new file mode 100644 index 000000000..e3791ba37 --- /dev/null +++ b/public/pages/CreateTransform/containers/CreateTransformForm/CreateTransformForm.test.tsx @@ -0,0 +1,408 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +import React from "react"; +import { fireEvent, render, waitFor } from "@testing-library/react"; +import { Redirect, Route, RouteComponentProps, Switch } from "react-router-dom"; +import { MemoryRouter as Router } from "react-router"; +import userEvent from "@testing-library/user-event"; +import { ServicesConsumer, ServicesContext } from "../../../../services"; +import { browserServicesMock, coreServicesMock } from "../../../../../test/mocks"; +import { BrowserServices } from "../../../../models/interfaces"; +import { ModalProvider, ModalRoot } from "../../../../components/Modal"; +import { BREADCRUMBS, ROUTES } from "../../../../utils/constants"; +import CreateTransformForm from "./CreateTransformForm"; +import { CoreServicesContext } from "../../../../components/core_services"; + +const indices = [ + { + "docs.count": 5, + "docs.deleted": 2, + health: "green", + index: "index_1", + pri: "1", + "pri.store.size": "100KB", + rep: "0", + status: "open", + "store.size": "100KB", + uuid: "some_uuid", + }, +]; + +const sampleMapping = { + index_1: { + mappings: { + properties: { + category: { + type: "text", + }, + customer_gender: { + type: "keyword", + }, + day_of_week: { + type: "keyword", + }, + day_of_week_i: { + type: "integer", + }, + geoip: { + properties: { + city_name: { + type: "keyword", + }, + region_name: { + type: "keyword", + }, + }, + }, + order_date: { + type: "date", + }, + products: { + properties: { + _id: { + type: "text", + fields: { + keyword: { + type: "keyword", + ignore_above: 256, + }, + }, + }, + category: { + type: "text", + fields: { + keyword: { + type: "keyword", + }, + }, + }, + price: { + type: "half_float", + }, + quantity: { + type: "integer", + }, + tax_amount: { + type: "half_float", + }, + taxful_price: { + type: "half_float", + }, + taxless_price: { + type: "half_float", + }, + }, + }, + taxful_total_price: { + type: "half_float", + }, + taxless_total_price: { + type: "half_float", + }, + total_quantity: { + type: "integer", + }, + type: { + type: "keyword", + }, + user: { + type: "keyword", + }, + }, + }, + }, +}; + +const indexData = [{ + _id: "H1tNZHoBkfvfBoG1npgz", + _index: "index_1", + _score: 1, + _source: { + category: "Women's Clothing", + customer_gender: "FEMALE", + day_of_week: "Monday", + day_of_week_i: 0, + geoip: { + city_name: "New York", + region_name: "New York" + }, + order_date: "2021-07-15T13:32:10+00:00", + products: [ + { + _id: "sold_product_588880_18574", + category: "Women's Clothing", + price: 28.99, + quantity: 1, + tax_amount: 0, + taxful_price: 28.99, + taxless_price: 28.99, + } + ], + taxful_total_price: 61.98, + taxless_total_price: 61.98, + total_quantity: 2, + type: "order", + user: "elyssa" + }, + _type: "_doc", +}]; + +function renderCreateTransformFormWithRouter() { + return { + ...render( + + + + + {(services: BrowserServices | null) => + services && ( + + + + ( +
+ +
+ )} + /> +
Testing transform landing page
} /> + +
+
+ ) + } +
+
+
+
+ ), + }; +} + +describe(" spec", () => { + browserServicesMock.transformService.getMappings = jest.fn().mockResolvedValue({ + ok: true, + response: sampleMapping, + }); + + browserServicesMock.rollupService.getMappings = jest.fn().mockResolvedValue({ + ok: true, + response: sampleMapping, + }); + + browserServicesMock.transformService.searchSampleData = jest.fn().mockResolvedValue({ + ok: true, + response: { + data: indexData, + total: { value: 1 } + } + }); + + it("renders the component", async () => { + const { container } = renderCreateTransformFormWithRouter(); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it("set breadcrumbs when mounting", async () => { + renderCreateTransformFormWithRouter(); + + expect(coreServicesMock.chrome.setBreadcrumbs).toHaveBeenCalledTimes(4); + expect(coreServicesMock.chrome.setBreadcrumbs).toHaveBeenCalledWith([ + BREADCRUMBS.INDEX_MANAGEMENT, + BREADCRUMBS.TRANSFORMS, + BREADCRUMBS.CREATE_TRANSFORM, + ]); + }); + + it("routes back to transform landing page if cancelled", async () => { + const { getByTestId, getByText } = renderCreateTransformFormWithRouter(); + + expect(getByTestId("createTransformCancelButton")).toBeEnabled(); + + userEvent.click(getByTestId("createTransformCancelButton")); + + await waitFor(() => getByText("Testing transform landing page")); + }) + + it("does not move to step 2 without info", async () => { + const { getByTestId, getByText, getByLabelText, queryByText } = renderCreateTransformFormWithRouter(); + browserServicesMock.transformService.getTransform = jest.fn().mockResolvedValue({ + ok: false, + response: {}, + }); + + expect(getByTestId("createTransformNextButton")).toBeEnabled(); + + userEvent.click(getByTestId("createTransformNextButton")); + await waitFor(() => {},{timeout:2000}); + + // Currently no pop up warnings? + // Check still on step 1 + expect(getByText("Job name and description")); + }); +}); + +describe(" creation", () => { + browserServicesMock.indexService.getIndices = jest.fn().mockResolvedValue({ + ok: true, + response: { indices, totalIndices: 1 }, + }); + + browserServicesMock.transformService.searchSampleData = jest.fn().mockResolvedValue({ + ok: true, + response: { + data: indexData, + total: { value: 1, relation: "gte" } + } + }); + + browserServicesMock.transformService.getMappings = jest.fn().mockResolvedValue({ + ok: true, + response: sampleMapping, + }); + + browserServicesMock.rollupService.getMappings = jest.fn().mockResolvedValue({ + ok: true, + response: sampleMapping, + }); + + browserServicesMock.indexService.getDataStreamsAndIndicesNames = jest.fn().mockResolvedValue({ + ok: true, + response: { + indices: ["index_1"], + dataStreams: ["data_stream_1"], + }, + }); + + it("routes from step 1 to step 2 and back", async () => { + const { getByTestId, getByLabelText, queryByText, getAllByTestId, getByText } = renderCreateTransformFormWithRouter(); + + browserServicesMock.transformService.getTransform = jest.fn().mockResolvedValue({ + ok: false, + response: {}, + }); + + fireEvent.focus(getByLabelText("Name")); + await userEvent.type(getByLabelText("Name"), "some_transform_id"); + fireEvent.blur(getByLabelText("Name")); + + fireEvent.focus(getByTestId("description")); + await userEvent.type(getByTestId("description"), "some description"); + fireEvent.blur(getByTestId("description")); + + await userEvent.type(getAllByTestId("comboBoxSearchInput")[0], "index_1"); + fireEvent.keyDown(getAllByTestId("comboBoxSearchInput")[0], { key: "Enter", code: "Enter" }); + + await userEvent.type(getAllByTestId("comboBoxSearchInput")[1], "some_target_index"); + fireEvent.keyDown(getAllByTestId("comboBoxSearchInput")[1], { key: "Enter", code: "Enter" }); + + userEvent.click(getByTestId("createTransformNextButton")); + + await waitFor(() => {},{timeout:4000}); + + //Check that it routes to step 2 + expect(queryByText("Job name and description")).toBeNull(); + expect(queryByText('Select fields to transform')).not.toBeNull(); + }); + + it("routes from step 1 to step 4", async () => { + const transform = { + _id: "some_transform_id", + _version: 3, + _seq_no: 7, + _primary_term: 1, + transform: { + transform_id: "some_transform_id", + enabled: true, + schedule: { + interval: { + period: 1, + unit: "Minutes", + start_time: 1602100553, + }, + }, + last_updated_time: 1602100553, + description: "some description", + source_index: "index_1", + target_index: "some_target_index", + page_size: 1000, + delay: 0, + continuous: false, + metadata_id: null, + enabledTime: null, + lastUpdatedTime: null, + schemaVersion: 1, + groups: [], + aggregations: {}, + }, + }; + + // Pretending like it passed even though we don't actually define groups or aggregations + browserServicesMock.transformService.putTransform = jest.fn().mockResolvedValue({ + ok: true, + response: transform, + }); + + const { getByTestId, getByLabelText, queryByText, getAllByTestId } = renderCreateTransformFormWithRouter(); + + fireEvent.focus(getByLabelText("Name")); + await userEvent.type(getByLabelText("Name"), "some_transform_id"); + fireEvent.blur(getByLabelText("Name")); + + fireEvent.focus(getByTestId("description")); + await userEvent.type(getByTestId("description"), "some description"); + fireEvent.blur(getByTestId("description")); + + await userEvent.type(getAllByTestId("comboBoxSearchInput")[0], "index_1"); + fireEvent.keyDown(getAllByTestId("comboBoxSearchInput")[0], { key: "Enter", code: "Enter" }); + + await userEvent.type(getAllByTestId("comboBoxSearchInput")[1], "some_target_index"); + fireEvent.keyDown(getAllByTestId("comboBoxSearchInput")[1], { key: "Enter", code: "Enter" }); + + await waitFor(() => {},{timeout:2000}); + userEvent.click(getByTestId("createTransformNextButton")); + + // Check that it routes to step 2 + await waitFor(() => {},{timeout:2000}); + expect(queryByText('Select fields to transform')).not.toBeNull(); + + // Does not test adding groups and aggregations, this fucntionality is + // covered by Cypress tests and component Jest tests + userEvent.click(getByTestId("createTransformNextButton")); + + // Check that it routes to step 3 + await waitFor(() => {},{timeout:2000}); + expect(queryByText("Job enabled by default")).not.toBeNull(); + userEvent.click(getByTestId("createTransformNextButton")); + + // Check that it routes to step 4 + await waitFor(() => {},{timeout:2000}); + expect(queryByText("You can only change the description and schedule after creating a job. Double-check your choices before proceeding.")).not.toBeNull(); + + //Test create + userEvent.click(getByTestId("createTransformSubmitButton")); + await waitFor(() => {}); + + expect(browserServicesMock.transformService.putTransform).toHaveBeenCalledTimes(1); + expect(coreServicesMock.notifications.toasts.addSuccess).toHaveBeenCalledTimes(1); + expect(coreServicesMock.notifications.toasts.addSuccess).toHaveBeenCalledWith(`Transform job "some_transform_id" successfully created.`); + }); +}) diff --git a/public/pages/CreateTransform/containers/CreateTransformForm/__snapshots__/CreateTransformForm.test.tsx.snap b/public/pages/CreateTransform/containers/CreateTransformForm/__snapshots__/CreateTransformForm.test.tsx.snap new file mode 100644 index 000000000..dac942739 --- /dev/null +++ b/public/pages/CreateTransform/containers/CreateTransformForm/__snapshots__/CreateTransformForm.test.tsx.snap @@ -0,0 +1,633 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` spec renders the component 1`] = ` +
+
+
+
+
+
+
+
+
+
+ 1 +
+
+ Set up indices +
+
+
+
+
+
+
+ 2 +
+
+ Define transforms +
+
+
+
+
+
+
+ 3 +
+
+ Specify schedule +
+
+
+
+
+
+
+ 4 +
+
+ Review and create +
+
+
+
+
+
+
+
+

+ Set up indices +

+
+
+
+
+

+ Job name and description +

+
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+ Specify a unique, descriptive name. +
+
+
+
+
+
+
+

+ Description +

+
+
+
+
+
+ + - optional + +
+
+
+
+
+
+
+