From 14147ab26e744bbad4fa1c174a24fd0f2b1e63aa Mon Sep 17 00:00:00 2001 From: Mateusz Borowczyk Date: Mon, 28 Oct 2024 11:18:07 +0100 Subject: [PATCH 1/4] Add button to disable expert mode from the banner (Issue #5942, PR #6000) # Description * Short description here * https://github.com/user-attachments/assets/ebda2440-4eb9-4ec2-a35e-4540e6752efe Related task: https://github.com/inmanta/web-console/issues/5999 closes #5942 # Self Check: Strike through any lines that are not applicable (`~~line~~`) then check the box - [ ] Attached issue to pull request - [ ] Changelog entry - [ ] Code is clear and sufficiently documented - [ ] Sufficient test cases (reproduces the bug/tests the requested feature) - [ ] Correct, in line with design - [ ] End user documentation is included or an issue is created for end-user documentation (add ref to issue here: ) --- .../unreleased/5942-expert-button-banner.yml | 6 + cypress/e2e/scenario-2.4-expert-mode.cy.js | 12 +- .../e2e/scenario-2.4-old-expert-mode.cy.js | 12 +- .../Managers/V2/POST/UpdateEnvConfig/index.ts | 1 + .../UpdateEnvConfig/useUpdateEnvConfig.ts | 71 ++++++++++ .../ExpertBanner/ExpertBanner.test.tsx | 134 ++++++++++++++++++ .../Components/ExpertBanner/ExpertBanner.tsx | 66 ++++++++- .../LicenseBanner/LicenseBanner.tsx | 6 +- .../Components/AutoCompleteInputProvider.tsx | 2 +- .../Components/UpdateBanner/UpdateBanner.tsx | 9 +- .../Root/Components/PageFrame/PageFrame.tsx | 2 +- src/UI/words.tsx | 4 + 12 files changed, 291 insertions(+), 34 deletions(-) create mode 100644 changelogs/unreleased/5942-expert-button-banner.yml create mode 100644 src/Data/Managers/V2/POST/UpdateEnvConfig/index.ts create mode 100644 src/Data/Managers/V2/POST/UpdateEnvConfig/useUpdateEnvConfig.ts create mode 100644 src/UI/Components/ExpertBanner/ExpertBanner.test.tsx diff --git a/changelogs/unreleased/5942-expert-button-banner.yml b/changelogs/unreleased/5942-expert-button-banner.yml new file mode 100644 index 000000000..0fc914eaa --- /dev/null +++ b/changelogs/unreleased/5942-expert-button-banner.yml @@ -0,0 +1,6 @@ +description: Add button to disable expert mode from the banner +issue-nr: 5942 +change-type: minor +destination-branches: [master, iso7] +sections: + minor-improvement: "{{description}}" diff --git a/cypress/e2e/scenario-2.4-expert-mode.cy.js b/cypress/e2e/scenario-2.4-expert-mode.cy.js index ade11e709..1805cb2a7 100644 --- a/cypress/e2e/scenario-2.4-expert-mode.cy.js +++ b/cypress/e2e/scenario-2.4-expert-mode.cy.js @@ -253,16 +253,8 @@ if (Cypress.env("edition") === "iso") { // expect to be redirected on the inventory page, and table to be empty cy.get('[aria-label="ServiceInventory-Empty"]').should("to.be.visible"); - // At the end go back to settings and turn expert mode off - cy.get(".pf-v5-c-nav__item").contains("Settings").click(); - cy.get("button").contains("Configuration").click(); - cy.get('[aria-label="Row-enable_lsm_expert_mode"]') - .find(".pf-v5-c-switch") - .click(); - cy.get('[data-testid="Warning"]').should("exist"); - cy.get('[aria-label="Row-enable_lsm_expert_mode"]') - .find('[aria-label="SaveAction"]') - .click(); + // At the end turn expert mode off through the banner + cy.get("button").contains("Disable").click(); cy.get('[data-testid="Warning"]').should("not.exist"); cy.get("[id='expert-mode-banner']").should("not.exist"); }); diff --git a/cypress/e2e/scenario-2.4-old-expert-mode.cy.js b/cypress/e2e/scenario-2.4-old-expert-mode.cy.js index d81c14cf8..02008b3be 100644 --- a/cypress/e2e/scenario-2.4-old-expert-mode.cy.js +++ b/cypress/e2e/scenario-2.4-old-expert-mode.cy.js @@ -353,16 +353,8 @@ if (Cypress.env("edition") === "iso") { cy.get("button").contains("Yes").click(); cy.get('[aria-label="ServiceInventory-Empty"').should("to.be.visible"); - // At the end go back to settings and turn expert mode off - cy.get(".pf-v5-c-nav__item").contains("Settings").click(); - cy.get("button").contains("Configuration").click(); - cy.get('[aria-label="Row-enable_lsm_expert_mode"]') - .find(".pf-v5-c-switch") - .click(); - cy.get('[data-testid="Warning"]').should("exist"); - cy.get('[aria-label="Row-enable_lsm_expert_mode"]') - .find('[aria-label="SaveAction"]') - .click(); + // At the end turn expert mode off through the banner + cy.get("button").contains("Disable").click(); cy.get('[data-testid="Warning"]').should("not.exist"); cy.get("[id='expert-mode-banner']").should("not.exist"); }); diff --git a/src/Data/Managers/V2/POST/UpdateEnvConfig/index.ts b/src/Data/Managers/V2/POST/UpdateEnvConfig/index.ts new file mode 100644 index 000000000..068295aa9 --- /dev/null +++ b/src/Data/Managers/V2/POST/UpdateEnvConfig/index.ts @@ -0,0 +1 @@ +export * from "./useUpdateEnvConfig"; diff --git a/src/Data/Managers/V2/POST/UpdateEnvConfig/useUpdateEnvConfig.ts b/src/Data/Managers/V2/POST/UpdateEnvConfig/useUpdateEnvConfig.ts new file mode 100644 index 000000000..035eba88f --- /dev/null +++ b/src/Data/Managers/V2/POST/UpdateEnvConfig/useUpdateEnvConfig.ts @@ -0,0 +1,71 @@ +import { + UseMutationResult, + useMutation, + useQueryClient, +} from "@tanstack/react-query"; +import { ParsedNumber } from "@/Core"; +import { PrimaryBaseUrlManager } from "@/UI"; +import { Dict } from "@/UI/Components"; +import { useFetchHelpers } from "../../helpers"; + +interface ConfigUpdate { + id: string; + value: string | boolean | ParsedNumber | Dict; +} + +/** + * React Query hook for updating environment configuration settings. + * + * @param {string} environment - The environment to use for creating headers. + * @returns {UseMutationResult}- The mutation object from `useMutation` hook. + */ +export const useUpdateEnvConfig = ( + environment: string, +): UseMutationResult => { + const client = useQueryClient(); + + const baseUrlManager = new PrimaryBaseUrlManager( + globalThis.location.origin, + globalThis.location.pathname, + ); + const { createHeaders, handleErrors } = useFetchHelpers(); + const headers = createHeaders(environment); + const baseUrl = baseUrlManager.getBaseUrl(process.env.API_BASEURL); + + /** + * Update the environment configuration setting. + * + * @param {ConfigUpdate} configUpdate - The info about the config setting to update + * + * @returns {Promise} - The promise object of the fetch request. + * @throws {Error} If the response is not successful, an error with the error message is thrown. + */ + const updateConfig = async (configUpdate: ConfigUpdate): Promise => { + const { id, value } = configUpdate; + + const response = await fetch( + baseUrl + `/api/v2/environment_settings/${id}`, + { + method: "POST", + body: JSON.stringify({ value }), + headers, + }, + ); + + await handleErrors(response); + }; + + return useMutation({ + mutationFn: updateConfig, + mutationKey: ["update_env_config"], + onSuccess: () => { + client.invalidateQueries({ + queryKey: ["get_env_config"], //for the future rework of the env getter + }); + client.invalidateQueries({ + queryKey: ["get_env_details"], //for the future rework of the env getter + }); + document.dispatchEvent(new Event("settings-update")); + }, + }); +}; diff --git a/src/UI/Components/ExpertBanner/ExpertBanner.test.tsx b/src/UI/Components/ExpertBanner/ExpertBanner.test.tsx new file mode 100644 index 000000000..3102701d4 --- /dev/null +++ b/src/UI/Components/ExpertBanner/ExpertBanner.test.tsx @@ -0,0 +1,134 @@ +import React, { act } from "react"; +import { MemoryRouter } from "react-router-dom"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { StoreProvider } from "easy-peasy"; +import { HttpResponse, http } from "msw"; +import { setupServer } from "msw/node"; +import { getStoreInstance } from "@/Data"; +import * as useUpdateEnvConfig from "@/Data/Managers/V2/POST/UpdateEnvConfig/useUpdateEnvConfig"; //import with that exact path is required for mock to work correctly +import { dependencies } from "@/Test"; +import { DependencyProvider } from "@/UI/Dependency"; +import { ExpertBanner } from "./ExpertBanner"; + +const setup = (flag: boolean) => { + const client = new QueryClient(); + + dependencies.environmentModifier.useIsExpertModeEnabled = jest.fn(() => flag); + const store = getStoreInstance(); + + return ( + + + + + + + + + + ); +}; + +describe("Given ExpertBanner", () => { + it("When expert_mode is set to true Then should render,", () => { + render(setup(true)); + + expect( + screen.getByText("LSM expert mode is enabled, proceed with caution."), + ).toBeVisible(); + + expect(screen.getByText("Disable expert mode")).toBeVisible(); + }); + + it("When expert_mode is set to true AND user clicks to disable expert mode it Then should fire mutation function", async () => { + const mutateSpy = jest.fn(); + const spy = jest + .spyOn(useUpdateEnvConfig, "useUpdateEnvConfig") + .mockReturnValue({ + data: undefined, + error: null, + failureCount: 0, + isError: false, + isIdle: false, + isSuccess: true, + isPending: false, + reset: jest.fn(), + isPaused: false, + context: undefined, + variables: { + id: "", + value: "", + }, + failureReason: null, + submittedAt: 0, + mutateAsync: jest.fn(), + status: "success", + mutate: mutateSpy, + }); + + render(setup(true)); + + await act(async () => { + await userEvent.click(screen.getByText("Disable expert mode")); + }); + + expect(mutateSpy).toHaveBeenCalledWith({ + id: "enable_lsm_expert_mode", + value: false, + }); + spy.mockRestore(); + }); + + it("When expert_mode is set to true AND user clicks to disable expert mode it AND something was wrong with the request Then AlertToast with error message should open", async () => { + const server = setupServer( + http.post( + "/api/v2/environment_settings/enable_lsm_expert_mode", + async () => { + return HttpResponse.json( + { + message: "Request or referenced resource does not exist", + }, + { + status: 404, + }, + ); + }, + ), + ); + + server.listen(); + render(setup(true)); + + await act(async () => { + await userEvent.click(screen.getByText("Disable expert mode")); + }); + + await waitFor(() => { + expect(screen.getByText("Something went wrong")).toBeVisible(); + }); + expect( + screen.getByText("Request or referenced resource does not exist"), + ).toBeVisible(); + + expect( + screen.getByText("LSM expert mode is enabled, proceed with caution."), + ).toBeVisible(); + expect(screen.getByText("Disable expert mode")).toBeVisible(); + + server.close(); + }); + + it("When expert_mode is set to false Then should not render,", () => { + render(setup(false)); + + expect( + screen.queryByText("LSM expert mode is enabled, proceed with caution."), + ).toBeNull(); + }); +}); diff --git a/src/UI/Components/ExpertBanner/ExpertBanner.tsx b/src/UI/Components/ExpertBanner/ExpertBanner.tsx index 95fb41262..693048781 100644 --- a/src/UI/Components/ExpertBanner/ExpertBanner.tsx +++ b/src/UI/Components/ExpertBanner/ExpertBanner.tsx @@ -1,20 +1,74 @@ -import React, { useContext } from "react"; -import { Banner } from "@patternfly/react-core"; +import React, { useContext, useEffect, useState } from "react"; +import { Banner, Button, Flex, Spinner } from "@patternfly/react-core"; +import styled from "styled-components"; +import { useUpdateEnvConfig } from "@/Data/Managers/V2/POST/UpdateEnvConfig"; import { DependencyContext } from "@/UI/Dependency"; +import { words } from "@/UI/words"; +import { ToastAlert } from "../ToastAlert"; -export const ExpertBanner = () => { +interface Props { + environmentId: string; +} + +/** + * A React component that displays a banner when the expert mode is enabled. + * + * @props {object} props - The properties passed to the component. + * @prop {string} environmentId -The ID of the environment. + * @returns { React.FC | null} The rendered banner if the expert mode is enabled, otherwise null. + */ +export const ExpertBanner: React.FC = ({ environmentId }) => { + const [errorMessage, setMessage] = useState(undefined); const { environmentModifier } = useContext(DependencyContext); + const { mutate, isError, error } = useUpdateEnvConfig(environmentId); + const [isLoading, setIsLoading] = useState(false); // isLoading is to indicate the asynchronous operation is in progress, as we need to wait until setting will be updated, getters are still in the V1 - task https://github.com/inmanta/web-console/issues/5999 + + useEffect(() => { + if (isError) { + setMessage(error.message); + setIsLoading(false); + } + }, [isError, error]); return environmentModifier.useIsExpertModeEnabled() ? ( - + <> + {isError && errorMessage && ( + + )} - LSM expert mode is enabled, proceed with caution. + + {words("banner.expertMode")} + + {isLoading && } + - + ) : null; }; + +const StyledSpinner = styled(Spinner)` + margin-left: 0.5rem; + --pf-v5-c-spinner--Color: white; +`; diff --git a/src/UI/Components/LicenseBanner/LicenseBanner.tsx b/src/UI/Components/LicenseBanner/LicenseBanner.tsx index e674e8162..202c49728 100644 --- a/src/UI/Components/LicenseBanner/LicenseBanner.tsx +++ b/src/UI/Components/LicenseBanner/LicenseBanner.tsx @@ -1,5 +1,5 @@ import React, { useContext } from "react"; -import { Banner } from "@patternfly/react-core"; +import { Banner, Flex } from "@patternfly/react-core"; import { DependencyContext } from "@/UI/Dependency"; import { words } from "@/UI/words"; @@ -16,7 +16,9 @@ export const LicenseBanner: React.FC = () => { return expirationMessage ? ( - {expirationMessage} + + {expirationMessage} + ) : null; }; diff --git a/src/UI/Components/ServiceInstanceForm/Components/AutoCompleteInputProvider.tsx b/src/UI/Components/ServiceInstanceForm/Components/AutoCompleteInputProvider.tsx index 160972ae3..65313775d 100644 --- a/src/UI/Components/ServiceInstanceForm/Components/AutoCompleteInputProvider.tsx +++ b/src/UI/Components/ServiceInstanceForm/Components/AutoCompleteInputProvider.tsx @@ -12,7 +12,7 @@ interface Props { isOptional: boolean; isDisabled?: boolean; handleInputChange: (value) => void; - alreadySelected: string[]; + alreadySelected: string[] | null; multi?: boolean; } diff --git a/src/UI/Components/UpdateBanner/UpdateBanner.tsx b/src/UI/Components/UpdateBanner/UpdateBanner.tsx index 8d836b155..babe3a08e 100644 --- a/src/UI/Components/UpdateBanner/UpdateBanner.tsx +++ b/src/UI/Components/UpdateBanner/UpdateBanner.tsx @@ -1,8 +1,9 @@ import React, { useContext, useState } from "react"; -import { Banner } from "@patternfly/react-core"; +import { Banner, Flex } from "@patternfly/react-core"; import { ApiHelper } from "@/Core"; import { GetVersionFileQueryManager } from "@/Data/Managers/GetVersionFile/OnteTimeQueryManager"; import { DependencyContext } from "@/UI/Dependency"; +import { words } from "@/UI/words"; interface Props { apiHelper: ApiHelper; @@ -25,9 +26,9 @@ export const UpdateBanner: React.FunctionComponent = (props) => { const banner = ( - You are running {currentVersion}, a new version is available! Please - hard-reload (Ctrl+F5 | Cmd + Shift + R) your page to load the new - version. + + {words("banner.updateBanner")(currentVersion)} + ); diff --git a/src/UI/Root/Components/PageFrame/PageFrame.tsx b/src/UI/Root/Components/PageFrame/PageFrame.tsx index 0f5cfd5b3..066f994e7 100644 --- a/src/UI/Root/Components/PageFrame/PageFrame.tsx +++ b/src/UI/Root/Components/PageFrame/PageFrame.tsx @@ -26,7 +26,7 @@ export const PageFrame: React.FC> = ({ return ( <>
- + {environmentId && }
diff --git a/src/UI/words.tsx b/src/UI/words.tsx index 23d1a05d0..5753f1418 100644 --- a/src/UI/words.tsx +++ b/src/UI/words.tsx @@ -783,12 +783,16 @@ const dict = { /** * Banners */ + "banner.expertMode": "LSM expert mode is enabled, proceed with caution. ", + "banner.updateBanner": (currentVersion: string) => + `You are running ${currentVersion}, a new version is available! Please hard-reload (Ctrl+F5 | Cmd + Shift + R) your page to load the new version.`, "banner.entitlement.expired": (days: number) => `Your license has expired ${days} days ago!`, "banner.certificate.expired": (days: number) => `Your license has expired ${days} days ago!`, "banner.certificate.will.expire": (days: number) => `Your license will expire in ${days} days.`, + "banner.disableExpertMode": "Disable expert mode", /** * Common From 0c674965ff36d7ea043bfd2ccc41d847121d0ac8 Mon Sep 17 00:00:00 2001 From: inmantaci Date: Mon, 28 Oct 2024 11:18:11 +0100 Subject: [PATCH 2/4] Merge tool: minor version bump --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c003cdfc5..d73fa2b59 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@inmanta/web-console", - "version": "2.0.1", + "version": "2.1.0", "description": "Web Console for Inmanta Orchestrator", "exports": "./dist/index.js", "repository": "https://github.com/inmanta/web-console.git", From abdac79cc14db8dfbe90f427988f9c49e764c669 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 28 Oct 2024 12:17:07 +0100 Subject: [PATCH 3/4] Build(deps): Bump http-proxy-middleware from 2.0.6 to 2.0.7 (PR #6014) Bumps [http-proxy-middleware](https://github.com/chimurai/http-proxy-middleware) from 2.0.6 to 2.0.7.
Release notes

Sourced from http-proxy-middleware's releases.

v2.0.7

Full Changelog: https://github.com/chimurai/http-proxy-middleware/compare/v2.0.6...v2.0.7

v2.0.7-beta.1

Full Changelog: https://github.com/chimurai/http-proxy-middleware/compare/v2.0.7-beta.0...v2.0.7-beta.1

v2.0.7-beta.0

Full Changelog: https://github.com/chimurai/http-proxy-middleware/compare/v2.0.6...v2.0.7-beta.0

Changelog

Sourced from http-proxy-middleware's changelog.

v2.0.7

  • ci(github actions): add publish.yml
  • fix(filter): handle errors
Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=http-proxy-middleware&package-manager=npm_and_yarn&previous-version=2.0.6&new-version=2.0.7)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/inmanta/web-console/network/alerts).
--- changelogs/unreleased/6014-dependabot.yml | 5 +++++ yarn.lock | 6 +++--- 2 files changed, 8 insertions(+), 3 deletions(-) create mode 100644 changelogs/unreleased/6014-dependabot.yml diff --git a/changelogs/unreleased/6014-dependabot.yml b/changelogs/unreleased/6014-dependabot.yml new file mode 100644 index 000000000..85d4d2e98 --- /dev/null +++ b/changelogs/unreleased/6014-dependabot.yml @@ -0,0 +1,5 @@ +change-type: patch +description: "Build(deps): Bump http-proxy-middleware from 2.0.6 to 2.0.7" +destination-branches: + - master +sections: {} diff --git a/yarn.lock b/yarn.lock index 46b01fa28..bad02e9d4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8908,8 +8908,8 @@ __metadata: linkType: hard "http-proxy-middleware@npm:^2.0.3": - version: 2.0.6 - resolution: "http-proxy-middleware@npm:2.0.6" + version: 2.0.7 + resolution: "http-proxy-middleware@npm:2.0.7" dependencies: "@types/http-proxy": "npm:^1.17.8" http-proxy: "npm:^1.18.1" @@ -8921,7 +8921,7 @@ __metadata: peerDependenciesMeta: "@types/express": optional: true - checksum: 10/768e7ae5a422bbf4b866b64105b4c2d1f468916b7b0e9c96750551c7732383069b411aa7753eb7b34eab113e4f77fb770122cb7fb9c8ec87d138d5ddaafda891 + checksum: 10/4a51bf612b752ad945701995c1c029e9501c97e7224c0cf3f8bf6d48d172d6a8f2b57c20fec469534fdcac3aa8a6f332224a33c6b0d7f387aa2cfff9b67216fd languageName: node linkType: hard From 7a154abc99b6329b59e58c5bbbb8c73f16d7161c Mon Sep 17 00:00:00 2001 From: Mateusz Borowczyk Date: Mon, 28 Oct 2024 19:08:08 +0100 Subject: [PATCH 4/4] Complete redesign of the Instance Composer, main focus was to align its general functionalities with regular form, and improve the user experience. This change includes: A right sidebar, to have better access to the form fields of different parts of the instance A left sidebar, from which we can drag and drop embedded entities and existing Inter-Service Relations from the inventory. Inter-Service Relations can only be edited when opened individualy in the Instance Composer. Zooming can now be done with a slider, and two new functionalities have been added. Zoom-to-fit and full-screen mode. (Issue #5868, PR #5985) # Description Pull request that merge all changes made to the instance composer to move away from old design and functionalities and make composer to follow form-like behaviour Task that are still required for full redesign: #5870 #5986 #5987 #5988 #5989 #5990 #5991 https://github.com/user-attachments/assets/12001341-4bb4-43dc-af3b-88c78993ebe4 closes #5868 # Self Check: Strike through any lines that are not applicable (`~~line~~`) then check the box - [ ] Attached issue to pull request - [ ] Changelog entry - [ ] Code is clear and sufficiently documented - [ ] Sufficient test cases (reproduces the bug/tests the requested feature) - [ ] Correct, in line with design - [ ] End user documentation is included or an issue is created for end-user documentation (add ref to issue here: ) --- changelogs/unreleased/5868-composer-v2.yml | 11 + .../e2e/scenario-8-instance-composer.cy.js | 1658 +++++++++-------- src/Core/Domain/Field.ts | 1 + src/Core/Domain/ServiceInstanceModel.ts | 2 +- src/Core/Domain/ServiceModel.ts | 4 +- .../V2/GETTERS/GetAllServiceModels/index.ts | 1 + .../useGetAllServiceModels.ts | 67 + .../useGetInstanceWithRelations.test.tsx | 73 +- .../useGetInstanceWithRelations.ts | 171 +- .../V2/GETTERS/GetInventoryList/index.ts | 1 + .../GetInventoryList/useGetInventoryList.ts | 102 + .../GetServiceModel/UseGetServiceModel.ts | 2 +- .../V2/POST/PostOrder/usePostOrder.ts | 2 +- src/Data/Store/ServicesSlice.test.ts | 2 + src/Slices/CompileDetails/Core/Mock.ts | 2 +- src/Slices/InstanceComposer/UI/Page.tsx | 52 - .../Core/Route.ts | 0 .../InstanceComposerCreator/UI/Page.tsx | 46 + .../UI/index.ts | 0 .../index.ts | 0 src/Slices/InstanceComposerEditor/UI/Page.tsx | 51 +- src/Slices/InstanceComposerViewer/UI/Page.tsx | 50 +- .../InstanceActions/InstanceActions.tsx | 4 +- .../Components/RowActionsMenu/RowActions.tsx | 9 +- .../TableControls/TableControls.tsx | 3 +- .../UI/Presenters/InstanceActionPresenter.ts | 1 - .../UI/ServiceInventory.test.tsx | 42 +- src/Test/Data/Service/EmbeddedEntity.ts | 16 + src/Test/Data/Service/index.ts | 1 + src/UI/Components/Diagram/Canvas.test.tsx | 609 +----- src/UI/Components/Diagram/Canvas.tsx | 651 +++---- .../Diagram/Context/CanvasProvider.tsx | 89 + .../Context/ComposerCreatorProvider.tsx | 134 ++ .../Context/ComposerEditorProvider.tsx | 151 ++ src/UI/Components/Diagram/Context/Context.tsx | 112 ++ .../Diagram/Context/EventWrapper.test.tsx | 413 ++++ .../Diagram/Context/EventWrapper.tsx | 190 ++ src/UI/Components/Diagram/Context/index.ts | 5 + src/UI/Components/Diagram/{ => Mocks}/Mock.ts | 870 ++++++++- src/UI/Components/Diagram/Mocks/index.ts | 1 + src/UI/Components/Diagram/Mocks/instance.json | 2 +- src/UI/Components/Diagram/actions.test.ts | 769 ++++++++ src/UI/Components/Diagram/actions.ts | 706 +++---- src/UI/Components/Diagram/anchors.ts | 2 + .../Diagram/components/ComposerActions.tsx | 169 ++ .../Diagram/components/DictModal.tsx | 30 +- .../Diagram/components/EntityForm.tsx | 188 ++ .../Diagram/components/FormModal.tsx | 319 ---- .../Diagram/components/RightSidebar.tsx | 285 +++ .../Components/Diagram/components/Toolbar.tsx | 116 -- src/UI/Components/Diagram/components/index.ts | 4 + src/UI/Components/Diagram/halo.ts | 62 +- src/UI/Components/Diagram/helpers.test.ts | 374 +++- src/UI/Components/Diagram/helpers.ts | 439 ++++- .../Diagram/icons/exit-fullscreen.svg | 1 + .../Diagram/icons/fit-to-screen.svg | 1 + .../Diagram/icons/request-fullscreen.svg | 1 + src/UI/Components/Diagram/init.ts | 471 ++--- src/UI/Components/Diagram/interfaces.ts | 173 +- src/UI/Components/Diagram/paper/index.ts | 1 + src/UI/Components/Diagram/paper/paper.ts | 313 ++++ src/UI/Components/Diagram/routers.ts | 7 +- src/UI/Components/Diagram/shapes.ts | 8 +- .../Diagram/stencil/helpers.test.ts | 107 ++ src/UI/Components/Diagram/stencil/helpers.ts | 105 ++ src/UI/Components/Diagram/stencil/index.ts | 1 + .../Diagram/stencil/instanceStencil.ts | 96 + .../Diagram/stencil/inventoryStencil.ts | 145 ++ src/UI/Components/Diagram/stencil/stencil.ts | 123 ++ src/UI/Components/Diagram/styles.ts | 89 +- src/UI/Components/Diagram/testSetup.ts | 9 + .../Components/Diagram/zoomHandler/index.ts | 1 + .../Diagram/zoomHandler/zoomHandler.test.tsx | 98 + .../Diagram/zoomHandler/zoomHandler.ts | 310 +++ .../InstanceProvider.test.tsx | 89 - .../InstanceProvider/InstanceProvider.tsx | 62 - src/UI/Components/InstanceProvider/index.ts | 1 - .../Components/FieldInput.tsx | 14 +- .../Helpers/FieldCreator.spec.ts | 5 + .../ServiceInstanceForm.tsx | 2 +- .../Components/TreeTable/TreeTable.test.tsx | 1 + src/UI/Root/Components/Header/Header.tsx | 23 +- src/UI/Root/PrimaryPageManager.tsx | 2 +- src/UI/Routing/Paths.ts | 2 +- src/UI/Routing/PrimaryRouteManager.ts | 2 +- src/UI/words.tsx | 63 +- 86 files changed, 7857 insertions(+), 3533 deletions(-) create mode 100644 changelogs/unreleased/5868-composer-v2.yml create mode 100644 src/Data/Managers/V2/GETTERS/GetAllServiceModels/index.ts create mode 100644 src/Data/Managers/V2/GETTERS/GetAllServiceModels/useGetAllServiceModels.ts create mode 100644 src/Data/Managers/V2/GETTERS/GetInventoryList/index.ts create mode 100644 src/Data/Managers/V2/GETTERS/GetInventoryList/useGetInventoryList.ts delete mode 100644 src/Slices/InstanceComposer/UI/Page.tsx rename src/Slices/{InstanceComposer => InstanceComposerCreator}/Core/Route.ts (100%) create mode 100644 src/Slices/InstanceComposerCreator/UI/Page.tsx rename src/Slices/{InstanceComposer => InstanceComposerCreator}/UI/index.ts (100%) rename src/Slices/{InstanceComposer => InstanceComposerCreator}/index.ts (100%) create mode 100644 src/UI/Components/Diagram/Context/CanvasProvider.tsx create mode 100644 src/UI/Components/Diagram/Context/ComposerCreatorProvider.tsx create mode 100644 src/UI/Components/Diagram/Context/ComposerEditorProvider.tsx create mode 100644 src/UI/Components/Diagram/Context/Context.tsx create mode 100644 src/UI/Components/Diagram/Context/EventWrapper.test.tsx create mode 100644 src/UI/Components/Diagram/Context/EventWrapper.tsx create mode 100644 src/UI/Components/Diagram/Context/index.ts rename src/UI/Components/Diagram/{ => Mocks}/Mock.ts (81%) create mode 100644 src/UI/Components/Diagram/Mocks/index.ts create mode 100644 src/UI/Components/Diagram/actions.test.ts create mode 100644 src/UI/Components/Diagram/components/ComposerActions.tsx create mode 100644 src/UI/Components/Diagram/components/EntityForm.tsx delete mode 100644 src/UI/Components/Diagram/components/FormModal.tsx create mode 100644 src/UI/Components/Diagram/components/RightSidebar.tsx delete mode 100644 src/UI/Components/Diagram/components/Toolbar.tsx create mode 100644 src/UI/Components/Diagram/components/index.ts create mode 100644 src/UI/Components/Diagram/icons/exit-fullscreen.svg create mode 100644 src/UI/Components/Diagram/icons/fit-to-screen.svg create mode 100644 src/UI/Components/Diagram/icons/request-fullscreen.svg create mode 100644 src/UI/Components/Diagram/paper/index.ts create mode 100644 src/UI/Components/Diagram/paper/paper.ts create mode 100644 src/UI/Components/Diagram/stencil/helpers.test.ts create mode 100644 src/UI/Components/Diagram/stencil/helpers.ts create mode 100644 src/UI/Components/Diagram/stencil/index.ts create mode 100644 src/UI/Components/Diagram/stencil/instanceStencil.ts create mode 100644 src/UI/Components/Diagram/stencil/inventoryStencil.ts create mode 100644 src/UI/Components/Diagram/stencil/stencil.ts create mode 100644 src/UI/Components/Diagram/zoomHandler/index.ts create mode 100644 src/UI/Components/Diagram/zoomHandler/zoomHandler.test.tsx create mode 100644 src/UI/Components/Diagram/zoomHandler/zoomHandler.ts delete mode 100644 src/UI/Components/InstanceProvider/InstanceProvider.test.tsx delete mode 100644 src/UI/Components/InstanceProvider/InstanceProvider.tsx delete mode 100644 src/UI/Components/InstanceProvider/index.ts diff --git a/changelogs/unreleased/5868-composer-v2.yml b/changelogs/unreleased/5868-composer-v2.yml new file mode 100644 index 000000000..79dd7dea8 --- /dev/null +++ b/changelogs/unreleased/5868-composer-v2.yml @@ -0,0 +1,11 @@ +description: "Complete redesign of the Instance Composer, main focus was to align its general functionalities with regular form, and improve the user experience. +This change includes: +A right sidebar, to have better access to the form fields of different parts of the instance +A left sidebar, from which we can drag and drop embedded entities and existing Inter-Service Relations from the inventory. +Inter-Service Relations can only be edited when opened individualy in the Instance Composer. +Zooming can now be done with a slider, and two new functionalities have been added. Zoom-to-fit and full-screen mode." +issue-nr: 5868 +change-type: minor +destination-branches: [master] +sections: + feature: "{{description}}" diff --git a/cypress/e2e/scenario-8-instance-composer.cy.js b/cypress/e2e/scenario-8-instance-composer.cy.js index 1d09b6f25..3617e7e7a 100644 --- a/cypress/e2e/scenario-8-instance-composer.cy.js +++ b/cypress/e2e/scenario-8-instance-composer.cy.js @@ -1,827 +1,835 @@ //TODO: tests are commented out as there is ongoing redesign of the instance composer which will affect how the user interact with it and how e2e test should look like -// /** -// * Shorthand method to clear the environment being passed. -// * By default, if no arguments are passed it will target the 'lsm-frontend' environment. -// * -// * @param {string} nameEnvironment -// */ -// const clearEnvironment = (nameEnvironment = "lsm-frontend") => { -// cy.visit("/console/"); -// cy.get('[aria-label="Environment card"]').contains(nameEnvironment).click(); -// cy.url().then((url) => { -// const location = new URL(url); -// const id = location.searchParams.get("env"); -// cy.request("DELETE", `/api/v1/decommission/${id}`); -// }); -// }; - -// /** -// * based on the environment id, it will recursively check if a compile is pending. -// * It will continue the recursion as long as the statusCode is equal to 200 -// * -// * @param {string} id -// */ -// const checkStatusCompile = (id) => { -// let statusCodeCompile = 200; - -// if (statusCodeCompile === 200) { -// cy.intercept(`/api/v1/notify/${id}`).as("IsCompiling"); -// // the timeout is necessary to avoid errors. -// // Cypress doesn't support while loops and this was the only workaround to wait till the statuscode is not 200 anymore. -// // the default timeout in cypress is 5000, but since we have recursion it goes into timeout for the nested awaits because of the recursion. -// cy.wait("@IsCompiling", { timeout: 15000 }).then((req) => { -// statusCodeCompile = req.response.statusCode; - -// if (statusCodeCompile === 200) { -// checkStatusCompile(id); -// } -// }); -// } -// }; - -// /** -// * Will by default execute the force update on the 'lsm-frontend' environment if no argumenst are being passed. -// * This method can be executed standalone, but is part of the cleanup cycle that is needed before running a scenario. -// * -// * @param {string} nameEnvironment -// */ -// const forceUpdateEnvironment = (nameEnvironment = "lsm-frontend") => { -// cy.visit("/console/"); -// cy.get('[aria-label="Environment card"]').contains(nameEnvironment).click(); -// cy.url().then((url) => { -// const location = new URL(url); -// const id = location.searchParams.get("env"); -// cy.request({ -// method: "POST", -// url: `/lsm/v1/exporter/export_service_definition`, -// headers: { "X-Inmanta-Tid": id }, -// body: { force_update: true }, -// }); -// checkStatusCompile(id); -// }); -// }; - -// if (Cypress.env("edition") === "iso") { -// describe("Scenario 8 - Instance Composer", () => { -// before(() => { -// clearEnvironment(); -// forceUpdateEnvironment(); -// }); - -// it("8.1 create instance with embedded entities", () => { -// // Select 'test' environment -// cy.visit("/console/"); -// cy.get('[aria-label="Environment card"]') -// .contains("lsm-frontend") -// .click(); -// cy.get(".pf-v5-c-nav__item").contains("Service Catalog").click(); - -// // click on Show Inventory on embedded-entity-service-extra, expect one instance already -// cy.get("#embedded-entity-service-extra", { timeout: 60000 }) -// .contains("Show inventory") -// .click(); -// cy.get('[aria-label="ServiceInventory-Empty"]').should("to.be.visible"); - -// // click on add instance with composer -// cy.get('[aria-label="AddInstanceToggle"]').click(); -// cy.get("#add-instance-composer-button").click(); - -// // Create instance on embedded-entity-service-extra -// cy.get(".canvas").should("be.visible"); - -// // Open and fill instance form of core attributes -// cy.get('[aria-label="new-entity-button"]').click(); -// cy.get('[aria-label="service-picker"]').click(); -// cy.get(".pf-v5-c-menu__item-text") -// .contains("embedded-entity-service-extra") -// .click(); - -// cy.get("#service_id").type("0002"); -// cy.get("#name").type("embedded-service"); -// cy.get("button").contains("Confirm").click(); - -// //try to deploy instance with only core attributes and expect 2 errors -// cy.get("button").contains("Deploy").click(); -// cy.get('[data-testid="ToastAlert"]') -// .contains( -// "Invalid request: 2 validation errors for embedded-entity-service-extra", -// ) -// .should("be.visible"); -// cy.get(".pf-v5-c-alert__action > .pf-v5-c-button").click(); - -// // add ro_meta core entity -// cy.get('[aria-label="new-entity-button"]').click(); -// cy.get('[aria-label="service-picker"]').click(); -// cy.get(".pf-v5-c-menu__item-text") -// .contains("ro_meta (embedded-entity-service-extra)") -// .click(); - -// cy.get("#name").type("ro_meta"); -// cy.get("#meta_data").type("meta_data1"); -// cy.get("#other_data").type("1"); -// cy.get("button").contains("Confirm").click(); - -// cy.get('[joint-selector="headerLabel"]').contains("ro_meta").click(); -// cy.get('[data-action="link"]') -// .trigger("mouseover") -// .trigger("mousedown") -// .trigger("mousemove", { -// clientX: 700, -// clientY: 300, -// }) -// .trigger("mouseup"); - -// //try to deploy instance with only one required embedded attribute connected and expect 1 error -// cy.get("button").contains("Deploy").click(); -// cy.get('[data-testid="ToastAlert"]') -// .contains( -// "Invalid request: 1 validation error for embedded-entity-service-extra", -// ) -// .should("be.visible"); -// cy.get(".pf-v5-c-alert__action > .pf-v5-c-button").click(); - -// cy.get('[aria-label="new-entity-button"]').click(); -// cy.get('[aria-label="service-picker"]').click(); -// cy.get(".pf-v5-c-menu__item-text") -// .contains("rw_meta (embedded-entity-service-extra)") -// .click(); -// cy.get("#name").type("rw_meta"); -// cy.get("#meta_data").type("meta_data2"); -// cy.get("#other_data").type("2"); -// cy.get("button").contains("Confirm").click(); - -// cy.get('[joint-selector="headerLabel"]').contains("rw_meta").click(); -// cy.get('[data-action="link"]') -// .trigger("mouseover") -// .trigger("mousedown") -// .trigger("mousemove", { -// clientX: 300, -// clientY: 300, -// }) -// .trigger("mouseup"); - -// cy.get('[aria-label="new-entity-button"]').click(); -// cy.get('[aria-label="service-picker"]').click(); - -// cy.get(".pf-v5-c-menu__item-text") -// .contains("rw_files (embedded-entity-service-extra)") -// .click(); -// cy.get("#name").type("rw_files1"); -// cy.get("#data").type("data1"); -// cy.get("button").contains("Confirm").click(); - -// cy.get('[joint-selector="headerLabel"]') -// .contains("rw_files") -// .trigger("mouseover") -// .trigger("mousedown") -// .trigger("mousemove", { -// clientX: 400, -// clientY: 500, -// }) -// .trigger("mouseup"); - -// //move root entity to the non-default position to assert persisting of the position works as intended by next scenario -// cy.get('[joint-selector="headerLabel"]') -// .contains("embedded-entity") -// .trigger("mouseover") -// .trigger("mousedown") -// .trigger("mousemove", { -// clientX: 1100, -// clientY: 450, -// }) -// .trigger("mouseup"); - -// cy.get('[joint-selector="headerLabel"]').contains("rw_files").click(); -// cy.get('[data-action="link"]') -// .trigger("mouseover") -// .trigger("mousedown") -// .trigger("mousemove", { -// clientX: 1200, -// clientY: 300, -// }) -// .trigger("mouseup"); - -// cy.get("button").contains("Deploy").click(); - -// // Check if only one row has been added to the table. -// cy.get('[aria-label="InstanceRow-Intro"]').should("have.length", 1); -// // await until all instances are being deployed and up -// cy.get('[data-label="State"]', { timeout: 90000 }).should( -// "have.text", -// "up", -// ); -// }); - -// it("8.2 edit instance with embedded entities", () => { -// // Select 'test' environment -// cy.visit("/console/"); -// cy.get('[aria-label="Environment card"]') -// .contains("lsm-frontend") -// .click(); -// cy.get(".pf-v5-c-nav__item").contains("Service Catalog").click(); - -// // click on Show Inventory on embedded-entity-service-extra, expect one instance already -// cy.get("#embedded-entity-service-extra", { timeout: 60000 }) -// .contains("Show inventory") -// .click(); -// cy.get('[aria-label="ServiceInventory-Success"]').should("to.be.visible"); - -// // click on kebab menu on embedded-entity-service-extra -// cy.get('[aria-label="row actions toggle"]').eq(0).click(); -// cy.get("button").contains("Edit in Composer").click(); - -// // Expect to be redirected to Instance Composer view with embedded-entity-service-extra shape visible -// cy.get(".canvas").should("be.visible"); -// cy.get('[data-type="app.ServiceEntityBlock"]').should("be.visible"); -// cy.get('[joint-selector="headerLabel"]') -// .contains("embedded-") -// .should("exist"); -// cy.get('[joint-selector="headerLabel"]') -// .contains("ro_meta") -// .should("exist"); -// cy.get('[joint-selector="headerLabel"]') -// .contains("rw_meta") -// .should("exist"); -// cy.get('[joint-selector="headerLabel"]') -// .contains("rw_files") -// .should("exist"); - -// cy.get('[aria-label="new-entity-button"]').click(); -// cy.get('[aria-label="service-picker"]').click(); -// cy.get(".pf-v5-c-menu__item-text") -// .contains("rw_files (embedded-entity-service-extra)") -// .click(); -// cy.get("#name").type("rw_files2"); -// cy.get("#data").type("data2"); -// cy.get("button").contains("Confirm").click(); - -// cy.get('[joint-selector="itemLabel_name_value"]') -// .contains("rw_files2") //easiest way to differentiate same type of entities is by the unique attributes values -// .click(); -// cy.get(".zoom-out").click(); -// cy.get(".zoom-out").click(); -// cy.get(".zoom-out").click(); -// cy.get(".zoom-out").click(); -// cy.get(".zoom-out").click(); - -// //try to add rw embedded entity which shouldn't be possible -// cy.get('[data-type="Link"]').should("have.length", "3"); -// cy.get('[data-action="link"]') -// .trigger("mouseover") -// .trigger("mousedown") -// .trigger("mousemove", { -// clientX: 800, -// clientY: 300, -// }) -// .trigger("mouseup"); - -// cy.get('[data-type="Link"]').should("have.length", "3"); -// cy.get('[data-action="delete"]').click(); - -// cy.get('[aria-label="new-entity-button"]').click(); -// cy.get('[aria-label="service-picker"]').click(); -// cy.get(".pf-v5-c-menu__item-text") -// .contains("ro_files (embedded-entity-service-extra)") -// .click(); -// cy.get("#name").type("ro_files2"); -// cy.get("#data").type("data2"); -// cy.get("button").contains("Confirm").click(); - -// cy.get(".zoom-out").click(); - -// cy.get('[joint-selector="headerLabel"]').contains("ro_files").click(); -// cy.get('[data-action="link"]') -// .trigger("mouseover") -// .trigger("mousedown") -// .trigger("mousemove", { -// clientX: 1200, -// clientY: 400, -// }) -// .trigger("mouseup"); - -// cy.get("button").contains("Deploy").click(); - -// // Check if only one row has been added to the table. -// cy.get('[aria-label="InstanceRow-Intro"]').should("have.length", 1); -// // await until all instances are being deployed and up -// cy.get('[data-label="State"]', { timeout: 90000 }).should( -// "have.text", -// "up", -// ); -// cy.get("#expand-toggle0").click(); -// cy.get("button").contains("Attributes").click(); -// cy.get('[aria-label="Toggle-ro_files"]').click(); -// cy.get('[aria-label="Toggle-ro_files$0"]').click(); -// cy.get( -// '[aria-label="Row-ro_files$0$data"] > [data-label="candidate"]', -// ).should("have.text", "data2"); -// cy.get( -// '[aria-label="Row-ro_files$0$name"] > [data-label="candidate"]', -// ).should("have.text", "ro_files2"); -// //go back to composer to further edit component -// cy.get('[aria-label="row actions toggle"]').eq(0).click(); -// cy.get("button").contains("Edit in Composer").click(); - -// //try to delete optional rw embedded entity -// cy.get(".zoom-out").click(); -// cy.get('[joint-selector="headerLabel"]').contains("rw_files").click(); -// cy.get('[data-action="delete"]').click(); - -// cy.get("button").contains("Deploy").click(); -// cy.get('[data-testid="ToastAlert"]') -// .contains( -// "Attribute rw_files cannot be updated because it has the attribute modifier rw", -// ) -// .should("be.visible"); -// cy.reload(); - -// //try to delete required embedded entity -// cy.get('[joint-selector="headerLabel"]').contains("ro_meta").click(); -// cy.get('[data-action="delete"]').click(); -// cy.get("button").contains("Deploy").click(); -// cy.get('[data-testid="ToastAlert"]') -// .contains("invalid: 1 validation error for dict") -// .should("be.visible"); -// cy.reload(); - -// cy.get('[joint-selector="headerLabel"]').contains("ro_files").click(); -// cy.get('[data-action="delete"]').click(); - -// cy.get('[joint-selector="headerLabel"]').contains("ro_meta").click(); -// cy.get('[data-action="edit"]').click(); -// cy.get("#meta_data").type("{backspace}-new"); -// cy.get("#name").should("not.exist"); -// cy.get("button").contains("Confirm").click(); - -// cy.get("button").contains("Deploy").click(); - -// // Check if only one row has been added to the table. -// cy.get('[aria-label="InstanceRow-Intro"]').should("have.length", 1); -// // await until all instances are being deployed and up -// cy.get('[data-label="State"]', { timeout: 90000 }).should( -// "have.text", -// "up", -// ); - -// cy.get("button").contains("Attributes").click(); -// cy.get('[aria-label="Row-ro_files"] > [data-label="candidate"]').should( -// "have.text", -// "{}", -// ); -// cy.get( -// '[aria-label="Row-ro_meta$meta_data"] > [data-label="candidate"]', -// ).should("have.text", "meta_data-new"); -// }); - -// it("8.3 create instances with inter-service relation", () => { -// // Select 'test' environment -// cy.visit("/console/"); -// cy.get('[aria-label="Environment card"]') -// .contains("lsm-frontend") -// .click(); -// cy.get(".pf-v5-c-nav__item").contains("Service Catalog").click(); - -// // click on Show Inventory on parent-service, expect one instance already -// cy.get("#parent-service", { timeout: 60000 }) -// .contains("Show inventory") -// .click(); -// cy.get('[aria-label="ServiceInventory-Empty"]').should("to.be.visible"); - -// // click on add instance in composer -// cy.get('[aria-label="AddInstanceToggle"]').click(); -// cy.get("#add-instance-composer-button").click(); -// cy.get(".canvas").should("be.visible"); - -// // Open and fill instance form of parent-service -// cy.get('[aria-label="new-entity-button"]').click(); -// cy.get('[aria-label="service-picker"]').click(); -// cy.get(".pf-v5-c-menu__item-text").contains("parent-service").click(); - -// cy.get("#service_id").type("0003"); -// cy.get("#name").type("parent-service"); -// cy.get("button").contains("Confirm").click(); - -// // Open and fill instance form of child-service and connect it with parent-service -// cy.get('[aria-label="new-entity-button"]').click(); -// cy.get('[aria-label="service-picker"]').click(); -// cy.get(".pf-v5-c-menu__item-text").contains("child-service").click(); - -// cy.get("#service_id").type("0004"); -// cy.get("#name").type("child-service"); -// cy.get("button").contains("Confirm").click(); - -// //check if errors is returned when we deployed service without inter-service relation set -// cy.get("button").contains("Deploy").click(); -// cy.get('[data-testid="ToastAlert"]') -// .contains(`Invalid request: 1 validation error for child-service`) -// .should("be.visible"); -// cy.get(".pf-v5-c-alert__action > .pf-v5-c-button").click(); - -// //connect services -// cy.get('[joint-selector="headerLabel"]') -// .contains("child-service") -// .click(); -// cy.get('[data-action="link"]') -// .trigger("mouseover") -// .trigger("mousedown") -// .trigger("mousemove", { -// clientX: 700, -// clientY: 300, -// }) -// .trigger("mouseup"); - -// cy.get("button").contains("Deploy").click(); - -// // Check if only one row has been added to the table. -// cy.get('[aria-label="InstanceRow-Intro"]').should("have.length", 1); -// // await until all instances are being deployed and up -// cy.get('[data-label="State"]', { timeout: 90000 }).should( -// "have.text", -// "up", -// ); - -// //Check child-service -// cy.get(".pf-v5-c-nav__item").contains("Service Catalog").click(); -// cy.get("#child-service", { timeout: 60000 }) -// .contains("Show inventory") -// .click(); -// cy.get('[aria-label="InstanceRow-Intro"]').should("have.length", 1); -// cy.get('[data-label="State"]', { timeout: 90000 }).should( -// "have.text", -// "up", -// ); - -// //go back to parent-service inventory view -// cy.get(".pf-v5-c-nav__item").contains("Service Catalog").click(); -// // click on Show Inventory on parent-service, expect one instance already -// cy.get("#parent-service", { timeout: 60000 }) -// .contains("Show inventory") -// .click(); -// // click on add instance in composer -// cy.get('[aria-label="AddInstanceToggle"]').click(); -// cy.get("#add-instance-composer-button").click(); -// cy.get(".canvas").should("be.visible"); - -// // Open and fill instance form of parent-service -// cy.get('[aria-label="new-entity-button"]').click(); -// cy.get('[aria-label="service-picker"]').click(); -// cy.get(".pf-v5-c-menu__item-text").contains("parent-service").click(); - -// cy.get("#service_id").type("0006"); -// cy.get("#name").type("parent-service2"); -// cy.get("button").contains("Confirm").click(); - -// // Open and fill instance forms for container-service then connect it with parent-service -// cy.get('[aria-label="new-entity-button"]').click(); -// cy.get('[aria-label="service-picker"]').click(); -// cy.get(".pf-v5-c-menu__item-text").contains("container-service").click(); - -// cy.get("#service_id").type("0007"); -// cy.get("#name").type("container-service"); -// cy.get("button").contains("Confirm").click(); - -// cy.get('[aria-label="new-entity-button"]').click(); -// cy.get('[aria-label="service-picker"]').click(); -// cy.get(".pf-v5-c-menu__item-text") -// .contains("child_container (container-service)") -// .click(); -// cy.get("#name").type("child_container"); -// cy.get("button").contains("Confirm").click(); - -// cy.get('[joint-selector="headerLabel"]') -// .contains("child_container") -// .click(); -// cy.get('[data-action="link"]') -// .trigger("mouseover") -// .trigger("mousedown") -// .trigger("mousemove", { -// clientX: 700, -// clientY: 300, -// }) -// .trigger("mouseup"); -// cy.get('[joint-selector="headerLabel"]') -// .contains("child_container") -// .click(); -// cy.get('[data-action="link"]') -// .trigger("mouseover") -// .trigger("mousedown") -// .trigger("mousemove", { -// clientX: 400, -// clientY: 300, -// }) -// .trigger("mouseup"); -// cy.get("button").contains("Deploy").click(); - -// // Check if only one row has been added to the table. -// cy.get('[aria-label="InstanceRow-Intro"]').should("have.length", 2); -// // await until all instances are being deployed and up -// cy.get('[data-label="State"]', { timeout: 90000 }) -// .eq(0, { timeout: 90000 }) -// .should("have.text", "up"); - -// //Check container-service -// cy.get(".pf-v5-c-nav__item").contains("Service Catalog").click(); -// cy.get("#container-service", { timeout: 60000 }) -// .contains("Show inventory") -// .click(); -// cy.get('[aria-label="InstanceRow-Intro"]').should("have.length", 1); -// cy.get('[data-label="State"]', { timeout: 90000 }).should( -// "have.text", -// "up", -// ); - -// //go back to parent-service inventory view -// cy.get(".pf-v5-c-nav__item").contains("Service Catalog").click(); -// // click on Show Inventory on parent-service, expect one instance already -// cy.get("#parent-service", { timeout: 60000 }) -// .contains("Show inventory") -// .click(); -// // click on add instance in composer -// cy.get('[aria-label="AddInstanceToggle"]').click(); -// cy.get("#add-instance-composer-button").click(); -// cy.get(".canvas").should("be.visible"); - -// // Open and fill instance form of parent-service -// cy.get('[aria-label="new-entity-button"]').click(); -// cy.get('[aria-label="service-picker"]').click(); -// cy.get(".pf-v5-c-menu__item-text").contains("parent-service").click(); - -// cy.get("#service_id").type("0008"); -// cy.get("#name").type("parent-service3"); -// cy.get("button").contains("Confirm").click(); -// // Open and fill instance form of child-with-many-parents-service then connect it with parent-service -// cy.get('[aria-label="new-entity-button"]').click(); -// cy.get('[aria-label="service-picker"]').click(); -// cy.get(".pf-v5-c-menu__item-text") -// .contains("child-with-many-parents-service") -// .click(); -// cy.get("#service_id").type("0009"); -// cy.get("#name").type("child-with-many-parents"); -// cy.get("button").contains("Confirm").click(); - -// cy.get('[joint-selector="headerLabel"]').contains("child-with").click(); -// cy.get('[data-action="link"]') -// .trigger("mouseover") -// .trigger("mousedown") -// .trigger("mousemove", { -// clientX: 700, -// clientY: 300, -// }) -// .trigger("mouseup"); - -// cy.get("button").contains("Deploy").click(); -// // Check if only one row has been added to the table. -// cy.get('[aria-label="InstanceRow-Intro"]').should("have.length", 3); -// // await until all instances are being deployed and up -// cy.get('[data-label="State"]', { timeout: 90000 }) -// .eq(0, { timeout: 90000 }) -// .should("have.text", "up"); -// //Check child-with-many-parents-service -// cy.get(".pf-v5-c-nav__item").contains("Service Catalog").click(); -// cy.get("#child-with-many-parents-service", { timeout: 60000 }) -// .contains("Show inventory") -// .click(); -// cy.get('[aria-label="InstanceRow-Intro"]').should("have.length", 1); -// cy.get('[data-label="State"]', { timeout: 90000 }).should( -// "have.text", -// "up", -// ); -// }); - -// it("8.4 edit instances inter-service relation", () => { -// // Select 'test' environment -// cy.visit("/console/"); -// cy.get('[aria-label="Environment card"]') -// .contains("lsm-frontend") -// .click(); -// cy.get(".pf-v5-c-nav__item").contains("Service Catalog").click(); - -// // click on Show Inventory on parent-service, expect three instances already -// cy.get("#parent-service", { timeout: 60000 }) -// .contains("Show inventory") -// .click(); -// cy.get('[aria-label="ServiceInventory-Success"]').should("to.be.visible"); - -// // click on kebab menu on first created parent-service -// cy.get(".pf-v5-c-drawer__content").scrollTo("bottom"); -// cy.get('[aria-label="row actions toggle"]').eq(2).click(); -// cy.get("button").contains("Edit in Composer").click(); - -// //Open and fill instance form of parent-service -// cy.get('[aria-label="new-entity-button"]').click(); -// cy.get('[aria-label="service-picker"]').click(); -// cy.get(".pf-v5-c-menu__item-text").contains("parent-service").click(); - -// cy.get("#service_id").type("00010"); -// cy.get("#name").type("new-parent-service"); -// cy.get("button").contains("Confirm").click(); -// cy.get('[data-type="Link"]').trigger("mouseover", { force: true }); -// cy.get(".joint-link_remove-circle").click(); -// cy.get(".joint-link_remove-circle").click(); -// cy.get(".joint-paper-scroller.joint-theme-default") -// .trigger("mouseover") -// .trigger("mousedown") -// .trigger("mousemove", { -// clientX: 900, -// clientY: 200, -// }) -// .trigger("mouseup"); -// //connect services -// cy.get('[joint-selector="headerLabel"]') -// .contains("child-service") -// .click(); -// cy.get('[data-action="link"]') -// .trigger("mouseover") -// .trigger("mousedown") -// .trigger("mousemove", { -// clientX: 900, -// clientY: 300, -// }) -// .trigger("mouseup"); -// cy.get("button").contains("Deploy").click(); - -// // Check if only one row has been added to the table. -// cy.get('[aria-label="InstanceRow-Intro"]').should("have.length", 4); -// cy.get('[data-label="State"]', { timeout: 90000 }) -// .eq(0, { timeout: 90000 }) -// .should("have.text", "up"); - -// // check if attribute was edited correctly -// cy.get(".pf-v5-c-nav__item").contains("Service Catalog").click(); -// cy.get("#child-service", { timeout: 60000 }) -// .contains("Show inventory") -// .click(); -// cy.get('[aria-label="InstanceRow-Intro"]').should("have.length", 1); -// cy.get('[data-label="State"]', { timeout: 90000 }).should( -// "have.text", -// "up", -// ); -// cy.get("#expand-toggle0").click(); -// cy.get("button").contains("Attributes").click(); -// cy.get( -// '[aria-label="Row-parent_entity"] > [data-label="active"] > .pf-v5-l-flex > div > .pf-v5-c-button', -// ).should("have.text", "new-parent-service"); - -// // go back to parent-service inventory view -// cy.get(".pf-v5-c-nav__item").contains("Service Catalog").click(); -// cy.get("#parent-service", { timeout: 60000 }) -// .contains("Show inventory") -// .click(); -// cy.get(".pf-v5-c-drawer__content").scrollTo("bottom"); -// cy.get('[aria-label="row actions toggle"]').eq(2).click(); -// cy.get("button").contains("Edit in Composer").click(); - -// // move container out of the way and remove connection -// cy.get('[joint-selector="headerLabel"]') -// .contains("container-service") -// .trigger("mouseover") -// .trigger("mousedown") -// .trigger("mousemove", { -// clientX: 1200, -// clientY: 400, -// }) -// .trigger("mouseup"); -// cy.get('[data-type="Link"]').eq(0).trigger("mouseover", { force: true }); -// cy.get(".joint-link_remove-circle").click(); -// cy.get(".joint-link_remove-circle").click(); - -// //Open and fill instance form of parent-service -// cy.get('[aria-label="new-entity-button"]').click(); -// cy.get('[aria-label="service-picker"]').click(); -// cy.get(".pf-v5-c-menu__item-text").contains("parent-service").click(); -// cy.get("#service_id").type("00011"); -// cy.get("#name").type("new-parent-service2"); -// cy.get("button").contains("Confirm").click(); - -// //connect child_container to new parent-service -// cy.get('[joint-selector="headerLabel"]') -// .contains("child_container") -// .click(); - -// cy.get('[data-action="link"]') -// .trigger("mouseover") -// .trigger("mousedown") -// .trigger("mousemove", { -// clientX: 1100, -// clientY: 300, -// }) -// .trigger("mouseup"); -// cy.get("button").contains("Deploy").click(); - -// // Check if only one row has been added to the table. -// cy.get('[aria-label="InstanceRow-Intro"]').should("have.length", 5); -// // await until all instances are being deployed and up -// cy.get('[data-label="State"]', { timeout: 90000 }) -// .eq(0, { timeout: 90000 }) -// .should("have.text", "up"); -// cy.get(".pf-v5-c-nav__item").contains("Service Catalog").click(); - -// // check if attribute was edited correctly -// cy.get("#container-service", { timeout: 60000 }) -// .contains("Show inventory") -// .click(); -// cy.get('[aria-label="InstanceRow-Intro"]').should("have.length", 1); -// cy.get('[data-label="State"]', { timeout: 90000 }).should( -// "have.text", -// "up", -// ); -// cy.get("#expand-toggle0").click(); -// cy.get("button").contains("Attributes").click(); -// cy.get('[aria-label="Toggle-child_container"]').click(); -// cy.get( -// '[aria-label="Row-child_container$parent_entity"] > [data-label="active"] > .pf-v5-l-flex > div > .pf-v5-c-button', -// ).should("have.text", "new-parent-service2"); -// //go back to parent-service inventory view -// cy.get(".pf-v5-c-nav__item").contains("Service Catalog").click(); -// // click on Show Inventory on parent-service -// cy.get("#parent-service", { timeout: 60000 }) -// .contains("Show inventory") -// .click(); -// cy.get(".pf-v5-c-drawer__content").scrollTo("bottom"); -// cy.get('[aria-label="row actions toggle"]').eq(2).click(); -// cy.get("button").contains("Edit in Composer").click(); - -// //Open and fill instance form of parent-service -// cy.get('[aria-label="new-entity-button"]').click(); -// cy.get('[aria-label="service-picker"]').click(); -// cy.get(".pf-v5-c-menu__item-text").contains("parent-service").click(); -// cy.get("#service_id").type("00012"); -// cy.get("#name").type("new-parent-service3"); -// cy.get("button").contains("Confirm").click(); - -// // connect child-with-many-parents to new parent-service -// cy.get('[joint-selector="headerLabel"]').contains("child-with").click(); -// cy.get('[data-action="link"]') -// .trigger("mouseover") -// .trigger("mousedown") -// .trigger("mousemove", { -// clientX: 900, -// clientY: 300, -// }) -// .trigger("mouseup"); -// cy.get("button").contains("Deploy").click(); - -// // Check if only one row has been added to the table. -// cy.get('[aria-label="InstanceRow-Intro"]').should("have.length", 6); -// // await until all instances are being deployed and up -// cy.get('[data-label="State"]', { timeout: 90000 }) -// .eq(0, { timeout: 90000 }) -// .should("have.text", "up"); -// cy.get(".pf-v5-c-nav__item").contains("Service Catalog").click(); - -// // check if attribute was edited correctly -// cy.get("#child-with-many-parents-service", { timeout: 60000 }) -// .contains("Show inventory") -// .click(); -// cy.get('[aria-label="InstanceRow-Intro"]').should("have.length", 1); -// cy.get('[data-label="State"]', { timeout: 90000 }).should( -// "have.text", -// "up", -// ); -// cy.get("#expand-toggle0").click(); -// cy.get("button").contains("Attributes").click(); -// cy.get( -// '[aria-label="Row-parent_entity"] > [data-label="active"] > .pf-v5-l-flex > div > .pf-v5-c-button', -// ) -// .eq(0) -// .should("have.text", "parent-service3"); -// cy.get( -// '[aria-label="Row-parent_entity"] > [data-label="active"] > .pf-v5-l-flex > div > .pf-v5-c-button', -// ) -// .eq(1) -// .should("have.text", "new-parent-service3"); -// }); - -// it("8.5 delete instance", () => { -// // Select 'test' environment -// cy.visit("/console/"); -// cy.get('[aria-label="Environment card"]') -// .contains("lsm-frontend") -// .click(); -// cy.get(".pf-v5-c-nav__item").contains("Service Catalog").click(); - -// // click on Show Inventory on embedded-entity-service-extra, expect one instance already -// cy.get("#parent-service", { timeout: 60000 }) -// .contains("Show inventory") -// .click(); -// cy.get('[aria-label="ServiceInventory-Success"]').should("to.be.visible"); -// cy.get('[aria-label="InstanceRow-Intro"]').should("have.length", 6); - -// // click on kebab menu on embedded-entity-service-extra -// cy.get('[aria-label="row actions toggle"]').eq(5).click(); -// cy.get("button").contains("Edit in Composer").click(); -// cy.get('[joint-selector="headerLabel"]') -// .contains("parent-service") -// .click(); -// cy.get('[data-action="delete"]').click(); -// cy.get("button").contains("Deploy").click(); - -// // Check if only one row has been deleted to the table. -// cy.get('[aria-label="InstanceRow-Intro"]', { timeout: 90000 }).should( -// "have.length", -// 5, -// ); -// }); -// }); -// } +/** + * Shorthand method to clear the environment being passed. + * By default, if no arguments are passed it will target the 'lsm-frontend' environment. + * + * @param {string} nameEnvironment + */ +const clearEnvironment = (nameEnvironment = "lsm-frontend") => { + cy.visit("/console/"); + cy.get('[aria-label="Environment card"]').contains(nameEnvironment).click(); + cy.url().then((url) => { + const location = new URL(url); + const id = location.searchParams.get("env"); + + cy.request("DELETE", `/api/v1/decommission/${id}`); + }); +}; + +/** + * based on the environment id, it will recursively check if a compile is pending. + * It will continue the recursion as long as the statusCode is equal to 200 + * + * @param {string} id + */ +const checkStatusCompile = (id) => { + let statusCodeCompile = 200; + + if (statusCodeCompile === 200) { + cy.intercept(`/api/v1/notify/${id}`).as("IsCompiling"); + // the timeout is necessary to avoid errors. + // Cypress doesn't support while loops and this was the only workaround to wait till the statuscode is not 200 anymore. + // the default timeout in cypress is 5000, but since we have recursion it goes into timeout for the nested awaits because of the recursion. + cy.wait("@IsCompiling", { timeout: 15000 }).then((req) => { + statusCodeCompile = req.response.statusCode; + + if (statusCodeCompile === 200) { + checkStatusCompile(id); + } + }); + } +}; + +/** + * Will by default execute the force update on the 'lsm-frontend' environment if no argumenst are being passed. + * This method can be executed standalone, but is part of the cleanup cycle that is needed before running a scenario. + * + * @param {string} nameEnvironment + */ +const forceUpdateEnvironment = (nameEnvironment = "lsm-frontend") => { + cy.visit("/console/"); + cy.get('[aria-label="Environment card"]').contains(nameEnvironment).click(); + cy.url().then((url) => { + const location = new URL(url); + const id = location.searchParams.get("env"); + + cy.request({ + method: "POST", + url: `/lsm/v1/exporter/export_service_definition`, + headers: { "X-Inmanta-Tid": id }, + body: { force_update: true }, + }); + checkStatusCompile(id); + }); +}; + +if (Cypress.env("edition") === "iso") { + describe("Scenario 8 - Instance Composer", async () => { + before(() => { + clearEnvironment(); + forceUpdateEnvironment(); + }); + + // Note: The fullscreen mode is tested in Jest. In Cypress this functionality has to be stubbed, and would be redundant with the Unit tests. + it("8.1 composer opens up has its default paning working", () => { + // Select 'test' environment + cy.visit("/console/"); + cy.get('[aria-label="Environment card"]') + .contains("lsm-frontend") + .click(); + cy.get(".pf-v5-c-nav__item").contains("Service Catalog").click(); + + // click on Show Inventory on embedded-entity-service-extra, expect no instances + cy.get("#container-service", { timeout: 60000 }) + .contains("Show inventory") + .click(); + cy.get('[aria-label="ServiceInventory-Empty"]').should("to.be.visible"); + // click on add instance with composer + cy.get('[aria-label="AddInstanceToggle"]').click(); + cy.get("#add-instance-composer-button").click(); + + // Expect Canvas to be visible and default instances to be present + cy.get(".canvas").should("be.visible"); + cy.get('[data-type="app.ServiceEntityBlock"').should("have.length", 2); + + //expect Zoom Handler and all its component visible and in default state + cy.get('[data-testid="zoomHandler"').should("be.visible"); + cy.get('[data-testid="fullscreen"').should("be.visible"); + cy.get('[data-testid="fit-to-screen"').should("be.visible"); + + cy.get('[data-testid="slider-input"').should("be.visible"); + cy.get('[data-testid="slider-input"').should("have.value", "120"); + + cy.get('[data-testid="slider-output"').should("be.visible"); + cy.get('[data-testid="slider-output"').should("have.text", "120"); + + cy.get(".units").should("be.visible"); + cy.get(".units").contains("%"); //should('have.text', '%'); won't work because of the special character + + //assertion that fit-to-screen button works can be only done by checking output and the input value, as I couldn't extract the transform property from the `.joint-layers` element + cy.get('[data-testid="fit-to-screen"').click(); + + cy.get('[data-testid="slider-input"').should("have.value", "220"); + cy.get('[data-testid="slider-output"').should("have.text", "220"); + + //assert that zoom button works + cy.get('[data-testid="slider-input"') + .invoke("val", 300) + .trigger("change"); + cy.get('[data-testid="slider-input"').should("have.value", "300"); + cy.get('[data-testid="slider-output"').should("have.text", "300"); + + cy.get('[data-testid="slider-input"').invoke("val", 80).trigger("change"); + cy.get('[data-testid="slider-input"').should("have.value", "80"); + cy.get('[data-testid="slider-output"').should("have.text", "80"); + + //expect Left Sidebar and it's all component visible and in default state + cy.get(".left_sidebar").should("be.visible"); + cy.get("#tabs-toolbar").should("be.visible"); + + cy.get("#instance-stencil").should("be.visible"); + cy.get("#inventory-stencil").should("not.be.visible"); + + //expect Left sidebar to have ability to switch between tabs + cy.get("#inventory-tab").click(); + + cy.get("#instance-stencil").should("not.be.visible"); + cy.get("#inventory-stencil").should("be.visible"); + + cy.get("#new-tab").click(); + + cy.get("#instance-stencil").should("be.visible"); + cy.get("#inventory-stencil").should("not.be.visible"); + }); + + it("8.2 composer create view can perform it's required functions and deploy created instance", () => { + // Select 'test' environment + cy.visit("/console/"); + cy.get('[aria-label="Environment card"]') + .contains("lsm-frontend") + .click(); + cy.get(".pf-v5-c-nav__item").contains("Service Catalog").click(); + + //Add parent instance + // click on Show Inventory of parent-service, expect no instances + cy.get("#parent-service", { timeout: 60000 }) + .contains("Show inventory") + .click(); + + cy.get('[aria-label="ServiceInventory-Empty"]').should("to.be.visible"); + // click on add instance with composer + cy.get('[aria-label="AddInstanceToggle"]').click(); + cy.get("#add-instance-composer-button").click(); + + // Expect Canvas to be visible + cy.get(".canvas").should("be.visible"); + + //assert that core instance have all attributes, can't be removed and can be edited + cy.get('[data-type="app.ServiceEntityBlock"') + .contains("parent-service") + .click(); + + //fill parent attributes + cy.get("button").contains("Edit").click(); + cy.get('[aria-label="TextInput-name"]').type("test_name"); + cy.get('[aria-label="TextInput-service_id"]').type("test_id"); + cy.get("button").contains("Save").click(); + + cy.get("button").contains("Deploy").click(); + + cy.get('[aria-label="InstanceRow-Intro"]').should("have.length", 1); + + //add another parent instance + cy.get('[aria-label="AddInstanceToggle"]').click(); + cy.get("#add-instance-composer-button").click(); + cy.get('[data-type="app.ServiceEntityBlock"') + .contains("parent-service") + .click(); + + //fill parent attributes + cy.get("button").contains("Edit").click(); + cy.get('[aria-label="TextInput-name"]').type("test_name2"); + cy.get('[aria-label="TextInput-service_id"]').type("test_id2"); + cy.get("button").contains("Save").click(); + + cy.get("button").contains("Deploy").click(); + + cy.get('[aria-label="InstanceRow-Intro"]').should("have.length", 2); + // await until two parent_service are deployed and up + cy.get('[data-label="State"]', { timeout: 90000 }) + .eq(1, { timeout: 90000 }) + .should("have.text", "up", { timeout: 90000 }); + cy.get('[data-label="State"]', { timeout: 90000 }) + .eq(0, { timeout: 90000 }) + .should("have.text", "up", { timeout: 90000 }); + + //Add child_service instance + cy.get(".pf-v5-c-nav__item").contains("Service Catalog").click(); + // click on Show Inventory of many-defaults service, expect no instances + cy.get("#many-defaults", { timeout: 60000 }) + .contains("Show inventory") + .click(); + cy.get('[aria-label="ServiceInventory-Empty"]').should("to.be.visible"); + // click on add instance with composer + cy.get('[aria-label="AddInstanceToggle"]').click(); + cy.get("#add-instance-composer-button").click(); + + // Expect Canvas to be visible + cy.get(".canvas").should("be.visible"); + + //assert if default entities are present, on init on the canvas we should have already basic required structure for the service instance + cy.get('[data-type="app.ServiceEntityBlock"').should("have.length", 2); + cy.get('[data-type="app.ServiceEntityBlock"') + .contains("embedded") + .should("be.visible"); + cy.get('[data-type="app.ServiceEntityBlock"') + .contains("many-defaults") + .should("be.visible"); + cy.get('[data-type="Link"').should("be.visible"); + + //assert default embedded entities are present and first one is disabled as it reached its max limit + cy.get("#instance-stencil").within(() => { + cy.get(".bodyTwo_embedded").should("be.visible"); + cy.get(".bodyTwo_embedded").should( + "have.class", + "stencil_body-disabled", + ); + + cy.get(".bodyTwo_extra_embedded").should("be.visible"); + cy.get(".bodyTwo_extra_embedded").should( + "have.not.class", + "stencil_body-disabled", + ); + }); + + cy.get('[aria-label="service-description"').should( + "have.text", + "Service entity with many default attributes.", + ); + + //assert that core instance have all attributes, can't be removed and can be edited + cy.get('[data-type="app.ServiceEntityBlock"') + .contains("many-defaults") + .click(); + cy.get("button").contains("Remove").should("be.disabled"); + cy.get("input").should("have.length", 21); + + //fill some of core attributes + cy.get("button").contains("Edit").click(); + + //strings + cy.get('[aria-label="TextInput-name"]').type("many-defaults"); + cy.get('[aria-label="TextInput-default_string"]').type( + "{backspace}default_string", + ); + cy.get('[aria-label="TextInput-default_empty_string"]').type( + "default_string_1", + ); + cy.get('[aria-label="TextInput-default_nullable_string"]').type( + "default_string_2", + ); + + //ints + cy.get('[aria-label="TextInput-default_int"]').type("{backspace}20"); + cy.get('[aria-label="TextInput-default_empty_int"]').type( + "{backspace}30", + ); + cy.get('[aria-label="TextInput-default_nullable_int"]').type( + "{backspace}40", + ); + + //floats + cy.get('[aria-label="TextInput-default_float"]').type("{backspace}2.0"); + cy.get('[aria-label="TextInput-default_empty_float"]').type( + "{backspace}3.0", + ); + cy.get('[aria-label="TextInput-default_nullable_float"]').type( + "{backspace}4.0", + ); + + //booleans + cy.get('[aria-label="BooleanToggleInput-default_bool"]').within(() => { + cy.get(".pf-v5-c-switch").click(); + }); + cy.get('[aria-label="BooleanToggleInput-default_empty_bool"]').within( + () => { + cy.get(".pf-v5-c-switch").click(); + }, + ); + cy.get("#default_nullable_bool-false").click(); + + //Dict values + cy.get('[aria-label="TextInput-default_dict"]').type( + '{selectAll}{backspace}{{}"test":"value"{}}', + ); + cy.get('[aria-label="TextInput-default_empty_dict"]').type( + '{selectAll}{backspace}{{}"test1":"value1"{}}', + ); + cy.get('[aria-label="TextInput-default_nullable_dict"]').type( + '{{}"test2":"value2"{}}', + ); + + cy.get("button").contains("Save").click(); + + //assert that embedded instance have all attributes, this particular embedded entity can't be removed but can be edited + cy.get('[data-type="app.ServiceEntityBlock"') + .contains("embedded") + .click(); + cy.get("button").contains("Remove").should("be.disabled"); + cy.get("input").should("have.length", 21); + + //fill some of embedded attributes, they are exactly the same as core attributes so we need to check only one fully, as the logic is the same + cy.get("button").contains("Edit").click(); + + //strings + cy.get('[aria-label="TextInput-name"]').type("embedded"); + cy.get('[aria-label="TextInput-default_string"]').type( + "{backspace}default_string", + ); + cy.get('[aria-label="TextInput-default_empty_string"]').type( + "default_string_1", + ); + cy.get('[aria-label="TextInput-default_nullable_string"]').type( + "default_string_2", + ); + + //ints + cy.get('[aria-label="TextInput-default_int"]').type("{backspace}21"); + cy.get('[aria-label="TextInput-default_empty_int"]').type("{backspace}1"); + cy.get('[aria-label="TextInput-default_nullable_int"]').type( + "{backspace}41", + ); + + //floats + cy.get('[aria-label="TextInput-default_float"]').type("{backspace}2.1"); + cy.get('[aria-label="TextInput-default_empty_float"]').type( + "{backspace}3.1", + ); + cy.get('[aria-label="TextInput-default_nullable_float"]').type( + "{backspace}4.1", + ); + + cy.get("button").contains("Save").click(); + + //Drag extra_embedded onto canvas and assert that is highlighted as loose element + cy.get(".bodyTwo_extra_embedded") + .trigger("mouseover") + .trigger("mousedown") + .trigger("mousemove", { + clientX: 800, + clientY: 500, + }) + .trigger("mouseup"); + + cy.get(".joint-loose_element-highlight").should("be.visible"); + + //assert that extra_embedded instance have all attributes, can be removed and can be edited + cy.get('[data-type="app.ServiceEntityBlock"') + .contains("extra_embedded") + .click(); + cy.get("button").contains("Remove").should("be.enabled"); + cy.get("button").contains("Edit").should("be.enabled"); + cy.get("input").should("have.length", 21); + + //remove extra_embedded instance to simulate that user added that by a mistake yet want to remove it + cy.get("button").contains("Remove").click(); + cy.get('[data-type="app.ServiceEntityBlock"') + .contains("extra_embedded") + .should("not.exist"); + + //Drag once again extra_embedded onto canvas and assert that is highlighted as loose element + cy.get(".bodyTwo_extra_embedded") + .trigger("mouseover") + .trigger("mousedown") + .trigger("mousemove", { + clientX: 800, + clientY: 500, + }) + .trigger("mouseup"); + + cy.get(".joint-loose_element-highlight").should("be.visible"); + + //assert that extra_embedded instance have all attributes, can be removed and can be edited + cy.get('[data-type="app.ServiceEntityBlock"') + .contains("extra_embedded") + .click(); + //fill some of embedded attributes, they are exactly the same as core attributes so we need to check only one fully, as the logic is the same + cy.get("button").contains("Edit").click(); + + //strings + cy.get('[aria-label="TextInput-name"]').type("extra_embedded"); + cy.get('[aria-label="TextInput-default_string"]').type( + "{backspace}default_string", + ); + cy.get('[aria-label="TextInput-default_empty_string"]').type( + "default_string_1", + ); + cy.get('[aria-label="TextInput-default_nullable_string"]').type( + "default_string_2", + ); + + //ints + cy.get('[aria-label="TextInput-default_int"]').type("{backspace}21"); + cy.get('[aria-label="TextInput-default_empty_int"]').type( + "{backspace}31", + ); + cy.get('[aria-label="TextInput-default_nullable_int"]').type( + "{backspace}41", + ); + + //floats + cy.get('[aria-label="TextInput-default_float"]').type("{backspace}2.1"); + cy.get('[aria-label="TextInput-default_empty_float"]').type( + "{backspace}3.1", + ); + cy.get('[aria-label="TextInput-default_nullable_float"]').type( + "{backspace}4.1", + ); + + cy.get("button").contains("Save").click(); + + //connect core instance with extra_embedded instance + cy.get('[data-name="fit-to-screen"]').click(); + cy.get('[data-type="app.ServiceEntityBlock"') + .contains("many-defaults") + .click(); + cy.get('[data-action="link"]') + .trigger("mouseover") + .trigger("mousedown") + .trigger("mousemove", { + clientX: 800, + clientY: 700, + }) + .trigger("mouseup"); + + cy.get('[data-type="Link"]').should("have.length", 2); + cy.get(".joint-loose_element-highlight").should("not.exist"); + + //add parent instance to the canvas and connect it to the core instance + cy.get("#inventory-tab").click(); + + cy.get(".bodyTwo_test_name") + .trigger("mouseover") + .trigger("mousedown") + .trigger("mousemove", { + clientX: 800, + clientY: 500, + }) + .trigger("mouseup"); + + cy.get('[data-type="app.ServiceEntityBlock"') + .contains("many-defaults") + .click(); + cy.get('[data-action="link"]') + .trigger("mouseover") + .trigger("mousedown") + .trigger("mousemove", { + clientX: 800, + clientY: 500, + }) + .trigger("mouseup"); + + cy.get('[data-type="Link"]').should("have.length", 3); + + //add another parent instance to the canvas and connect it to the embedded instance + cy.get('[data-name="fit-to-screen"]').click(); + + cy.get(".bodyTwo_test_name2") + .trigger("mouseover") + .trigger("mousedown") + .trigger("mousemove", { + clientX: 600, + clientY: 400, + }) + .trigger("mouseup"); + cy.get('[data-name="fit-to-screen"]').click(); + cy.get('[data-type="app.ServiceEntityBlock"') + .contains("embedded") + .click(); + cy.get('[data-action="link"]') + .trigger("mouseover") + .trigger("mousedown") + .trigger("mousemove", { + clientX: 600, + clientY: 400, + }) + .trigger("mouseup"); + cy.get('[data-type="Link"]').should("have.length", 4); + + cy.get("button").contains("Deploy").click(); + + //assert that many-defaults is deployed and up + + cy.get('[aria-label="InstanceRow-Intro"]').should("have.length", 1); + // await until parent_service is deployed and up + cy.get('[data-label="State"]', { timeout: 90000 }).should( + "have.text", + "up", + ); + + //check if embedded entities are present and relations are assigned correctly + cy.get("#expand-toggle0").click(); + cy.get("button").contains("Attributes").click(); + + cy.get('[aria-label="Toggle-embedded"]').click(); + + cy.get('[aria-label="Toggle-extra_embedded"]').click(); + cy.get('[aria-label="Toggle-extra_embedded$0"]').click(); + + cy.get('[aria-label="Row-parent_service"]').within(() => { + cy.get('[data-label="active"]').should("have.text", "test_name"); + }); + cy.get('[aria-label="Row-embedded$parent_service"]').within(() => { + cy.get('[data-label="active"]').should("have.text", "test_name2"); + }); + cy.get('[aria-label="Row-extra_embedded$0$parent_service"]').within( + () => { + cy.get('[data-label="active"]').should("have.text", "null"); + }, + ); + }); + + it("8.3 composer edit view can perform it's required functions and deploy edited instance", () => { + // Select 'test' environment + cy.visit("/console/"); + cy.get('[aria-label="Environment card"]') + .contains("lsm-frontend") + .click(); + cy.get(".pf-v5-c-nav__item").contains("Service Catalog").click(); + // click on Show Inventory of many-defaults service, expect no instances + cy.get("#many-defaults", { timeout: 60000 }) + .contains("Show inventory") + .click(); + + cy.get('[aria-label="ServiceInventory-Success"]').should("to.be.visible"); + cy.get('[aria-label="InstanceRow-Intro"]').should("have.length", 1); + // click on edit instance with composer + cy.get('[aria-label="row actions toggle"]').click(); + cy.get("button").contains("Edit in Composer").click(); + + // Expect Canvas to be visible + cy.get(".canvas").should("be.visible"); + + //assert if default entities are present, on init on the canvas we should have already basic required structure for the service instance + cy.get('[data-type="app.ServiceEntityBlock"]').should("have.length", 5); + cy.get('[data-type="Link"').should("have.length", 4); + + cy.get('[data-type="app.ServiceEntityBlock"]') + .contains("embedded") + .should("be.visible"); + cy.get('[data-type="app.ServiceEntityBlock"]') + .contains("extra_embedded") + .should("be.visible"); + cy.get('[data-type="app.ServiceEntityBlock"]') + .contains("many-defaults") + .should("be.visible"); + + //related Services should be disabled from editing and removing + cy.get('[data-testid="header-parent-service"]').should("have.length", 2); + + cy.get('[data-testid="header-parent-service"]').eq(0).click(); + cy.get("button").contains("Remove").should("be.disabled"); + cy.get("button").contains("Edit").should("be.disabled"); + + cy.get('[data-testid="header-parent-service"]').eq(1).click(); + cy.get("button").contains("Remove").should("be.disabled"); + cy.get("button").contains("Edit").should("be.disabled"); + + //edit some of core attributes + cy.get('[data-type="app.ServiceEntityBlock"]') + .contains("many-defaults") + .click(); + cy.get("button").contains("Edit").click(); + + cy.get('[aria-label="TextInput-default_string"]').type( + "{selectAll}{backspace}updated_string", + ); + cy.get('[aria-label="TextInput-default_int"]').type("{backspace}2"); + cy.get('[aria-label="TextInput-default_float"]').type("{backspace}2"); + cy.get('[aria-label="BooleanToggleInput-default_bool"]').within(() => { + cy.get(".pf-v5-c-switch").click(); + }); + cy.get('[aria-label="TextInput-default_empty_dict"]').type( + '{selectAll}{backspace}{{}"test2":"value2"{}}', + ); + cy.get("button").contains("Save").click(); + + //edit some of embedded attributes + cy.get('[data-type="app.ServiceEntityBlock"]') + .contains("embedded") + .click(); + cy.get("button").contains("Remove").should("be.disabled"); + cy.get("button").contains("Edit").click(); + + cy.get('[aria-label="TextInput-default_string"]').type( + "{selectAll}{backspace}updated_string", + ); + cy.get('[aria-label="TextInput-default_int"]').type("{backspace}2"); + cy.get('[aria-label="TextInput-default_float"]').type("{backspace}2"); + cy.get('[aria-label="BooleanToggleInput-default_bool"]').within(() => { + cy.get(".pf-v5-c-switch").click(); + }); + cy.get('[aria-label="TextInput-default_empty_dict"]').type( + '{selectAll}{backspace}{{}"test2":"value2"{}}', + ); + cy.get("button").contains("Save").click(); + + //remove extra_embedded instance + cy.get('[data-type="app.ServiceEntityBlock"]') + .contains("extra_embedded") + .click(); + cy.get("button").contains("Remove").click(); + + cy.get("button").contains("Deploy").click(); + + //assert that many-defaults is deployed and up + cy.get('[aria-label="InstanceRow-Intro"]').should("have.length", 1); + // await until parent_service is deployed and up + cy.get('[data-label="State"]', { timeout: 90000 }).should( + "have.text", + "up", + ); + + cy.get('[aria-label="ServiceInventory-Success"]').should("to.be.visible"); + //assert that many-defaults is deployed and up + cy.get('[aria-label="InstanceRow-Intro"]').should("have.length", 1); + // await until parent_service is deployed and up + cy.get('[data-label="State"]', { timeout: 90000 }).should( + "have.text", + "up", + ); + + //check if core attributes and embedded entities are updated + cy.get("#expand-toggle0").click(); + cy.get("button").contains("Attributes").click(); + + cy.get('[aria-label="Row-default_int"]').within(() => { + cy.get('[data-label="active"]').should("have.text", "20"); + cy.get('[data-label="candidate"]').should("have.text", "22"); + }); + + cy.get('[aria-label="Row-default_string"]').within(() => { + cy.get('[data-label="active"]').should("have.text", "default_string"); + cy.get('[data-label="candidate"]').should( + "have.text", + "updated_string", + ); + }); + + cy.get('[aria-label="Toggle-embedded"]').click(); + + cy.get('[aria-label="Row-embedded$default_int"]').within(() => { + cy.get('[data-label="active"]').should("have.text", "21"); + cy.get('[data-label="candidate"]').should("have.text", "22"); + }); + + cy.get('[aria-label="Row-embedded$default_float"]').within(() => { + cy.get('[data-label="active"]').should("have.text", "2.1"); + cy.get('[data-label="candidate"]').should("have.text", "2"); + }); + + cy.get('[aria-label="Row-embedded$default_string"]').within(() => { + cy.get('[data-label="active"]').should("have.text", "default_string"); + cy.get('[data-label="candidate"]').should( + "have.text", + "updated_string", + ); + }); + + //assert that extra_embedded attrs are empty as instance was removed + cy.get('[aria-label="Toggle-extra_embedded"]').click(); + cy.get('[aria-label="Toggle-extra_embedded$0"]').click(); + + cy.get('[aria-label="Row-extra_embedded$0$default_int"]').within(() => { + cy.get('[data-label="active"]').should("have.text", "21"); + cy.get('[data-label="candidate"]').should("have.text", ""); + }); + + cy.get('[aria-label="Row-extra_embedded$0$default_float"]').within(() => { + cy.get('[data-label="active"]').should("have.text", "2.1"); + cy.get('[data-label="candidate"]').should("have.text", ""); + }); + + cy.get('[aria-label="Row-extra_embedded$0$default_string"]').within( + () => { + cy.get('[data-label="active"]').should("have.text", "default_string"); + cy.get('[data-label="candidate"]').should("have.text", ""); + }, + ); + }); + + it("8.4 composer edit view is able to add/remove instances relations", () => { + // Select 'test' environment + cy.visit("/console/"); + cy.get('[aria-label="Environment card"]') + .contains("lsm-frontend") + .click(); + cy.get(".pf-v5-c-nav__item").contains("Service Catalog").click(); + // click on Show Inventory of many-defaults service, expect no instances + cy.get("#child-service", { timeout: 60000 }) + .contains("Show inventory") + .click(); + cy.get('[aria-label="ServiceInventory-Empty"]').should("to.be.visible"); + // click on add instance with composer + cy.get('[aria-label="AddInstanceToggle"]').click(); + cy.get("#add-instance-composer-button").click(); + + // Expect Canvas to be visible + cy.get(".canvas").should("be.visible"); + + //assert if default entities are present, on init on the canvas we should have already basic required structure for the service instance + cy.get('[data-type="app.ServiceEntityBlock"').should("have.length", 1); + + cy.get('[data-type="app.ServiceEntityBlock"]').click(); + cy.get("button").contains("Remove").should("be.disabled"); + cy.get("button").contains("Edit").click(); + + cy.get('[aria-label="TextInput-name"]').type("test_child"); + cy.get('[aria-label="TextInput-service_id"]').type("test_child_id"); + cy.get("button").contains("Save").click(); + + //add parent instance to the canvas and connect it to the core instance + cy.get("#inventory-tab").click(); + + cy.get(".bodyTwo_test_name") + .trigger("mouseover") + .trigger("mousedown") + .trigger("mousemove", { + clientX: 800, + clientY: 600, + }) + .trigger("mouseup"); + + cy.get('[data-type="app.ServiceEntityBlock"') + .contains("child-service") + .click(); + cy.get('[data-action="link"]') + .trigger("mouseover") + .trigger("mousedown") + .trigger("mousemove", { + clientX: 800, + clientY: 600, + }) + .trigger("mouseup"); + + cy.get('[data-type="Link"]').should("have.length", 1); + + cy.get("button").contains("Deploy").click(); + cy.get('[aria-label="InstanceRow-Intro"]').should("have.length", 1); + // await until parent_service is deployed and up + cy.get('[data-label="State"]', { timeout: 90000 }).should( + "have.text", + "up", + ); + + //check if relation is assigned correctly + cy.get("#expand-toggle0").click(); + cy.get("button").contains("Attributes").click(); + + cy.get('[aria-label="Row-parent_entity"]').within(() => { + cy.get('[data-label="active"]').should("have.text", "test_name"); + }); + + // click on edit instance with composer + cy.get('[aria-label="row actions toggle"]').click(); + cy.get("button").contains("Edit in Composer").click(); + + // Expect Canvas to be visible + cy.get(".canvas").should("be.visible"); + + //assert if default entities are present, on init on the canvas we should have already basic required structure for the service instance + cy.get('[data-type="app.ServiceEntityBlock"]').should("have.length", 2); + cy.get('[data-type="Link"').should("have.length", 1); + + cy.get('[data-type="app.ServiceEntityBlock"]') + .contains("parent-service") + .click(); + cy.get("button").contains("Remove").click(); + + //add parent instance to the canvas and connect it to the core instance + cy.get("#inventory-tab").click(); + + //click on core instance to focus canvas view near it + cy.get('[data-type="app.ServiceEntityBlock"') + .contains("child-service") + .click(); + + cy.get(".bodyTwo_test_name2") + .trigger("mouseover") + .trigger("mousedown") + .trigger("mousemove", { + clientX: 800, + clientY: 500, + }) + .trigger("mouseup"); + + cy.get('[data-type="app.ServiceEntityBlock"') + .contains("child-service") + .click(); + cy.get('[data-action="link"]') + .trigger("mouseover") + .trigger("mousedown") + .trigger("mousemove", { + clientX: 800, + clientY: 500, + }) + .trigger("mouseup"); + + cy.get("button").contains("Deploy").click(); + + // await until parent_service is deployed and up + cy.get('[data-label="State"]', { timeout: 90000 }).should( + "have.text", + "up", + ); + + //check if relation is assigned correctly + cy.get("button").contains("Attributes").click(); + + cy.get('[aria-label="Row-parent_entity"]').within(() => { + cy.get('[data-label="active"]').should("have.text", "test_name2"); + }); + }); + }); +} diff --git a/src/Core/Domain/Field.ts b/src/Core/Domain/Field.ts index c7fb491ef..56d5cf874 100644 --- a/src/Core/Domain/Field.ts +++ b/src/Core/Domain/Field.ts @@ -23,6 +23,7 @@ export type FieldLikeWithFormState = Field; interface BaseField { name: string; description?: string; + id?: string; isOptional: boolean; isDisabled: boolean; suggestion?: FormSuggestion | null; diff --git a/src/Core/Domain/ServiceInstanceModel.ts b/src/Core/Domain/ServiceInstanceModel.ts index 9a7af34c4..6b78d566f 100644 --- a/src/Core/Domain/ServiceInstanceModel.ts +++ b/src/Core/Domain/ServiceInstanceModel.ts @@ -64,7 +64,7 @@ export interface ServiceInstanceModel service_entity_version?: ParsedNumber; desired_state_version?: ParsedNumber; transfer_context?: string; - metadata?: { [key: string]: string }; + metadata?: Record; } /** diff --git a/src/Core/Domain/ServiceModel.ts b/src/Core/Domain/ServiceModel.ts index f393f9278..e12c53115 100644 --- a/src/Core/Domain/ServiceModel.ts +++ b/src/Core/Domain/ServiceModel.ts @@ -116,7 +116,7 @@ export interface ServiceModel extends ServiceIdentifier { config: Config; instance_summary?: InstanceSummary | null; embedded_entities: EmbeddedEntity[]; - inter_service_relations?: InterServiceRelation[]; + inter_service_relations: InterServiceRelation[]; strict_modifier_enforcement?: boolean; key_attributes?: string[] | null; owner: null | string; @@ -151,7 +151,7 @@ export interface EmbeddedEntity extends RelationAttribute { description?: string; attributes: AttributeModel[]; embedded_entities: EmbeddedEntity[]; - inter_service_relations?: InterServiceRelation[]; + inter_service_relations: InterServiceRelation[]; key_attributes?: string[] | null; attribute_annotations?: AttributeAnnotations; } diff --git a/src/Data/Managers/V2/GETTERS/GetAllServiceModels/index.ts b/src/Data/Managers/V2/GETTERS/GetAllServiceModels/index.ts new file mode 100644 index 000000000..dafc8d005 --- /dev/null +++ b/src/Data/Managers/V2/GETTERS/GetAllServiceModels/index.ts @@ -0,0 +1 @@ +export * from "./useGetAllServiceModels"; diff --git a/src/Data/Managers/V2/GETTERS/GetAllServiceModels/useGetAllServiceModels.ts b/src/Data/Managers/V2/GETTERS/GetAllServiceModels/useGetAllServiceModels.ts new file mode 100644 index 000000000..5595518ee --- /dev/null +++ b/src/Data/Managers/V2/GETTERS/GetAllServiceModels/useGetAllServiceModels.ts @@ -0,0 +1,67 @@ +import { UseQueryResult, useQuery } from "@tanstack/react-query"; +import { ServiceModel } from "@/Core"; +import { PrimaryBaseUrlManager } from "@/UI"; +import { useFetchHelpers } from "../../helpers"; + +/** + * Return Signature of the useGetAllServiceModels React Query + */ +interface useGetAllServiceModels { + useOneTime: () => UseQueryResult; + useContinuous: () => UseQueryResult; +} + +/** + * React Query hook to fetch all the service models available in the given environment. + * + * @param environment {string} - the environment in which the instance belongs + * + * @returns {useGetAllServiceModels} An object containing the different available queries. + * @returns {UseQueryResult} returns.useOneTime - Fetch the service models with a single query. + * @returns {UseQueryResult} returns.useContinuous - Fetch the service models with a recursive query with an interval of 5s. + */ +export const useGetAllServiceModels = ( + environment: string, +): useGetAllServiceModels => { + const { createHeaders, handleErrors } = useFetchHelpers(); + const headers = createHeaders(environment); + + const baseUrlManager = new PrimaryBaseUrlManager( + globalThis.location.origin, + globalThis.location.pathname, + ); + const baseUrl = baseUrlManager.getBaseUrl(process.env.API_BASEURL); + + /** + * Fetches all service models from the service catalog. + * + * @returns {Promise<{ data: ServiceModel[] }>} A promise that resolves to an object containing an array of service models. + * @throws Will throw an error if the fetch operation fails. + */ + const fetchServices = async (): Promise<{ data: ServiceModel[] }> => { + const response = await fetch(`${baseUrl}/lsm/v1/service_catalog`, { + headers, + }); + + await handleErrors(response, `Failed to fetch Service Models`); + + return response.json(); + }; + + return { + useOneTime: (): UseQueryResult => + useQuery({ + queryKey: ["get_all_service_models-one_time"], + queryFn: fetchServices, + retry: false, + select: (data) => data.data, + }), + useContinuous: (): UseQueryResult => + useQuery({ + queryKey: ["get_all_service_models-continuous"], + queryFn: fetchServices, + refetchInterval: 5000, + select: (data) => data.data, + }), + }; +}; diff --git a/src/Data/Managers/V2/GETTERS/GetInstanceWithRelations/useGetInstanceWithRelations.test.tsx b/src/Data/Managers/V2/GETTERS/GetInstanceWithRelations/useGetInstanceWithRelations.test.tsx index 5e387e718..c01cb2c47 100644 --- a/src/Data/Managers/V2/GETTERS/GetInstanceWithRelations/useGetInstanceWithRelations.test.tsx +++ b/src/Data/Managers/V2/GETTERS/GetInstanceWithRelations/useGetInstanceWithRelations.test.tsx @@ -4,7 +4,11 @@ import { renderHook, waitFor } from "@testing-library/react"; import { HttpResponse, http } from "msw"; import { setupServer } from "msw/node"; import { ServiceInstanceModel } from "@/Core"; -import { testInstance } from "@/UI/Components/Diagram/Mock"; +import { + childModel, + testInstance, + testService, +} from "@/UI/Components/Diagram/Mocks"; import { useGetInstanceWithRelations } from "./useGetInstanceWithRelations"; export const server = setupServer( @@ -19,6 +23,34 @@ export const server = setupServer( }); } + if (params.request.url.match(/child_id/)) { + return HttpResponse.json({ + data: { + id: "child_id", + environment: "env", + service_entity: "child-service", + version: 4, + config: {}, + state: "up", + candidate_attributes: null, + active_attributes: { + name: "child-test", + service_id: "123523534623", + parent_entity: "test_mpn_id", + should_deploy_fail: false, + }, + rollback_attributes: null, + created_at: "2023-09-19T14:40:08.999123", + last_updated: "2023-09-19T14:40:36.178723", + callback: [], + deleted: false, + deployment_progress: null, + service_identity_attribute_value: "child-test", + referenced_by: [], + }, + }); + } + return HttpResponse.json({ data: { ...testInstance, id: "test_mpn_id" }, }); @@ -50,7 +82,8 @@ const createWrapper = () => { test("if the fetched instance has referenced instance(s), then query will return the given instance with that related instance(s)", async () => { const { result } = renderHook( - () => useGetInstanceWithRelations("test_id", "env").useOneTime(), + () => + useGetInstanceWithRelations("test_id", "env", testService).useOneTime(), { wrapper: createWrapper(), }, @@ -60,15 +93,41 @@ test("if the fetched instance has referenced instance(s), then query will return expect(result.current.data).toBeDefined(); expect(result.current.data?.instance.id).toEqual("test_id"); - expect(result.current.data?.relatedInstances).toHaveLength(1); + expect(result.current.data?.interServiceRelations).toHaveLength(1); + expect( + (result.current.data?.interServiceRelations as ServiceInstanceModel[])[0] + .id, + ).toEqual("test_mpn_id"); +}); + +test("if the fetched instance has inter-service relation(s) in the model, then query will return the given instance with that related instance(s)", async () => { + const { result } = renderHook( + () => + useGetInstanceWithRelations("child_id", "env", childModel).useOneTime(), + { + wrapper: createWrapper(), + }, + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data).toBeDefined(); + expect(result.current.data?.instance.id).toEqual("child_id"); + expect(result.current.data?.interServiceRelations).toHaveLength(1); expect( - (result.current.data?.relatedInstances as ServiceInstanceModel[])[0].id, + (result.current.data?.interServiceRelations as ServiceInstanceModel[])[0] + .id, ).toEqual("test_mpn_id"); }); -test("when instance returned has not referenced instance(s), then the query will return the given instance without relatedInstances", async () => { +test("when instance returned has not referenced instance(s), then the query will return the given instance without interServiceRelations", async () => { const { result } = renderHook( - () => useGetInstanceWithRelations("test_mpn_id", "env").useOneTime(), + () => + useGetInstanceWithRelations( + "test_mpn_id", + "env", + testService, + ).useOneTime(), { wrapper: createWrapper(), }, @@ -78,5 +137,5 @@ test("when instance returned has not referenced instance(s), then the query will expect(result.current.data).toBeDefined(); expect(result.current.data?.instance.id).toEqual("test_mpn_id"); - expect(result.current.data?.relatedInstances).toHaveLength(0); + expect(result.current.data?.interServiceRelations).toHaveLength(0); }); diff --git a/src/Data/Managers/V2/GETTERS/GetInstanceWithRelations/useGetInstanceWithRelations.ts b/src/Data/Managers/V2/GETTERS/GetInstanceWithRelations/useGetInstanceWithRelations.ts index 060c1542f..e3ea3b9bc 100644 --- a/src/Data/Managers/V2/GETTERS/GetInstanceWithRelations/useGetInstanceWithRelations.ts +++ b/src/Data/Managers/V2/GETTERS/GetInstanceWithRelations/useGetInstanceWithRelations.ts @@ -1,5 +1,10 @@ import { UseQueryResult, useQuery } from "@tanstack/react-query"; -import { ServiceInstanceModel } from "@/Core"; +import { + EmbeddedEntity, + InstanceAttributeModel, + ServiceInstanceModel, + ServiceModel, +} from "@/Core"; import { PrimaryBaseUrlManager } from "@/UI"; import { useFetchHelpers } from "../../helpers"; @@ -8,7 +13,7 @@ import { useFetchHelpers } from "../../helpers"; */ export interface InstanceWithRelations { instance: ServiceInstanceModel; - relatedInstances?: ServiceInstanceModel[]; + interServiceRelations: ServiceInstanceModel[]; coordinates?: string; } @@ -17,17 +22,20 @@ export interface InstanceWithRelations { */ interface GetInstanceWithRelationsHook { useOneTime: () => UseQueryResult; + useContinuous: () => UseQueryResult; } /** - * React Query hook to fetch an instance with its related instances from the API. + * React Query hook to fetch an instance with its related instances from the API. The related instances are all instances connected with given instance by inter-service relation, both, as a parent and as a child. * @param {string} id - The ID of the instance to fetch. * @param {string} environment - The environment in which we are looking for instances. + * @param {ServiceModel} serviceModel - The service Model of the instance (optional as it can be undefined at the init of the component that use the hook) * @returns {GetInstanceWithRelationsHook} An object containing a custom hook to fetch the instance with its related instances. */ export const useGetInstanceWithRelations = ( instanceId: string, environment: string, + serviceModel?: ServiceModel, ): GetInstanceWithRelationsHook => { //extracted headers to avoid breaking rules of Hooks const { createHeaders, handleErrors } = useFetchHelpers(); @@ -44,7 +52,7 @@ export const useGetInstanceWithRelations = ( * @returns {Promise<{data: ServiceInstanceModel}>} An object containing the fetched instance. * @throws Error if the instance fails to fetch. */ - const fetchInstance = async ( + const fetchSingleInstance = async ( id: string, ): Promise<{ data: ServiceInstanceModel }> => { //we use this endpoint instead /lsm/v1/service_inventory/{service_entity}/{service_id} because referenced_by property includes only ids, without information about service_entity for given ids @@ -61,32 +69,142 @@ export const useGetInstanceWithRelations = ( }; /** - * Fetches a service instance with its related instances. + * This function is responsible for extracting the names of all inter-service relations from the provided service model or embedded entity. + * It also recursively extracts the names of all relations from any embedded entities within the provided service model or embedded entity. + * + * @param {ServiceModel | EmbeddedEntity} serviceModel - The service model or embedded entity from which to extract the relations. + * @returns {string[]} An array of the names of all relations. + */ + const getAllRelationNames = ( + serviceModel: ServiceModel | EmbeddedEntity, + ): string[] => { + const relations = + serviceModel.inter_service_relations.map((relation) => relation.name) || + []; + + const nestedRelations = serviceModel.embedded_entities.flatMap((entity) => + getAllRelationNames(entity), + ); + + return [...relations, ...nestedRelations]; + }; + + /** + * This function is responsible for extracting the names of all embedded entities from the provided service model or embedded entity. + * It also recursively extracts the names of all embedded entities from any embedded entities within the provided service model or embedded entity. + * + * @param {ServiceModel | EmbeddedEntity} serviceModel - The service model or embedded entity from which to extract the embedded entities. + * @returns {string[]} An array of the names of all embedded entities. + */ + const getAllEmbeddedEntityNames = ( + serviceModel: ServiceModel | EmbeddedEntity, + ): string[] => { + const embeddedEntities = serviceModel.embedded_entities.map( + (entity) => entity.name, + ); + const nestedEmbeddedEntities = serviceModel.embedded_entities.flatMap( + (entity) => getAllEmbeddedEntityNames(entity), + ); + + return [...embeddedEntities, ...nestedEmbeddedEntities]; + }; + + /** + * This function extracts all the inter-service-relation ids from the provided attributes. + * It does this by mapping over the provided relation names and extracting the corresponding Ids from the attributes. + * It also recursively extracts the Ids of all related instances from any embedded entities within the provided attributes. + * + * @param {InstanceAttributeModel} attributes - The attributes from which to extract the related Ids. + * @param {string[]} relationNames - The names of the relations to extract. + * @param {string[]} embeddedNames - The names of the embedded entities that can have relations. + * + * @returns {string[]} An array of the Ids of all related instances. + */ + const getInterServiceRelationIds = ( + attributes: InstanceAttributeModel, + relationNames: string[], + embeddedNames: string[], + ): string[] => { + // Map relation names to corresponding IDs from attributes + const relationIds = relationNames + .map((relationName) => attributes[relationName]) + .filter((id): id is string => typeof id === "string"); // Filter to ensure only you only keep strings + + // Extract IDs from embedded relations recursively + const embeddedRelationsIds = embeddedNames.flatMap((embeddedName) => { + const embeddedAttributes = attributes[embeddedName]; + + if (!embeddedAttributes) { + return []; + } + + if (Array.isArray(embeddedAttributes)) { + // Recursively collect IDs from an array of embedded attributes + return embeddedAttributes.flatMap((embedded) => + getInterServiceRelationIds(embedded, relationNames, embeddedNames), + ); + } else { + // Recursively collect IDs from a single embedded attribute + return getInterServiceRelationIds( + embeddedAttributes as InstanceAttributeModel, //InstanceAttributeModel is a Record so casting is required here + relationNames, + embeddedNames, + ); + } + }); + + // Combine and filter out falsy values (undefined, null, "") + const ids = [...relationIds, ...embeddedRelationsIds].filter(Boolean); + + return ids; + }; + + /** + * For a given instance Id, fetches the root instance with its corresponding inter-service-relation instances. * @param {string} id - The ID of the instance to fetch. * @returns {Promise} An object containing the fetched instance and its related instances. * @throws Error if the instance fails to fetch. */ - const fetchInstances = async (id: string): Promise => { - const relatedInstances: ServiceInstanceModel[] = []; - const instance = (await fetchInstance(id)).data; - - if (instance.referenced_by !== null) { - await Promise.all( - instance.referenced_by.map(async (relatedId) => { - const relatedInstance = await fetchInstance(relatedId); + const fetchInstanceWithRelations = async ( + id: string, + ): Promise => { + const interServiceRelations: ServiceInstanceModel[] = []; + const { data: instance } = await fetchSingleInstance(id); + let serviceIds: string[] = []; - if (relatedInstance) { - relatedInstances.push(relatedInstance.data); - } + if (serviceModel) { + const attributesToFetch = getAllRelationNames(serviceModel); + const uniqueAttributes = [...new Set(attributesToFetch)]; + const allEmbedded = getAllEmbeddedEntityNames(serviceModel); + const attributes = + instance.active_attributes || instance.candidate_attributes || {}; //we don't operate on rollback attributes - return relatedInstance; - }), + serviceIds = getInterServiceRelationIds( + attributes, + uniqueAttributes, + allEmbedded, ); } + const allIds = [ + ...new Set([...serviceIds, ...(instance.referenced_by || [])]), + ]; + + await Promise.all( + allIds.map(async (relatedId) => { + const interServiceRelation = await fetchSingleInstance(relatedId); + + if (interServiceRelation) { + interServiceRelations.push(interServiceRelation.data); + } + + return interServiceRelation; + }), + ); + return { instance, - relatedInstances, + interServiceRelations, }; }; @@ -102,8 +220,21 @@ export const useGetInstanceWithRelations = ( instanceId, environment, ], - queryFn: () => fetchInstances(instanceId), + queryFn: () => fetchInstanceWithRelations(instanceId), + retry: false, + enabled: serviceModel !== undefined, + }), + useContinuous: (): UseQueryResult => + useQuery({ + queryKey: [ + "get_instance_with_relations-continuous", + instanceId, + environment, + ], + queryFn: () => fetchInstanceWithRelations(instanceId), retry: false, + refetchInterval: 5000, + enabled: serviceModel !== undefined, }), }; }; diff --git a/src/Data/Managers/V2/GETTERS/GetInventoryList/index.ts b/src/Data/Managers/V2/GETTERS/GetInventoryList/index.ts new file mode 100644 index 000000000..ca293fe29 --- /dev/null +++ b/src/Data/Managers/V2/GETTERS/GetInventoryList/index.ts @@ -0,0 +1 @@ +export * from "./useGetInventoryList"; diff --git a/src/Data/Managers/V2/GETTERS/GetInventoryList/useGetInventoryList.ts b/src/Data/Managers/V2/GETTERS/GetInventoryList/useGetInventoryList.ts new file mode 100644 index 000000000..1a6a10793 --- /dev/null +++ b/src/Data/Managers/V2/GETTERS/GetInventoryList/useGetInventoryList.ts @@ -0,0 +1,102 @@ +import { UseQueryResult, useQuery } from "@tanstack/react-query"; +import { ServiceInstanceModel } from "@/Core"; +import { PrimaryBaseUrlManager } from "@/UI"; +import { useFetchHelpers } from "../../helpers"; + +/** + * Inventories interface + * + * It is used to group service instances by a service name + */ +export interface Inventories { + [serviceName: string]: ServiceInstanceModel[]; +} + +/** + * Return Signature of the useGetInventoryList React Query + */ +interface GetInventoryList { + useOneTime: () => UseQueryResult; + useContinuous: () => UseQueryResult; +} + +/** + * React Query hook to fetch the service inventory of each service in the list of service names. + * + * @param {string[]} serviceNames - the array of service names + * @param environment {string} - the environment in which the instance belongs + * + * @returns {GetInventoryList} An object containing the different available queries. + * @returns {UseQueryResult} returns.useOneTime - Fetch the service inventories as a single query. + * @returns {UseQueryResult} returns.useContinuous - Fetch the service inventories with a recursive query with an interval of 5s. + */ +export const useGetInventoryList = ( + serviceNames: string[], + environment: string, +): GetInventoryList => { + const { createHeaders, handleErrors } = useFetchHelpers(); + const headers = createHeaders(environment); + + const baseUrlManager = new PrimaryBaseUrlManager( + globalThis.location.origin, + globalThis.location.pathname, + ); + const baseUrl = baseUrlManager.getBaseUrl(process.env.API_BASEURL); + + /** + * Fetches the inventory for a single service. + * + * @param service - The name of the service to fetch the inventory for. + * @returns A promise that resolves to an object containing an array of service instance models. + * @throws Will throw an error if the fetch operation fails. + */ + const fetchSingleService = async ( + service: string, + ): Promise<{ data: ServiceInstanceModel[] }> => { + const response = await fetch( + `${baseUrl}/lsm/v1/service_inventory/${service}?limit=1000`, + { + headers, + }, + ); + + await handleErrors( + response, + `Failed to fetch service inventory for name: ${service}`, + ); + + return response.json(); + }; + + /** + * Fetches the inventory for all services. + * + * @returns A promise that resolves to an object mapping service names to arrays of service instances. + * @throws Will throw an error if the fetch operation for any service fails. + */ + const fetchAllServices = async (): Promise => { + const responses = await Promise.all( + serviceNames.map(async (serviceName) => fetchSingleService(serviceName)), + ); + + // Map the responses to an object of service names and arrays of service instances for each service + return Object.fromEntries( + responses.map((response, index) => [serviceNames[index], response.data]), + ); + }; + + return { + useOneTime: (): UseQueryResult => + useQuery({ + queryKey: ["get_inventory_list-one_time", serviceNames], + queryFn: fetchAllServices, + retry: false, + }), + useContinuous: (): UseQueryResult => + useQuery({ + queryKey: ["get_inventory_list-continuous", serviceNames], + queryFn: fetchAllServices, + refetchInterval: 5000, + }), + }; +}; diff --git a/src/Data/Managers/V2/GETTERS/GetServiceModel/UseGetServiceModel.ts b/src/Data/Managers/V2/GETTERS/GetServiceModel/UseGetServiceModel.ts index 396dc1601..f93a8fb72 100644 --- a/src/Data/Managers/V2/GETTERS/GetServiceModel/UseGetServiceModel.ts +++ b/src/Data/Managers/V2/GETTERS/GetServiceModel/UseGetServiceModel.ts @@ -4,7 +4,7 @@ import { PrimaryBaseUrlManager } from "@/UI"; import { useFetchHelpers } from "../../helpers"; /** - * Return Signature of the useServiceModel React Query + * Return Signature of the useGetServiceModel React Query */ interface GetServiceModel { useOneTime: () => UseQueryResult; diff --git a/src/Data/Managers/V2/POST/PostOrder/usePostOrder.ts b/src/Data/Managers/V2/POST/PostOrder/usePostOrder.ts index 85bd3c9e1..5fae08fcb 100644 --- a/src/Data/Managers/V2/POST/PostOrder/usePostOrder.ts +++ b/src/Data/Managers/V2/POST/PostOrder/usePostOrder.ts @@ -31,7 +31,7 @@ export const usePostOrder = ( method: "POST", body: JSON.stringify({ service_order_items: serviceOrderItems, - description: words("inventory.instanceComposer.orderDescription"), + description: words("instanceComposer.orderDescription"), }), headers, }); diff --git a/src/Data/Store/ServicesSlice.test.ts b/src/Data/Store/ServicesSlice.test.ts index b70bc2a91..9b52f394e 100644 --- a/src/Data/Store/ServicesSlice.test.ts +++ b/src/Data/Store/ServicesSlice.test.ts @@ -6,6 +6,7 @@ describe("ServicesSlice", () => { const serviceModels: ServiceModel[] = [ { attributes: [], + inter_service_relations: [], environment: "env-id", lifecycle: { initial_state: "", states: [], transfers: [] }, name: "test_service", @@ -16,6 +17,7 @@ describe("ServicesSlice", () => { }, { attributes: [], + inter_service_relations: [], environment: "env-id", lifecycle: { initial_state: "", states: [], transfers: [] }, name: "another_test_service", diff --git a/src/Slices/CompileDetails/Core/Mock.ts b/src/Slices/CompileDetails/Core/Mock.ts index 43b990c95..979d08b10 100644 --- a/src/Slices/CompileDetails/Core/Mock.ts +++ b/src/Slices/CompileDetails/Core/Mock.ts @@ -20,7 +20,7 @@ export const data: CompileDetails = { started: "2021-09-10T09:05:25.000000", completed: "2021-09-10T09:05:30.000000", command: - "/opt/inmanta/bin/python3 -m inmanta.app -vvv export -X -e f7e84432-855c-4d04-b422-50c3ab925a4a", + "/opt/inmanta/bin/python3 -m inmanta.app -vvv export -e f7e84432-855c-4d04-b422-50c3ab925a4a", name: "Recompiling configuration model", errstream: "error", outstream: "success", diff --git a/src/Slices/InstanceComposer/UI/Page.tsx b/src/Slices/InstanceComposer/UI/Page.tsx deleted file mode 100644 index 798039841..000000000 --- a/src/Slices/InstanceComposer/UI/Page.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import React, { useContext } from "react"; -import { DependencyContext, useRouteParams, words } from "@/UI"; -import { EmptyView, PageContainer, ServicesProvider } from "@/UI/Components"; -import Canvas from "@/UI/Components/Diagram/Canvas"; - -/** - * Renders the Page component for the Instance Composer Page. - * If the composer feature is enabled, it renders the Canvas component wrapped in a ServicesProvider. - * If the composer feature is disabled, it renders an EmptyView component with a message indicating that the composer is disabled. - */ -export const Page = () => { - const { service: serviceName } = useRouteParams<"InstanceComposer">(); - const { featureManager } = useContext(DependencyContext); - - return featureManager.isComposerEnabled() ? ( - ( - - - - )} - /> - ) : ( - - ); -}; - -/** - * Wrapper component for the Page component. - * Renders the PageContainer component with the provided props and children. - */ -const PageWrapper: React.FC> = ({ - children, - ...props -}) => ( - - {children} - -); diff --git a/src/Slices/InstanceComposer/Core/Route.ts b/src/Slices/InstanceComposerCreator/Core/Route.ts similarity index 100% rename from src/Slices/InstanceComposer/Core/Route.ts rename to src/Slices/InstanceComposerCreator/Core/Route.ts diff --git a/src/Slices/InstanceComposerCreator/UI/Page.tsx b/src/Slices/InstanceComposerCreator/UI/Page.tsx new file mode 100644 index 000000000..4053120f4 --- /dev/null +++ b/src/Slices/InstanceComposerCreator/UI/Page.tsx @@ -0,0 +1,46 @@ +import React, { useContext } from "react"; +import { DependencyContext, useRouteParams, words } from "@/UI"; +import { EmptyView, PageContainer } from "@/UI/Components"; +import { ComposerCreatorProvider } from "@/UI/Components/Diagram/Context/ComposerCreatorProvider"; + +/** + * Renders the Page component for the Instance Composer Creator Page. + * If the composer feature is enabled, it renders the Canvas. + * If the composer feature is disabled, it renders an EmptyView component with a message indicating that the composer is disabled. + * + * @returns {React.FC} The Page component. + */ +export const Page: React.FC = () => { + const { service: serviceName } = useRouteParams<"InstanceComposer">(); + const { featureManager } = useContext(DependencyContext); + + if (!featureManager.isComposerEnabled()) { + ; + } + + return ( + + + + ); +}; + +/** + * Wrapper component for the Page component. + * Renders the PageContainer component with the provided props and children. + */ +const PageWrapper: React.FC> = ({ + children, + ...props +}) => ( + + {children} + +); diff --git a/src/Slices/InstanceComposer/UI/index.ts b/src/Slices/InstanceComposerCreator/UI/index.ts similarity index 100% rename from src/Slices/InstanceComposer/UI/index.ts rename to src/Slices/InstanceComposerCreator/UI/index.ts diff --git a/src/Slices/InstanceComposer/index.ts b/src/Slices/InstanceComposerCreator/index.ts similarity index 100% rename from src/Slices/InstanceComposer/index.ts rename to src/Slices/InstanceComposerCreator/index.ts diff --git a/src/Slices/InstanceComposerEditor/UI/Page.tsx b/src/Slices/InstanceComposerEditor/UI/Page.tsx index 2ff6ccaf2..cb02d5320 100644 --- a/src/Slices/InstanceComposerEditor/UI/Page.tsx +++ b/src/Slices/InstanceComposerEditor/UI/Page.tsx @@ -1,39 +1,37 @@ import React, { useContext } from "react"; import { DependencyContext, useRouteParams, words } from "@/UI"; -import { EmptyView, PageContainer, ServicesProvider } from "@/UI/Components"; -import { InstanceProvider } from "@/UI/Components/InstanceProvider"; +import { EmptyView, PageContainer } from "@/UI/Components"; +import { ComposerEditorProvider } from "@/UI/Components/Diagram/Context/ComposerEditorProvider"; /** * Renders the Page component for the Instance Composer Editor Page. * If the composer feature is enabled, it renders the Canvas component wrapped in a ServicesProvider. * If the composer feature is disabled, it renders an EmptyView component with a message indicating that the composer is disabled. + * + * @returns {React.FC} The Page component. */ -export const Page = () => { +export const Page: React.FC = () => { const { service: serviceName, instance } = useRouteParams<"InstanceComposerEditor">(); const { featureManager } = useContext(DependencyContext); - return featureManager.isComposerEnabled() ? ( - ( - - - - )} - /> - ) : ( - + if (!featureManager.isComposerEnabled()) { + return ( + + ); + } + + return ( + + + ); }; @@ -45,10 +43,7 @@ const PageWrapper: React.FC> = ({ children, ...props }) => ( - + {children} ); diff --git a/src/Slices/InstanceComposerViewer/UI/Page.tsx b/src/Slices/InstanceComposerViewer/UI/Page.tsx index 802446e13..69a045745 100644 --- a/src/Slices/InstanceComposerViewer/UI/Page.tsx +++ b/src/Slices/InstanceComposerViewer/UI/Page.tsx @@ -1,38 +1,37 @@ import React, { useContext } from "react"; import { DependencyContext, useRouteParams, words } from "@/UI"; -import { EmptyView, PageContainer, ServicesProvider } from "@/UI/Components"; -import { InstanceProvider } from "@/UI/Components/InstanceProvider"; +import { EmptyView, PageContainer } from "@/UI/Components"; +import { ComposerEditorProvider } from "@/UI/Components/Diagram/Context/ComposerEditorProvider"; /** * Renders the Page component for the Instance Composer Viewer Page. * If the composer feature is enabled, it renders the Canvas component wrapped in a ServicesProvider. * If the composer feature is disabled, it renders an EmptyView component with a message indicating that the composer is disabled. + * + * @returns {React.FC} The Page component. */ -export const Page = () => { +export const Page: React.FC = () => { const { service: serviceName, instance } = useRouteParams<"InstanceComposerViewer">(); const { featureManager } = useContext(DependencyContext); - return featureManager.isComposerEnabled() ? ( - ( - - - - )} - /> - ) : ( - + if (!featureManager.isComposerEnabled()) { + return ( + + ); + } + + return ( + + + ); }; @@ -44,10 +43,7 @@ const PageWrapper: React.FC> = ({ children, ...props }) => ( - + {children} ); diff --git a/src/Slices/ServiceInstanceDetails/UI/Components/InstanceActions/InstanceActions.tsx b/src/Slices/ServiceInstanceDetails/UI/Components/InstanceActions/InstanceActions.tsx index 55c91cdb3..d3e7fde4f 100644 --- a/src/Slices/ServiceInstanceDetails/UI/Components/InstanceActions/InstanceActions.tsx +++ b/src/Slices/ServiceInstanceDetails/UI/Components/InstanceActions/InstanceActions.tsx @@ -150,7 +150,7 @@ export const InstanceActions: React.FC = () => { instance: instance.id, })} > - {words("inventory.instanceComposer.editButton")} + {words("instanceComposer.editButton")} ) : ( @@ -167,7 +167,7 @@ export const InstanceActions: React.FC = () => { instance: instance.id, })} > - {words("inventory.instanceComposer.showButton")} + {words("instanceComposer.showButton")} )} diff --git a/src/Slices/ServiceInventory/UI/Components/RowActionsMenu/RowActions.tsx b/src/Slices/ServiceInventory/UI/Components/RowActionsMenu/RowActions.tsx index 723f09551..0f7ee3e1f 100644 --- a/src/Slices/ServiceInventory/UI/Components/RowActionsMenu/RowActions.tsx +++ b/src/Slices/ServiceInventory/UI/Components/RowActionsMenu/RowActions.tsx @@ -37,7 +37,6 @@ export interface InstanceActionsProps { editDisabled: boolean; deleteDisabled: boolean; diagnoseDisabled: boolean; - composerAvailable: boolean; availableStates: string[]; } @@ -46,7 +45,6 @@ export const RowActions: React.FunctionComponent = ({ editDisabled, deleteDisabled, diagnoseDisabled, - composerAvailable, availableStates, }) => { const { routeManager, environmentModifier, featureManager } = @@ -61,8 +59,7 @@ export const RowActions: React.FunctionComponent = ({ const menuRef = React.useRef(null); const [isOpen, setIsOpen] = React.useState(false); - const composerEnabled = - featureManager.isComposerEnabled() && composerAvailable; + const composerEnabled = featureManager.isComposerEnabled(); const onToggleClick = () => { setIsOpen(!isOpen); @@ -181,7 +178,7 @@ export const RowActions: React.FunctionComponent = ({ })} isDisabled={editDisabled} > - {words("inventory.instanceComposer.editButton")} + {words("instanceComposer.editButton")} )} @@ -194,7 +191,7 @@ export const RowActions: React.FunctionComponent = ({ instance: instance.id, })} > - {words("inventory.instanceComposer.showButton")} + {words("instanceComposer.showButton")} )} diff --git a/src/Slices/ServiceInventory/UI/Components/TableControls/TableControls.tsx b/src/Slices/ServiceInventory/UI/Components/TableControls/TableControls.tsx index 16dfa595f..44c2db2b3 100644 --- a/src/Slices/ServiceInventory/UI/Components/TableControls/TableControls.tsx +++ b/src/Slices/ServiceInventory/UI/Components/TableControls/TableControls.tsx @@ -37,8 +37,7 @@ export const TableControls: React.FC = ({ const [isOpen, setIsOpen] = useState(false); const { routeManager, featureManager } = useContext(DependencyContext); - const composerEnabled = - service.owner === null && featureManager.isComposerEnabled(); + const composerEnabled = featureManager.isComposerEnabled(); const states = service.lifecycle.states.map((state) => state.name).sort(); diff --git a/src/Slices/ServiceInventory/UI/Presenters/InstanceActionPresenter.ts b/src/Slices/ServiceInventory/UI/Presenters/InstanceActionPresenter.ts index 95634f9cc..5bee85703 100644 --- a/src/Slices/ServiceInventory/UI/Presenters/InstanceActionPresenter.ts +++ b/src/Slices/ServiceInventory/UI/Presenters/InstanceActionPresenter.ts @@ -23,7 +23,6 @@ export class InstanceActionPresenter implements ActionPresenter { editDisabled: this.isTransferDisabled(id, "on_update"), deleteDisabled: this.isTransferDisabled(id, "on_delete"), diagnoseDisabled: instance.deleted, - composerAvailable: this.serviceEntity.owner === null, availableStates: this.getAvailableStates(), }); } diff --git a/src/Slices/ServiceInventory/UI/ServiceInventory.test.tsx b/src/Slices/ServiceInventory/UI/ServiceInventory.test.tsx index 0bd652fee..3fc1b3c5f 100644 --- a/src/Slices/ServiceInventory/UI/ServiceInventory.test.tsx +++ b/src/Slices/ServiceInventory/UI/ServiceInventory.test.tsx @@ -353,38 +353,6 @@ test("ServiceInventory shows instance summary chart", async () => { ).toBeInTheDocument(); }); -test("ServiceInventory shows disabled composer buttons for non-root instances ", async () => { - const { component, apiHelper } = setup({ ...Service.a, owner: "owner" }); - - render(component); - - await act(async () => { - apiHelper.resolve( - Either.right({ - data: [{ ...ServiceInstance.a, id: "a" }], - links: { ...Pagination.links }, - metadata: Pagination.metadata, - }), - ); - }); - - expect(screen.queryByRole("Add in Composer")).not.toBeInTheDocument(); - - const menuToggle = await screen.findByRole("button", { - name: "row actions toggle", - }); - - await act(async () => { - await userEvent.click(menuToggle); - }); - - const button = screen.queryByRole("menuitem", { - name: "Edit in Composer", - }); - - expect(button).not.toBeInTheDocument(); -}); - test("ServiceInventory shows enabled composer buttons for root instances ", async () => { const { component, apiHelper } = setup(Service.a); @@ -436,7 +404,13 @@ test("ServiceInventory shows only button to display instance in the composer for ); }); - expect(screen.queryByText("Add in Composer")).not.toBeInTheDocument(); + await act(async () => { + await userEvent.click( + screen.getByRole("button", { name: "AddInstanceToggle" }), + ); + }); + + expect(screen.getByText("Add in Composer")).toBeInTheDocument(); const menuToggle = await screen.findByRole("button", { name: "row actions toggle", @@ -448,7 +422,7 @@ test("ServiceInventory shows only button to display instance in the composer for expect(await screen.findByText("Show in Composer")).toBeEnabled(); - expect(screen.queryByText("Edit in Composer")).not.toBeInTheDocument(); + expect(screen.getByText("Edit in Composer")).toBeInTheDocument(); }); test("GIVEN ServiceInventory WHEN sorting changes AND we are not on the first page THEN we are sent back to the first page", async () => { diff --git a/src/Test/Data/Service/EmbeddedEntity.ts b/src/Test/Data/Service/EmbeddedEntity.ts index 0a6d30aaf..a82a1e0e5 100644 --- a/src/Test/Data/Service/EmbeddedEntity.ts +++ b/src/Test/Data/Service/EmbeddedEntity.ts @@ -31,6 +31,7 @@ export const a: EmbeddedEntity = { }, ], embedded_entities: [], + inter_service_relations: [], name: "allocated", description: "Circuit allocated attributes", modifier: "r", @@ -171,6 +172,7 @@ export const a: EmbeddedEntity = { }, ], embedded_entities: [], + inter_service_relations: [], name: "allocated", description: "Allocated attributes for customer endpoint ", modifier: "r", @@ -178,6 +180,7 @@ export const a: EmbeddedEntity = { upper_limit: 1, }, ], + inter_service_relations: [], name: "customer_endpoint", description: "Attributes for customer endpoint which are provided through the NB API", @@ -377,6 +380,7 @@ export const a: EmbeddedEntity = { }, ], embedded_entities: [], + inter_service_relations: [], name: "allocated", description: "Allocated attributes for CSP endpoint", modifier: "r", @@ -384,6 +388,7 @@ export const a: EmbeddedEntity = { upper_limit: 1, }, ], + inter_service_relations: [], name: "csp_endpoint", description: "Attributes for CSP endpoint which are provided through the NB API", @@ -392,6 +397,7 @@ export const a: EmbeddedEntity = { upper_limit: 1, }, ], + inter_service_relations: [], name: "circuits", description: "Circuit attributes ", modifier: "rw+", @@ -446,6 +452,7 @@ export const nestedEditable: EmbeddedEntity[] = [ }, ], embedded_entities: [], + inter_service_relations: [], name: "embedded_single", description: "description", modifier: "rw", @@ -453,6 +460,7 @@ export const nestedEditable: EmbeddedEntity[] = [ upper_limit: 1, }, ], + inter_service_relations: [], name: "embedded", description: "description", modifier: "rw+", @@ -510,6 +518,7 @@ export const nestedEditable: EmbeddedEntity[] = [ ], }, ], + inter_service_relations: [], name: "another_embedded", modifier: "rw+", lower_limit: 0, @@ -528,6 +537,7 @@ export const nestedEditable: EmbeddedEntity[] = [ }, ], embedded_entities: [], + inter_service_relations: [], name: "not_editable", modifier: "rw", lower_limit: 1, @@ -546,6 +556,7 @@ export const nestedEditable: EmbeddedEntity[] = [ }, ], embedded_entities: [], + inter_service_relations: [], name: "editable_embedded_entity_relation_with_rw_attributes", modifier: "rw+", lower_limit: 1, @@ -584,6 +595,7 @@ export const multiNestedEditable: EmbeddedEntity[] = [ validation_parameters: null, }, ], + inter_service_relations: [], embedded_entities: [ { attributes: [ @@ -676,12 +688,14 @@ export const multiNestedEditable: EmbeddedEntity[] = [ inter_service_relations: [], }, ], + inter_service_relations: [], name: "another_embedded", modifier: "rw+", lower_limit: 0, description: "description", }, ], + inter_service_relations: [], name: "embedded_single", description: "description", modifier: "rw+", @@ -700,6 +714,7 @@ export const multiNestedEditable: EmbeddedEntity[] = [ export const embedded: EmbeddedEntity = { attributes: attributesList, embedded_entities: [], + inter_service_relations: [], name: "embedded", description: "desc", modifier: "rw", @@ -727,6 +742,7 @@ export const embedded_base: EmbeddedEntity = { lower_limit: 0, }, ], + inter_service_relations: [], name: "embedded_base", description: "desc", modifier: "rw", diff --git a/src/Test/Data/Service/index.ts b/src/Test/Data/Service/index.ts index bea1b3e59..80b819e1a 100644 --- a/src/Test/Data/Service/index.ts +++ b/src/Test/Data/Service/index.ts @@ -26,6 +26,7 @@ export const a: ServiceModel = { embedded_entities: EmbeddedEntity.list, owner: null, owned_entities: [], + inter_service_relations: [], }; export const b: ServiceModel = { diff --git a/src/UI/Components/Diagram/Canvas.test.tsx b/src/UI/Components/Diagram/Canvas.test.tsx index 8df8eea84..f6b92692a 100644 --- a/src/UI/Components/Diagram/Canvas.test.tsx +++ b/src/UI/Components/Diagram/Canvas.test.tsx @@ -1,15 +1,17 @@ import React, { act } from "react"; import { Route, Routes, useLocation } from "react-router-dom"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + QueryClient, + QueryClientProvider, + UseQueryResult, +} from "@tanstack/react-query"; import { render, queries, within as baseWithin } from "@testing-library/react"; import { userEvent } from "@testing-library/user-event"; import { StoreProvider } from "easy-peasy"; -import { HttpResponse, PathParams, http } from "msw"; -import { setupServer } from "msw/node"; import { RemoteData, ServiceModel } from "@/Core"; import { getStoreInstance } from "@/Data"; import { InstanceWithRelations } from "@/Data/Managers/V2/GETTERS/GetInstanceWithRelations"; - +import { Inventories } from "@/Data/Managers/V2/GETTERS/GetInventoryList"; import { dependencies } from "@/Test"; import * as customQueries from "@/Test/Utils/custom-queries"; import { @@ -18,17 +20,12 @@ import { PrimaryRouteManager, words, } from "@/UI"; -import Canvas from "@/UI/Components/Diagram/Canvas"; -import { ComposerServiceOrderItem } from "@/UI/Components/Diagram/interfaces"; +import { Canvas } from "@/UI/Components/Diagram/Canvas"; import CustomRouter from "@/UI/Routing/CustomRouter"; import history from "@/UI/Routing/history"; -import { - mockedInstanceThree, - mockedInstanceThreeServiceModel, - mockedInstanceTwo, - mockedInstanceTwoServiceModel, - mockedInstanceWithRelations, -} from "./Mock"; +import { CanvasProvider } from "./Context/CanvasProvider"; +import { InstanceComposerContext } from "./Context/Context"; +import { mockedInstanceTwo, mockedInstanceTwoServiceModel } from "./Mocks"; import services from "./Mocks/services.json"; import "@testing-library/jest-dom"; import { defineObjectsForJointJS } from "./testSetup"; @@ -41,6 +38,7 @@ const user = userEvent.setup(); const screen = baseWithin(document.body, allQueries); const setup = ( + mainService: ServiceModel, instance?: InstanceWithRelations, serviceModels: ServiceModel[] = services as unknown as ServiceModel[], editable: boolean = true, @@ -81,23 +79,27 @@ const setup = ( - - , + }} + > + + + } /> + } /> - } - /> - } - /> - + + + @@ -105,215 +107,61 @@ const setup = ( ); }; -const deleteAndAssert = async ( - name: string, - assertionTwo: number, - assertionThree: number, -) => { - const container = await screen.findByTestId("header-" + name); - - await act(async () => { - await user.click(container); - }); - - const handle3 = document.querySelector('[data-action="delete"]') as Element; - - await act(async () => { - await user.click(handle3); - }); - //Delay has to be done as library base itself on listeners that are async - await new Promise((resolve) => setTimeout(resolve, 100)); - - const updatedEntities3 = document.querySelectorAll( - '[data-type="app.ServiceEntityBlock"]', - ); - const updatedConnectors3 = document.querySelectorAll('[data-type="Link"]'); - - expect(updatedEntities3).toHaveLength(assertionTwo); - expect(updatedConnectors3).toHaveLength(assertionThree); -}; - -/** - * Creates a shape with the specified name and ID. - * - * @param {string} shapeName - The name of the shape to be selected. - * @param {string} name - The name to be entered in the TextInput field. - * @param {string} id - The ID to be entered in the TextInput field. - * @returns {Promise} - */ -const createShapeWithNameAndId = async ( - shapeName: string, - name: string, - id: string, -) => { - const button = screen.getByLabelText("new-entity-button"); - - await act(async () => { - await user.click(button); - }); - - const select = screen.getByLabelText("service-picker"); - - await act(async () => { - await user.click(select); - }); - await act(async () => { - await user.click(screen.getByRole("option", { name: shapeName })); - }); - - const input1 = screen.getByLabelText("TextInput-name"); - - await act(async () => { - await user.type(input1, name); - }); - - const input2 = screen.getByLabelText("TextInput-service_id"); - - await act(async () => { - await user.type(input2, id); - }); - - await act(async () => { - await user.click(screen.getByLabelText("confirm-button")); - }); -}; - beforeAll(() => { defineObjectsForJointJS(); }); describe("Canvas.tsx", () => { it("renders canvas correctly", async () => { - const component = setup(); - - render(component); - }); - - it("navigating out of the View works correctly", async () => { - const component = setup(); + const component = setup(mockedInstanceTwoServiceModel, mockedInstanceTwo, [ + mockedInstanceTwoServiceModel, + ]); render(component); - expect(window.location.pathname).toEqual("/"); - - await act(async () => { - await user.click(screen.getByRole("button", { name: "Cancel" })); - }); - expect(window.location.pathname).toEqual( - "/lsm/catalog/parent-service/inventory", - ); - }); - it("renders created core service successfully", async () => { - const component = setup(); + const leftSidebar = screen.getByTestId("left_sidebar"); - render(component); - const shapeName = "parent-service"; - const name = "name-001"; - const id = "id-001"; - - await createShapeWithNameAndId(shapeName, name, id); + expect(screen.getByTestId("Composer-Container")).toBeVisible(); + expect(screen.getByTestId("canvas")).toBeVisible(); + expect(leftSidebar).toBeVisible(); + expect(leftSidebar.children.length).toBe(3); + expect(leftSidebar.children[0].id).toBe("instance-stencil"); + expect(leftSidebar.children[1].id).toBe("inventory-stencil"); + expect(leftSidebar.children[2].id).toBe("tabs-toolbar"); + expect(screen.getByTestId("zoomHandler")).toBeVisible(); - //validate shape const headerLabel = await screen.findByJointSelector("headerLabel"); - expect(headerLabel).toHaveTextContent(shapeName); + expect(headerLabel).toHaveTextContent("test-service"); const header = screen.getByJointSelector("header"); expect(header).toHaveClass("-core"); - - const nameValue = screen.getByJointSelector("itemLabel_name_value"); - - expect(nameValue).toHaveTextContent(name); - - const shouldDeployValue = screen.getByJointSelector( - "itemLabel_should_deploy_fail_value", - ); - - expect(shouldDeployValue).toHaveTextContent("false"); - - expect( - screen.getByJointSelector("itemLabel_service_id_value"), - ).toHaveTextContent(id); }); - it("renders created non-core service successfully", async () => { - const component = setup(); - - render(component); - const shapeName = "child-service"; - const name = "name-001"; - const id = "id-001"; - - await createShapeWithNameAndId(shapeName, name, id); - - //validate shape - const headerLabel = await screen.findByJointSelector("headerLabel"); - - expect(headerLabel).toHaveTextContent(shapeName); - - const header = screen.getByJointSelector("header"); - - expect(header).not.toHaveClass("-core"); - expect(header).not.toHaveClass("-embedded"); - - const nameValue = screen.getByJointSelector("itemLabel_name_value"); - - expect(nameValue).toHaveTextContent(name); - - const shouldDeployValue = screen.getByJointSelector( - "itemLabel_should_deploy_fail_value", - ); - - expect(shouldDeployValue).toHaveTextContent("false"); - - expect( - screen.getByJointSelector("itemLabel_service_id_value"), - ).toHaveTextContent(id); - }); - - it("renders shapes with expandable attributes + with Dict value", async () => { - const component = setup(mockedInstanceTwo, [mockedInstanceTwoServiceModel]); + it("navigating out of the View works correctly", async () => { + const component = setup(mockedInstanceTwoServiceModel, mockedInstanceTwo, [ + mockedInstanceTwoServiceModel, + ]); render(component); - - //wrapper that holds attr values - const attrs = await screen.findByJointSelector("labelsGroup_1"); - - expect(attrs.childNodes).toHaveLength(4); - - const button = await screen.findByJointSelector("toggleButton"); - - await act(async () => { - await user.click(button); - }); - - const attrsTwo = await screen.findByJointSelector("labelsGroup_1"); - - expect(attrsTwo.childNodes).toHaveLength(9); - - const refreshedButton = await screen.findByJointSelector("toggleButton"); + expect(window.location.pathname).toEqual("/"); await act(async () => { - await user.click(refreshedButton); + await user.click(screen.getByRole("button", { name: "Cancel" })); }); - - const attrsThree = await screen.findByJointSelector("labelsGroup_1"); - - expect(attrsThree.childNodes).toHaveLength(4); + expect(window.location.pathname).toEqual( + "/lsm/catalog/test-service/inventory", + ); }); it("renders shapes dict Value that can be viewed in dict Modal", async () => { - const component = setup(mockedInstanceTwo, [mockedInstanceTwoServiceModel]); + const component = setup(mockedInstanceTwoServiceModel, mockedInstanceTwo, [ + mockedInstanceTwoServiceModel, + ]); render(component); - const button = await screen.findByJointSelector("toggleButton"); - - await act(async () => { - await user.click(button); - }); - const dictValue = await screen.findByJointSelector( "itemLabel_dictOne_value", ); @@ -329,7 +177,7 @@ describe("Canvas.tsx", () => { const title = document.querySelector(".pf-v5-c-modal-box__title"); expect(title).toHaveTextContent( - words("inventory.instanceComposer.dictModal")("dictOne"), + words("instanceComposer.dictModal")("dictOne"), ); const value = document.querySelector(".pf-v5-c-code-block__code"); @@ -356,347 +204,4 @@ describe("Canvas.tsx", () => { expect(modal).not.toBeVisible(); }); - - it("renders created embedded entity successfully", async () => { - const component = setup(); - - render(component); - const name = "name-001"; - const button = screen.getByLabelText("new-entity-button"); - - await act(async () => { - await user.click(button); - }); - - //create shape - const select = screen.getByLabelText("service-picker"); - - await act(async () => { - await user.click(select); - }); - - await act(async () => { - await user.click( - screen.getByRole("option", { - name: "child_container (container-service)", - }), - ); - }); - - const input1 = screen.getByLabelText("TextInput-name"); - - await act(async () => { - await user.type(input1, name); - }); - - await act(async () => { - await user.click(screen.getByLabelText("confirm-button")); - }); - - //validate shape - const headerLabel = await screen.findByJointSelector("headerLabel"); - - expect(headerLabel).toHaveTextContent("child_container"); - - const header = screen.getByJointSelector("header"); - - expect(header).toHaveClass("-embedded"); - - const nameValue = screen.getByJointSelector("itemLabel_name_value"); - - expect(nameValue).toHaveTextContent(name); - }); - - it("edits correctly services", async () => { - const component = setup(); - - render(component); - - const shapeName = "container-service"; - const name = "name-001"; - const id = "id-001"; - - await createShapeWithNameAndId(shapeName, name, id); - - const headerLabel = await screen.findByJointSelector("headerLabel"); - - expect(headerLabel).toHaveTextContent(shapeName); - - const shape = document.querySelector( - '[data-type="app.ServiceEntityBlock"]', - ) as Element; - - await act(async () => { - await user.click(shape); - }); - - const handle = document.querySelector('[data-action="edit"]') as Element; - - await act(async () => { - await user.click(handle); - }); - - const dialog = await screen.findByRole("dialog"); - - expect(dialog).toBeVisible(); - - const selectMenu = screen.getByLabelText("service-picker"); - - expect(selectMenu).toBeDisabled(); - expect(selectMenu).toHaveTextContent("container-service"); - - const nameInput = screen.getByLabelText("TextInput-name"); - - expect(nameInput).toHaveValue(name); - - const newName = "new-name"; - - await act(async () => { - await user.type(nameInput, `{selectAll}{backspace}${newName}`); - }); - - const confirmButton = screen.getByLabelText("confirm-button"); - - await act(async () => { - await user.click(confirmButton); - }); - - const nameValue = screen.getByJointSelector("itemLabel_name_value"); - - expect(nameValue).toHaveTextContent(newName); - }); - - it("renders deleting single instance correctly", async () => { - const component = setup(); - - render(component); - - const shapeName = "container-service"; - const name = "name-001"; - const id = "id-001"; - - await createShapeWithNameAndId(shapeName, name, id); - - const headerLabel = await screen.findByJointSelector("headerLabel"); - - expect(headerLabel).toHaveTextContent(shapeName); - - const shape = document.querySelector( - '[data-type="app.ServiceEntityBlock"]', - ) as Element; - - await act(async () => { - await user.click(shape); - }); - - const handle = document.querySelector('[data-action="delete"]') as Element; - - await act(async () => { - await user.click(handle); - }); - - const shape2 = document.querySelector( - '[data-type="app.ServiceEntityBlock"]', - ) as Element; - - expect(shape2).toBeNull(); - }); - - it("renders correctly fetched instances", async () => { - const component = setup(mockedInstanceWithRelations); - - render(component); - - const attrIndicators = await screen.findAllByJointSelector("info"); - const entities = document.querySelectorAll( - '[data-type="app.ServiceEntityBlock"]', - ); - const connectors = document.querySelectorAll('[data-type="Link"]'); - - expect(entities).toHaveLength(4); - expect(attrIndicators).toHaveLength(4); - expect(connectors).toHaveLength(3); - - await act(async () => { - await user.hover(connectors[0]); - }); - - const removeLinkHandle = document.querySelector( - ".joint-link_remove-circle", - ) as Element; - - expect(removeLinkHandle).toBeInTheDocument(); - - const labels = document.querySelectorAll(".joint-label-text"); - - expect(labels[0]).toBeVisible(); - expect(labels[1]).toBeVisible(); - expect(labels[0]).toHaveTextContent("child-service"); - expect(labels[1]).toHaveTextContent("parent-service"); - }); - - it("renders correctly fetched instances with missing optional entities", async () => { - const component = setup(mockedInstanceThree, [ - mockedInstanceThreeServiceModel, - ]); - - render(component); - - const attrIndicators = await screen.findAllByJointSelector("info"); - const entities = document.querySelectorAll( - '[data-type="app.ServiceEntityBlock"]', - ); - - expect(entities).toHaveLength(1); - expect(attrIndicators).toHaveLength(1); - }); - - it("deletes shape correctly", async () => { - const component = setup(mockedInstanceWithRelations); - - render(component); - - const attrIndicators = await screen.findAllByJointSelector("info"); - const entities = document.querySelectorAll( - '[data-type="app.ServiceEntityBlock"]', - ); - const connectors = document.querySelectorAll('[data-type="Link"]'); - - expect(entities).toHaveLength(4); - expect(attrIndicators).toHaveLength(4); - expect(connectors).toHaveLength(3); - - await deleteAndAssert("parent-service", 3, 1); - await deleteAndAssert("child-service", 2, 1); - await deleteAndAssert("child_container", 1, 0); - }); - - it("sends request with correct data to the backend when instance is being deployed", async () => { - const server = setupServer( - http.post< - PathParams, - { service_order_items: ComposerServiceOrderItem[] } - >("/lsm/v2/order", async ({ request }) => { - const reqBody = await request.json(); - - expect(reqBody.service_order_items[0]).toStrictEqual({ - instance_id: expect.any(String), - service_entity: "parent-service", - config: {}, - action: "create", - attributes: { - name: "name-001", - should_deploy_fail: false, - service_id: "id-001", - }, - edits: null, - metadata: { - coordinates: expect.any(String), - }, - }); - - expect( - JSON.parse( - reqBody.service_order_items[0].metadata?.coordinates as string, - ), - ).toEqual([ - { - id: expect.any(String), - name: "parent-service", - attributes: { - name: "name-001", - should_deploy_fail: false, - service_id: "id-001", - }, - coordinates: { - x: 0, - y: 0, - }, - }, - ]); - - return HttpResponse.json(); - }), - ); - const component = setup(); - - server.listen(); - render(component); - const shapeName = "parent-service"; - const name = "name-001"; - const id = "id-001"; - - await createShapeWithNameAndId(shapeName, name, id); - - await act(async () => { - await user.click(screen.getByRole("button", { name: "Deploy" })); - }); - - expect( - await screen.findByText("Instance Composed successfully"), - ).toBeVisible(); - server.close(); - }); - - it("when editable prop is set to false, disable interactions", async () => { - const component = setup( - mockedInstanceWithRelations, - services as unknown as ServiceModel[], - false, - ); - - render(component); - - const attrIndicators = await screen.findAllByJointSelector("info"); - const entities = document.querySelectorAll( - '[data-type="app.ServiceEntityBlock"]', - ); - const connectors = document.querySelectorAll('[data-type="Link"]'); - - expect(entities).toHaveLength(4); - expect(attrIndicators).toHaveLength(4); - expect(connectors).toHaveLength(3); - - expect(screen.getByLabelText("new-entity-button")).toBeDisabled(); - expect(screen.getByText("Deploy")).toBeDisabled(); - - await act(async () => { - await user.click(entities[0]); - }); - - const editHandle = document.querySelector( - '[data-action="edit"]', - ) as Element; - - expect(editHandle).toBeNull(); - - const deleteHandle = document.querySelector( - '[data-action="delete"]', - ) as Element; - - expect(deleteHandle).toBeNull(); - - const linkHandle = document.querySelector( - '[data-action="link"]', - ) as Element; - - expect(linkHandle).toBeNull(); - - await act(async () => { - await user.hover(connectors[0]); - }); - - const removeLinkHandle = document.querySelector( - ".joint-link_remove-circle", - ) as Element; - - expect(removeLinkHandle).toBeNull(); - - const labels = document.querySelectorAll(".joint-label-text"); - - expect(labels[0]).toBeVisible(); - expect(labels[1]).toBeVisible(); - expect(labels[0]).toHaveTextContent("child-service"); - expect(labels[1]).toHaveTextContent("parent-service"); - }); }); diff --git a/src/UI/Components/Diagram/Canvas.tsx b/src/UI/Components/Diagram/Canvas.tsx index 0f1721bf9..cc1b00484 100644 --- a/src/UI/Components/Diagram/Canvas.tsx +++ b/src/UI/Components/Diagram/Canvas.tsx @@ -1,435 +1,314 @@ import React, { useContext, useEffect, useRef, useState } from "react"; import "@inmanta/rappid/joint-plus.css"; -import { useNavigate } from "react-router-dom"; -import { dia } from "@inmanta/rappid"; -import { AlertVariant } from "@patternfly/react-core"; +import { ui } from "@inmanta/rappid"; import styled from "styled-components"; -import { ServiceModel } from "@/Core"; -import { sanitizeAttributes } from "@/Data"; -import { InstanceWithRelations } from "@/Data/Managers/V2/GETTERS/GetInstanceWithRelations"; -import { usePostMetadata } from "@/Data/Managers/V2/POST/PostMetadata"; -import { usePostOrder } from "@/Data/Managers/V2/POST/PostOrder"; -import diagramInit, { DiagramHandlers } from "@/UI/Components/Diagram/init"; -import { - ComposerServiceOrderItem, - ActionEnum, - DictDialogData, -} from "@/UI/Components/Diagram/interfaces"; - -import { CanvasWrapper } from "@/UI/Components/Diagram/styles"; -import { DependencyContext } from "@/UI/Dependency"; -import { words } from "@/UI/words"; -import { ToastAlert } from "../ToastAlert"; -import DictModal from "./components/DictModal"; -import FormModal from "./components/FormModal"; -import Toolbar from "./components/Toolbar"; -import { getServiceOrderItems, createConnectionRules } from "./helpers"; -import { ServiceEntityBlock } from "./shapes"; +import { CanvasContext, InstanceComposerContext } from "./Context"; +import { EventWrapper } from "./Context/EventWrapper"; +import { DictModal, RightSidebar, ComposerActions } from "./components"; +import { createConnectionRules, createStencilState } from "./helpers"; +import { diagramInit } from "./init"; +import { StencilSidebar } from "./stencil"; +import { CanvasWrapper } from "./styles"; +import { ZoomHandlerService } from "./zoomHandler"; /** - * Canvas component for creating, displaying and editing an Instance. + * Properties for the Canvas component. * - * @param {ServiceModel[]} services - The list of service models. - * @param {string} mainServiceName - The name of the main service. - * @param {InstanceWithRelations} instance - The instance with references. - * @returns {JSX.Element} The rendered Canvas component. + * @interface + * @prop {boolean} editable - A flag indicating if the diagram is editable. */ -const Canvas: React.FC<{ - services: ServiceModel[]; - mainServiceName: string; - instance?: InstanceWithRelations; +interface Props { editable: boolean; -}> = ({ services, mainServiceName, instance, editable = true }) => { - const { environmentHandler, routeManager } = useContext(DependencyContext); - const environment = environmentHandler.useId(); - const oderMutation = usePostOrder(environment); - const metadataMutation = usePostMetadata(environment); - const canvas = useRef(null); - const [looseEmbedded, setLooseEmbedded] = useState>(new Set()); - const [alertMessage, setAlertMessage] = useState(""); - const [alertType, setAlertType] = useState(AlertVariant.danger); - const [isFormModalOpen, setIsFormModalOpen] = useState(false); - const [cellToEdit, setCellToEdit] = useState(null); - const [dictToDisplay, setDictToDisplay] = useState( - null, - ); - const [diagramHandlers, setDiagramHandlers] = - useState(null); - const [instancesToSend, setInstancesToSend] = useState< - Map - >(new Map()); - const [isDirty, setIsDirty] = useState(false); - - const navigate = useNavigate(); - const url = routeManager.useUrl("Inventory", { - service: mainServiceName, - }); - - /** - * Handles the event triggered when there are loose embedded entities on the canvas. - * - * @param {CustomEvent} event - The event object. - */ - const handleLooseEmbeddedEvent = (event) => { - const customEvent = event as CustomEvent; - const eventData: { kind: "remove" | "add"; id: string } = JSON.parse( - customEvent.detail, - ); - - if (eventData.kind === "remove") { - setLooseEmbedded((prevSet) => { - const newSet = new Set(prevSet); +} - newSet.delete(eventData.id); - - return newSet; - }); - } else { - setLooseEmbedded((prevSet) => { - const newSet = new Set(prevSet); - - newSet.add(eventData.id); - - return newSet; - }); +/** + * Canvas component for creating, displaying and editing an Instance. + * + * @props {Props} props - The properties passed to the component. + * @prop {boolean} editable - A flag indicating if the diagram is editable. + * + * @returns {React.FC} The rendered Canvas component. + */ +export const Canvas: React.FC = ({ editable }) => { + const { mainService, instance, serviceModels, relatedInventoriesQuery } = + useContext(InstanceComposerContext); + const { + setStencilState, + setServiceOrderItems, + diagramHandlers, + setDiagramHandlers, + } = useContext(CanvasContext); + const Canvas = useRef(null); + const LeftSidebar = useRef(null); + const ZoomHandler = useRef(null); + const [scroller, setScroller] = useState(null); + const [isStencilStateReady, setIsStencilStateReady] = useState(false); + + const [leftSidebar, setLeftSidebar] = useState(null); // without this state it could happen that cells would load before sidebar is ready so its state could be outdated + + // create stencil state and set flag to true to enable the other components to be created - the flag is created to allow the components to depend from that states, passing the state as a dependency would cause an infinite loop + useEffect(() => { + if (!mainService) { + return; } - }; - - /** - * Handles the event triggered when the user wants to see the dictionary properties of an entity. - * - * @param {CustomEvent} event - The event object. - */ - const handleDictEvent = (event) => { - const customEvent = event as CustomEvent; - - setDictToDisplay(JSON.parse(customEvent.detail)); - }; - - /** - * Handles the event triggered when the user wants to edit an entity. - * - * @param {CustomEvent} event - The event object. - */ - const handleEditEvent = (event) => { - const customEvent = event as CustomEvent; - - setCellToEdit(customEvent.detail); - setIsFormModalOpen(true); - }; + setStencilState(createStencilState(mainService, !!instance)); + setIsStencilStateReady(true); + }, [mainService, instance, setStencilState]); - /** - * Handles the filtering of the unchanged entities and sending serviceOrderItems to the backend. - * - */ - const handleDeploy = () => { - const coordinates = diagramHandlers?.getCoordinates(); - - const serviceOrderItems = getServiceOrderItems(instancesToSend, services) - .filter((item) => item.action !== null) - .map((instance) => ({ - ...instance, - metadata: { - coordinates: JSON.stringify(coordinates), - }, - })); - - //Temporary workaround to update coordinates in metadata, as currently order endpoint don't handle metadata in the updates. - // can't test in jest as I can't add any test-id to the halo handles though. - if (instance) { - metadataMutation.mutate({ - service_entity: mainServiceName, - service_id: instance.instance.id, - key: "coordinates", - body: { - current_version: instance.instance.version, - value: JSON.stringify(coordinates), - }, - }); + // create the diagram & set diagram handlers and the scroller only when service models and main service is defined and the stencil state is ready + useEffect(() => { + if (!mainService || !serviceModels || !isStencilStateReady) { + return; } - oderMutation.mutate(serviceOrderItems); - }; - - /** - * Handles the update of a service entity block. - * - * @param {ServiceEntityBlock} cell - The service entity block to be updated. - * @param {ActionEnum} action - The action to be performed on the service entity block. - */ - const handleUpdate = (cell: ServiceEntityBlock, action: ActionEnum) => { - const newInstance: ComposerServiceOrderItem = { - instance_id: cell.id, - service_entity: cell.getName(), - config: {}, - action: null, - attributes: cell.get("sanitizedAttrs"), - edits: null, - embeddedTo: cell.get("embeddedTo"), - relatedTo: cell.getRelations(), - }; - - setInstancesToSend((prevInstances) => { - const updatedInstance = prevInstances.get(cell.id as string); - - switch (action) { - case "update": - newInstance.action = - updatedInstance?.action === "create" ? "create" : "update"; - - return new Map(prevInstances.set(cell.id as string, newInstance)); - case "create": - newInstance.action = action; - - return new Map(prevInstances.set(cell.id as string, newInstance)); - default: - if ( - updatedInstance && - (updatedInstance.action === null || - updatedInstance.action === "update") - ) { - return new Map( - prevInstances.set(cell.id as string, { - instance_id: cell.id, - service_entity: cell.getName(), - config: {}, - action: "delete", - attributes: null, - edits: null, - embeddedTo: cell.attributes.embeddedTo, - relatedTo: cell.attributes.relatedTo, - }), - ); - } else { - const newInstances = new Map(prevInstances); - - newInstances.delete(cell.id as string); - - return newInstances; - } - } - }); - }; - - useEffect(() => { - const connectionRules = createConnectionRules(services, {}); + const connectionRules = createConnectionRules(serviceModels, {}); const actions = diagramInit( - canvas, + Canvas, + (newScroller) => { + setScroller(newScroller); + }, connectionRules, - handleUpdate, editable, + mainService, ); setDiagramHandlers(actions); - if (instance) { - const isMainInstance = true; - - try { - const cells = actions.addInstance(instance, services, isMainInstance); - const newInstances = new Map(); - - cells.forEach((cell) => { - if (cell.type === "app.ServiceEntityBlock") { - newInstances.set(cell.id, { - instance_id: cell.id, - service_entity: cell.entityName, - config: {}, - action: null, - attributes: cell.instanceAttributes, - embeddedTo: cell.embeddedTo, - relatedTo: cell.relatedTo, - }); - } - }); - setInstancesToSend(newInstances); - } catch (error) { - setAlertType(AlertVariant.danger); - setAlertMessage(String(error)); - } - } return () => { + setStencilState(createStencilState(mainService)); + setIsStencilStateReady(false); actions.removeCanvas(); }; - }, [instance, services, mainServiceName, editable]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mainService, serviceModels, isStencilStateReady]); + /** + * create the left-sidebar only if the left-sidebar ref, the scroller, the related inventories by inter-service relations, the main service and service models are defined + * It's done in separate useEffect to enable eventual re-renders of the sidebar independently of the diagram, e.g. when the related inventories by inter-service relations are loaded + */ useEffect(() => { - if (!isDirty) { - setIsDirty( - Array.from(instancesToSend).filter( - ([_key, item]) => item.action !== null, - ).length > 0, - ); + if ( + !LeftSidebar.current || + !scroller || + !relatedInventoriesQuery.data || + !mainService || + !serviceModels + ) { + return; } - }, [instancesToSend, services, isDirty]); + const leftSidebar = new StencilSidebar( + LeftSidebar.current, + scroller, + relatedInventoriesQuery.data, + mainService, + serviceModels, + ); + + setLeftSidebar(leftSidebar); + + return () => leftSidebar.remove(); + }, [scroller, relatedInventoriesQuery.data, mainService, serviceModels]); + + /** + * add the instances to the diagram only when the stencil sidebar, diagram handlers, service models, main service and stencil state are defined + * we need to add instances after stencil has been created to ensure that the stencil will get updated in case there are any embedded entities and relations that will get appendend on the canvas + */ useEffect(() => { - if (oderMutation.isSuccess) { - //If response is successful then show feedback notification and redirect user to the service inventory view - setAlertType(AlertVariant.success); - setAlertMessage(words("inventory.instanceComposer.success")); - - setTimeout(() => { - navigate(url); - }, 1000); - } else if (oderMutation.isError) { - setAlertType(AlertVariant.danger); - setAlertMessage(oderMutation.error.message); + if ( + !leftSidebar || + !diagramHandlers || + !serviceModels || + !mainService || + !isStencilStateReady + ) { + return; } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [oderMutation.isSuccess, oderMutation.isError]); + const newInstances = new Map(); + + const cells = diagramHandlers.addInstance(serviceModels, instance); + + cells.forEach((cell) => { + newInstances.set(cell.id, { + instance_id: cell.id, + service_entity: cell.get("entityName"), + config: {}, + action: instance ? null : "create", + attributes: cell.get("instanceAttributes"), + embeddedTo: cell.get("embeddedTo"), + relatedTo: cell.get("relatedTo"), + }); + }); - useEffect(() => { - document.addEventListener("openDictsModal", handleDictEvent); - document.addEventListener("openEditModal", handleEditEvent); - document.addEventListener("looseEmbedded", handleLooseEmbeddedEvent); + setServiceOrderItems(newInstances); return () => { - document.removeEventListener("openDictsModal", handleDictEvent); - document.removeEventListener("openEditModal", handleEditEvent); - document.addEventListener("looseEmbedded", handleLooseEmbeddedEvent); + setStencilState(createStencilState(mainService)); + setIsStencilStateReady(false); }; - }, []); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [diagramHandlers, isStencilStateReady, leftSidebar]); + + /** + * add the zoom handler only when the scroller is defined as it's needed to create the zoom handler + */ + useEffect(() => { + if (!ZoomHandler.current || !scroller) { + return; + } + const zoomHandler = new ZoomHandlerService(ZoomHandler.current, scroller); + + return () => zoomHandler.remove(); + }, [scroller]); return ( - <> - {alertMessage && ( - - )} - - { - if (cellToEdit && !value) { - setCellToEdit(null); - } - setIsFormModalOpen(value); - }} - services={services} - cellView={cellToEdit} - onConfirm={(fields, entity, selected) => { - if (diagramHandlers) { - const sanitizedAttrs = sanitizeAttributes(fields, entity); - - if (cellToEdit) { - //deep copy - const shape = diagramHandlers.editEntity( - cellToEdit, - selected.model as ServiceModel, - entity, - ); - - shape.set("sanitizedAttrs", sanitizedAttrs); - handleUpdate(shape, ActionEnum.UPDATE); - } else { - const shape = diagramHandlers.addEntity( - entity, - selected.model as ServiceModel, - selected.name === mainServiceName, - selected.isEmbedded, - selected.holderName, - ); - - shape.set("sanitizedAttrs", sanitizedAttrs); - handleUpdate(shape, ActionEnum.CREATE); - } - } - }} - /> - { - setIsFormModalOpen(true); - }} - serviceName={mainServiceName} - handleDeploy={handleDeploy} - isDeployDisabled={ - instancesToSend.size < 1 || !isDirty || looseEmbedded.size > 0 - } - editable={editable} - /> + + + -
- - - - + + + + - + ); }; -export default Canvas; - -const ZoomWrapper = styled.div` - display: flex; - gap: 1px; +/** + * Container for the zoom & fullscreen tools from JointJS + */ +const ZoomHandlerContainer = styled.div` position: absolute; - bottom: 16px; - right: 16px; - box-shadow: 0px 4px 4px 0px - var(--pf-v5-global--BackgroundColor--dark-transparent-200); - border-radius: 5px; - background: var(--pf-v5-global--BackgroundColor--dark-transparent-200); - - button { - display: flex; - width: 24px; - height: 22px; - flex-direction: column; - justify-content: center; - align-items: center; - flex-shrink: 0; - background: var(--pf-v5-global--BackgroundColor--100); - padding: 0; + bottom: 12px; + right: 316px; + filter: drop-shadow( + 0.05rem 0.2rem 0.2rem + var(--pf-v5-global--BackgroundColor--dark-transparent-200) + ); + + .joint-toolbar { + padding: 0.5rem 2rem; border: 0; + } - &.zoom-in { - border-top-left-radius: 4px; - border-bottom-left-radius: 4px; + button.joint-widget.joint-theme-default { + border: 0; + &:hover { + background: transparent; } - &.zoom-out { - border-top-right-radius: 4px; - border-bottom-right-radius: 4px; + } + + .joint-widget.joint-theme-default { + --slider-background: linear-gradient( + to right, + var(--pf-v5-global--active-color--100) 0%, + var(--pf-v5-global--active-color--100) 20%, + var(--pf-v5-global--palette--black-400) 20%, + var(--pf-v5-global--palette--black-400) 100% + ); + + output { + color: var(--pf-v5-global--palette--black-400); } - &:hover { - background: var(--pf-v5-global--BackgroundColor--light-300); + + .units { + color: var(--pf-v5-global--palette--black-400); } - &:active { - background: var(--pf-v5-global--Color--light-300); + + /*********** Baseline, reset styles ***********/ + input[type="range"] { + -webkit-appearance: none; + appearance: none; + background: var(--slider-background); + cursor: pointer; + width: 8rem; + margin-right: 0.5rem; + } + + /* Removes default focus */ + input[type="range"]:focus { + outline: none; + } + + /******** Chrome, Safari, Opera and Edge Chromium styles ********/ + /* slider track */ + input[type="range"]::-webkit-slider-runnable-track { + background: var(--slider-background); + border-radius: 0.5rem; + height: 0.15rem; + } + + /* slider thumb */ + input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; /* Override default look */ + appearance: none; + margin-top: -3.6px; /* Centers thumb on the track */ + background-color: var(--pf-v5-global--active-color--100); + border-radius: 0.5rem; + height: 0.7rem; + width: 0.7rem; + } + + /*********** Firefox styles ***********/ + /* slider track */ + input[type="range"]::-moz-range-track { + background-color: var(--slider-background); + border-radius: 0.5rem; + height: 0.15rem; + } + + /* slider thumb */ + input[type="range"]::-moz-range-thumb { + background-color: var(--pf-v5-global--active-color--100); + border: none; /*Removes extra border that FF applies*/ + border-radius: 0.5rem; + height: 0.7rem; + width: 0.7rem; + } + + input[type="range"]:focus::-moz-range-thumb { + outline: 3px solid var(--pf-v5-global--active-color--100); + outline-offset: 0.125rem; } } `; + +/** + * Container for the JointJS canvas. + */ +const CanvasContainer = styled.div` + width: calc(100% - 540px); //240 left sidebar + 300 right sidebar + height: 100%; + background: var(--pf-v5-global--BackgroundColor--light-300); + + * { + font-family: var(--pf-v5-global--FontFamily--monospace); + } + .joint-paper-background { + background: var(--pf-v5-global--BackgroundColor--light-300); + } + + .source-arrowhead, + .target-arrowhead { + fill: var(--pf-v5-global--palette--black-500); + stroke-width: 1; + } +`; + +/** + * To be able to have draggable items on the canvas, we need to have a stencil container to which we append the JointJS stencil objects that handle the drag and drop functionality. + */ +const LeftSidebarContainer = styled.div` + width: 240px; + height: 100%; + background: var(--pf-v5-global--BackgroundColor--100); + filter: drop-shadow( + 0.1rem 0.1rem 0.15rem + var(--pf-v5-global--BackgroundColor--dark-transparent-200) + ); +`; diff --git a/src/UI/Components/Diagram/Context/CanvasProvider.tsx b/src/UI/Components/Diagram/Context/CanvasProvider.tsx new file mode 100644 index 000000000..3a90aa9ba --- /dev/null +++ b/src/UI/Components/Diagram/Context/CanvasProvider.tsx @@ -0,0 +1,89 @@ +import React, { useEffect, useState } from "react"; +import { dia } from "@inmanta/rappid"; +import { Field, InstanceAttributeModel } from "@/Core"; +import { DiagramHandlers } from "../init"; +import { + ComposerServiceOrderItem, + DictDialogData, + StencilState, +} from "../interfaces"; +import { CanvasContext } from "./Context"; + +/** + * CanvasProvider component + * + * This component is a context provider for the Canvas component and its children. + * It maintains the state for various properties related to the canvas and also provides functions to update these states. + * + * @props {React.PropsWithChildren} props - The properties that define the behavior and display of the component. + * @prop {React.ReactNode} children - The children components to be wrapped by the provider. + * + * @returns {React.FC>} The CanvasProvider component. + */ +export const CanvasProvider: React.FC> = ({ + children, +}) => { + const [looseElement, setLooseElement] = useState>(new Set()); + const [cellToEdit, setCellToEdit] = useState(null); + const [dictToDisplay, setDictToDisplay] = useState( + null, + ); + const [fields, setFields] = useState([]); + const [formState, setFormState] = useState({}); + const [serviceOrderItems, setServiceOrderItems] = useState< + Map + >(new Map()); + const [isDirty, setIsDirty] = useState(false); + const [diagramHandlers, setDiagramHandlers] = + useState(null); + const [stencilState, setStencilState] = useState(null); + + useEffect(() => { + // check if any of the edited serviceOrderItems got its action changed from default - its a condition to disable the deploy button when we are in the edit view + if (!isDirty) { + setIsDirty( + Array.from(serviceOrderItems).filter( + ([_key, item]) => item.action !== null, + ).length > 0, + ); + } + }, [serviceOrderItems, isDirty]); + + return ( + { + setDiagramHandlers(value); + }, + dictToDisplay, + setDictToDisplay: (value: DictDialogData | null) => { + setDictToDisplay(value); + }, + cellToEdit, + setCellToEdit: (value: dia.CellView | null) => { + setCellToEdit(value); + }, + looseElement, + setLooseElement: (value: Set) => { + setLooseElement(value); + }, + fields, + setFields: (value: Field[]) => { + setFields(value); + }, + formState, + setFormState: (value: InstanceAttributeModel) => { + setFormState(value); + }, + serviceOrderItems, + setServiceOrderItems, + stencilState, + setStencilState, + isDirty, + }} + > + {children} + + ); +}; diff --git a/src/UI/Components/Diagram/Context/ComposerCreatorProvider.tsx b/src/UI/Components/Diagram/Context/ComposerCreatorProvider.tsx new file mode 100644 index 000000000..bb1ea9fbd --- /dev/null +++ b/src/UI/Components/Diagram/Context/ComposerCreatorProvider.tsx @@ -0,0 +1,134 @@ +import React, { useContext, useEffect, useState } from "react"; +import { useGetAllServiceModels } from "@/Data/Managers/V2/GETTERS/GetAllServiceModels"; +import { useGetInventoryList } from "@/Data/Managers/V2/GETTERS/GetInventoryList"; +import { DependencyContext, words } from "@/UI"; +import { ErrorView, LoadingView } from "@/UI/Components"; +import { Canvas } from "@/UI/Components/Diagram/Canvas"; +import { findInterServiceRelations } from "../helpers"; +import { CanvasProvider } from "./CanvasProvider"; +import { InstanceComposerContext } from "./Context"; + +/** + * Props interface for the ComposerCreatorProvider component + * + * This interface represents the properties that the ComposerCreatorProvider component expects to receive. + * + * @interface + * @property {string} serviceName - The name of the service for which inter-service related services and inventories are being fetched. + */ +interface Props { + serviceName: string; +} + +/** + * ComposerCreatorProvider component + * + * This component is responsible for providing the service model related data to the Canvas component through Context. + * It fetches the service models for the entire catalog, and the inventories for all the Inter-service relations that can be connected to the created instance. + * It also handles the state and effects related to these data. + * + * @props {Props} props - The properties that define the behavior and display of the component. + * @prop {string} serviceName - The name of the service for which inter-service related services and inventories are being fetched. + + * @returns {React.FC} The ComposerCreatorProvider component. + */ +export const ComposerCreatorProvider: React.FC = ({ serviceName }) => { + const [interServiceRelationNames, setInterServiceRelationNames] = useState< + string[] + >([]); + const { environmentHandler } = useContext(DependencyContext); + const environment = environmentHandler.useId(); + + const serviceModels = useGetAllServiceModels(environment).useContinuous(); + + const relatedInventoriesQuery = useGetInventoryList( + interServiceRelationNames, + environment, + ).useContinuous(); + + useEffect(() => { + if (serviceModels.isSuccess) { + const mainService = serviceModels.data.find( + (service) => service.name === serviceName, + ); + + if (mainService) { + setInterServiceRelationNames(findInterServiceRelations(mainService)); + } + } + }, [serviceModels.isSuccess, serviceName, serviceModels.data]); + + if (serviceModels.isError) { + const message = words("error.general")(serviceModels.error.message); + const retry = serviceModels.refetch; + const ariaLabel = "ComposerCreatorProvider-serviceModels_failed"; + + return renderErrorView(message, ariaLabel, retry); + } + + if (relatedInventoriesQuery.isError) { + const message = words("error.general")( + relatedInventoriesQuery.error.message, + ); + const retry = relatedInventoriesQuery.refetch; + const ariaLabel = "ComposerCreatorProvider-relatedInventoriesQuery_failed"; + + return renderErrorView(message, ariaLabel, retry); + } + + if (serviceModels.isSuccess) { + const mainService = serviceModels.data.find( + (service) => service.name === serviceName, + ); + + if (!mainService) { + const message = words("instanceComposer.noServiceModel.errorMessage")( + serviceName, + ); + const retry = serviceModels.refetch; + const ariaLabel = "ComposerCreatorProvider-noServiceModel_failed"; + + return renderErrorView(message, ariaLabel, retry); + } + + return ( + + + + + + ); + } + + return ; +}; + +/** + * Renders an error view component. + * + * @param {string} message - The error message to display. + * @param {string} ariaLabel - The ARIA label for accessibility. + * @param {Function} retry - The function to call when retrying the action. + * + * @returns {React.ReactElement} The rendered error view component. + */ +export const renderErrorView = ( + message: string, + ariaLabel: string, + retry: () => void, +): React.ReactElement => ( + +); diff --git a/src/UI/Components/Diagram/Context/ComposerEditorProvider.tsx b/src/UI/Components/Diagram/Context/ComposerEditorProvider.tsx new file mode 100644 index 000000000..c33ab8268 --- /dev/null +++ b/src/UI/Components/Diagram/Context/ComposerEditorProvider.tsx @@ -0,0 +1,151 @@ +import React, { useContext, useEffect, useMemo, useState } from "react"; +import { useGetAllServiceModels } from "@/Data/Managers/V2/GETTERS/GetAllServiceModels"; +import { useGetInstanceWithRelations } from "@/Data/Managers/V2/GETTERS/GetInstanceWithRelations"; +import { useGetInventoryList } from "@/Data/Managers/V2/GETTERS/GetInventoryList"; +import { DependencyContext, words } from "@/UI"; +import { ErrorView, LoadingView } from "@/UI/Components"; +import { Canvas } from "@/UI/Components/Diagram/Canvas"; +import { findInterServiceRelations } from "../helpers"; +import { CanvasProvider } from "./CanvasProvider"; +import { InstanceComposerContext } from "./Context"; +import { renderErrorView } from "."; + +/** + * Props interface for the ComposerEditorProvider component + * + * This interface represents the properties that the ComposerEditorProvider component expects to receive. + * + * @interface + * @prop {string} serviceName - The name of the service to be fetched. + * @prop {string} instance - The ID of the instance to be fetched. + * @prop {boolean} editable - A flag indicating if the canvas should editable. + */ +interface Props { + serviceName: string; + instance: string; + editable: boolean; +} + +/** + * ComposerEditorProvider component + * + * This component is responsible for providing the service model related data to the Canvas component through Context. + * It fetches the service models for the entire catalog, and the inventories for the all Inter-service relations that can be connected to the created instance and most importantly instance user want to edit with all it's closest inter-service relations. + * The difference from ComposerCreatorProvider is that this component also fetches the instance data, it's done to avoid unnecessary requests when displaying composer for creating new instances + * It also handles the state and effects related to these data. + * + * @props {Props} props - The properties that define the behavior and display of the component. + * @prop {string} serviceName - The name of the service for which the instance is being fetched. + * @prop {string} instance - The ID of the instance to be fetched. + * @prop {boolean} editable - A flag indicating if the instance is editable. + * + * @returns {React.FC} The ComposerEditorProvider component. + */ +export const ComposerEditorProvider: React.FC = ({ + serviceName, + instance, + editable, +}) => { + const [interServiceRelationNames, setInterServiceRelationNames] = useState< + string[] + >([]); + const { environmentHandler } = useContext(DependencyContext); + const environment = environmentHandler.useId(); + + const serviceModelsQuery = + useGetAllServiceModels(environment).useContinuous(); + + const mainService = useMemo(() => { + const data = serviceModelsQuery.data; + + if (data) { + return data.find((service) => service.name === serviceName); + } else { + return undefined; + } + }, [serviceModelsQuery.data, serviceName]); + + const instanceWithRelationsQuery = useGetInstanceWithRelations( + instance, + environment, + mainService, + ).useOneTime(); + + const relatedInventoriesQuery = useGetInventoryList( + interServiceRelationNames, + environment, + ).useContinuous(); + + useEffect(() => { + if (serviceModelsQuery.isSuccess) { + if (mainService) { + setInterServiceRelationNames(findInterServiceRelations(mainService)); + } + } + }, [ + serviceModelsQuery.isSuccess, + serviceName, + serviceModelsQuery.data, + mainService, + ]); + + if (serviceModelsQuery.isError) { + return ( + + ); + } + if (instanceWithRelationsQuery.isError) { + const message = words("error.general")( + instanceWithRelationsQuery.error.message, + ); + const retry = instanceWithRelationsQuery.refetch; + const ariaLabel = "ComposerEditor-instanceWithRelationsQuery_failed"; + + return renderErrorView(message, ariaLabel, retry); + } + + if (relatedInventoriesQuery.isError) { + const message = words("error.general")( + relatedInventoriesQuery.error.message, + ); + const retry = relatedInventoriesQuery.refetch; + const ariaLabel = "ComposerEditor-relatedInventoriesQuery_failed"; + + return renderErrorView(message, ariaLabel, retry); + } + + if (serviceModelsQuery.isSuccess && instanceWithRelationsQuery.isSuccess) { + if (!mainService) { + const message = words("instanceComposer.noServiceModel.errorMessage")( + serviceName, + ); + const retry = serviceModelsQuery.refetch; + const ariaLabel = "ComposerEditor-NoMainService_failed"; + + return renderErrorView(message, ariaLabel, retry); + } + + return ( + + + + + + ); + } + + return ; +}; diff --git a/src/UI/Components/Diagram/Context/Context.tsx b/src/UI/Components/Diagram/Context/Context.tsx new file mode 100644 index 000000000..3845c604e --- /dev/null +++ b/src/UI/Components/Diagram/Context/Context.tsx @@ -0,0 +1,112 @@ +import { createContext } from "react"; +import { dia } from "@inmanta/rappid"; +import { UseQueryResult } from "@tanstack/react-query"; +import { Field, InstanceAttributeModel, ServiceModel } from "@/Core"; +import { InstanceWithRelations } from "@/Data/Managers/V2/GETTERS/GetInstanceWithRelations"; +import { Inventories } from "@/Data/Managers/V2/GETTERS/GetInventoryList"; +import { DiagramHandlers } from "../init"; +import { + ComposerServiceOrderItem, + DictDialogData, + StencilState, +} from "../interfaces"; + +/** + * The InstanceComposerCreatorProviderInterface + * Reflects the InstanceComposerContext. + */ +interface InstanceComposerCreatorProviderInterface { + instance: InstanceWithRelations | null; + serviceModels: ServiceModel[]; + mainService: ServiceModel; + relatedInventoriesQuery: UseQueryResult; +} + +/** + * InstanceComposerContext + * Should be used to provide context to the Composer Page. + */ +export const InstanceComposerContext = + createContext({ + instance: null, + serviceModels: [], + mainService: {} as ServiceModel, + relatedInventoriesQuery: {} as UseQueryResult, + }); + +/** + * The CanvasProviderInterface + * Reflects the CanvasContext. + */ +interface CanvasProviderInterface { + diagramHandlers: DiagramHandlers | null; + setDiagramHandlers: (value: DiagramHandlers) => void; + + dictToDisplay: DictDialogData | null; + setDictToDisplay: (value: DictDialogData | null) => void; + + formState: InstanceAttributeModel; + setFormState: (value: InstanceAttributeModel) => void; + + fields: Field[]; + setFields: (value: Field[]) => void; + + cellToEdit: dia.CellView | null; + setCellToEdit: (value: dia.CellView | null) => void; + + looseElement: Set; + setLooseElement: (value: Set) => void; + + serviceOrderItems: Map; + setServiceOrderItems: React.Dispatch< + React.SetStateAction> + >; + + stencilState: StencilState | null; + setStencilState: React.Dispatch>; + + isDirty: boolean; +} + +/** + * CanvasContext is a React context that provides a way to share the state of the Composer between all its children components. + * It is used to share common information that can be considered "global" for a tree of React components. + * + * The context includes the following values: + * @prop {DiagramHandlers | null} diagramHandlers: Handlers for the diagram. + * @prop {(value: DiagramHandlers): void} setDiagramHandlers: Function to set the diagram handlers. + * @prop {DictDialogData | null} dictToDisplay: Dictionary to display. + * @prop {(value: DictDialogData | null): void} setDictToDisplay: Function to set the dictionary to display. + * @prop {InstanceAttributeModel} formState: The state of the form. + * @prop {(value: InstanceAttributeModel): void} setFormState: Function to set the state of the form. + * @prop { Field[]} fields: The form fields. + * @prop {(value: Field[]): void} setFields: Function to set the form fields. + * @prop {dia.CellView | null} cellToEdit: The cell to edit. + * @prop {(value: dia.CellView | null): void} setCellToEdit: Function to set the cell to edit. + * @prop {Set} looseElement: The set of loose embedded entities on canvas. + * @prop {(value: Set): void} setLooseElement: Function to set the loose embedded entities. + * @prop {Map} serviceOrderItems: The instances to send to the backend. + * @prop {React.Dispatch>>} setServiceOrderItems: Function to set the instances to send. + * @prop {StencilState | null} stencilState: The state of the stencil it holds information about amount of embedded entities of each type on the canvas, and limitation and minimal requirements of those in the instance. + * @prop {React.Dispatch>} setStencilState: Function to set the state of the stencil. + * @prop {boolean} isDirty: A flag indicating whether the canvas is dirty, which mean that service instance was modified. + */ +export const CanvasContext = createContext({ + diagramHandlers: null, + setDiagramHandlers: () => {}, + dictToDisplay: null, + setDictToDisplay: () => {}, + formState: {}, + setFormState: () => {}, + fields: [], + setFields: () => {}, + cellToEdit: null, + setCellToEdit: () => {}, + looseElement: new Set(), + setLooseElement: () => {}, + serviceOrderItems: new Map(), + setServiceOrderItems: () => {}, + stencilState: {}, + setStencilState: () => {}, + isDirty: false, +}); diff --git a/src/UI/Components/Diagram/Context/EventWrapper.test.tsx b/src/UI/Components/Diagram/Context/EventWrapper.test.tsx new file mode 100644 index 000000000..f0a22f6e5 --- /dev/null +++ b/src/UI/Components/Diagram/Context/EventWrapper.test.tsx @@ -0,0 +1,413 @@ +import React, { act, useContext, useEffect } from "react"; +import "@testing-library/jest-dom"; +import { screen } from "@testing-library/dom"; +import { render } from "@testing-library/react"; +import { ActionEnum } from "../interfaces"; +import { ServiceEntityBlock } from "../shapes"; +import { CanvasProvider } from "./CanvasProvider"; +import { CanvasContext } from "./Context"; +import { EventWrapper } from "./EventWrapper"; + +const setup = (testingComponent: JSX.Element) => { + return ( + + {testingComponent} + + ); +}; + +describe("looseElement event handler - triggered when entity is being added to the canvas, gets or loose connection to other entity - keep track on the unconnected entities", () => { + const TestingComponent = (): JSX.Element => { + const { looseElement } = useContext(CanvasContext); + + return ( +
+ {looseElement.size} +
+ ); + }; + + it("looseElement Event handler can successfully add and remove items", async () => { + render(setup()); + + expect(screen.getByTestId("looseElement")).toHaveTextContent("0"); + + await act(async () => { + document.dispatchEvent( + new CustomEvent("looseElement", { + detail: JSON.stringify({ kind: "add", id: "1" }), + }), + ); + }); + expect(screen.getByTestId("looseElement")).toHaveTextContent("1"); + + await act(async () => { + document.dispatchEvent( + new CustomEvent("looseElement", { + detail: JSON.stringify({ kind: "remove", id: "1" }), + }), + ); + }); + expect(screen.getByTestId("looseElement")).toHaveTextContent("0"); + }); + + it("looseElement Event handler won't duplicate the same id", async () => { + render(setup()); + + expect(screen.getByTestId("looseElement")).toHaveTextContent("0"); + + await act(async () => { + document.dispatchEvent( + new CustomEvent("looseElement", { + detail: JSON.stringify({ kind: "add", id: "1" }), + }), + ); + }); + expect(screen.getByTestId("looseElement")).toHaveTextContent("1"); + + await act(async () => { + document.dispatchEvent( + new CustomEvent("looseElement", { + detail: JSON.stringify({ kind: "add", id: "1" }), + }), + ); + }); + expect(screen.getByTestId("looseElement")).toHaveTextContent("1"); + }); +}); + +describe("dictToDisplay - event handler that accepts dictionary value to display it in the modal as we aren't displaying those in the canvas", () => { + const TestingComponent = (): JSX.Element => { + const { dictToDisplay } = useContext(CanvasContext); + + return ( +
+ {JSON.stringify(dictToDisplay)} +
+ ); + }; + + it("dictToDisplay Event handler assign the data correctly to the Context", async () => { + render(setup()); + + expect(screen.getByTestId("dictToDisplay")).toHaveTextContent("null"); + + await act(async () => { + document.dispatchEvent( + new CustomEvent("openDictsModal", { + detail: JSON.stringify({ test: "value" }), + }), + ); + }); + expect(screen.getByTestId("dictToDisplay")).toHaveTextContent( + '{"test":"value"}', + ); + }); +}); + +describe("cellToEdit - event handler that recieves cell object from the canvas to pass it to the Right Sidebar component", () => { + const TestingComponent = (): JSX.Element => { + const { cellToEdit } = useContext(CanvasContext); + + return ( +
+ {JSON.stringify(cellToEdit)} +
+ ); + }; + + it("looseElement Event handler assign the data correctly to the Context", async () => { + render(setup()); + + expect(screen.getByTestId("cellToEdit")).toHaveTextContent("null"); + + await act(async () => { + document.dispatchEvent( + new CustomEvent("sendCellToSidebar", { + detail: JSON.stringify({ test: "value" }), + }), + ); + }); + expect(screen.getByTestId("cellToEdit")).toHaveTextContent( + '{\\"test\\":\\"value\\"}', + ); + }); +}); + +describe("updateServiceOrderItems - event handler that keeps track of the elements of the instance that should be converted to the complete instance at the deploy", () => { + const InstancesComponent = (): JSX.Element => { + const { serviceOrderItems, setServiceOrderItems } = + useContext(CanvasContext); + + useEffect(() => { + const testInstances = new Map(); + + testInstances.set("1", { + attributes: "value1", + id: "1", + action: ActionEnum.CREATE, + }); + testInstances.set("2", { + attributes: "value2", + id: "2", + action: ActionEnum.CREATE, + }); + setServiceOrderItems(testInstances); + }, [setServiceOrderItems]); + + return ( +
+ + {JSON.stringify(Array.from(serviceOrderItems.keys()))} + + {Array.from(serviceOrderItems.entries()).map((value) => ( +
+ {JSON.stringify(value[1].attributes)} +
+ ))} +
+ ); + }; + + it("updateServiceOrderItems Event handler won't assign the data when update is for the the inter-service relation instance that aren't added in the Context", async () => { + await act(async () => render(setup())); + + expect(screen.getByTestId("instancesIds")).toHaveTextContent('["1","2"]'); + + await act(async () => { + document.dispatchEvent( + new CustomEvent("updateServiceOrderItems", { + detail: { + cell: new ServiceEntityBlock() + .set("attributes", "value3") + .set("id", "3"), + action: ActionEnum.UPDATE, + }, + }), + ); + }); + + expect(screen.getByTestId("instancesIds")).toHaveTextContent('["1","2"]'); + }); + + it("updateServiceOrderItems Event handler will add instance to the Context", async () => { + await act(async () => render(setup())); + + expect(screen.getByTestId("instancesIds")).toHaveTextContent('["1","2"]'); + + await act(async () => { + document.dispatchEvent( + new CustomEvent("updateServiceOrderItems", { + detail: { + cell: new ServiceEntityBlock() + .set("sanitizedAttrs", "value3") + .set("id", "3"), + action: ActionEnum.CREATE, + }, + }), + ); + }); + + expect(screen.getByTestId("instancesIds")).toHaveTextContent( + '["1","2","3"]', + ); + expect(screen.getByTestId("3")).toHaveTextContent("value3"); + }); + + it("updateServiceOrderItems Event handler updates correctly the instance", async () => { + await act(async () => render(setup())); + + expect(screen.getByTestId("2")).toHaveTextContent("value2"); + + await act(async () => { + document.dispatchEvent( + new CustomEvent("updateServiceOrderItems", { + detail: { + cell: new ServiceEntityBlock() + .set("sanitizedAttrs", "updatedValue2") + .set("id", "2"), + action: ActionEnum.UPDATE, + }, + }), + ); + }); + expect(screen.getByTestId("2")).toHaveTextContent("updatedValue2"); + }); +}); + +describe("updateStencil - eventHandler that updates how many elements(embedded/inter-service relation) are in the canvas, to keep track to disable/enable stencil elements from the left sidebar", () => { + const TestingComponent = (): JSX.Element => { + const { stencilState, setStencilState } = useContext(CanvasContext); + + useEffect(() => { + setStencilState({ + test: { current: 0, min: 0, max: 1 }, + test2: { current: 0, min: 0, max: null }, + }); + }, [setStencilState]); + + if (!stencilState) { + return
Empty
; + } + + return ( +
+ {Object.keys(stencilState).map((key) => ( + + {stencilState[key].current} + + ))} + {Object.keys(stencilState).map((key) => ( +
+
+
+
+
+ ))} +
+ ); + }; + + it("updateStencil Event handler assign the data correctly to the Context", async () => { + render(setup()); + + expect(screen.getByTestId("test-current")).toHaveTextContent("0"); + expect(screen.getByTestId("test2-current")).toHaveTextContent("0"); + + await act(async () => { + document.dispatchEvent( + new CustomEvent("updateStencil", { + detail: { name: "test", action: "add" }, + }), + ); + }); + + expect(screen.getByTestId("test-current")).toHaveTextContent("1"); + expect(screen.getByTestId("test2-current")).toHaveTextContent("0"); + + await act(async () => { + document.dispatchEvent( + new CustomEvent("updateStencil", { + detail: { name: "test2", action: "add" }, + }), + ); + }); + + expect(screen.getByTestId("test-current")).toHaveTextContent("1"); + expect(screen.getByTestId("test2-current")).toHaveTextContent("1"); + + await act(async () => { + document.dispatchEvent( + new CustomEvent("updateStencil", { + detail: { name: "test3", action: "add" }, + }), + ); + }); + + expect(screen.getByTestId("test-current")).toHaveTextContent("1"); + expect(screen.getByTestId("test2-current")).toHaveTextContent("1"); + expect(screen.queryByTestId("test23-current")).toBeNull(); + + await act(async () => { + document.dispatchEvent( + new CustomEvent("updateStencil", { + detail: { name: "test", action: "remove" }, + }), + ); + }); + + expect(screen.getByTestId("test-current")).toHaveTextContent("0"); + expect(screen.getByTestId("test2-current")).toHaveTextContent("1"); + + await act(async () => { + document.dispatchEvent( + new CustomEvent("updateStencil", { + detail: { name: "test2", action: "remove" }, + }), + ); + }); + + expect(screen.getByTestId("test-current")).toHaveTextContent("0"); + expect(screen.getByTestId("test2-current")).toHaveTextContent("0"); + }); + + it("updateStencil Event handler correctly apply classNames to the elements in the DOM", async () => { + render(setup()); + + expect(screen.getByTestId("body_test")).not.toHaveClass( + "stencil_accent-disabled", + ); + expect(screen.getByTestId("bodyTwo_test")).not.toHaveClass( + "stencil_body-disabled", + ); + expect(screen.getByTestId("text_test")).not.toHaveClass( + "stencil_text-disabled", + ); + expect(screen.getByTestId("body_test2")).not.toHaveClass( + "stencil_accent-disabled", + ); + expect(screen.getByTestId("bodyTwo_test2")).not.toHaveClass( + "stencil_body-disabled", + ); + expect(screen.getByTestId("text_test2")).not.toHaveClass( + "stencil_text-disabled", + ); + + await act(async () => { + document.dispatchEvent( + new CustomEvent("updateStencil", { + detail: { name: "test", action: "add" }, + }), + ); + }); + + //expect disabled classes as test got current increased to max value + expect(screen.getByTestId("body_test")).toHaveClass( + "stencil_accent-disabled", + ); + expect(screen.getByTestId("bodyTwo_test")).toHaveClass( + "stencil_body-disabled", + ); + expect(screen.getByTestId("text_test")).toHaveClass( + "stencil_text-disabled", + ); + + await act(async () => { + document.dispatchEvent( + new CustomEvent("updateStencil", { + detail: { name: "test2", action: "add" }, + }), + ); + }); + + //expect no disabled classes as test2 doesn't have max set + expect(screen.getByTestId("body_test2")).not.toHaveClass( + "stencil_accent-disabled", + ); + expect(screen.getByTestId("bodyTwo_test2")).not.toHaveClass( + "stencil_body-disabled", + ); + expect(screen.getByTestId("text_test2")).not.toHaveClass( + "stencil_text-disabled", + ); + + await act(async () => { + document.dispatchEvent( + new CustomEvent("updateStencil", { + detail: { name: "test", action: "remove" }, + }), + ); + }); + + //expect no disabled classes as test got current decreased to 0, below max value + expect(screen.getByTestId("body_test")).not.toHaveClass( + "stencil_accent-disabled", + ); + expect(screen.getByTestId("bodyTwo_test")).not.toHaveClass( + "stencil_body-disabled", + ); + expect(screen.getByTestId("text_test")).not.toHaveClass( + "stencil_text-disabled", + ); + }); +}); diff --git a/src/UI/Components/Diagram/Context/EventWrapper.tsx b/src/UI/Components/Diagram/Context/EventWrapper.tsx new file mode 100644 index 000000000..7dbb56ace --- /dev/null +++ b/src/UI/Components/Diagram/Context/EventWrapper.tsx @@ -0,0 +1,190 @@ +import React, { useContext, useEffect } from "react"; +import { updateServiceOrderItems } from "../helpers"; +import { ActionEnum, EmbeddedEventEnum } from "../interfaces"; +import { ServiceEntityBlock } from "../shapes"; +import { CanvasContext } from "./Context"; + +/** + * EventWrapper component + * + * This component is a higher-order component that wraps its children and provides event handling for all necessary communication from within JointJS code to the Composer. + * It uses the CanvasContext to get and set various state variables. + * + * @props {React.PropsWithChildren} props - The properties that define the behavior and display of the component. + * @prop {React.ReactNode} children - The children components to be wrapped. + * + * @returns {React.FC} The EventWrapper component. + */ +export const EventWrapper: React.FC = ({ + children, +}) => { + const { + setStencilState, + setServiceOrderItems, + setCellToEdit, + setDictToDisplay, + looseElement, + setLooseElement, + } = useContext(CanvasContext); + + /** + * Handles the event triggered when there are loose embedded entities on the canvas. + * The loose embedded entities are the entities that are not connected to the main entity. If there are any, they will be highlighted and deploy button will be disabled. + * + * @param {CustomEvent} event - The event object. + * + * @returns {void} + */ + const handleLooseElementEvent = (event): void => { + const customEvent = event as CustomEvent; + const eventData: { kind: EmbeddedEventEnum; id: string } = JSON.parse( + customEvent.detail, + ); + const newSet = new Set(looseElement); + + if (eventData.kind === "remove") { + newSet.delete(eventData.id); + } else { + newSet.add(eventData.id); + } + setLooseElement(newSet); + }; + + /** + * Handles the event triggered when the user wants to see the dictionary properties of an entity. + * + * @param {CustomEvent} event - The event object. + * + * @returns {void} + */ + const handleDictEvent = (event): void => { + const customEvent = event as CustomEvent; + + setDictToDisplay(JSON.parse(customEvent.detail)); + }; + + /** + * Handles the event triggered when the user wants to edit an entity. + * + * @param {CustomEvent} event - The event object. + * + * @returns {void} + */ + const handleEditEvent = (event): void => { + const customEvent = event as CustomEvent; + + setCellToEdit(customEvent.detail); + }; + + /** + * Handles the event triggered when the user made update to the instance cell + * we need to assert first if the instance triggered the event is in the serviceOrderItems map, + * as the inter-service related instances aren't added to the serviceOrderItems map as we don't want to accidentally delete or edit them, + * yet they can exist on the canvas and be removed from it. + * + * @param {CustomEvent} event - The event object. + * + * @returns {void} + */ + const handleUpdateServiceOrderItems = (event): void => { + const customEvent = event as CustomEvent; + const { cell, action } = customEvent.detail as { + cell: ServiceEntityBlock; + action: ActionEnum; + }; + + setServiceOrderItems((prev) => { + // inter-service related instances aren't added to the serviceOrderItems map, this condition is here to prevent situation where we try to remove the inter-service related instance from the canvas and it ends up here with status to delete it from the inventory + if (prev.has(String(cell.id)) || action === ActionEnum.CREATE) { + return updateServiceOrderItems(cell, action, prev); + } + + return prev; + }); + }; + + /** + * Handles updating the stencil state + * If the current count of elements created from a certain type of stencil is more than or equal to the max count, the corresponding stencil element will be disabled. + * + * @param {CustomEvent} event - The event object. + * + * @returns {void} + */ + const handleUpdateStencilState = (event): void => { + const customEvent = event as CustomEvent; + const eventData: { name: string; action: EmbeddedEventEnum } = + customEvent.detail; + + //event listener doesn't get updated state outside setStencilState function, so all logic has to be done inside it + setStencilState((prev) => { + const stencilStateCopy = JSON.parse(JSON.stringify(prev)); + + const name = eventData.name; + const stencil = stencilStateCopy[name]; + + // If the stencil doesn't exist, return the previous state - that's the case when we add inter-service related instance through appendInstance() function which is connected through it's own embedded entity - then the stencil state doesn't have that stencil stored + if (!stencil) { + return stencilStateCopy; + } + + switch (eventData.action) { + case EmbeddedEventEnum.ADD: + stencil.current += 1; + break; + case EmbeddedEventEnum.REMOVE: + stencil.current -= 1; + break; + default: + break; + } + + const elements = [ + { selector: `.body_${name}`, className: "stencil_accent-disabled" }, + { selector: `.bodyTwo_${name}`, className: "stencil_body-disabled" }, + { selector: `.text_${name}`, className: "stencil_text-disabled" }, + ]; + + const shouldDisable = + stencil.max !== null && + stencil.max !== undefined && + stencil.current >= stencil.max; + + // As in the docstrings mentioned, If the current count of the instances created from given stencil is more than or equal to the max count, disable the stencil of given embedded entity + elements.forEach(({ selector, className }) => { + const element = document.querySelector(selector); + + if (element) { + element.classList.toggle(className, shouldDisable); + } + }); + + return stencilStateCopy; + }); + }; + + useEffect(() => { + document.addEventListener("openDictsModal", handleDictEvent); + document.addEventListener("sendCellToSidebar", handleEditEvent); + document.addEventListener("looseElement", handleLooseElementEvent); + document.addEventListener( + "updateServiceOrderItems", + handleUpdateServiceOrderItems, + ); + document.addEventListener("updateStencil", handleUpdateStencilState); + + return () => { + document.removeEventListener("openDictsModal", handleDictEvent); + document.removeEventListener("sendCellToSidebar", handleEditEvent); + document.removeEventListener("looseElement", handleLooseElementEvent); + document.removeEventListener( + "updateServiceOrderItems", + handleUpdateServiceOrderItems, + ); + document.removeEventListener("updateStencil", handleUpdateStencilState); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return <>{children}; +}; diff --git a/src/UI/Components/Diagram/Context/index.ts b/src/UI/Components/Diagram/Context/index.ts new file mode 100644 index 000000000..b759cf208 --- /dev/null +++ b/src/UI/Components/Diagram/Context/index.ts @@ -0,0 +1,5 @@ +export * from "./EventWrapper"; +export * from "./Context"; +export * from "./ComposerEditorProvider"; +export * from "./ComposerCreatorProvider"; +export * from "./CanvasProvider"; diff --git a/src/UI/Components/Diagram/Mock.ts b/src/UI/Components/Diagram/Mocks/Mock.ts similarity index 81% rename from src/UI/Components/Diagram/Mock.ts rename to src/UI/Components/Diagram/Mocks/Mock.ts index 8bfd6f8ff..f3e66ad4c 100644 --- a/src/UI/Components/Diagram/Mock.ts +++ b/src/UI/Components/Diagram/Mocks/Mock.ts @@ -1471,7 +1471,7 @@ export const testParentService: ComposerServiceOrderItem = { }, }; -export const relatedServices: ComposerServiceOrderItem[] = [ +export const interServiceRelations: ComposerServiceOrderItem[] = [ { instance_id: "13920268-cce0-4491-93b5-11316aa2fc37", service_entity: "child-service", @@ -1537,7 +1537,7 @@ export const mockedInstanceWithRelations: InstanceWithRelations = { service_identity_attribute_value: "test12345", referenced_by: [], }, - relatedInstances: [ + interServiceRelations: [ { id: "7cd8b669-597a-4341-9b71-07f550b89826", environment: "efa7c243-81aa-4986-b0b1-c89583cbf846", @@ -1608,7 +1608,9 @@ export const mockedInstanceTwo: InstanceWithRelations = { attrFour: "789", service_id: "012", should_deploy_fail: false, - dictTwo: {}, + dictTwo: { + data: "string", + }, }, rollback_attributes: null, created_at: "2023-09-19T14:39:30.770002", @@ -1619,7 +1621,7 @@ export const mockedInstanceTwo: InstanceWithRelations = { service_identity_attribute_value: "test12345", referenced_by: [], }, - relatedInstances: [], + interServiceRelations: [], }; export const mockedInstanceTwoServiceModel: ServiceModel = { @@ -2365,6 +2367,7 @@ export const mockedInstanceTwoServiceModel: ServiceModel = { }, total: 1, }, + key_attributes: ["dictOne"], owner: null, owned_entities: [], }; @@ -2398,7 +2401,7 @@ export const mockedInstanceThree: InstanceWithRelations = { service_identity_attribute_value: "test12345", referenced_by: [], }, - relatedInstances: [], + interServiceRelations: [], }; export const mockedInstanceThreeServiceModel: ServiceModel = { @@ -3144,6 +3147,7 @@ export const containerModel: ServiceModel = { total: 1, }, }; + export const childModel: ServiceModel = { attributes: [ { @@ -3844,3 +3848,859 @@ export const childModel: ServiceModel = { total: 1, }, }; + +export const parentModel: ServiceModel = { + attributes: [ + { + name: "name", + description: undefined, + modifier: "rw", + attribute_annotations: {}, + type: "string", + default_value: null, + default_value_set: false, + validation_type: null, + validation_parameters: null, + }, + { + name: "should_deploy_fail", + description: undefined, + modifier: "rw+", + attribute_annotations: {}, + type: "bool", + default_value: false, + default_value_set: true, + validation_type: null, + validation_parameters: null, + }, + { + name: "service_id", + description: undefined, + modifier: "rw", + attribute_annotations: {}, + type: "string", + default_value: null, + default_value_set: false, + validation_type: null, + validation_parameters: null, + }, + ], + embedded_entities: [], + inter_service_relations: [], + environment: "fadd0253-fdbc-4822-a046-2c7bb9eaa68a", + name: "parent-service", + description: undefined, + lifecycle: { + states: [ + { + name: "delete_validating_up", + label: "success", + export_resources: true, + validate_self: null, + validate_others: "active", + purge_resources: false, + deleted: false, + values: {}, + }, + { + name: "update_acknowledged_failed", + label: "warning", + export_resources: true, + validate_self: "candidate", + validate_others: "active", + purge_resources: false, + deleted: false, + values: {}, + }, + { + name: "start", + label: null, + export_resources: false, + validate_self: "candidate", + validate_others: null, + purge_resources: false, + deleted: false, + values: {}, + }, + { + name: "delete_validating_update_failed", + label: "warning", + export_resources: true, + validate_self: null, + validate_others: "active", + purge_resources: false, + deleted: false, + values: {}, + }, + { + name: "acknowledged", + label: null, + export_resources: false, + validate_self: "candidate", + validate_others: null, + purge_resources: false, + deleted: false, + values: {}, + }, + { + name: "rejected", + label: "danger", + export_resources: false, + validate_self: "candidate", + validate_others: null, + purge_resources: false, + deleted: false, + values: {}, + }, + { + name: "update_rejected", + label: "warning", + export_resources: true, + validate_self: "candidate", + validate_others: "active", + purge_resources: false, + deleted: false, + values: {}, + }, + { + name: "update_rejected_failed", + label: "warning", + export_resources: true, + validate_self: "candidate", + validate_others: "active", + purge_resources: false, + deleted: false, + values: {}, + }, + { + name: "creating", + label: null, + export_resources: true, + validate_self: "active", + validate_others: "active", + purge_resources: false, + deleted: false, + values: {}, + }, + { + name: "update_inprogress", + label: null, + export_resources: true, + validate_self: "active", + validate_others: "active", + purge_resources: false, + deleted: false, + values: {}, + }, + { + name: "failed", + label: "danger", + export_resources: true, + validate_self: "active", + validate_others: "active", + purge_resources: false, + deleted: false, + values: {}, + }, + { + name: "update_failed", + label: "warning", + export_resources: true, + validate_self: "active", + validate_others: "active", + purge_resources: false, + deleted: false, + values: {}, + }, + { + name: "up", + label: "success", + export_resources: true, + validate_self: "active", + validate_others: "active", + purge_resources: false, + deleted: false, + values: {}, + }, + { + name: "deleting", + label: null, + export_resources: true, + validate_self: "active", + validate_others: "active", + purge_resources: true, + deleted: false, + values: {}, + }, + { + name: "terminated", + label: null, + export_resources: false, + validate_self: null, + validate_others: null, + purge_resources: false, + deleted: true, + values: {}, + }, + { + name: "rollback", + label: null, + export_resources: true, + validate_self: "active", + validate_others: "active", + purge_resources: false, + deleted: false, + values: {}, + }, + { + name: "update_start", + label: null, + export_resources: true, + validate_self: "candidate", + validate_others: "active", + purge_resources: false, + deleted: false, + values: {}, + }, + { + name: "delete_validating_creating", + label: "info", + export_resources: true, + validate_self: null, + validate_others: "active", + purge_resources: false, + deleted: false, + values: {}, + }, + { + name: "update_start_failed", + label: "warning", + export_resources: true, + validate_self: "candidate", + validate_others: "active", + purge_resources: false, + deleted: false, + values: {}, + }, + { + name: "delete_validating_failed", + label: "warning", + export_resources: true, + validate_self: null, + validate_others: "active", + purge_resources: false, + deleted: false, + values: {}, + }, + { + name: "update_acknowledged", + label: null, + export_resources: true, + validate_self: "candidate", + validate_others: "active", + purge_resources: false, + deleted: false, + values: {}, + }, + ], + transfers: [ + { + source: "start", + target: "acknowledged", + error: "rejected", + on_update: false, + on_delete: false, + api_set_state: false, + resource_based: false, + auto: true, + validate: true, + config_name: null, + description: "initial validation", + target_operation: null, + error_operation: null, + }, + { + source: "acknowledged", + target: "creating", + error: null, + on_update: false, + on_delete: false, + api_set_state: false, + resource_based: false, + auto: true, + validate: false, + config_name: null, + description: "ack to creating", + target_operation: "promote", + error_operation: null, + }, + { + source: "creating", + target: "up", + error: "failed", + on_update: false, + on_delete: false, + api_set_state: false, + resource_based: true, + auto: false, + validate: false, + config_name: null, + description: "creating done", + target_operation: null, + error_operation: null, + }, + { + source: "up", + target: "update_start", + error: null, + on_update: true, + on_delete: false, + api_set_state: false, + resource_based: false, + auto: false, + validate: false, + config_name: null, + description: "up to update_start", + target_operation: null, + error_operation: null, + }, + { + source: "update_start", + target: "update_acknowledged", + error: "update_rejected", + on_update: false, + on_delete: false, + api_set_state: false, + resource_based: false, + auto: true, + validate: true, + config_name: null, + description: "update_start to update_ack", + target_operation: null, + error_operation: null, + }, + { + source: "update_start_failed", + target: "update_acknowledged_failed", + error: "update_rejected_failed", + on_update: false, + on_delete: false, + api_set_state: false, + resource_based: false, + auto: true, + validate: true, + config_name: null, + description: "update_start_failed to update_ack_failed", + target_operation: null, + error_operation: null, + }, + { + source: "update_acknowledged", + target: "update_inprogress", + error: null, + on_update: false, + on_delete: false, + api_set_state: false, + resource_based: false, + auto: true, + validate: false, + config_name: null, + description: "update_ack to update_inprogress", + target_operation: "promote", + error_operation: null, + }, + { + source: "update_acknowledged_failed", + target: "update_inprogress", + error: null, + on_update: false, + on_delete: false, + api_set_state: false, + resource_based: false, + auto: true, + validate: false, + config_name: null, + description: "update_ack_failed to update_inprogress_failed", + target_operation: "promote", + error_operation: null, + }, + { + source: "update_inprogress", + target: "up", + error: "update_failed", + on_update: false, + on_delete: false, + api_set_state: false, + resource_based: true, + auto: false, + validate: false, + config_name: null, + description: "update done", + target_operation: null, + error_operation: null, + }, + { + source: "update_start", + target: "update_start", + error: "update_start_failed", + on_update: false, + on_delete: false, + api_set_state: false, + resource_based: true, + auto: false, + validate: false, + config_name: null, + description: "update start failed", + target_operation: null, + error_operation: null, + }, + { + source: "update_rejected", + target: "update_rejected", + error: "update_rejected_failed", + on_update: false, + on_delete: false, + api_set_state: false, + resource_based: true, + auto: false, + validate: false, + config_name: null, + description: "upd_reject failed", + target_operation: null, + error_operation: null, + }, + { + source: "update_start_failed", + target: "update_start", + error: "update_start_failed", + on_update: false, + on_delete: false, + api_set_state: false, + resource_based: true, + auto: false, + validate: false, + config_name: null, + description: "update start recover", + target_operation: null, + error_operation: null, + }, + { + source: "update_rejected_failed", + target: "update_rejected", + error: "update_rejected_failed", + on_update: false, + on_delete: false, + api_set_state: false, + resource_based: true, + auto: false, + validate: false, + config_name: null, + description: "upd_reject recover", + target_operation: null, + error_operation: null, + }, + { + source: "update_rejected", + target: "up", + error: null, + on_update: false, + on_delete: false, + api_set_state: true, + resource_based: false, + auto: false, + validate: false, + config_name: null, + description: "abort update", + target_operation: "clear candidate", + error_operation: null, + }, + { + source: "update_rejected_failed", + target: "failed", + error: null, + on_update: false, + on_delete: false, + api_set_state: true, + resource_based: false, + auto: false, + validate: false, + config_name: null, + description: "abort update failed", + target_operation: "clear candidate", + error_operation: null, + }, + { + source: "update_failed", + target: "rollback", + error: null, + on_update: false, + on_delete: false, + api_set_state: true, + resource_based: false, + auto: false, + validate: false, + config_name: null, + description: "rollback in update_failed", + target_operation: "rollback", + error_operation: null, + }, + { + source: "rollback", + target: "up", + error: "failed", + on_update: false, + on_delete: false, + api_set_state: false, + resource_based: true, + auto: false, + validate: false, + config_name: null, + description: "rollback done", + target_operation: null, + error_operation: null, + }, + { + source: "up", + target: "up", + error: "failed", + on_update: false, + on_delete: false, + api_set_state: false, + resource_based: true, + auto: false, + validate: false, + config_name: null, + description: "up to failed", + target_operation: null, + error_operation: null, + }, + { + source: "failed", + target: "up", + error: "failed", + on_update: false, + on_delete: false, + api_set_state: false, + resource_based: true, + auto: false, + validate: false, + config_name: null, + description: "failed to up", + target_operation: null, + error_operation: null, + }, + { + source: "update_failed", + target: "up", + error: "update_failed", + on_update: false, + on_delete: false, + api_set_state: false, + resource_based: true, + auto: false, + validate: false, + config_name: null, + description: "update_failed to up", + target_operation: null, + error_operation: null, + }, + { + source: "rejected", + target: "start", + error: null, + on_update: true, + on_delete: false, + api_set_state: false, + resource_based: false, + auto: false, + validate: false, + config_name: null, + description: "rejected to start", + target_operation: null, + error_operation: null, + }, + { + source: "update_rejected", + target: "update_start", + error: null, + on_update: true, + on_delete: false, + api_set_state: false, + resource_based: false, + auto: false, + validate: false, + config_name: null, + description: "update_rejected to start_update", + target_operation: null, + error_operation: null, + }, + { + source: "update_rejected_failed", + target: "update_start_failed", + error: null, + on_update: true, + on_delete: false, + api_set_state: false, + resource_based: false, + auto: false, + validate: false, + config_name: null, + description: "update_rejected_failed to start_update_failed", + target_operation: null, + error_operation: null, + }, + { + source: "deleting", + target: "terminated", + error: "deleting", + on_update: false, + on_delete: false, + api_set_state: false, + resource_based: true, + auto: false, + validate: false, + config_name: null, + description: "deletion done", + target_operation: null, + error_operation: null, + }, + { + source: "rejected", + target: "terminated", + error: null, + on_update: false, + on_delete: true, + api_set_state: false, + resource_based: false, + auto: false, + validate: false, + config_name: null, + description: "rejected to terminated", + target_operation: null, + error_operation: null, + }, + { + source: "up", + target: "delete_validating_up", + error: null, + on_update: false, + on_delete: true, + api_set_state: false, + resource_based: false, + auto: false, + validate: false, + config_name: null, + description: "up to delete_validating_up", + target_operation: null, + error_operation: null, + }, + { + source: "creating", + target: "delete_validating_creating", + error: null, + on_update: false, + on_delete: true, + api_set_state: false, + resource_based: false, + auto: false, + validate: false, + config_name: null, + description: "creating to delete_validating_creating", + target_operation: null, + error_operation: null, + }, + { + source: "failed", + target: "delete_validating_failed", + error: null, + on_update: false, + on_delete: true, + api_set_state: false, + resource_based: false, + auto: false, + validate: false, + config_name: null, + description: "failed to delete_validating_failed", + target_operation: null, + error_operation: null, + }, + { + source: "update_failed", + target: "delete_validating_update_failed", + error: null, + on_update: false, + on_delete: true, + api_set_state: false, + resource_based: false, + auto: false, + validate: false, + config_name: null, + description: "update_failed to delete_validating_update_failed", + target_operation: null, + error_operation: null, + }, + { + source: "delete_validating_up", + target: "delete_validating_up", + error: "delete_validating_up", + on_update: false, + on_delete: false, + api_set_state: false, + resource_based: true, + auto: false, + validate: false, + config_name: null, + description: "delete_validating_up to delete_validating_up", + target_operation: null, + error_operation: null, + }, + { + source: "delete_validating_failed", + target: "delete_validating_failed", + error: "delete_validating_failed", + on_update: false, + on_delete: false, + api_set_state: false, + resource_based: true, + auto: false, + validate: false, + config_name: null, + description: "delete_validating_failed to delete_validating_failed", + target_operation: null, + error_operation: null, + }, + { + source: "delete_validating_creating", + target: "delete_validating_creating", + error: "delete_validating_creating", + on_update: false, + on_delete: false, + api_set_state: false, + resource_based: true, + auto: false, + validate: false, + config_name: null, + description: "delete_validating_creating to delete_validating_creating", + target_operation: null, + error_operation: null, + }, + { + source: "delete_validating_update_failed", + target: "delete_validating_update_failed", + error: "delete_validating_update_failed", + on_update: false, + on_delete: false, + api_set_state: false, + resource_based: true, + auto: false, + validate: false, + config_name: null, + description: + "delete_validating_update_failed to delete_validating_update_failed", + target_operation: null, + error_operation: null, + }, + { + source: "delete_validating_up", + target: "deleting", + error: "up", + on_update: false, + on_delete: false, + api_set_state: false, + resource_based: false, + auto: true, + validate: true, + config_name: null, + description: "validating delete", + target_operation: null, + error_operation: null, + }, + { + source: "delete_validating_creating", + target: "deleting", + error: "creating", + on_update: false, + on_delete: false, + api_set_state: false, + resource_based: false, + auto: true, + validate: true, + config_name: null, + description: "validating delete", + target_operation: null, + error_operation: null, + }, + { + source: "delete_validating_failed", + target: "deleting", + error: "failed", + on_update: false, + on_delete: false, + api_set_state: false, + resource_based: false, + auto: true, + validate: true, + config_name: null, + description: "validating delete", + target_operation: null, + error_operation: null, + }, + { + source: "delete_validating_update_failed", + target: "deleting", + error: "update_failed", + on_update: false, + on_delete: false, + api_set_state: false, + resource_based: false, + auto: true, + validate: true, + config_name: null, + description: "validating delete", + target_operation: null, + error_operation: null, + }, + ], + initial_state: "start", + name: "lsm::simple_with_delete_validate", + }, + config: {}, + service_identity: "name", + service_identity_display_name: undefined, + strict_modifier_enforcement: true, + owner: null, + relation_to_owner: null, + instance_summary: { + by_state: { + up: 1, + delete_validating_up: 0, + update_acknowledged_failed: 0, + start: 0, + delete_validating_update_failed: 0, + acknowledged: 0, + rejected: 0, + update_rejected: 0, + update_rejected_failed: 0, + creating: 0, + update_inprogress: 0, + failed: 0, + update_failed: 0, + deleting: 0, + rollback: 0, + update_start: 0, + delete_validating_creating: 0, + update_start_failed: 0, + delete_validating_failed: 0, + update_acknowledged: 0, + }, + by_label: { + no_label: 0, + info: 0, + success: 1, + danger: 0, + warning: 0, + }, + total: 1, + }, + owned_entities: [], +}; diff --git a/src/UI/Components/Diagram/Mocks/index.ts b/src/UI/Components/Diagram/Mocks/index.ts new file mode 100644 index 000000000..557e2be45 --- /dev/null +++ b/src/UI/Components/Diagram/Mocks/index.ts @@ -0,0 +1 @@ +export * from "./Mock"; diff --git a/src/UI/Components/Diagram/Mocks/instance.json b/src/UI/Components/Diagram/Mocks/instance.json index 0101e2d29..3900618ff 100644 --- a/src/UI/Components/Diagram/Mocks/instance.json +++ b/src/UI/Components/Diagram/Mocks/instance.json @@ -111,5 +111,5 @@ "referenced_by": null } }, - "relatedInstances": [] + "interServiceRelations": [] } diff --git a/src/UI/Components/Diagram/actions.test.ts b/src/UI/Components/Diagram/actions.test.ts new file mode 100644 index 000000000..b5e748cf2 --- /dev/null +++ b/src/UI/Components/Diagram/actions.test.ts @@ -0,0 +1,769 @@ +import { dia } from "@inmanta/rappid"; +import { EmbeddedEntity, InstanceAttributeModel, ServiceModel } from "@/Core"; +import { InstanceWithRelations } from "@/Data/Managers/V2/GETTERS/GetInstanceWithRelations"; +import { + childModel, + containerModel, + mockedInstanceWithRelations, + parentModel, +} from "./Mocks"; +import services from "./Mocks/services.json"; +import { + addDefaultEntities, + appendEmbeddedEntity, + appendInstance, + createComposerEntity, + addInfoIcon, + populateGraphWithDefault, + updateAttributes, +} from "./actions"; +import { ComposerPaper } from "./paper"; +import { ServiceEntityBlock } from "./shapes"; +import { defineObjectsForJointJS } from "./testSetup"; + +beforeAll(() => { + defineObjectsForJointJS(); +}); + +describe("createComposerEntity", () => { + it("creates a new core entity", () => { + const coreEntity = createComposerEntity({ + serviceModel: parentModel, + isCore: true, + isInEditMode: false, + }); + + expect(coreEntity.get("holderName")).toBe(undefined); + expect(coreEntity.get("isEmbedded")).toBe(undefined); + expect(coreEntity.get("isCore")).toBe(true); + expect(coreEntity.get("isInEditMode")).toBe(false); + }); + + it("creates a new embedded entity", () => { + const embeddedEntity = createComposerEntity({ + serviceModel: containerModel.embedded_entities[0], + isCore: false, + isInEditMode: false, + isEmbedded: true, + holderName: containerModel.name, + }); + + expect(embeddedEntity.get("holderName")).toBe(containerModel.name); + expect(embeddedEntity.get("isEmbedded")).toBe(true); + expect(embeddedEntity.get("isCore")).toBe(undefined); + expect(embeddedEntity.get("isInEditMode")).toBe(false); + }); + it("creates a new entity with inster-service relations", () => { + const childEntity = createComposerEntity({ + serviceModel: childModel, + isCore: false, + isInEditMode: false, + }); + + expect(childEntity.get("holderName")).toBe(undefined); + expect(childEntity.get("isEmbedded")).toBe(undefined); + expect(childEntity.get("isCore")).toBe(undefined); + expect(childEntity.get("isInEditMode")).toBe(false); + expect(childEntity.get("relatedTo")).toMatchObject(new Map()); + }); +}); + +describe("updateAttributes", () => { + it("set attributes, sanitizedAttrs and displayed items on initial update", () => { + const instanceAsTable = new ServiceEntityBlock().setName(parentModel.name); + const attributes = mockedInstanceWithRelations.instance + .active_attributes as InstanceAttributeModel; // instance based on parent-service model + const isInitial = true; + + updateAttributes( + instanceAsTable, + ["name", "service_id"], + attributes, + isInitial, + ); + + expect(instanceAsTable.get("sanitizedAttrs")).toMatchObject({ + name: "test12345", + service_id: "123412", + should_deploy_fail: false, + }); + expect(instanceAsTable.get("instanceAttributes")).toMatchObject({ + name: "test12345", + service_id: "123412", + should_deploy_fail: false, + }); + expect(instanceAsTable.get("items")).toMatchObject([ + [ + { + id: "name", + label: "name", + span: 2, + }, + { + id: "service_id", + label: "service_id", + span: 2, + }, + ], + [ + { + id: "name_value", + label: "test12345", + }, + { + id: "service_id_value", + label: "123412", + }, + ], + ]); + }); + + it("when there is no key attributes only attributes and sanitizedAttrs are set", () => { + const instanceAsTable = new ServiceEntityBlock().setName(parentModel.name); + const attributes = mockedInstanceWithRelations.instance + .active_attributes as InstanceAttributeModel; // instance based on parent-service model + const isInitial = true; + + updateAttributes(instanceAsTable, [], attributes, isInitial); + + expect(instanceAsTable.get("sanitizedAttrs")).toMatchObject({ + name: "test12345", + service_id: "123412", + should_deploy_fail: false, + }); + expect(instanceAsTable.get("instanceAttributes")).toMatchObject({ + name: "test12345", + service_id: "123412", + should_deploy_fail: false, + }); + expect(instanceAsTable.get("items")).toMatchObject([[], []]); + }); + + it("sanitized Attributes won't be overridden if isInitial property is set to false or if there are sanitizedAttributes already set", () => { + //sanitizedAttrs property is updated from the sidebar level as it requires fields to be present + const instanceAsTable = new ServiceEntityBlock().setName(parentModel.name); + const attributes = mockedInstanceWithRelations.instance + .active_attributes as InstanceAttributeModel; // instance based on parent-service model + const isInitial = true; + + updateAttributes(instanceAsTable, [], attributes, isInitial); + + expect(instanceAsTable.get("sanitizedAttrs")).toMatchObject({ + name: "test12345", + service_id: "123412", + should_deploy_fail: false, + }); + expect(instanceAsTable.get("instanceAttributes")).toMatchObject({ + name: "test12345", + service_id: "123412", + should_deploy_fail: false, + }); + expect(instanceAsTable.get("items")).toMatchObject([[], []]); + + const updatedIsInitial = false; + const updatedAttributes = { + name: "newName", + service_id: "newId", + should_deploy_fail: false, + }; + + updateAttributes(instanceAsTable, [], updatedAttributes, updatedIsInitial); + + expect(instanceAsTable.get("sanitizedAttrs")).toMatchObject({ + name: "test12345", + service_id: "123412", + should_deploy_fail: false, + }); + expect(instanceAsTable.get("instanceAttributes")).toMatchObject({ + name: "newName", + service_id: "newId", + should_deploy_fail: false, + }); + + const updatedIsInitial2 = true; + + updateAttributes(instanceAsTable, [], updatedAttributes, updatedIsInitial2); + + expect(instanceAsTable.get("sanitizedAttrs")).toMatchObject({ + name: "test12345", + service_id: "123412", + should_deploy_fail: false, + }); + expect(instanceAsTable.get("instanceAttributes")).toMatchObject({ + name: "newName", + service_id: "newId", + should_deploy_fail: false, + }); + }); +}); + +describe("createComposerEntity", () => { + it("return empty array for service without embedded entities to add to the graph ", () => { + const graph = new dia.Graph({}); + const embedded = addDefaultEntities(graph, parentModel); + + expect(embedded).toMatchObject([]); + }); + + it("adds default entity for service with embedded entities to the graph ", () => { + const dispatchEventSpy = jest.spyOn(document, "dispatchEvent"); + const graph = new dia.Graph({}); + + const embedded = addDefaultEntities(graph, containerModel); + + expect(dispatchEventSpy).toHaveBeenCalledTimes(1); + + //assert the arguments of the first call - calls is array of the arguments of each call + expect( + (dispatchEventSpy.mock.calls[0][0] as CustomEvent).detail, + ).toMatchObject({ + action: "add", + name: "child_container", + }); + expect(embedded.length).toBe(1); + + expect(embedded[0].getName()).toStrictEqual("child_container"); + }); + + it("adds default entity for service with nested embedded entities to the graph ", () => { + const dispatchEventSpy = jest.spyOn(document, "dispatchEvent"); + + const graph = new dia.Graph({}); + const attributes = { + name: "", + }; + + const createdEntity1 = createComposerEntity({ + serviceModel: { + ...containerModel.embedded_entities[0], + embedded_entities: [{ ...containerModel.embedded_entities[0] }], + }, + isCore: false, + isInEditMode: false, + attributes, + isEmbedded: true, + holderName: "container-service", + }); + const createdEntity2 = createComposerEntity({ + serviceModel: containerModel.embedded_entities[0], + isCore: false, + isInEditMode: false, + attributes, + isEmbedded: true, + holderName: "child_container", + }); + + addDefaultEntities(graph, { + ...containerModel, + embedded_entities: [ + { + ...containerModel.embedded_entities[0], + embedded_entities: containerModel.embedded_entities, + }, + ], + }); + + expect(dispatchEventSpy).toHaveBeenCalledTimes(2); + + //assert the arguments of the first call - calls is array of the arguments of each call + expect( + (dispatchEventSpy.mock.calls[0][0] as CustomEvent).detail, + ).toMatchObject({ + action: "add", + name: "child_container", + }); + //assert the arguments of the second call + expect( + (dispatchEventSpy.mock.calls[1][0] as CustomEvent).detail, + ).toMatchObject({ + action: "add", + name: "child_container", + }); + + const addedCells = graph + .getCells() + .filter((cell) => cell.get("type") !== "Link") as ServiceEntityBlock[]; + + //we return only top level embedded entities from addDefaultEntities so to get all we need to check graph directly + expect(addedCells).toHaveLength(2); + + expect(addedCells[0].get("embeddedTo")).toStrictEqual( + createdEntity1.get("embeddedTo"), + ); + + expect(addedCells[0].get("embeddedTo")).toStrictEqual( + createdEntity2.get("embeddedTo"), + ); + }); +}); + +describe("populateGraphWithDefault", () => { + it("adds default entity for instance without embedded entities to the graph", () => { + const graph = new dia.Graph({}); + + populateGraphWithDefault(graph, parentModel); + const addedCells = graph.getCells() as ServiceEntityBlock[]; + + expect(addedCells).toHaveLength(1); + + expect(addedCells[0].getName()).toStrictEqual(parentModel.name); + }); + + it("adds all required default entities for instance with embedded entities to the graph", () => { + const graph = new dia.Graph({}); + + populateGraphWithDefault(graph, containerModel); + const addedCells = graph + .getCells() + .filter((cell) => cell.get("type") !== "Link") as ServiceEntityBlock[]; + const AddedLinks = graph + .getCells() + .filter((cell) => cell.get("type") === "Link") as ServiceEntityBlock[]; + + expect(addedCells).toHaveLength(2); + expect(AddedLinks).toHaveLength(1); + + expect(addedCells[0].getName()).toBe(containerModel.name); + expect(addedCells[1].getName()).toBe( + containerModel.embedded_entities[0].name, + ); + expect(addedCells[1].get("isEmbedded")).toBeTruthy(); + expect(addedCells[1].get("holderName")).toBe(containerModel.name); + expect(addedCells[1].get("embeddedTo")).toBe(addedCells[0].id); + }); +}); + +describe("addInfoIcon", () => { + it('sets "info" attribute with active icon and active tooltip if presentedAttrs are set to active', () => { + const addedEntity = new ServiceEntityBlock(); + + expect(addedEntity.get("attrs")).toBeDefined(); + expect(addedEntity.get("attrs")?.info).toBeUndefined(); + + addInfoIcon(addedEntity, "active"); + expect(addedEntity.get("attrs")).toBeDefined(); + expect(addedEntity.get("attrs")?.info).toMatchObject({ + preserveAspectRatio: "none", + cursor: "pointer", + x: "calc(0.85*w)", + "xlink:href": expect.any(String), + "data-tooltip": "Active Attributes", + y: 8, + width: 14, + height: 14, + }); + }); + + it('sets "info" attribute with candidate icon and candidate tooltip if presentedAttrs are set to candidate', () => { + const addedEntity = new ServiceEntityBlock(); + + expect(addedEntity.get("attrs")).toBeDefined(); + expect(addedEntity.get("attrs")?.info).toBeUndefined(); + + addInfoIcon(addedEntity, "candidate"); + + expect(addedEntity.get("attrs")).toBeDefined(); + expect(addedEntity.get("attrs")?.info).toMatchObject({ + preserveAspectRatio: "none", + cursor: "pointer", + x: "calc(0.85*w)", + "xlink:href": expect.any(String), + "data-tooltip": "Candidate Attributes", + y: 6, + width: 15, + height: 15, + }); + }); +}); + +describe("appendEmbeddedEntity", () => { + const setup = () => { + const graph = new dia.Graph({}); + const paper = new ComposerPaper({}, graph, true).paper; + const embeddedModel = containerModel.embedded_entities[0]; + + return { graph, paper, embeddedModel }; + }; + + interface EachProps { + embeddedEntity: EmbeddedEntity; + entityAttributes: InstanceAttributeModel; + embeddedTo: string | dia.Cell.ID; + holderName: string; + presentedAttrs: "candidate" | "active"; + isBlockedFromEditing: boolean; + expectedMap: Map; + expectedCalls: number; + } + + it.each` + entityAttributes | embeddedTo | holderName | isBlockedFromEditing | expectedMap + ${{ name: "child123" }} | ${"123"} | ${"container-service"} | ${false} | ${new Map([])} + ${{ name: "child123", parent_entity: "1234" }} | ${"123"} | ${"container-service"} | ${false} | ${new Map().set("1234", "parent_entity")} + ${{ name: "child123" }} | ${"123"} | ${"container-service"} | ${true} | ${new Map([])} + ${{ name: "child123" }} | ${"123"} | ${"container-service"} | ${false} | ${new Map([])} + ${{ name: "child123" }} | ${"123"} | ${"container-service"} | ${false} | ${new Map([])} + ${{ name: "child123" }} | ${"123"} | ${"container-service"} | ${false} | ${new Map([])} + `( + "append embedded entity to the graph", + ({ + entityAttributes, + embeddedTo, + holderName, + isBlockedFromEditing, + expectedMap, + }: EachProps) => { + const dispatchEventSpy = jest.spyOn(document, "dispatchEvent"); + + const { graph, paper, embeddedModel } = setup(); + const presentedAttrs = undefined; + + appendEmbeddedEntity( + paper, + graph, + embeddedModel, + entityAttributes, + embeddedTo, + holderName, + presentedAttrs, + isBlockedFromEditing, + ); + + const cells = graph.getCells(); + + expect(cells).toHaveLength(1); + expect(cells[0].get("entityName")).toBe("child_container"); + expect(cells[0].get("holderName")).toBe("container-service"); + expect(cells[0].get("isEmbedded")).toBe(true); + expect(cells[0].get("embeddedTo")).toBe("123"); + + //we assign to this property attributes without inter-service relations + expect(cells[0].get("instanceAttributes")).toMatchObject({ + name: entityAttributes.name, + }); + + expect(cells[0].get("isBlockedFromEditing")).toBe(isBlockedFromEditing); + expect(cells[0].get("cantBeRemoved")).toBe(true); + expect(cells[0].get("relatedTo")).toMatchObject(expectedMap); + expect(dispatchEventSpy).toHaveBeenCalledTimes(1); + //assert the arguments of the first call - calls is array of the arguments of each call + expect( + (dispatchEventSpy.mock.calls[0][0] as CustomEvent).detail, + ).toMatchObject({ + action: "add", + name: "child_container", + }); + }, + ); + + it("append embedded entities to the graph and paper", () => { + const dispatchEventSpy = jest.spyOn(document, "dispatchEvent"); + + const { graph, paper, embeddedModel } = setup(); + const presentedAttrs = undefined; + const entityAttributes = [ + { name: "child123", parent_entity: "1234" }, + { name: "child1233", parent_entity: "12346" }, + ]; + const embeddedTo = "123"; + const holderName = "container-service"; + const expectedMap = new Map().set("1234", "parent_entity"); + const expectedMap2 = new Map().set("12346", "parent_entity"); + + appendEmbeddedEntity( + paper, + graph, + embeddedModel, + entityAttributes, + embeddedTo, + holderName, + presentedAttrs, + ); + + const cells = graph.getCells(); + + expect(cells).toHaveLength(2); + expect(dispatchEventSpy).toHaveBeenCalledTimes(2); + + //assert first cell + expect(cells[0].get("entityName")).toBe("child_container"); + expect(cells[0].get("isEmbedded")).toBe(true); + expect(cells[0].get("embeddedTo")).toBe("123"); + + expect(cells[0].get("isBlockedFromEditing")).toBe(false); + expect(cells[0].get("cantBeRemoved")).toBe(true); + expect(cells[0].get("relatedTo")).toMatchObject(expectedMap); + + //assert second cell + expect(cells[1].get("entityName")).toBe("child_container"); + expect(cells[1].get("isEmbedded")).toBe(true); + expect(cells[1].get("embeddedTo")).toBe("123"); + + expect(cells[1].get("isBlockedFromEditing")).toBe(false); + expect(cells[1].get("cantBeRemoved")).toBe(true); + expect(cells[1].get("relatedTo")).toMatchObject(expectedMap2); + }); + + const infoAssertion = (y, width, height, tooltip) => ({ + preserveAspectRatio: "none", + cursor: "pointer", + x: "calc(0.85*w)", + "xlink:href": expect.any(String), + "data-tooltip": tooltip, + y, + width, + height, + }); + + it.each` + presentedAttrs | expectedInfoObject + ${undefined} | ${undefined} + ${"active"} | ${infoAssertion(8, 14, 14, "Active Attributes")} + ${"candidate"} | ${infoAssertion(6, 15, 15, "Candidate Attributes")} + `( + "append nested embedded entity to the graph and paper", + ({ presentedAttrs, expectedInfoObject }) => { + const dispatchEventSpy = jest.spyOn(document, "dispatchEvent"); + + const { graph, paper } = setup(); + + const nestedEmbeddedModel: EmbeddedEntity = { + ...containerModel.embedded_entities[0], + embedded_entities: [ + { ...containerModel.embedded_entities[0], name: "nested_container" }, + ], + }; + + const entityAttributes = [ + { + name: "child123", + parent_entity: "1234", + nested_container: { name: "child1233", parent_entity: "12346" }, + }, + ]; + const embeddedTo = "123"; + const holderName = "container-service"; + const isBlockedFromEditing = false; + const expectedMap = new Map().set("1234", "parent_entity"); + const expectedMap2 = new Map().set("12346", "parent_entity"); + + appendEmbeddedEntity( + paper, + graph, + nestedEmbeddedModel, + entityAttributes, + embeddedTo, + holderName, + presentedAttrs, + ); + + expect(dispatchEventSpy).toHaveBeenCalledTimes(2); + + const cells = graph.getCells(); + const filteredCells = graph + .getCells() + .filter((cell) => cell.get("type") !== "Link"); + + expect(cells).toHaveLength(3); // 3rd cell is a Link + expect(filteredCells).toHaveLength(2); // 3rd cell is a Link + + //assert first cell + expect(filteredCells[0].get("entityName")).toBe("child_container"); + expect(filteredCells[0].get("holderName")).toBe("container-service"); + expect(filteredCells[0].get("isEmbedded")).toBe(true); + expect(filteredCells[0].get("embeddedTo")).toBe("123"); + + expect(filteredCells[0].get("isBlockedFromEditing")).toBe( + isBlockedFromEditing, + ); + expect(filteredCells[0].get("cantBeRemoved")).toBe(true); + expect(filteredCells[0].get("relatedTo")).toMatchObject(expectedMap); + expect(filteredCells[1].get("attrs")?.info).toStrictEqual( + expectedInfoObject, + ); + + //assert second cell + expect(filteredCells[1].get("entityName")).toBe("nested_container"); + expect(filteredCells[1].get("holderName")).toBe("child_container"); + + expect(filteredCells[1].get("isEmbedded")).toBe(true); + expect(filteredCells[1].get("embeddedTo")).toBe(filteredCells[0].id); + + expect(filteredCells[1].get("isBlockedFromEditing")).toBe( + isBlockedFromEditing, + ); + expect(filteredCells[1].get("cantBeRemoved")).toBe(true); + expect(filteredCells[1].get("relatedTo")).toMatchObject(expectedMap2); + expect(filteredCells[1].get("attrs")?.info).toStrictEqual( + expectedInfoObject, + ); + }, + ); +}); + +describe("appendInstance", () => { + const setup = () => { + const graph = new dia.Graph({}); + const paper = new ComposerPaper({}, graph, true).paper; + const serviceModels = services as unknown as ServiceModel[]; + + return { graph, paper, serviceModels }; + }; + + it("throws error if doesn't find serviceModel for given Instance", () => { + const { graph, paper } = setup(); + + expect(() => + appendInstance( + paper, + graph, + mockedInstanceWithRelations, + [], + true, + false, + ), + ).toThrow("The instance attribute model is missing"); + }); + + it("appends instance to the graph", () => { + const { graph, paper, serviceModels } = setup(); + + const mockedInstance: InstanceWithRelations = { + instance: mockedInstanceWithRelations.instance, + interServiceRelations: [], + }; + const isCore = true; + + appendInstance(paper, graph, mockedInstance, serviceModels, isCore); + + const cells = graph.getCells(); + + expect(cells).toHaveLength(1); + expect(cells[0].get("entityName")).toBe("parent-service"); + expect(cells[0].get("isCore")).toBe(true); + expect(cells[0].get("isBlockedFromEditing")).toBe(false); + expect(cells[0].get("isInEditMode")).toBe(true); + expect(cells[0].get("cantBeRemoved")).toBe(false); + expect(cells[0].get("sanitizedAttrs")).toStrictEqual({ + name: "test12345", + service_id: "123412", + should_deploy_fail: false, + }); + expect(cells[0].get("instanceAttributes")).toStrictEqual({ + name: "test12345", + service_id: "123412", + should_deploy_fail: false, + }); + }); + + it("appends instance with relations to the graph and paper", () => { + const { graph, paper, serviceModels } = setup(); + + appendInstance( + paper, + graph, + mockedInstanceWithRelations, + serviceModels, + true, + false, + ); + const cells = graph.getCells(); + + expect(cells).toHaveLength(7); + + const filteredCells = cells.filter((cell) => cell.get("type") !== "Link"); + + expect(filteredCells).toHaveLength(4); + + //assert that the first cell is the parent-service, which would be core instance and has it's attributes set as expected + expect(filteredCells[0].get("entityName")).toBe("parent-service"); + expect(filteredCells[0].get("isCore")).toBe(true); + expect(filteredCells[0].get("isEmbedded")).toBe(undefined); + expect(filteredCells[0].get("isBlockedFromEditing")).toBe(false); + expect(filteredCells[0].get("isInEditMode")).toBe(true); + expect(filteredCells[0].get("cantBeRemoved")).toBe(false); + expect(filteredCells[0].get("sanitizedAttrs")).toStrictEqual({ + name: "test12345", + service_id: "123412", + should_deploy_fail: false, + }); + expect(filteredCells[0].get("instanceAttributes")).toStrictEqual({ + name: "test12345", + service_id: "123412", + should_deploy_fail: false, + }); + + //assert that the first cell is the child-service, which would be inter-service relation in edit mode and has its attributes set as expected + expect(filteredCells[1].get("entityName")).toBe("child-service"); + expect(filteredCells[1].get("isCore")).toBe(undefined); + expect(filteredCells[1].get("isEmbedded")).toBe(undefined); + expect(filteredCells[1].get("isBlockedFromEditing")).toBe(true); + expect(filteredCells[1].get("isInEditMode")).toBe(true); + expect(filteredCells[1].get("cantBeRemoved")).toBe(false); + expect(filteredCells[3].get("relatedTo")).toStrictEqual( + new Map().set("085cdf92-0894-4b82-8d46-1dd9552e7ba3", "parent_entity"), + ); + expect(filteredCells[1].get("sanitizedAttrs")).toStrictEqual({ + name: "child-test", + parent_entity: "085cdf92-0894-4b82-8d46-1dd9552e7ba3", + service_id: "123523534623", + should_deploy_fail: false, + }); + expect(filteredCells[1].get("instanceAttributes")).toStrictEqual({ + name: "child-test", + parent_entity: "085cdf92-0894-4b82-8d46-1dd9552e7ba3", + service_id: "123523534623", + should_deploy_fail: false, + }); + + //assert that the first cell is the container-service, which would be main body of another inter-service relation in edit mode and has its attributes set as expected + expect(filteredCells[2].get("entityName")).toBe("container-service"); + expect(filteredCells[2].get("isCore")).toBe(undefined); + expect(filteredCells[2].get("isEmbedded")).toBe(undefined); + expect(filteredCells[2].get("isBlockedFromEditing")).toBe(true); + expect(filteredCells[2].get("isInEditMode")).toBe(true); + expect(filteredCells[2].get("cantBeRemoved")).toBe(false); + expect(filteredCells[2].get("sanitizedAttrs")).toStrictEqual({ + child_container: { + name: "123124124", + parent_entity: "085cdf92-0894-4b82-8d46-1dd9552e7ba3", + }, + name: "test-container1123", + service_id: "123412312", + should_deploy_fail: false, + }); + expect(filteredCells[2].get("instanceAttributes")).toStrictEqual({ + child_container: { + name: "123124124", + parent_entity: "085cdf92-0894-4b82-8d46-1dd9552e7ba3", + }, + name: "test-container1123", + service_id: "123412312", + should_deploy_fail: false, + }); + + //assert that the first cell is the container-service, which would be embedded entity of container-service and would be target point of inter-service relation in edit mode and has its attributes set as expected + expect(filteredCells[3].get("entityName")).toBe("child_container"); + expect(filteredCells[3].get("isCore")).toBe(undefined); + expect(filteredCells[3].get("isEmbedded")).toBe(true); + expect(filteredCells[3].get("isBlockedFromEditing")).toBe(true); + expect(filteredCells[3].get("isInEditMode")).toBe(true); + expect(filteredCells[3].get("cantBeRemoved")).toBe(true); + expect(filteredCells[3].get("holderName")).toBe("container-service"); + expect(filteredCells[3].get("embeddedTo")).toBe( + "1548332f-86ab-42fe-bd32-4f3adb9e650b", + ); + expect(filteredCells[3].get("relatedTo")).toStrictEqual( + new Map().set("085cdf92-0894-4b82-8d46-1dd9552e7ba3", "parent_entity"), + ); + + expect(filteredCells[3].get("sanitizedAttrs")).toStrictEqual({ + name: "123124124", + parent_entity: "085cdf92-0894-4b82-8d46-1dd9552e7ba3", + }); + expect(filteredCells[3].get("instanceAttributes")).toStrictEqual({ + name: "123124124", + parent_entity: "085cdf92-0894-4b82-8d46-1dd9552e7ba3", + }); + }); +}); diff --git a/src/UI/Components/Diagram/actions.ts b/src/UI/Components/Diagram/actions.ts index 28714b0cb..0e5b822fc 100644 --- a/src/UI/Components/Diagram/actions.ts +++ b/src/UI/Components/Diagram/actions.ts @@ -1,176 +1,97 @@ -import { dia, linkTools } from "@inmanta/rappid"; +import { dia } from "@inmanta/rappid"; import { DirectedGraph } from "@joint/layout-directed-graph"; import { EmbeddedEntity, InstanceAttributeModel, ServiceModel } from "@/Core"; import { InstanceWithRelations } from "@/Data/Managers/V2/GETTERS/GetInstanceWithRelations"; import { words } from "@/UI/words"; import { - findCorrespondingId, - moveCellFromColliding, - toggleLooseElement, -} from "./helpers"; + CreateModifierHandler, + FieldCreator, + createFormState, +} from "../ServiceInstanceForm"; +import { findCorrespondingId, findFullInterServiceRelations } from "./helpers"; import activeImage from "./icons/active-icon.svg"; import candidateImage from "./icons/candidate-icon.svg"; -import { ActionEnum, ConnectionRules, relationId } from "./interfaces"; +import { + ComposerEntityOptions, + EmbeddedEventEnum, + relationId, +} from "./interfaces"; import { Link, ServiceEntityBlock } from "./shapes"; /** - * Function to display the methods to alter the connection objects - currently, the only function visible is the one removing connections. - * https://resources.jointjs.com/docs/jointjs/v3.6/joint.html#dia.LinkView - * https://resources.jointjs.com/docs/jointjs/v3.6/joint.html#linkTools + * Function that creates, appends and returns created Entity * - * @param {dia.Paper} paper JointJS paper object - * @param {dia.Graph} graph JointJS graph object - * @param {dia.LinkView} linkView - The view for the joint.dia.Link model. - * @function {(cell: ServiceEntityBlock, action: ActionEnum): void} linkView - The view for the joint.dia.Link model. - * @returns {void} + * @param {ServiceModel} serviceModel that we want to base created entity on + * @param {boolean} isCore defines whether created entity is main one in given View + * @param {boolean} isInEditMode defines whether created entity is is representation of existing instance or new one + * @param {InstanceAttributeModel} [attributes] of the entity + * @param {boolean} [isEmbedded] defines whether created entity is embedded + * @param {string} [holderName] - name of the entity to which it is embedded/connected + * @param {string | dia.Cell.ID} [embeddedTo] - id of the entity/shape in which this shape is embedded + * @param {boolean} [isBlockedFromEditing] - boolean value determining if the instance is blocked from editing + * @param {boolean} [cantBeRemoved] - boolean value determining if the instance can't be removed + * @param {string} [stencilName] - name of the stencil that should be disabled in the sidebar + * @param {string} [id] - unique id of the entity, optional + * + * @returns {ServiceEntityBlock} created JointJS shape */ -export function showLinkTools( - paper: dia.Paper, - graph: dia.Graph, - linkView: dia.LinkView, - updateInstancesToSend: (cell: ServiceEntityBlock, action: ActionEnum) => void, - connectionRules: ConnectionRules, -) { - const source = linkView.model.source(); - const target = linkView.model.target(); - - const sourceCell = graph.getCell( - source.id as dia.Cell.ID, - ) as ServiceEntityBlock; - const targetCell = graph.getCell( - target.id as dia.Cell.ID, - ) as ServiceEntityBlock; +export function createComposerEntity({ + serviceModel, + isCore, + isInEditMode, + attributes, + isEmbedded = false, + holderName = "", + embeddedTo, + isBlockedFromEditing = false, + cantBeRemoved = false, + stencilName, + id, +}: ComposerEntityOptions): ServiceEntityBlock { + //Create shape for Entity + const instanceAsTable = new ServiceEntityBlock(); - /** - * checks if the connection between cells can be deleted thus if we should hide linkTool - * @param cellOne ServiceEntityBlock - * @param cellTwo ServiceEntityBlock - * @returns boolean - */ - const shouldHideLinkTool = ( - cellOne: ServiceEntityBlock, - cellTwo: ServiceEntityBlock, - ) => { - const nameOne = cellOne.getName(); - const nameTwo = cellTwo.getName(); - - const elementConnectionRule = connectionRules[nameOne].find( - (rule) => rule.name === nameTwo, - ); + instanceAsTable.setName(serviceModel.name); - const isElementInEditMode: boolean | undefined = - cellOne.get("isInEditMode"); + //if there is if provided, we use it, if not we use default one, created by JointJS + if (id) { + instanceAsTable.set("id", id); + } - if ( - isElementInEditMode && - elementConnectionRule && - elementConnectionRule.modifier !== "rw+" - ) { - return true; - } + if (isEmbedded) { + instanceAsTable.setTabColor("embedded"); + instanceAsTable.set("embeddedTo", embeddedTo); + instanceAsTable.set("isEmbedded", isEmbedded); + instanceAsTable.set("holderName", holderName); + // If the instance is not core, we need to apply its stencil name to the shape to later disable its corresponding stencil in the sidebar + instanceAsTable.set("stencilName", stencilName); + } else if (isCore) { + instanceAsTable.set("isCore", isCore); + instanceAsTable.setTabColor("core"); + } - return false; - }; + instanceAsTable.set("isInEditMode", isInEditMode); + instanceAsTable.set("serviceModel", serviceModel); + instanceAsTable.set("isBlockedFromEditing", isBlockedFromEditing); + instanceAsTable.set("cantBeRemoved", cantBeRemoved); if ( - shouldHideLinkTool(sourceCell, targetCell) || - shouldHideLinkTool(targetCell, sourceCell) + serviceModel.inter_service_relations && + serviceModel.inter_service_relations.length > 0 ) { - return; + instanceAsTable.set("relatedTo", new Map()); } - const tools = new dia.ToolsView({ - tools: [ - new linkTools.Remove({ - distance: "50%", - markup: [ - { - tagName: "circle", - selector: "button", - attributes: { - r: 7, - class: "joint-link_remove-circle", - "stroke-width": 2, - cursor: "pointer", - }, - }, - { - tagName: "path", - selector: "icon", - attributes: { - d: "M -3 -3 3 3 M -3 3 3 -3", - class: "joint-link_remove-path", - "stroke-width": 2, - "pointer-events": "none", - }, - }, - ], - action: (_evt, linkView: dia.LinkView, toolView: dia.ToolView) => { - const source = linkView.model.source(); - const target = linkView.model.target(); - - const sourceCell = graph.getCell( - source.id as dia.Cell.ID, - ) as ServiceEntityBlock; - const targetCell = graph.getCell( - target.id as dia.Cell.ID, - ) as ServiceEntityBlock; - - /** - * Function that remove any data in this connection between cells - * @param elementCell cell that we checking - * @param disconnectingCell cell that is being connected to elementCell - * @returns boolean whether connections was set - */ - const wasConnectionDataRemoved = ( - elementCell: ServiceEntityBlock, - disconnectingCell: ServiceEntityBlock, - ): boolean => { - const elementRelations = elementCell.getRelations(); - - // resolve any possible embedded connections between cells, - if ( - elementCell.get("isEmbedded") && - elementCell.get("embeddedTo") === disconnectingCell.id - ) { - elementCell.set("embeddedTo", undefined); - toggleLooseElement(paper.findViewByModel(elementCell), "add"); - updateInstancesToSend(elementCell, ActionEnum.UPDATE); - - return true; - } - - // resolve any possible relation connections between cells - if ( - elementRelations && - elementRelations.has(disconnectingCell.id as string) - ) { - elementCell.removeRelation(disconnectingCell.id as string); - - updateInstancesToSend(sourceCell, ActionEnum.UPDATE); - - return true; - } else { - return false; - } - }; - - const wasConnectionFromSourceSet = wasConnectionDataRemoved( - sourceCell, - targetCell, - ); - - if (!wasConnectionFromSourceSet) { - wasConnectionDataRemoved(targetCell, sourceCell); - } - - linkView.model.remove({ ui: true, tool: toolView.cid }); - }, - }), - ], - }); + if (attributes) { + updateAttributes( + instanceAsTable, + serviceModel.key_attributes || [], + attributes, + true, + ); + } - linkView.addTools(tools); + return instanceAsTable; } /** @@ -179,78 +100,122 @@ export function showLinkTools( * * @param {dia.Graph} graph JointJS graph object * @param {dia.Paper} paper JointJS paper object - * @param {ServiceInstanceModel} serviceInstance that we want to display - * @param {ServiceModel} service that hold definitions for attributes which we want to display as instance Object doesn't differentiate core attributes from i.e. embedded entities + * @param {InstanceWithRelations} instanceWithRelations that we want to display + * @param {ServiceModel[]} services that hold definitions for attributes which we want to display as instance Object doesn't differentiate core attributes from i.e. embedded entities * @param {boolean} isMainInstance boolean value determining if the instance is the core one - * @param {string} relatedTo id of the service instance with which appended instance has relation - * @returns {ServiceEntityBlock} appendedInstance to allow connect related Instances added concurrently + * @param {boolean} isBlockedFromEditing boolean value determining if the instance is blocked from editing + * + * @returns {ServiceEntityBlock} appendedInstance to allow connect related by inter-service relations Instances added concurrently */ export function appendInstance( paper: dia.Paper, graph: dia.Graph, instanceWithRelations: InstanceWithRelations, services: ServiceModel[], - isMainInstance = false, - instanceToConnectRelation?: ServiceEntityBlock, -): ServiceEntityBlock { + isMainInstance = true, + isBlockedFromEditing = false, +): ServiceEntityBlock[] { const serviceInstance = instanceWithRelations.instance; const serviceInstanceModel = services.find( (model) => model.name === serviceInstance.service_entity, ); if (!serviceInstanceModel) { - throw Error(words("inventory.instanceComposer.errorMessage")); + throw Error(words("instanceComposer.errorMessage.missingModel")); } - const instanceAsTable = new ServiceEntityBlock().setName( - serviceInstance.service_entity, - ); - instanceAsTable.set("id", instanceWithRelations.instance.id); - instanceAsTable.set("isEmbedded", false); - instanceAsTable.set("isInEditMode", true); + const attributes = + serviceInstance.candidate_attributes || + serviceInstance.active_attributes || + undefined; + + const stencilName = serviceInstance.service_identity_attribute_value + ? serviceInstance.service_identity_attribute_value + : serviceInstance.id; + + const instanceAsTable = createComposerEntity({ + serviceModel: serviceInstanceModel, + isCore: isMainInstance, + isInEditMode: true, + attributes, + cantBeRemoved: + isMainInstance && !serviceInstanceModel.strict_modifier_enforcement, + isBlockedFromEditing: + !serviceInstanceModel.strict_modifier_enforcement || isBlockedFromEditing, + stencilName: isMainInstance ? stencilName : undefined, + id: instanceWithRelations.instance.id, + }); - if (isMainInstance) { - instanceAsTable.setTabColor("core"); - } + instanceAsTable.addTo(graph); + + let embeddedEntities: ServiceEntityBlock[] = []; //check for any presentable attributes, where candidate attrs have priority, if there is a set, then append them to JointJS shape and try to display and connect embedded entities if (serviceInstance.candidate_attributes) { - handleAttributes( + embeddedEntities = addEmbeddedEntities( graph, paper, instanceAsTable, serviceInstanceModel, serviceInstance.candidate_attributes, "candidate", - instanceToConnectRelation, + isBlockedFromEditing, ); + addInfoIcon(instanceAsTable, "candidate"); } else if (serviceInstance.active_attributes) { - handleAttributes( + embeddedEntities = addEmbeddedEntities( graph, paper, instanceAsTable, serviceInstanceModel, serviceInstance.active_attributes, "active", - instanceToConnectRelation, + isBlockedFromEditing, ); + addInfoIcon(instanceAsTable, "active"); } - if (instanceWithRelations.relatedInstances) { - //map through relatedInstances and either append them or connect to them - instanceWithRelations.relatedInstances.forEach((relatedInstance) => { - const isInstanceMain = false; - const cellAdded = graph.getCell(relatedInstance.id); + //map through inter-service related instances and either append them and connect to them or connect to already existing ones + instanceWithRelations.interServiceRelations.forEach( + (interServiceRelation) => { + const cellAdded = graph.getCell(interServiceRelation.id); + const isBlockedFromEditing = true; + //If cell isn't in the graph, we need to append it and connect it to the one we are currently working on if (!cellAdded) { - appendInstance( + const isMainInstance = false; + const appendedInstances = appendInstance( paper, graph, - { instance: relatedInstance }, + { instance: interServiceRelation, interServiceRelations: [] }, services, - isInstanceMain, - instanceAsTable, + isMainInstance, + isBlockedFromEditing, ); + + //disable Inventory Stencil for inter-service relation instance + const elements = [ + { + selector: `.body_${appendedInstances[0].get("stencilName")}`, + className: "stencil_accent-disabled", + }, + { + selector: `.bodyTwo_${appendedInstances[0].get("stencilName")}`, + className: "stencil_body-disabled", + }, + { + selector: `.text_${appendedInstances[0].get("stencilName")}`, + className: "stencil_text-disabled", + }, + ]; + + elements.forEach(({ selector, className }) => { + const element = document.querySelector(selector); + + if (element) { + element.classList.add(className); + } + }); } else { //If cell is already in the graph, we need to check if it got in its inter-service relations the one with id that corresponds with created instanceAsTable let isConnected = false; @@ -273,9 +238,8 @@ export function appendInstance( ); } } - //If doesn't, or the one we are looking for isn't among the ones stored, we need go through every connected shape and do the same assertion, - //as the fact that we have that cell as relatedInstance tells us that either that or its embedded entities has connection + //as the fact that we have that cell as interServiceRelation tells us that either that or its embedded entities has connection if (!isConnected) { const neighbors = graph.getNeighbors(cellAdded as dia.Element); @@ -309,16 +273,19 @@ export function appendInstance( serviceInstanceModel.strict_modifier_enforcement, ); } - }); - } - //auto-layout provided by JointJS + }, + ); + + connectAppendedEntities([instanceAsTable, ...embeddedEntities], graph); + + // auto-layout provided by JointJS DirectedGraph.layout(graph, { nodeSep: 80, edgeSep: 80, - rankDir: "TB", + rankDir: "BT", }); - return instanceAsTable; + return [...embeddedEntities, instanceAsTable]; } /** @@ -331,7 +298,9 @@ export function appendInstance( * @param {InstanceAttributeModel} entityAttributes - attributes of given entity * @param {string | null} embeddedTo - id of the entity/shape in which this shape is embedded * @param {string} holderName - name of the entity to which it is embedded/connected - * @param {ServiceEntityBlock} instanceToConnectRelation - eventual shape to which inter-service relations should be connected + * @param {"candidate" | "active"} ConnectionRules - flag whether we are displaying candidate or active attributes + * @param {boolean} isBlockedFromEditing boolean value determining if the instance is blocked from editin + * * @returns {ServiceEntityBlock[]} created JointJS shapes */ export function appendEmbeddedEntity( @@ -339,9 +308,8 @@ export function appendEmbeddedEntity( graph: dia.Graph, embeddedEntity: EmbeddedEntity, entityAttributes: InstanceAttributeModel | InstanceAttributeModel[], - embeddedTo: string | null, + embeddedTo: string | dia.Cell.ID, holderName: string, - instanceToConnectRelation?: ServiceEntityBlock, presentedAttr?: "candidate" | "active", isBlockedFromEditing?: boolean, ): ServiceEntityBlock[] { @@ -351,27 +319,37 @@ export function appendEmbeddedEntity( * Create single Embedded Entity, and handle setting all of the essential data and append it and it's eventual children to the graph. * Then connect it with it's eventual children and other entities that have inter-service relation to this Entity * - * @param entityInstance instance of entity Attributes - * @returns ServiceEntityBlock + * @param {InstanceAttributeModel} entityInstance instance of entity Attributes + * @returns {ServiceEntityBlock} appended embedded entity to the graph */ function appendSingleEntity( entityInstance: InstanceAttributeModel, ): ServiceEntityBlock { - const flatAttributes = embeddedEntity.attributes.map( - (attribute) => attribute.name, - ); - //Create shape for Entity - const instanceAsTable = new ServiceEntityBlock() - .setTabColor("embedded") - .setName(embeddedEntity.name); + const instanceAsTable = createComposerEntity({ + serviceModel: embeddedEntity, + isCore: false, + isInEditMode: isBlockedFromEditing || false, + attributes: entityInstance, + isEmbedded: true, + holderName, + embeddedTo, + isBlockedFromEditing, + cantBeRemoved: embeddedEntity.modifier !== "rw+", + }); - appendColumns(instanceAsTable, flatAttributes, entityInstance); - instanceAsTable.set("isEmbedded", true); - instanceAsTable.set("holderName", holderName); - instanceAsTable.set("embeddedTo", embeddedTo); - instanceAsTable.set("isBlockedFromEditing", isBlockedFromEditing); - instanceAsTable.set("isInEditMode", true); + if (presentedAttr) { + addInfoIcon(instanceAsTable, presentedAttr); + } + + document.dispatchEvent( + new CustomEvent("updateStencil", { + detail: { + name: embeddedEntity.name, + action: EmbeddedEventEnum.ADD, + }, + }), + ); //add to graph instanceAsTable.addTo(graph); @@ -384,15 +362,11 @@ export function appendEmbeddedEntity( entity, entityInstance[entity.name] as InstanceAttributeModel, instanceAsTable.id as string, - entity.name, - instanceToConnectRelation, + embeddedEntity.name, presentedAttr, isBlockedFromEditing, ); - appendedEntity.forEach((entity) => { - handleInfoIcon(entity, presentedAttr); - }); connectEntities( graph, instanceAsTable, @@ -401,22 +375,13 @@ export function appendEmbeddedEntity( ); }); - embeddedEntity.inter_service_relations?.map((relation) => { + const relations = embeddedEntity.inter_service_relations || []; + + relations.map((relation) => { const relationId = entityInstance[relation.name] as relationId; if (relationId) { instanceAsTable.addRelation(relationId, relation.name); - if ( - instanceToConnectRelation && - relationId === instanceToConnectRelation.id - ) { - connectEntities( - graph, - instanceAsTable, - [instanceToConnectRelation], - isBlockedFromEditing, - ); - } } }); @@ -431,91 +396,135 @@ export function appendEmbeddedEntity( } /** - * Function that creates, appends and returns created Entity, differs from appendInstance by the fact that is used from the scope of Instance Composer and uses different set of data + * Populates a graph with default required entities derived from a service model. * - * @param {dia.Graph} graph JointJS graph object - * @param {dia.Paper} paper JointJS paper object - * @param {ServiceModel} serviceModel that we want to base created entity on - * @param {InstanceAttributeModel} entity created in the from - * @param {boolean} isCore defines whether created entity is main one in given View - * @param {string} holderName - name of the entity to which it is embedded/connected - * @returns {ServiceEntityBlock} created JointJS shape + * @param {dia.Graph} graph - The jointJS graph to populate. + * @param {ServiceModel} serviceModel - The service model to use for populating the graph. + * @returns {void} */ -export function appendEntity( +export function populateGraphWithDefault( graph: dia.Graph, - serviceModel: ServiceModel | EmbeddedEntity, - entity: InstanceAttributeModel, - isCore: boolean, - isEmbedded = false, - holderName = "", -): ServiceEntityBlock { - //Create shape for Entity - const instanceAsTable = new ServiceEntityBlock().setName(serviceModel.name); + serviceModel: ServiceModel, +): void { + //the most reliable way to get attributes default state is to use Field Creator - if (isEmbedded) { - instanceAsTable.setTabColor("embedded"); - } else if (isCore) { - instanceAsTable.setTabColor("core"); - } + const fieldCreator = new FieldCreator(new CreateModifierHandler()); + const fields = fieldCreator.attributesToFields(serviceModel.attributes); - instanceAsTable.set("isEmbedded", isEmbedded); - instanceAsTable.set("holderName", holderName); + const coreEntity = createComposerEntity({ + serviceModel, + isCore: true, + isInEditMode: false, + attributes: createFormState(fields), + }); - if ( - serviceModel.inter_service_relations && - serviceModel.inter_service_relations.length > 0 - ) { - instanceAsTable.set("relatedTo", new Map()); - } - const attributesNames = serviceModel.attributes.map( - (attribute) => attribute.name, - ); + coreEntity.addTo(graph); - appendColumns(instanceAsTable, attributesNames, entity); - //add to graph - instanceAsTable.addTo(graph); + const defaultEntities = addDefaultEntities(graph, serviceModel); - moveCellFromColliding(graph, instanceAsTable); + defaultEntities.forEach((entity) => { + entity.set("embeddedTo", coreEntity.id); + }); + connectEntities(graph, coreEntity, defaultEntities); - return instanceAsTable; + DirectedGraph.layout(graph, { + nodeSep: 80, + edgeSep: 80, + rankDir: "BT", + }); +} + +/** + * Adds default entities to a graph based on a service model or an embedded entity. + * + * @param {dia.Graph} graph - The jointJS graph to which entities should be added. + * @param {ServiceModel | EmbeddedEntity} service - The service model or embedded entity used to generate the default entities. + * @returns {ServiceEntityBlock[]} An array of service entity blocks that have been added to the graph. + */ +export function addDefaultEntities( + graph: dia.Graph, + service: ServiceModel | EmbeddedEntity, +): ServiceEntityBlock[] { + const embedded_entities = service.embedded_entities + .filter((embedded_entity) => embedded_entity.lower_limit > 0) + .map((embedded_entity) => { + const fieldCreator = new FieldCreator(new CreateModifierHandler()); + const fields = fieldCreator.attributesToFields( + embedded_entity.attributes, + ); + + const embeddedEntity = createComposerEntity({ + serviceModel: embedded_entity, + isCore: false, + isInEditMode: false, + attributes: createFormState(fields), + isEmbedded: true, + holderName: service.name, + }); + + document.dispatchEvent( + new CustomEvent("updateStencil", { + detail: { + name: embedded_entity.name, + action: EmbeddedEventEnum.ADD, + }, + }), + ); + + embeddedEntity.addTo(graph); + const subEmbeddedEntities = addDefaultEntities(graph, embedded_entity); + + subEmbeddedEntities.forEach((entity) => { + entity.set("embeddedTo", embeddedEntity.id); + }); + connectEntities(graph, embeddedEntity, subEmbeddedEntities); + + return embeddedEntity; + }); + + return embedded_entities; } /** * Function that iterates through service instance attributes for values and appends in jointJS entity for display * * @param {ServiceEntityBlock} serviceEntity - shape of the entity to which columns will be appended - * @param {string[]} attributesKeywords - names of the attributes that we iterate for the values + * @param {string[]} keyAttributes - names of the attributes that we iterate for the values * @param {InstanceAttributeModel} serviceInstanceAttributes - attributes of given instance/entity - * @param {boolean=true} isInitial - boolean indicating whether should we appendColumns or edit - default = true + * @param {boolean=true} isInitial - boolean indicating whether should we updateAttributes or edit - default = true * @returns {void} */ -export function appendColumns( +export function updateAttributes( serviceEntity: ServiceEntityBlock, - attributesKeywords: string[], + keyAttributes: string[], serviceInstanceAttributes: InstanceAttributeModel, isInitial = true, ) { - const instanceAttributes = {}; - const attributes = attributesKeywords.map((key) => { - instanceAttributes[key] = serviceInstanceAttributes[key]; + const attributesToDisplay = keyAttributes.map((key) => { + const value = serviceInstanceAttributes + ? (serviceInstanceAttributes[key] as string) + : ""; return { name: key, - value: serviceInstanceAttributes[key] as string, + value: value || "", }; }); - serviceEntity.set("instanceAttributes", instanceAttributes); - if (isInitial) { - serviceEntity.appendColumns(attributes); + serviceEntity.appendColumns(attributesToDisplay); + } else { + serviceEntity.editColumns( + attributesToDisplay, + serviceEntity.attributes.isCollapsed, + ); + } + + serviceEntity.set("instanceAttributes", serviceInstanceAttributes); + if (isInitial && !serviceEntity.get("sanitizedAttrs")) { //for initial appending instanceAttributes are equal sanitized ones - if (!serviceEntity.get("sanitizedAttrs")) { - serviceEntity.set("sanitizedAttrs", instanceAttributes); - } - } else { - serviceEntity.editColumns(attributes, serviceEntity.attributes.isCollapsed); + serviceEntity.set("sanitizedAttrs", serviceInstanceAttributes); } } @@ -532,7 +541,7 @@ function connectEntities( source: ServiceEntityBlock, targets: ServiceEntityBlock[], isBlocked?: boolean, -) { +): void { targets.map((target) => { const link = new Link(); @@ -542,11 +551,12 @@ function connectEntities( link.source(source); link.target(target); graph.addCell(link); + graph.trigger("link:connect", link); }); } /** - * Function that appends attributes into the entity and creates nested embedded entities that relates to given instance/entity + * Function that appends attributes into the entity and creates nested embedded entities that connects to given instance/entity * * @param {dia.Graph} graph JointJS graph object * @param {dia.Paper} paper JointJS paper object @@ -556,60 +566,48 @@ function connectEntities( * @param {"candidate" | "active"} presentedAttrs *optional* identify used set of attributes if they are taken from Service Instance * @param {ServiceEntityBlock=} instanceToConnectRelation *optional* shape to which eventually should embedded entity or be connected to * - * @returns {void} + * @returns {ServiceEntityBlock[]} - returns array of created embedded entities, that are connected to given entity */ -function handleAttributes( +function addEmbeddedEntities( graph: dia.Graph, paper: dia.Paper, instanceAsTable: ServiceEntityBlock, serviceModel: ServiceModel, attributesValues: InstanceAttributeModel, - presentedAttr?: "candidate" | "active", - instanceToConnectRelation?: ServiceEntityBlock, -) { - const { attributes, embedded_entities } = serviceModel; - const attributesNames = attributes.map((attribute) => attribute.name); - - handleInfoIcon(instanceAsTable, presentedAttr); - appendColumns(instanceAsTable, attributesNames, attributesValues); - instanceAsTable.set( - "isBlockedFromEditing", - !serviceModel.strict_modifier_enforcement, - ); - //add to graph - instanceAsTable.addTo(graph); + presentedAttr: "candidate" | "active", + isBlockedFromEditing = false, +): ServiceEntityBlock[] { + const { embedded_entities } = serviceModel; //iterate through embedded entities to create and connect them - embedded_entities.forEach((entity) => { - //we are basing iteration on service Model, if there is no value in the instance, skip that entity - if (!attributesValues[entity.name]) { - return; - } - const appendedEntities = appendEmbeddedEntity( - paper, - graph, - entity, - attributesValues[entity.name] as InstanceAttributeModel, - instanceAsTable.id as string, - serviceModel.name, - instanceToConnectRelation, - presentedAttr, - !serviceModel.strict_modifier_enforcement, - ); + //we are basing iteration on service Model, if there is no value in the instance, skip that entity + const createdEmbedded = embedded_entities + .filter((entity) => !!attributesValues[entity.name]) + .flatMap((entity) => { + const appendedEntities = appendEmbeddedEntity( + paper, + graph, + entity, + attributesValues[entity.name] as InstanceAttributeModel, + instanceAsTable.id, + serviceModel.name, + presentedAttr, + !serviceModel.strict_modifier_enforcement || isBlockedFromEditing, + ); + + connectEntities( + graph, + instanceAsTable, + appendedEntities, + !serviceModel.strict_modifier_enforcement, + ); - appendedEntities.map((entity) => { - handleInfoIcon(entity, presentedAttr); + return appendedEntities; }); - connectEntities( - graph, - instanceAsTable, - appendedEntities, - !serviceModel.strict_modifier_enforcement, - ); - }); + const relations = serviceModel.inter_service_relations || []; - serviceModel.inter_service_relations?.forEach((relation) => { + relations.forEach((relation) => { const relationId = attributesValues[relation.name]; if (relationId) { @@ -621,19 +619,9 @@ function handleAttributes( instanceAsTable.addRelation(relationId as string, relation.name); } } - if ( - instanceToConnectRelation && - instanceToConnectRelation.id && - relationId - ) { - connectEntities( - graph, - instanceAsTable, - [instanceToConnectRelation], - !serviceModel.strict_modifier_enforcement, - ); - } }); + + return createdEmbedded; } /** @@ -642,10 +630,10 @@ function handleAttributes( * @param {"candidate" | "active"=} presentedAttrs *optional* indentify used set of attributes if they are taken from Service Instance * @returns {void} */ -function handleInfoIcon( +export function addInfoIcon( instanceAsTable: ServiceEntityBlock, - presentedAttrs?: "candidate" | "active", -) { + presentedAttrs: "candidate" | "active", +): void { const infoAttrs = { preserveAspectRatio: "none", cursor: "pointer", @@ -663,7 +651,7 @@ function handleInfoIcon( height: 15, }, }); - } else if (presentedAttrs === "active") { + } else { instanceAsTable.attr({ info: { ...infoAttrs, @@ -676,3 +664,47 @@ function handleInfoIcon( }); } } + +/** + * Connects the appended entities to the entities they are related to. + * This function look for Map of relations in the appended entities and connects them through the id + * + * @param {ServiceEntityBlock[]} appendedInstances - The appended entities to connect. + * @param {dia.Graph} graph - The graph to which the entities are appended. + * @returns {void} + */ +const connectAppendedEntities = ( + appendedEntities: ServiceEntityBlock[], + graph: dia.Graph, +): void => { + appendedEntities.forEach((cell) => { + const relationMap = cell.get("relatedTo") as Map; + const model = cell.get("serviceModel") as ServiceModel; + const relations = findFullInterServiceRelations(model); + + //if there is relationMap, we iterate through them, and search for cell with corresponding id + if (relationMap) { + relationMap.forEach((_value, key) => { + const relatedCell = graph.getCell(key) as ServiceEntityBlock; + + //if we find the cell, we check if it has relation with the cell we are currently working on + if (relatedCell) { + const relation = relations.find( + (relation) => relation.entity_type === relatedCell.getName(), + ); + + //if it has, we connect them + if (relation) { + relatedCell.set("cantBeRemoved", relation.modifier !== "rw+"); + connectEntities( + graph, + relatedCell, + [cell], + relation.modifier !== "rw+", + ); + } + } + }); + } + }); +}; diff --git a/src/UI/Components/Diagram/anchors.ts b/src/UI/Components/Diagram/anchors.ts index 8bc46e304..b145952a3 100644 --- a/src/UI/Components/Diagram/anchors.ts +++ b/src/UI/Components/Diagram/anchors.ts @@ -1,3 +1,5 @@ +/* istanbul ignore file */ +//above comment is to ignore this file from test coverage as it's a JointJS file that is hard to test with Jest due to the fact that JointJS base itself on native browser functions that aren't supported by Jest environement import { g, dia, anchors } from "@inmanta/rappid"; export const anchorNamespace = { ...anchors }; diff --git a/src/UI/Components/Diagram/components/ComposerActions.tsx b/src/UI/Components/Diagram/components/ComposerActions.tsx new file mode 100644 index 000000000..6615415c9 --- /dev/null +++ b/src/UI/Components/Diagram/components/ComposerActions.tsx @@ -0,0 +1,169 @@ +import React, { useCallback, useContext, useEffect, useState } from "react"; +import "@inmanta/rappid/joint-plus.css"; +import { useNavigate } from "react-router-dom"; +import { AlertVariant, Flex, FlexItem } from "@patternfly/react-core"; +import styled from "styled-components"; +import { usePostMetadata } from "@/Data/Managers/V2/POST/PostMetadata"; +import { usePostOrder } from "@/Data/Managers/V2/POST/PostOrder"; +import { DependencyContext } from "@/UI/Dependency"; +import { words } from "@/UI/words"; +import { ToastAlert } from "../../ToastAlert"; +import { CanvasContext, InstanceComposerContext } from "../Context/Context"; +import { getServiceOrderItems } from "../helpers"; +import { SavedCoordinates } from "../interfaces"; +import { StyledButton } from "./RightSidebar"; + +/** + * Properties for the ComposerActions component. + * + * @interface + * @prop {string} serviceName - The name of the service. + * @prop {boolean} editable - A flag indicating if the diagram is editable. + */ +interface Props { + serviceName: string; + editable: boolean; +} + +/** + * ComposerActions component + * + * This component represents the actions for the Composer. + * It contains controls to cancel creating or editing instance or sending serviceOrderItems to the backend. + * Also, it shows feedback notification to the user. + * + * @props {Props} props - The properties passed to the component. + * @prop {string} props.serviceName - The name of the service. + * @prop {boolean} props.editable - A flag indicating if the diagram is editable. + * @prop {DiagramHandlers | null} props.diagramHandlers - The handlers for various diagram actions. + * + * @returns {React.FC} The ComposerActions component. + */ +export const ComposerActions: React.FC = ({ serviceName, editable }) => { + const { serviceModels, mainService, instance } = useContext( + InstanceComposerContext, + ); + const { serviceOrderItems, isDirty, looseElement, diagramHandlers } = + useContext(CanvasContext); + const { routeManager, environmentHandler } = useContext(DependencyContext); + + const [alertMessage, setAlertMessage] = useState(""); + const [alertType, setAlertType] = useState(AlertVariant.danger); + + const environment = environmentHandler.useId(); + + const metadataMutation = usePostMetadata(environment); + const orderMutation = usePostOrder(environment); + + const navigate = useNavigate(); + const url = routeManager.useUrl("Inventory", { + service: serviceName, + }); + const handleRedirect = useCallback(() => navigate(url), [navigate, url]); + + /** + * Handles the filtering of the unchanged entities and sending serviceOrderItems to the backend. + * + */ + const handleDeploy = () => { + let coordinates: SavedCoordinates[] = []; + + if (!diagramHandlers) { + setAlertType(AlertVariant.danger); + setAlertMessage("failed to save instance coordinates on deploy"); + } else { + coordinates = diagramHandlers.getCoordinates(); + } + + const orderItems = getServiceOrderItems(serviceOrderItems, serviceModels) + .filter((item) => item.action !== null) + .map((instance) => ({ + ...instance, + metadata: { + coordinates: JSON.stringify(coordinates), + }, + })); + + // Temporary workaround to update coordinates in metadata, as currently order endpoint don't handle metadata in the updates. + // can't test in jest as I can't add any test-id to the halo handles though. + if (instance) { + metadataMutation.mutate({ + service_entity: mainService.name, + service_id: instance.instance.id, + key: "coordinates", + body: { + current_version: instance.instance.version, + value: JSON.stringify(coordinates), + }, + }); + } + + orderMutation.mutate(orderItems); + }; + + useEffect(() => { + if (orderMutation.isSuccess) { + //If response is successful then show feedback notification and redirect user to the service inventory view + setAlertType(AlertVariant.success); + setAlertMessage(words("instanceComposer.success")); + + setTimeout(() => { + navigate(url); + }, 1000); + } else if (orderMutation.isError) { + setAlertType(AlertVariant.danger); + setAlertMessage(orderMutation.error.message); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [orderMutation.isSuccess, orderMutation.isError]); + + return ( + + {alertMessage && ( + + )} + + + + {words("cancel")} + + 0 || + !editable + } + > + {words("deploy")} + + + + + ); +}; + +const Container = styled(Flex)` + padding: 0 0 20px; +`; diff --git a/src/UI/Components/Diagram/components/DictModal.tsx b/src/UI/Components/Diagram/components/DictModal.tsx index 6473a2072..d5cce0298 100644 --- a/src/UI/Components/Diagram/components/DictModal.tsx +++ b/src/UI/Components/Diagram/components/DictModal.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useContext, useState } from "react"; import { ClipboardCopyButton, CodeBlock, @@ -6,22 +6,24 @@ import { CodeBlockCode, Modal, } from "@patternfly/react-core"; -import { DictDialogData } from "../interfaces"; +import { CanvasContext } from "../Context/Context"; -interface DictModal { - dictToDisplay: DictDialogData | null; - setDictToDisplay: (value: DictDialogData | null) => void; -} - -//TODO: move this to global modal with right sidebar PR -const DictModal = ({ dictToDisplay, setDictToDisplay }: DictModal) => { +/** + * Modal to display the values of a dictionary. + * + * @note to be replaced by global modal in the future. + * + * @returns {React.FC} The DictModal component. + */ +export const DictModal: React.FC = () => { + const { dictToDisplay, setDictToDisplay } = useContext(CanvasContext); const [copied, setCopied] = useState(false); - return ( + return dictToDisplay !== null ? ( { setDictToDisplay(null); @@ -58,7 +60,5 @@ const DictModal = ({ dictToDisplay, setDictToDisplay }: DictModal) => { )} - ); + ) : null; }; - -export default DictModal; diff --git a/src/UI/Components/Diagram/components/EntityForm.tsx b/src/UI/Components/Diagram/components/EntityForm.tsx new file mode 100644 index 000000000..4a2a7d160 --- /dev/null +++ b/src/UI/Components/Diagram/components/EntityForm.tsx @@ -0,0 +1,188 @@ +import React, { useEffect, useState } from "react"; +import { Alert, Flex, FlexItem, Form } from "@patternfly/react-core"; +import { set, uniqueId } from "lodash"; +import styled from "styled-components"; +import { Field, InstanceAttributeModel, ServiceModel } from "@/Core"; +import { + CreateModifierHandler, + EditModifierHandler, + FieldCreator, +} from "@/UI/Components/ServiceInstanceForm"; +import { FieldInput } from "@/UI/Components/ServiceInstanceForm/Components"; +import { words } from "@/UI/words"; +import { StyledButton } from "./RightSidebar"; + +interface Props { + serviceModel: ServiceModel; + isEdited: boolean; + initialState: InstanceAttributeModel; + onSave: (fields: Field[], formState: InstanceAttributeModel) => void; + onCancel: () => void; + isForDisplay: boolean; +} + +/** + * `EntityForm` is a React functional component that renders a form for a service entity. + * The form fields are created based on the attributes of the service model. + * unlike the InstanceForm, this is a sub-form and doesn't have the embedded/inter-service-relation form elements + * + * When the form is submitted, the `onSave` callback is called with the form fields and the form state. + * The form can be reset to its initial state by clicking the cancel button, which also calls the `onCancel` callback. + * + * @props {Props} props - The properties passed to the component. + * @prop {ServiceModel} serviceModel - The service model for which to create the form. + * @prop {boolean} isEdited - A flag that indicates whether the form is in edit mode. + * @prop {InstanceAttributeModel} initialState - The initial state of the form. + * @prop {Function} onSave - The callback to call when the form is submitted. + * @prop {Function} onCancel - The callback to call when the cancel button is clicked. + * @prop {boolean} isForDisplay - A flag that indicates whether the form is for display only. + * + * @returns {React.FC} The EntityForm component. + */ +export const EntityForm: React.FC = ({ + serviceModel, + isEdited, + initialState, + onSave, + onCancel, + isForDisplay, +}) => { + const [fields, setFields] = useState([]); + const [formState, setFormState] = + useState(initialState); + + /** + * function to update the state within the form. + * + * @param {string} path - The path within the form state to update. + * @param {unknown} value - The new value to set at the specified path. + * @param {boolean} [multi] - Optional flag indicating if the update is for an array of values. + * @returns {void} + */ + const getUpdate = (path: string, value: unknown, multi = false): void => { + //if multi is true, it means the field is a multi-select field and we need to update the array of values + if (multi) { + setFormState((prev) => { + const clone = { ...prev }; + let selection = (clone[path] as string[]) || []; + + //if the value is already in the array, remove it, otherwise add it + if (selection.includes(value as string)) { + selection = selection.filter((item) => item !== (value as string)); + } else { + selection.push(value as string); + } + + //update the form state with the new selection property with help of _lodash set function + return set(clone, path, selection); + }); + } else { + setFormState((prev) => { + const clone = { ...prev }; + + //update the form state with the new value with help of _lodash set function + return set(clone, path, value); + }); + } + }; + + /** + * Handles the cancel action for the form. + * Resets the form state to its initial state and calls the onCancel callback. + * + * @returns {void} + */ + const handleCancel = (): void => { + setFormState(initialState); + onCancel(); + }; + + /** + * Handles the save action for the form. + * Prevents the default button click behavior and calls the onSave callback with the current fields and form state. + * + * @param {React.MouseEvent} event - The mouse event triggered by clicking the save button. + * + * @returns {void} + */ + const handleSave = ( + event: React.MouseEvent, + ): void => { + event.preventDefault(); + onSave(fields, formState); + }; + + useEffect(() => { + const fieldCreator = new FieldCreator( + isEdited ? new EditModifierHandler() : new CreateModifierHandler(), + ); + const selectedFields = fieldCreator.attributesToFields( + serviceModel.attributes, + ); + + setFields(selectedFields.map((field) => ({ ...field, id: uniqueId() }))); + setFormState(initialState); + }, [serviceModel, isEdited, initialState]); + + return ( + + {fields.length <= 0 && ( + + + + )} + +
{ + event.preventDefault(); + onSave(fields, formState); + }} + > + {fields.map((field) => ( + + ))} + +
+ {!isForDisplay && ( + + + + {words("cancel")} + + + + + {words("save")} + + + + )} +
+ ); +}; + +const StyledFlex = styled(Flex)` + min-height: 100%; + width: 100%; + overflow-y: scroll; +`; diff --git a/src/UI/Components/Diagram/components/FormModal.tsx b/src/UI/Components/Diagram/components/FormModal.tsx deleted file mode 100644 index f026f77a2..000000000 --- a/src/UI/Components/Diagram/components/FormModal.tsx +++ /dev/null @@ -1,319 +0,0 @@ -import React, { useCallback, useEffect, useState } from "react"; -import { dia } from "@inmanta/rappid"; -import { - Alert, - Button, - Flex, - FlexItem, - Form, - MenuToggle, - MenuToggleElement, - Modal, - Select, - SelectOption, -} from "@patternfly/react-core"; -import { set } from "lodash"; -import styled from "styled-components"; -import { - EmbeddedEntity, - Field, - InstanceAttributeModel, - ServiceModel, -} from "@/Core"; -import { words } from "@/UI/words"; -import { - createFormState, - CreateModifierHandler, - EditModifierHandler, - FieldCreator, -} from "../../ServiceInstanceForm"; -import { FieldInput } from "../../ServiceInstanceForm/Components"; -import { ServiceEntityBlock } from "../shapes"; - -interface PossibleForm { - key: string; - value: string; - model: ServiceModel | EmbeddedEntity | undefined; - isEmbedded: boolean; - holderName: string; -} - -interface Selected { - name: string; - model: ServiceModel | EmbeddedEntity; - isEmbedded: boolean; - holderName: string; -} - -const FormModal = ({ - isOpen, - toggleIsOpen, - services, - cellView, - onConfirm, -}: { - isOpen: boolean; - toggleIsOpen: (value: boolean) => void; - services: ServiceModel[]; - cellView: dia.CellView | null; - onConfirm: ( - fields: Field[], - entity: InstanceAttributeModel, - selected: Selected, - ) => void; -}) => { - const [possibleForms, setPossibleForms] = useState([]); - const [fields, setFields] = useState([]); - const [formState, setFormState] = useState({}); - const [isSelectOpen, setIsSelectOpen] = useState(false); - const [selected, setSelected] = useState(undefined); - - const clearStates = () => { - setIsSelectOpen(false); - setFields([]); - setFormState({}); - setSelected(undefined); - }; - - const toggle = (toggleRef: React.Ref) => ( - setIsSelectOpen(val)} - isExpanded={isSelectOpen} - aria-label="service-picker" - disabled={cellView !== null} - isFullWidth - isFullHeight={false} - > - {selected?.name || - words("inventory.instanceComposer.formModal.placeholder")} - - ); - - const onEntityChosen = useCallback( - (value: string, possibleForms: PossibleForm[]) => { - const chosenModel = possibleForms.find( - (service) => service.value === value, - ); - - if (chosenModel && chosenModel.model) { - setSelected({ - name: value as string, - model: chosenModel.model, - isEmbedded: chosenModel.isEmbedded, - holderName: chosenModel.holderName, - }); - - const fieldCreator = new FieldCreator( - cellView?.model.get("isInEditMode") - ? new EditModifierHandler() - : new CreateModifierHandler(), - ); - const selectedFields = fieldCreator.attributesToFields( - chosenModel.model.attributes, - ); - - setFields(selectedFields); - if (cellView) { - setFormState( - (cellView.model as ServiceEntityBlock).get("instanceAttributes"), - ); - } else { - setFormState(createFormState(selectedFields)); - } - } - setIsSelectOpen(false); - }, - [cellView], - ); - - const getUpdate = (path: string, value: unknown, multi = false): void => { - if (multi) { - setFormState((prev) => { - const clone = { ...prev }; - let selection = (clone[path] as string[]) || []; - - if (selection.includes(value as string)) { - selection = selection.filter((item) => item !== (value as string)); - } else { - selection.push(value as string); - } - - return set(clone, path, selection); - }); - } else { - setFormState((prev) => { - const clone = { ...prev }; - - return set(clone, path, value); - }); - } - }; - - useEffect(() => { - /** Iterate through all of services and its embedded entities to extract possible forms for shapes - * - * @param {(ServiceModel | EmbeddedEntity)[]}services array of services available to iterate through - * @param {PossibleForm[]} values array of previously created forms, as we support concurrency - * @param {string} prefix is a name of the entity/instance that holds given nested embedded entity - * @returns - */ - const getPossibleForms = ( - services: (ServiceModel | EmbeddedEntity)[], - values: PossibleForm[], - prefix = "", - ) => { - services.forEach((service) => { - const joinedPrefix = - (prefix !== "" ? prefix + "." : prefix) + service.name; - const displayedPrefix = prefix !== "" ? ` (${prefix})` : ""; - - values.push({ - key: service.name + "-" + prefix, - value: service.name + displayedPrefix, - model: service, - isEmbedded: prefix !== "", - holderName: prefix, //holderName is used in process of creating entity on canvas - }); - - getPossibleForms(service.embedded_entities, values, joinedPrefix); - }); - - return values; - }; - - const tempPossibleForms = getPossibleForms(services, [ - { - key: "default_option", - value: "Choose a Service", - model: undefined, - isEmbedded: false, - holderName: "", - }, - ]); - - setPossibleForms(tempPossibleForms); - - if (cellView) { - const entity = cellView.model as ServiceEntityBlock; - const entityName = entity.getName(); - - onEntityChosen( - entity.get("isEmbedded") - ? `${entityName} (${entity.get("holderName")})` - : entityName, - tempPossibleForms, - ); - } - }, [services, cellView, onEntityChosen]); - - return ( - { - clearStates(); - toggleIsOpen(false); - }} - actions={[ - { - clearStates(); - toggleIsOpen(false); - }} - > - {words("cancel")} - , - { - if (selected) onConfirm(fields, formState, selected); - clearStates(); - toggleIsOpen(false); - }} - > - {words("confirm")} - , - ]} - > - - - - - -
- {fields.map((field) => ( - - ))} - -
- - {fields.length <= 0 && ( - - - - )} -
-
- ); -}; - -export default FormModal; - -const StyledModal = styled(Modal)` - height: 600px; -`; -const StyledButton = styled(Button)` - --pf-v5-c-button--PaddingTop: 0px; - --pf-v5-c-button--PaddingBottom: 0px; - width: 101px; - height: 30px; -`; diff --git a/src/UI/Components/Diagram/components/RightSidebar.tsx b/src/UI/Components/Diagram/components/RightSidebar.tsx new file mode 100644 index 000000000..1f2c564ca --- /dev/null +++ b/src/UI/Components/Diagram/components/RightSidebar.tsx @@ -0,0 +1,285 @@ +import React, { useContext, useEffect, useState } from "react"; +import { + Button, + Flex, + FlexItem, + TextContent, + Title, +} from "@patternfly/react-core"; +import styled from "styled-components"; +import { Field, InstanceAttributeModel, ServiceModel } from "@/Core"; +import { sanitizeAttributes } from "@/Data"; +import { words } from "@/UI/words"; +import { CanvasContext, InstanceComposerContext } from "../Context/Context"; +import { updateServiceOrderItems } from "../helpers"; +import { ActionEnum, EmbeddedEventEnum } from "../interfaces"; +import { EntityForm } from "./EntityForm"; + +/** + * `RightSidebar` is a React functional component that renders a sidebar for editing and removing entities. + * The sidebar displays the details of the selected entity and provides options to edit or remove the entity. + * The state of the sidebar is updated based on the selected entity and the user's interactions with the sidebar and/or composer's canvas. + * When the user submits the edit form, the `onSave` callback is called with the updated attributes and the form state. + * The user can also remove the entity by clicking the remove button, which triggers the `action:delete` event on the entity. + * + * The removal of the entity has different end result based on the type of the entity: + * - Core entity cannot be removed or deleted in the Composer. + * - Embedded entities are removed from the canvas(if service model allows it), and will be erased from the service instance. + * - Inter-service relation entities are removed from the canvas(if service model allows it), but won't be deleted from the environment. + * + * @returns {React.FC} The RightSidebar component. + */ +export const RightSidebar: React.FC = () => { + const { cellToEdit, diagramHandlers, setServiceOrderItems, stencilState } = + useContext(CanvasContext); + const { mainService } = useContext(InstanceComposerContext); + const [description, setDescription] = useState(null); + const [isRemovable, setIsRemovable] = useState(false); + const [isFormOpen, setIsFormOpen] = useState(false); + const [model, setModel] = useState(null); + const [attributes, setAttributes] = useState({}); + + /** + * Handles the removal of a cell. + * + * If the cell to edit is not defined, the function returns early. + * Triggers the delete action on the cell. + * If the cell is embedded, dispatches a custom event to update the stencil. + * If the cell is an inter-service relation entity, enables the Inventory Stencil Element for the instance. + * + * @returns {void} + */ + const onRemove = (): void => { + if (!cellToEdit) { + return; + } + const { model } = cellToEdit; + + //logic of deleting cell stayed in the halo which triggers the event + cellToEdit.trigger("action:delete"); + const isEmbedded = model.get("isEmbedded"); + + if (isEmbedded) { + //dispatch event instead of calling function directly from context + document.dispatchEvent( + new CustomEvent("updateStencil", { + detail: { + name: model.get("entityName"), + action: EmbeddedEventEnum.REMOVE, + }, + }), + ); + } + + //stencilName is only available for inter-service relation entities + const stencilName = model.get("stencilName"); + + if (stencilName) { + //enable Inventory Stencil element for inter-service relation instance + const elements = [ + { + selector: `.body_${stencilName}`, + className: "stencil_accent-disabled", + }, + { + selector: `.bodyTwo_${stencilName}`, + className: "stencil_body-disabled", + }, + { + selector: `.text_${stencilName}`, + className: "stencil_text-disabled", + }, + ]; + + elements.forEach(({ selector, className }) => { + const element = document.querySelector(selector); + + if (element) { + element.classList.remove(className); + } + }); + } + }; + + /** + * Handles the edit action for the form. + * Opens the form by setting the form open state to true. + */ + const onEdit = (): void => { + setIsFormOpen(true); + }; + + /** + * Handles the cancel action for the form. + * Closes the form by setting the form open state to false. + */ + const onCancel = (): void => { + setIsFormOpen(false); + }; + + /** + * Handles the save action for the form. + * Sanitizes the form attributes and updates the entity in the diagram. + * Updates the service order items with the new shape and closes the form. + * + * @param {Field[]} fields - The fields of the form. + * @param {InstanceAttributeModel} formState - The current state of the form. + */ + const onSave = (fields: Field[], formState: InstanceAttributeModel) => { + if (cellToEdit && diagramHandlers && model) { + const sanitizedAttrs = sanitizeAttributes(fields, formState); + + if (cellToEdit) { + const shape = diagramHandlers.editEntity(cellToEdit, model, formState); + + shape.set("sanitizedAttrs", sanitizedAttrs); + + setServiceOrderItems((prev) => + updateServiceOrderItems(shape, ActionEnum.UPDATE, prev), + ); + } + } + setIsFormOpen(false); + }; + + useEffect(() => { + if (isFormOpen) { + setIsFormOpen(false); //as sidebar is always open, we need to close form when we click on another entity + } + + if (!cellToEdit) { + setDescription(mainService.description || null); + + return; + } + + const { model } = cellToEdit; + const serviceModel = model.get("serviceModel"); + const entityName = model.get("entityName"); + const instanceAttributes = model.get("instanceAttributes"); + + if (serviceModel) { + setDescription(serviceModel.description); + setModel(serviceModel); + } + + if (instanceAttributes) { + setAttributes(instanceAttributes); + } + + setIsRemovable(() => { + const isCellCore = model.get("isCore"); + + //children entities are not allowed to be removed, as well as rw embedded entities in the edit form + const canBeRemoved = !model.get("cantBeRemoved"); + + if (!stencilState) { + return !isCellCore && canBeRemoved; + } + + const entityState = stencilState[entityName]; + + if (!entityState) { + return !isCellCore && canBeRemoved; + } + + const lowerLimit = entityState.min; + + const isLowerLimitReached = + lowerLimit && entityState.current === lowerLimit; + + return !isCellCore && canBeRemoved && !isLowerLimitReached; + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [cellToEdit]); + + return ( + + + + + {words("details")} + + {description && ( + + + {description} + + + )} + + {!!cellToEdit && !!model && ( + + )} + + {!isFormOpen && ( + + + + {words("remove")} + + + + + {words("edit")} + + + + )} + + + ); +}; + +const Wrapper = styled.div` + height: 100%; + width: 300px; + position: absolute; + z-index: 1px; + top: 1px; + right: 1px; + background: var(--pf-v5-global--BackgroundColor--100); + padding: 16px; + filter: drop-shadow( + -0.1rem 0.1rem 0.15rem var(--pf-v5-global--BackgroundColor--dark-transparent-200) + ); + overflow: auto; +`; + +export const StyledButton = styled(Button)` + --pf-v5-c-button--PaddingTop: 0px; + --pf-v5-c-button--PaddingBottom: 0px; + width: 101px; + height: 30px; +`; + +const StyledFlex = styled(Flex)` + min-height: 100%; +`; diff --git a/src/UI/Components/Diagram/components/Toolbar.tsx b/src/UI/Components/Diagram/components/Toolbar.tsx deleted file mode 100644 index 9dc8c297c..000000000 --- a/src/UI/Components/Diagram/components/Toolbar.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import React, { useCallback, useContext } from "react"; -import "@inmanta/rappid/joint-plus.css"; -import { useNavigate } from "react-router-dom"; -import { Button, Flex, FlexItem, Tooltip } from "@patternfly/react-core"; -import styled from "styled-components"; -import { DependencyContext } from "@/UI/Dependency"; -import { words } from "@/UI/words"; -import entityIcon from "../icons/new-entity-icon.svg"; - -const Toolbar = ({ - openEntityModal, - handleDeploy, - serviceName, - isDeployDisabled, - editable, -}: { - openEntityModal: () => void; - handleDeploy: () => void; - serviceName: string; - isDeployDisabled: boolean; - editable: boolean; -}) => { - const { routeManager } = useContext(DependencyContext); - const navigate = useNavigate(); - const url = routeManager.useUrl("Inventory", { - service: serviceName, - }); - const handleRedirect = useCallback(() => navigate(url), [navigate, url]); - - return ( - - - - - - { - event.currentTarget.blur(); - openEntityModal(); - }} - aria-label="new-entity-button" - isDisabled={!editable} - > - - - Create new entity icon - - {words("inventory.addInstance.button")} - - - - - - - - - - {words("cancel")} - - - {words("deploy")} - - - - - ); -}; - -export default Toolbar; - -const Container = styled(Flex)` - padding: 0 0 20px; -`; - -const IconButton = styled(Button)` - --pf-v5-c-button--PaddingTop: 3px; - --pf-v5-c-button--PaddingRight: 10px; - --pf-v5-c-button--PaddingBottom: 3px; - --pf-v5-c-button--PaddingLeft: 10px; - height: 36px; -`; - -const StyledButton = styled(Button)` - --pf-v5-c-button--PaddingTop: 3px; - --pf-v5-c-button--PaddingBottom: 3px; - width: 101px; - height: 36px; -`; diff --git a/src/UI/Components/Diagram/components/index.ts b/src/UI/Components/Diagram/components/index.ts new file mode 100644 index 000000000..3560dfb5f --- /dev/null +++ b/src/UI/Components/Diagram/components/index.ts @@ -0,0 +1,4 @@ +export * from "./DictModal"; +export * from "./EntityForm"; +export * from "./RightSidebar"; +export * from "./ComposerActions"; diff --git a/src/UI/Components/Diagram/halo.ts b/src/UI/Components/Diagram/halo.ts index 9c47559ef..4bac039b4 100644 --- a/src/UI/Components/Diagram/halo.ts +++ b/src/UI/Components/Diagram/halo.ts @@ -1,14 +1,24 @@ import { dia, highlighters, ui } from "@inmanta/rappid"; import { checkIfConnectionIsAllowed, toggleLooseElement } from "./helpers"; -import { ActionEnum, ConnectionRules } from "./interfaces"; +import { ActionEnum, ConnectionRules, EmbeddedEventEnum } from "./interfaces"; import { ServiceEntityBlock } from "./shapes"; +/** + * Creates a halo around a cell view in a graph. + * + * it removes all default handles and adds custom event listeners when the link is being triggered from the button and when it's being dropped, both on other cell and on the empty space, and delete the cell. that is being triggered from the form sidebar + * + * @param graph - The graph containing the cell view. + * @param paper - The paper to draw on. + * @param cellView - The cell view to create a halo around. + * @param connectionRules - The rules for connecting cells. + * @returns The created halo. + */ const createHalo = ( graph: dia.Graph, paper: dia.Paper, cellView: dia.CellView, connectionRules: ConnectionRules, - updateInstancesToSend: (cell: ServiceEntityBlock, action: ActionEnum) => void, ) => { const halo = new ui.Halo({ cellView: cellView, @@ -22,28 +32,17 @@ const createHalo = ( halo.removeHandle("unlink"); halo.removeHandle("remove"); - halo.addHandle({ - name: "delete", - }); // this change is purely to keep order of the halo buttons halo.changeHandle("link", { name: "link", }); - halo.addHandle({ - name: "edit", - }); - // additional listeners to add logic for appended tools, if there will be need for any validation on remove then I think we will need custom handle anyway - halo.on("action:delete:pointerdown", function () { + halo.listenTo(cellView, "action:delete", function () { //cellView.model has the same structure as dia.Element needed as parameter to .getNeighbors() yet typescript complains const connectedElements = graph.getNeighbors(cellView.model as dia.Element); - if ( - cellView.model.get("isEmbedded") && - cellView.model.get("embeddedTo") === undefined - ) { - toggleLooseElement(cellView, "remove"); - } + toggleLooseElement(cellView, EmbeddedEventEnum.REMOVE); + connectedElements.forEach((element) => { const elementAsService = element as ServiceEntityBlock; const isEmbedded = element.get("isEmbedded"); @@ -55,7 +54,10 @@ const createHalo = ( //if one of those were embedded into other then update connectedElement as it's got indirectly edited if (isEmbedded && isEmbeddedToThisCell) { element.set("embeddedTo", undefined); - toggleLooseElement(paper.findViewByModel(element), "add"); + toggleLooseElement( + paper.findViewByModel(element), + EmbeddedEventEnum.ADD, + ); didElementChange = true; } if (element.id === cellView.model.get("embeddedTo")) { @@ -73,17 +75,26 @@ const createHalo = ( } if (didElementChange) { - updateInstancesToSend(elementAsService, ActionEnum.UPDATE); + document.dispatchEvent( + new CustomEvent("updateServiceOrderItems", { + detail: { cell: elementAsService, action: ActionEnum.UPDATE }, + }), + ); } }); - updateInstancesToSend( - cellView.model as ServiceEntityBlock, - ActionEnum.DELETE, + document.dispatchEvent( + new CustomEvent("updateServiceOrderItems", { + detail: { cell: cellView.model, action: ActionEnum.DELETE }, + }), ); + graph.removeLinks(cellView.model); cellView.remove(); halo.remove(); + graph.removeCells([cellView.model]); + //trigger click on blank canvas to clear right sidebar + paper.trigger("blank:pointerdown"); }); halo.on("action:link:pointerdown", function () { @@ -159,15 +170,6 @@ const createHalo = ( }); }); - halo.on("action:edit:pointerdown", function (event) { - event.stopPropagation(); - document.dispatchEvent( - new CustomEvent("openEditModal", { - detail: cellView, - }), - ); - }); - return halo; }; diff --git a/src/UI/Components/Diagram/helpers.test.ts b/src/UI/Components/Diagram/helpers.test.ts index e63644dd1..ded415023 100644 --- a/src/UI/Components/Diagram/helpers.test.ts +++ b/src/UI/Components/Diagram/helpers.test.ts @@ -13,6 +13,7 @@ import { import { ComposerServiceOrderItem, ConnectionRules, + EmbeddedEventEnum, EmbeddedRule, InterServiceRule, LabelLinkView, @@ -21,13 +22,14 @@ import { import { childModel, containerModel, - relatedServices, + parentModel, + interServiceRelations, testApiInstance, testApiInstanceModel, testEmbeddedApiInstances, -} from "./Mock"; +} from "./Mocks"; import services from "./Mocks/services.json"; -import { appendEntity } from "./actions"; +import { createComposerEntity } from "./actions"; import { createConnectionRules, shapesDataTransform, @@ -38,7 +40,11 @@ import { checkIfConnectionIsAllowed, updateLabelPosition, toggleLooseElement, + findInterServiceRelations, + findFullInterServiceRelations, + showLinkTools, } from "./helpers"; +import { ComposerPaper } from "./paper"; import { Link, ServiceEntityBlock } from "./shapes"; jest.mock("uuid", () => ({ @@ -54,11 +60,11 @@ describe("extractRelationsIds", () => { }; it.each` - serviceModel | serviceInstance | expectedLength - ${Service.ServiceWithAllAttrs} | ${ServiceInstance.allAttrs} | ${0} - ${{ ...Service.ServiceWithAllAttrs, inter_service_relations: undefined }} | ${ServiceInstance.allAttrs} | ${0} - ${Service.withRelationsOnly} | ${serviceInstanceForThirdTest} | ${0} - ${Service.withRelationsOnly} | ${ServiceInstance.allAttrs} | ${0} + serviceModel | serviceInstance | expectedLength + ${Service.ServiceWithAllAttrs} | ${ServiceInstance.allAttrs} | ${0} + ${{ ...Service.ServiceWithAllAttrs }} | ${ServiceInstance.allAttrs} | ${0} + ${Service.withRelationsOnly} | ${serviceInstanceForThirdTest} | ${0} + ${Service.withRelationsOnly} | ${ServiceInstance.allAttrs} | ${0} `( "should return empty array for given service model examples", ({ @@ -509,8 +515,8 @@ describe("shapesDataTransform", () => { }, }; const result = shapesDataTransform( - relatedServices[0], - relatedServices, + interServiceRelations[0], + interServiceRelations, childModel, ); @@ -534,8 +540,8 @@ describe("shapesDataTransform", () => { }, }; const result = shapesDataTransform( - relatedServices[1], - relatedServices, + interServiceRelations[1], + interServiceRelations, containerModel, ); @@ -984,24 +990,36 @@ Object.defineProperty(global.SVGSVGElement.prototype, "createSVGPoint", { }); describe("checkIfConnectionIsAllowed", () => { + const serviceA = createComposerEntity({ + serviceModel: Service.a, + isCore: false, + isInEditMode: false, + attributes: InstanceAttributesA, + }); + it("WHEN one element has rule describing other THEN return true", () => { const rules = createConnectionRules([Service.a], {}); const graph = new dia.Graph(); const paper = new dia.Paper({ model: graph, }); - const serviceA = appendEntity(graph, Service.a, InstanceAttributesA, false); - const serviceB = appendEntity( - graph, - Service.a.embedded_entities[0], - (InstanceAttributesA["circuits"] as InstanceAttributeModel[])[0], - false, - ); + + const embeddedService = createComposerEntity({ + serviceModel: Service.a.embedded_entities[0], + isCore: false, + isInEditMode: false, + attributes: ( + InstanceAttributesA["circuits"] as InstanceAttributeModel[] + )[0], + isEmbedded: true, + }); + + graph.addCells([serviceA, embeddedService]); const result = checkIfConnectionIsAllowed( graph, paper.findViewByModel(serviceA), - paper.findViewByModel(serviceB), + paper.findViewByModel(embeddedService), rules, ); @@ -1014,36 +1032,77 @@ describe("checkIfConnectionIsAllowed", () => { const paper = new dia.Paper({ model: graph, }); - const serviceA = appendEntity(graph, Service.a, InstanceAttributesA, false); - const serviceB = appendEntity(graph, Service.b, InstanceAttributesB, false); + + const independendService = createComposerEntity({ + serviceModel: Service.b, + isCore: false, + isInEditMode: false, + attributes: InstanceAttributesB, + }); + + graph.addCells([serviceA, independendService]); const result = checkIfConnectionIsAllowed( graph, paper.findViewByModel(serviceA), - paper.findViewByModel(serviceB), + paper.findViewByModel(independendService), rules, ); expect(result).toBeFalsy(); }); - it("WHEN one element has rule describing other, but the other is blocked from editing THEN return false", () => { + it("WHEN one element has rule describing other, and the other is blocked from editing THEN return true", () => { const rules = createConnectionRules([Service.a], {}); const graph = new dia.Graph(); const paper = new dia.Paper({ model: graph, }); - const serviceA = appendEntity(graph, Service.a, InstanceAttributesA, false); - const serviceB = appendEntity( + + const blockedService = createComposerEntity({ + serviceModel: Service.a.embedded_entities[0], + isCore: false, + isInEditMode: false, + attributes: ( + InstanceAttributesA["circuits"] as InstanceAttributeModel[] + )[0], + isBlockedFromEditing: true, + }); + + graph.addCells([serviceA, blockedService]); + + const result = checkIfConnectionIsAllowed( graph, - Service.a.embedded_entities[0], - (InstanceAttributesA["circuits"] as InstanceAttributeModel[])[0], - false, - true, + paper.findViewByModel(serviceA), + paper.findViewByModel(blockedService), + rules, ); + expect(result).toBeTruthy(); + }); + + it("WHEN one element has rule describing other, but is blocked from editing THEN return false", () => { + const rules = createConnectionRules([Service.a], {}); + const graph = new dia.Graph(); + const paper = new dia.Paper({ + model: graph, + }); + serviceA.set("isBlockedFromEditing", true); + const serviceB = createComposerEntity({ + serviceModel: Service.a.embedded_entities[0], + isCore: false, + isInEditMode: false, + attributes: ( + InstanceAttributesA["circuits"] as InstanceAttributeModel[] + )[0], + isEmbedded: true, + holderName: Service.a.name, + }); + + graph.addCells([serviceA, serviceB]); + const result = checkIfConnectionIsAllowed( graph, paper.findViewByModel(serviceA), @@ -1052,6 +1111,9 @@ describe("checkIfConnectionIsAllowed", () => { ); expect(result).toBeFalsy(); + + //set back to default + serviceA.set("isBlockedFromEditing", false); }); it("WHEN one element has rule describing other, but the other is and embedded entity already connected to parent THEN return false", () => { @@ -1061,32 +1123,36 @@ describe("checkIfConnectionIsAllowed", () => { model: graph, }); - const serviceA = appendEntity(graph, Service.a, InstanceAttributesA, false); - const serviceA2 = appendEntity( - graph, - Service.a, - InstanceAttributesA, - false, - ); - const serviceB = appendEntity( - graph, - Service.a.embedded_entities[0], - (InstanceAttributesA["circuits"] as InstanceAttributeModel[])[0], - false, - true, - ); + const connectedCoreEntity = createComposerEntity({ + serviceModel: Service.a, + isCore: true, + isInEditMode: false, + attributes: InstanceAttributesA, + }); + + const connectedEmbeddedEntity = createComposerEntity({ + serviceModel: Service.a.embedded_entities[0], + isCore: true, + isInEditMode: false, + attributes: ( + InstanceAttributesA["circuits"] as InstanceAttributeModel[] + )[0], + isEmbedded: true, + holderName: "service_name_a", + }); + + graph.addCells([serviceA, connectedCoreEntity, connectedEmbeddedEntity]); const link = new Link(); - link.source(serviceA2); - link.target(serviceB); + link.source(connectedCoreEntity); + link.target(connectedEmbeddedEntity); link.addTo(graph); - serviceB.set("holderName", "service_name_a"); const result = checkIfConnectionIsAllowed( graph, paper.findViewByModel(serviceA), - paper.findViewByModel(serviceB), + paper.findViewByModel(connectedEmbeddedEntity), rules, ); @@ -1386,6 +1452,7 @@ describe("getServiceOrderItems", () => { expect(serviceOrderItems).toEqual([coreCopy]); }); }); + describe("updateLabelPosition", () => { Object.defineProperty(global.SVGElement.prototype, "getBBox", { writable: true, @@ -1419,18 +1486,21 @@ describe("updateLabelPosition", () => { const paper = new dia.Paper({ model: graph, }); - const sourceService = appendEntity( - graph, - Service.a, - InstanceAttributesA, - false, - ); - const targetService = appendEntity( - graph, - Service.b, - InstanceAttributesB, - false, - ); + + const sourceService = createComposerEntity({ + serviceModel: Service.a, + isCore: false, + isEmbedded: false, + isInEditMode: false, + attributes: InstanceAttributesA, + }); + const targetService = createComposerEntity({ + serviceModel: Service.a, + isCore: false, + isEmbedded: false, + isInEditMode: false, + attributes: InstanceAttributesB, + }); graph.addCell(sourceService); graph.addCell(targetService); @@ -1555,9 +1625,19 @@ describe("toggleLooseElement", () => { }); //add highlighter - const entity = appendEntity(graph, Service.a, InstanceAttributesA, false); + const entity = createComposerEntity({ + serviceModel: Service.a, + isCore: false, + isEmbedded: false, + isInEditMode: false, + attributes: InstanceAttributesA, + }); + + graph.addCell(entity); + + toggleLooseElement(paper.findViewByModel(entity), EmbeddedEventEnum.ADD); - toggleLooseElement(paper.findViewByModel(entity), "add"); + //assert the arguments of the first call - calls is array of the arguments of each call expect((dispatchEventSpy.mock.calls[0][0] as CustomEvent).detail).toEqual( JSON.stringify({ kind: "add", id: entity.id }), ); @@ -1566,10 +1646,12 @@ describe("toggleLooseElement", () => { ).not.toBeNull(); //remove - toggleLooseElement(paper.findViewByModel(entity), "remove"); + toggleLooseElement(paper.findViewByModel(entity), EmbeddedEventEnum.REMOVE); expect( dia.HighlighterView.get(paper.findViewByModel(entity), "loose_element"), ).toBeNull(); + + //assert the arguments of the second call expect((dispatchEventSpy.mock.calls[1][0] as CustomEvent).detail).toEqual( JSON.stringify({ kind: "remove", id: entity.id }), ); @@ -1580,16 +1662,176 @@ describe("toggleLooseElement", () => { const paper = new dia.Paper({ model: graph, }); - const entity = appendEntity(graph, Service.a, InstanceAttributesA, false); - toggleLooseElement(paper.findViewByModel(entity), "add"); + const entity = createComposerEntity({ + serviceModel: Service.a, + isCore: false, + isEmbedded: false, + isInEditMode: false, + attributes: InstanceAttributesA, + }); + + graph.addCell(entity); + + toggleLooseElement(paper.findViewByModel(entity), EmbeddedEventEnum.ADD); expect( dia.HighlighterView.get(paper.findViewByModel(entity), "loose_element"), ).not.toBeNull(); - toggleLooseElement(paper.findViewByModel(entity), "remove"); + toggleLooseElement(paper.findViewByModel(entity), EmbeddedEventEnum.REMOVE); expect( dia.HighlighterView.get(paper.findViewByModel(entity), "loose_element"), ).toBeNull(); }); }); + +describe("findInterServiceRelations", () => { + it("it returns empty array WHEN service doesn't have inter-service relations", () => { + const result = findInterServiceRelations(parentModel); + + expect(result).toEqual([]); + }); + + it("it returns related service names WHEN service have direct inter-service relations", () => { + const result = findInterServiceRelations(childModel); + + expect(result).toEqual(["parent-service"]); + }); + + it("it returns related service names WHEN service have inter-service relations in embedded entities", () => { + const result = findInterServiceRelations(containerModel); + + expect(result).toEqual(["parent-service"]); + }); +}); + +describe("findIFullInterServiceRelations", () => { + it("it returns empty array WHEN service doesn't have inter-service relations", () => { + const result = findFullInterServiceRelations(parentModel); + + expect(result).toEqual([]); + }); + + it("it returns related service names WHEN service have direct inter-service relations", () => { + const result = findFullInterServiceRelations(childModel); + + expect(result).toEqual([ + { + description: "", + entity_type: "parent-service", + lower_limit: 1, + modifier: "rw+", + name: "parent_entity", + upper_limit: 1, + }, + ]); + }); + + it("it returns related service names WHEN service have inter-service relations in embedded entities", () => { + const result = findFullInterServiceRelations(containerModel); + + expect(result).toEqual([ + { + description: "", + entity_type: "parent-service", + lower_limit: 1, + modifier: "rw+", + name: "parent_entity", + upper_limit: 1, + }, + ]); + }); +}); + +describe("showLinkTools", () => { + const setup = ( + isParentInEditMode: boolean, + isChildInEditMode: boolean, + modifier: "rw+" | "rw", + ) => { + const editable = true; + const graph = new dia.Graph({}); + const connectionRules = createConnectionRules( + [parentModel, childModel], + {}, + ); + const paper = new ComposerPaper(connectionRules, graph, editable).paper; + + connectionRules[childModel.name][0].modifier = modifier; + + const parentEntity = createComposerEntity({ + serviceModel: parentModel, + isCore: false, + isInEditMode: isParentInEditMode, + }); + const childEntity = createComposerEntity({ + serviceModel: childModel, + isCore: false, + isInEditMode: isChildInEditMode, + isEmbedded: true, + }); + + graph.addCell(parentEntity); + graph.addCell(childEntity); + + const link = new Link(); + + link.source(parentEntity); + link.target(childEntity); + + graph.addCell(link); + const linkView = paper.findViewByModel(link) as dia.LinkView; + + return { graph, linkView, connectionRules }; + }; + + it("adds tools to the link when instances aren't in EditMode and there is no rule with rw modifier", () => { + const isParentInEditMode = false; + const isChildInEditMode = false; + const modifier = "rw+"; + const { graph, linkView, connectionRules } = setup( + isParentInEditMode, + isChildInEditMode, + modifier, + ); + + expect(linkView.hasTools()).toBeFalsy(); + + showLinkTools(graph, linkView, connectionRules); + + expect(linkView.hasTools()).toBeTruthy(); + }); + + it("adds tools to the link when only instance without rule is in edit mode", () => { + const isParentInEditMode = true; + const isChildInEditMode = false; + const modifier = "rw"; + const { graph, linkView, connectionRules } = setup( + isParentInEditMode, + isChildInEditMode, + modifier, + ); + + expect(linkView.hasTools()).toBeFalsy(); + + showLinkTools(graph, linkView, connectionRules); + + expect(linkView.hasTools()).toBeTruthy(); + }); + + it("doesn't add tools to the link when instance with rw rule is in edit mode", () => { + const isParentInEditMode = false; + const isChildInEditMode = true; + const modifier = "rw"; + const { graph, linkView, connectionRules } = setup( + isParentInEditMode, + isChildInEditMode, + modifier, + ); + + expect(linkView.hasTools()).toBeFalsy(); + + showLinkTools(graph, linkView, connectionRules); + expect(linkView.hasTools()).toBeFalsy(); + }); +}); diff --git a/src/UI/Components/Diagram/helpers.ts b/src/UI/Components/Diagram/helpers.ts index 9fad97548..29f06da1f 100644 --- a/src/UI/Components/Diagram/helpers.ts +++ b/src/UI/Components/Diagram/helpers.ts @@ -1,9 +1,10 @@ -import { dia, g, highlighters } from "@inmanta/rappid"; +import { dia, g, highlighters, linkTools } from "@inmanta/rappid"; import { isEqual } from "lodash"; import { v4 as uuidv4 } from "uuid"; import { EmbeddedEntity, InstanceAttributeModel, + InterServiceRelation, ServiceInstanceModel, ServiceModel, } from "@/Core"; @@ -15,15 +16,26 @@ import { TypeEnum, LabelLinkView, SavedCoordinates, + EmbeddedEventEnum, + StencilState, + ActionEnum, } from "@/UI/Components/Diagram/interfaces"; +import { words } from "@/UI/words"; import { ServiceEntityBlock } from "./shapes"; +/** + * Extracts the IDs of the relations of a service instance. + * + * @param service - The service model. + * @param instance - The service instance. + * @returns {string[]} An array of relation IDs. + */ export const extractRelationsIds = ( service: ServiceModel, instance: ServiceInstanceModel, ): string[] => { - const relationKeys = service.inter_service_relations?.map( + const relationKeys = service.inter_service_relations.map( (relation) => relation.name, ); @@ -101,24 +113,28 @@ export const createConnectionRules = ( * Function that takes source of the connection and eventual target, and check if the rules allows connection between entities, and * whether source & target didn't exhaust eventual limits for given type of connection * - * @param {dia.Graph} graph - * @param {dia.CellView | dia.ElementView | undefined} tgtView - * @param {dia.CellView | dia.ElementView} srcView - * @param {ConnectionRules} rules - * @returns {boolean} + * @param {dia.Graph} graph - jointjs graph + * @param {dia.CellView | dia.ElementView | undefined} targetView - target of the connection + * @param {dia.CellView | dia.ElementView} sourceView - source of the connection + * @param {ConnectionRules} rules - rules for connections + * @returns {boolean} - whether connection is allowed */ export const checkIfConnectionIsAllowed = ( graph: dia.Graph, - tgtView: dia.CellView | dia.ElementView | undefined, - srcView: dia.CellView | dia.ElementView, + targetView: dia.CellView | dia.ElementView | undefined, + sourceView: dia.CellView | dia.ElementView, rules: ConnectionRules, ): boolean => { + if (!targetView) { + return false; + } + let areSourceConnectionsExhausted = false; let areTargetConnectionExhausted = false; let doesSourceIsEmbeddedWithExhaustedConnections = false; let doesTargetIsEmbeddedWithExhaustedConnections = false; - const targetName = (tgtView?.model as ServiceEntityBlock).getName(); - const sourceName = (srcView.model as ServiceEntityBlock).getName(); + const targetName = (targetView.model as ServiceEntityBlock).getName(); + const sourceName = (sourceView.model as ServiceEntityBlock).getName(); const targetRule = rules[targetName].find( (object) => object.name === sourceName, @@ -131,10 +147,10 @@ export const checkIfConnectionIsAllowed = ( //to receive neighbors we need to convert celView to Element const allElements = graph.getElements(); const sourceAsElement = allElements.find( - (element) => element.cid === srcView.model.cid, + (element) => element.cid === sourceView.model.cid, ); const targetAsElement = allElements.find( - (element) => element.cid === tgtView?.model.cid, + (element) => element.cid === targetView.model.cid, ); if (sourceAsElement && targetAsElement) { @@ -150,6 +166,19 @@ export const checkIfConnectionIsAllowed = ( const isSourceInEditMode: boolean | undefined = sourceAsElement.get("isInEditMode"); + const isSourceBlockedFromEditing: boolean | undefined = sourceAsElement.get( + "isBlockedFromEditing", + ); + + if (isSourceBlockedFromEditing) { + const targetHolder = targetAsElement.get("holderName"); + const isTargetEmbedded = targetAsElement.get("isEmbedded"); + + if (isTargetEmbedded && targetHolder === sourceName) { + return false; // if source is blocked from editing then we can't connect embedded entities to it + } + } + areTargetConnectionExhausted = checkWhetherConnectionRulesAreExhausted( connectedElementsToTarget, targetRule, @@ -176,15 +205,26 @@ export const checkIfConnectionIsAllowed = ( ); } - return ( - !areSourceConnectionsExhausted && - !areTargetConnectionExhausted && - !( - doesTargetIsEmbeddedWithExhaustedConnections || - doesSourceIsEmbeddedWithExhaustedConnections - ) && - (sourceRule !== undefined || targetRule !== undefined) - ); + const elementsCanBeConnected = + sourceRule !== undefined || targetRule !== undefined; + //the info about the connection between elements can be one directional + const connectionIsInterServiceRelation = + (sourceRule && sourceRule.kind === TypeEnum.INTERSERVICE) || + (targetRule && targetRule.kind === TypeEnum.INTERSERVICE); + + if (elementsCanBeConnected) { + //if elements have interservice relation then we need to check if they are exhausted + if (connectionIsInterServiceRelation) { + return !areSourceConnectionsExhausted && !areTargetConnectionExhausted; + } else { + return !( + doesTargetIsEmbeddedWithExhaustedConnections || + doesSourceIsEmbeddedWithExhaustedConnections + ); + } + } + + return elementsCanBeConnected; }; /** @@ -193,15 +233,19 @@ export const checkIfConnectionIsAllowed = ( * @param {ServiceEntityBlock[]} connectedElements list of connected elements to given shape * @param {EmbeddedRule | InterServiceRule | undefined} rule telling which shapes can connect to each other and about their limitations * @param {boolean} editMode which defines whether connectionts rule is assesed for instance edited or newly created - * @returns {boolean} + * @returns {boolean} - whether connection are exhausted */ export const checkWhetherConnectionRulesAreExhausted = ( connectedElements: ServiceEntityBlock[], rule: EmbeddedRule | InterServiceRule | undefined, editMode: boolean, ): boolean => { + if (!rule) { + return false; + } + const targetConnectionsForGivenRule = connectedElements.filter( - (element) => element.getName() === rule?.name, + (element) => element.getName() === rule.name, ); //if is in edit mode and its modifier is r/rw then the connections are basically exhausted @@ -209,8 +253,8 @@ export const checkWhetherConnectionRulesAreExhausted = ( return true; } //undefined and null are equal to no limit - if (rule?.upperLimit !== undefined && rule?.upperLimit !== null) { - return targetConnectionsForGivenRule.length >= rule?.upperLimit; + if (rule.upperLimit !== undefined && rule.upperLimit !== null) { + return targetConnectionsForGivenRule.length >= rule.upperLimit; } else { return false; } @@ -220,12 +264,12 @@ export const checkWhetherConnectionRulesAreExhausted = ( * Function that checks if source is embedded, and if it is then if it is connected to the its owner/holder, and * also if the the entity that we would like to connect is also the same type as the owner/holder. * - * Second boolean is to make it possible to connect nested embedded/related entities. + * Second boolean is to make it possible to connect nested embedded/inter-service related entities. * * @param {dia.Element} source element that originate our connection * @param {ServiceEntityBlock[]} connectedElementsToSource array of elements that are connected to the given entity * @param {dia.Element} target element that is destination for the connection - * @returns {boolean} + * @returns {boolean} - whether element is embedded and its available connections are exhausted */ const doesElementIsEmbeddedWithExhaustedConnections = ( source: dia.Element, @@ -264,8 +308,9 @@ const doesElementIsEmbeddedWithExhaustedConnections = ( * go through all of them to group, and sort them * @param {ComposerServiceOrderItem} parentInstance Instance that is the main object and to which other instance are eventually connected * @param {ComposerServiceOrderItem[]} instances all of the instances that were created/edited in the instance, not including parentInstance + * @param {ServiceModel | EmbeddedEntity} serviceModel - ServiceModel or EmbeddedEntity that is the model for the current iteration to build upon * @param {boolean=} isEmbedded boolean informing whether instance passed is embedded or not - * @returns + * @returns {ComposerServiceOrderItem} - object that could be sent to the backend or embedded into other object that could be sent */ export const shapesDataTransform = ( parentInstance: ComposerServiceOrderItem, @@ -334,14 +379,16 @@ export const shapesDataTransform = ( if (parentInstance.relatedTo) { Array.from(parentInstance.relatedTo).forEach(([id, attributeName]) => { if (parentInstance.attributes) { - const model = serviceModel.inter_service_relations?.find( + const model = serviceModel.inter_service_relations.find( (relation) => relation.name === attributeName, ); if (model) { if (model.upper_limit !== 1) { if (Array.isArray(parentInstance.attributes[attributeName])) { - (parentInstance.attributes[attributeName] as string[]).push(id); + (parentInstance.attributes[attributeName] as dia.Cell.ID[]).push( + id, + ); } else { parentInstance.attributes[attributeName] = [id]; } @@ -380,12 +427,12 @@ export const shapesDataTransform = ( }; /** - * Function that takes Map of standalone instances that include core, embedded and related entities and + * Function that takes Map of standalone instances that include core, embedded and inter-service related entities and * bundle in proper Instance Objects that could be accepted by the order_api request * * @param {Map}instances Map of Instances - * @param {ServiceModel[]} services - * @returns ComposerServiceOrderItem[] + * @param {ServiceModel[]} services - Array of service models + * @returns {ComposerServiceOrderItem[]} */ export const getServiceOrderItems = ( instances: Map, @@ -407,6 +454,7 @@ export const getServiceOrderItems = ( : mapToArray[index].relatedTo; }); const topServicesNames = services.map((service) => service.name); + // topInstances are instances that have top-level attributes from given serviceModel, and theoretically are the ones accepting embedded-entities const topInstances = deepCopiedMapToArray.filter((instance) => topServicesNames.includes(instance.service_entity), @@ -436,17 +484,22 @@ const isSingularRelation = (model?: EmbeddedEntity) => { return !!model && !!model.upper_limit && model.upper_limit === 1; }; +interface CorrespondingId { + id: dia.Cell.ID; + attributeName: string; +} + /** - * * Find if the relations of some instance includes Id of the instance passed through prop - * @param neighborRelations map of ids that could include id of intanceAsTable - * @param instanceAsTable Instance to which should instances connect to - * @returns + * @param {Map} neighborRelations map of ids that could include id of instanceAsTable + * @param {ServiceEntityBlock} instanceAsTable Instance to which should instances connect to + * + * @returns {CorrespondingId | undefined} */ export const findCorrespondingId = ( - neighborRelations: Map, + neighborRelations: Map, instanceAsTable: ServiceEntityBlock, -) => { +): CorrespondingId | undefined => { return Array.from(neighborRelations, ([id, attributeName]) => ({ id, attributeName, @@ -505,15 +558,16 @@ export const updateLabelPosition = ( /** * Toggle the highlighting of a loose element in a diagram cell view. * @param {dia.CellView} cellView - The cell view containing the element. - * @param {"add" | "remove"} kind - The action to perform, either "add" to add highlighting or "remove" to remove highlighting. + * @param {EmbeddedEventEnum} kind - The action to perform, either "add" to add highlighting or "remove" to remove highlighting. + * * @returns {void} */ export const toggleLooseElement = ( cellView: dia.CellView, - kind: "add" | "remove", + kind: EmbeddedEventEnum, ): void => { switch (kind) { - case "add": + case EmbeddedEventEnum.ADD: highlighters.mask.add(cellView, "root", "loose_element", { padding: 0, className: "loose_element-highlight", @@ -523,7 +577,7 @@ export const toggleLooseElement = ( }, }); break; - case "remove": + case EmbeddedEventEnum.REMOVE: const highlighter = dia.HighlighterView.get(cellView, "loose_element"); if (highlighter) { @@ -534,7 +588,7 @@ export const toggleLooseElement = ( break; } document.dispatchEvent( - new CustomEvent("looseEmbedded", { + new CustomEvent("looseElement", { detail: JSON.stringify({ kind, id: cellView.model.id, @@ -547,6 +601,7 @@ export const toggleLooseElement = ( * Gets the coordinates of all cells in the graph. https://resources.jointjs.com/docs/jointjs/v4.0/joint.html#dia.Graph * * @param {dia.Graph} graph - The graph from which to get the cells. + * * @returns {SavedCoordinates[]} An array of objects, each containing the id, name, attributes, and coordinates of a cell. */ export const getCellsCoordinates = (graph: dia.Graph): SavedCoordinates[] => { @@ -567,11 +622,13 @@ export const getCellsCoordinates = (graph: dia.Graph): SavedCoordinates[] => { * * @param {dia.Graph} graph - The graph to which to apply the coordinates. * @param {SavedCoordinates[]} coordinates - The coordinates to apply to the cells. + * + * @returns {void} */ export const applyCoordinatesToCells = ( graph: dia.Graph, coordinates: SavedCoordinates[], -) => { +): void => { const cells = graph.getCells(); coordinates.forEach((element) => { @@ -592,27 +649,283 @@ export const applyCoordinatesToCells = ( }; /** - * Moves a cell away from any cells it is colliding with. + * Finds the inter-service relations entity types for the given service model or embedded entity. + * + * @param {ServiceModel | EmbeddedEntity} serviceModel - The service model or embedded entity to find inter-service relations for. * - * @param {dia.Graph} graph - The graph containing the cell. - * @param {dia.Cell} cell - The cell to move. + * @returns {string[]} An array of entity types that have inter-service relations with the given service model or embedded entity */ -export const moveCellFromColliding = (graph: dia.Graph, cell: dia.Cell) => { - let isColliding = false; +export const findInterServiceRelations = ( + serviceModel: ServiceModel | EmbeddedEntity, +): string[] => { + const result = + serviceModel.inter_service_relations.map( + (relation) => relation.entity_type, + ) || []; - do { - const overlappingCells = graph - .findModelsInArea(cell.getBBox()) - .filter((el) => el.id !== cell.id); + const embeddedEntitiesResult = serviceModel.embedded_entities.flatMap( + (embedded_entity) => findInterServiceRelations(embedded_entity), + ); - if (overlappingCells.length > 0) { - isColliding = true; - // an overlap found, revert the position - const coordinates = cell.position(); + return result.concat(embeddedEntitiesResult); +}; - cell.set("position", { x: coordinates.x + 50, y: coordinates.y }); - } else { - isColliding = false; +/** + * Finds the inter-service relations objects for the given service model or embedded entity. + * + * @param {ServiceModel | EmbeddedEntity} serviceModel - The service model or embedded entity to find inter-service relations for. + * + * @returns {string[]} An array of inter-service relations objects that have inter-service relations + * */ +export const findFullInterServiceRelations = ( + serviceModel: ServiceModel | EmbeddedEntity, +): InterServiceRelation[] => { + const result = serviceModel.inter_service_relations || []; + + const embeddedEntitiesResult = serviceModel.embedded_entities.flatMap( + (embedded_entity) => findFullInterServiceRelations(embedded_entity), + ); + + return result.concat(embeddedEntitiesResult); +}; + +/** + * Creates a stencil state for a given service model or embedded entity. + * + * @param serviceModel - The service model or embedded entity to create a stencil state for. + * @param isInEditMode - A boolean indicating whether the stencil is in edit mode. Defaults to false. + * @returns {StencilState} The created stencil state. + */ +export const createStencilState = ( + serviceModel: ServiceModel | EmbeddedEntity, + isInEditMode = false, +): StencilState => { + let stencilState: StencilState = {}; + + serviceModel.embedded_entities.forEach((entity) => { + stencilState[entity.name] = { + min: entity.lower_limit, + max: entity.modifier === "rw" && isInEditMode ? 0 : entity.upper_limit, + current: 0, + }; + if (entity.embedded_entities) { + stencilState = { + ...stencilState, + ...createStencilState(entity), + }; } - } while (isColliding); + }); + + return stencilState; }; + +/** + * Updates the instances to send based on the action performed on a cell. + * + * @param cell - The cell that the action was performed on. + * @param action - The action that was performed. + * @param serviceOrderItems - The current map of instances to send. + * @returns {Map} The updated map of instances to send. + */ +export const updateServiceOrderItems = ( + cell: ServiceEntityBlock, + action: ActionEnum, + serviceOrderItems: Map, +): Map => { + const newInstance: ComposerServiceOrderItem = { + instance_id: cell.id, + service_entity: cell.getName(), + config: {}, + action: null, + attributes: cell.get("sanitizedAttrs"), + edits: null, + embeddedTo: cell.get("embeddedTo"), + relatedTo: cell.getRelations(), + }; + const copiedInstances = new Map(serviceOrderItems); // copy + + const updatedInstance = serviceOrderItems.get(String(cell.id)); + + switch (action) { + case ActionEnum.UPDATE: + if (!updatedInstance) { + throw new Error(words("instanceComposer.error.updateInstanceNotInMap")); //updating instance that doesn't exist in the map shouldn't happen + } + + //action in the instance isn't the same as action passed to this function, this assertion is to make sure that the update action won't change the action state of newly created instance. + newInstance.action = + updatedInstance.action === ActionEnum.CREATE + ? ActionEnum.CREATE + : ActionEnum.UPDATE; + copiedInstances.set(String(cell.id), newInstance); + break; + case ActionEnum.CREATE: + newInstance.action = action; + copiedInstances.set(String(cell.id), newInstance); + break; + default: + if ( + updatedInstance && + (updatedInstance.action === null || + updatedInstance.action === ActionEnum.UPDATE) + ) { + copiedInstances.set(String(cell.id), { + instance_id: cell.id, + service_entity: cell.getName(), + config: {}, + action: ActionEnum.DELETE, + attributes: null, + edits: null, + embeddedTo: cell.attributes.embeddedTo, + relatedTo: cell.attributes.relatedTo, + }); + } else { + copiedInstances.delete(String(cell.id)); + } + break; + } + + return copiedInstances; +}; + +/** + * Function to display the methods to alter the connection objects - currently, the only function visible is the one removing connections. + * https://resources.jointjs.com/docs/jointjs/v3.6/joint.html#dia.LinkView + * https://resources.jointjs.com/docs/jointjs/v3.6/joint.html#linkTools + * + * @param {dia.Graph} graph JointJS graph object + * @param {dia.LinkView} linkView - The view for the joint.dia.Link model. + * @param {ConnectionRules} connectionRules - The rules for the connections between entities. + * + * @returns {void} + */ +export function showLinkTools( + graph: dia.Graph, + linkView: dia.LinkView, + connectionRules: ConnectionRules, +) { + const source = linkView.model.source(); + const target = linkView.model.target(); + + if (!source.id || !target.id) { + return; + } + + const sourceCell = graph.getCell(source.id) as ServiceEntityBlock; + const targetCell = graph.getCell(target.id) as ServiceEntityBlock; + + /** + * checks if the connection between cells can be deleted thus if we should hide linkTool + * @param {ServiceEntityBlock} cellOne ServiceEntityBlock + * @param {ServiceEntityBlock} cellTwo ServiceEntityBlock + * @returns {boolean} + */ + const shouldHideLinkTool = ( + cellOne: ServiceEntityBlock, + cellTwo: ServiceEntityBlock, + ): boolean => { + const nameOne = cellOne.getName(); + const nameTwo = cellTwo.getName(); + + const elementConnectionRule = connectionRules[nameOne].find( + (rule) => rule.name === nameTwo, + ); + + const isElementInEditMode: boolean | undefined = + cellOne.get("isInEditMode"); + + if ( + isElementInEditMode && + elementConnectionRule && + elementConnectionRule.modifier !== "rw+" + ) { + return true; + } + + return false; + }; + + if ( + shouldHideLinkTool(sourceCell, targetCell) || + shouldHideLinkTool(targetCell, sourceCell) + ) { + return; + } + + const tools = new dia.ToolsView({ + tools: [ + new linkTools.Remove({ + distance: "50%", + markup: [ + { + tagName: "circle", + selector: "button", + attributes: { + r: 7, + class: "joint-link_remove-circle", + "stroke-width": 2, + cursor: "pointer", + }, + }, + { + tagName: "path", + selector: "icon", + attributes: { + d: "M -3 -3 3 3 M -3 3 3 -3", + class: "joint-link_remove-path", + "stroke-width": 2, + "pointer-events": "none", + }, + }, + ], + action: (_evt, linkView: dia.LinkView, toolView: dia.ToolView) => { + const { model } = linkView; + const source = model.source(); + const target = model.target(); + + const sourceCell = graph.getCell( + source.id as dia.Cell.ID, + ) as ServiceEntityBlock; + const targetCell = graph.getCell( + target.id as dia.Cell.ID, + ) as ServiceEntityBlock; + + /** + * Function that remove any data in this connection between cells + * @param {ServiceEntityBlock} elementCell cell that we checking + * @param {ServiceEntityBlock} disconnectingCell cell that is being connected to elementCell + * @returns {void} + */ + const removeConnectionData = ( + elementCell: ServiceEntityBlock, + disconnectingCell: ServiceEntityBlock, + ): void => { + const elementRelations = elementCell.getRelations(); + + // resolve any possible relation connections between cells + if ( + elementRelations && + elementRelations.has(String(disconnectingCell.id)) + ) { + elementCell.removeRelation(String(disconnectingCell.id)); + + document.dispatchEvent( + new CustomEvent("updateServiceOrderItems", { + detail: { cell: sourceCell, actions: ActionEnum.UPDATE }, + }), + ); + } + }; + + //as the connection between two cells is bidirectional we need attempt to remove data from both cells + removeConnectionData(sourceCell, targetCell); + removeConnectionData(targetCell, sourceCell); + + model.remove({ ui: true, tool: toolView.cid }); + }, + }), + ], + }); + + linkView.addTools(tools); +} diff --git a/src/UI/Components/Diagram/icons/exit-fullscreen.svg b/src/UI/Components/Diagram/icons/exit-fullscreen.svg new file mode 100644 index 000000000..953c7d914 --- /dev/null +++ b/src/UI/Components/Diagram/icons/exit-fullscreen.svg @@ -0,0 +1 @@ + diff --git a/src/UI/Components/Diagram/icons/fit-to-screen.svg b/src/UI/Components/Diagram/icons/fit-to-screen.svg new file mode 100644 index 000000000..83bdbdff1 --- /dev/null +++ b/src/UI/Components/Diagram/icons/fit-to-screen.svg @@ -0,0 +1 @@ + diff --git a/src/UI/Components/Diagram/icons/request-fullscreen.svg b/src/UI/Components/Diagram/icons/request-fullscreen.svg new file mode 100644 index 000000000..4fde2ef6e --- /dev/null +++ b/src/UI/Components/Diagram/icons/request-fullscreen.svg @@ -0,0 +1 @@ + diff --git a/src/UI/Components/Diagram/init.ts b/src/UI/Components/Diagram/init.ts index c5c88dfcf..b4359d44b 100644 --- a/src/UI/Components/Diagram/init.ts +++ b/src/UI/Components/Diagram/init.ts @@ -1,112 +1,56 @@ +import { RefObject } from "react"; import { dia, shapes, ui } from "@inmanta/rappid"; import { InstanceAttributeModel, ServiceModel } from "@/Core"; import { InstanceWithRelations } from "@/Data/Managers/V2/GETTERS/GetInstanceWithRelations"; import { - appendColumns, - appendEntity, + updateAttributes, appendInstance, - showLinkTools, + populateGraphWithDefault, } from "./actions"; -import { anchorNamespace } from "./anchors"; -import createHalo from "./halo"; import { applyCoordinatesToCells, - checkIfConnectionIsAllowed, getCellsCoordinates, toggleLooseElement, } from "./helpers"; -import collapseButton from "./icons/collapse-icon.svg"; -import expandButton from "./icons/expand-icon.svg"; import { - ActionEnum, ConnectionRules, + EmbeddedEventEnum, SavedCoordinates, - TypeEnum, - serializedCell, } from "./interfaces"; -import { routerNamespace } from "./routers"; -import { Link, ServiceEntityBlock } from "./shapes"; - -export default function diagramInit( - canvas, +import { ComposerPaper } from "./paper"; +import { ServiceEntityBlock } from "./shapes"; + +/** + * Initializes the diagram. + * + * This function creates a new JointJS graph and paper, sets up a paper scroller, and attaches event listeners. + * It also sets up tooltips for elements with the `data-tooltip` attribute. + * + * @param {RefObject} canvasRef - A reference to the HTML div element that will contain the diagram. + * @param {Function} setScroller - A function to update the state of the scroller. + * @param {ConnectionRules} connectionRules - The rules for connecting elements in the diagram. + * @param {boolean} editable - A flag indicating if the diagram is editable. + * @param {ServiceModel} mainService - The main service model for the diagram. + * + * @returns {DiagramHandlers} An object containing handlers for various diagram actions. + */ +export function diagramInit( + canvasRef: RefObject, + setScroller, connectionRules: ConnectionRules, - updateInstancesToSend: (cell: ServiceEntityBlock, action: ActionEnum) => void, editable: boolean, + mainService: ServiceModel, ): DiagramHandlers { /** * https://resources.jointjs.com/docs/jointjs/v3.6/joint.html#dia.Graph */ const graph = new dia.Graph({}, { cellNamespace: shapes }); - /** - * https://resources.jointjs.com/docs/jointjs/v3.6/joint.html#dia.Paper - */ - const paper = new dia.Paper({ - model: graph, - width: 1000, - height: 1000, - gridSize: 1, - interactive: { linkMove: false }, - defaultConnectionPoint: { - name: "boundary", - args: { - extrapolate: true, - sticky: true, - }, - }, - defaultConnector: { name: "rounded" }, - async: true, - frozen: true, - sorting: dia.Paper.sorting.APPROX, - cellViewNamespace: shapes, - routerNamespace: routerNamespace, - defaultRouter: { name: "customRouter" }, - anchorNamespace: anchorNamespace, - defaultAnchor: { name: "customAnchor" }, - snapLinks: true, - linkPinning: false, - magnetThreshold: 0, - background: { color: "transparent" }, - highlighting: { - connecting: { - name: "addClass", - options: { - className: "column-connected", - }, - }, - }, - defaultLink: () => new Link(), - validateConnection: (srcView, srcMagnet, tgtView, tgtMagnet) => { - const baseValidators = - srcMagnet !== tgtMagnet && srcView.cid !== tgtView.cid; - - const srcViewAsElement = graph - .getElements() - .find((element) => element.cid === srcView.model.cid); - - //find srcView as Element to get Neighbors and check if it's already connected to the target - if (srcViewAsElement) { - const connectedElements = graph.getNeighbors(srcViewAsElement); - const isConnected = connectedElements.find( - (connectedElement) => connectedElement.cid === tgtView.model.cid, - ); - const isAllowed = checkIfConnectionIsAllowed( - graph, - tgtView, - srcView, - connectionRules, - ); - - return isConnected === undefined && isAllowed && baseValidators; - } - - return baseValidators; - }, - }); - /** * https://resources.jointjs.com/docs/rappid/v3.6/ui.PaperScroller.html */ + const paper = new ComposerPaper(connectionRules, graph, editable).paper; + const scroller = new ui.PaperScroller({ paper, cursor: "grab", @@ -124,239 +68,46 @@ export default function diagramInit( }, }); - canvas.current.appendChild(scroller.el); - scroller.render().center(); - scroller.centerContent(); - - new ui.Tooltip({ - rootTarget: ".canvas", - target: "[data-tooltip]", - padding: 20, - }); + setScroller(scroller); - paper.on( - "element:showDict", - (_elementView: dia.ElementView, event: dia.Event) => { - document.dispatchEvent( - new CustomEvent("openDictsModal", { - detail: event.target.parentElement.attributes.dict.value, - }), - ); - }, - ); - - paper.on( - "element:toggleButton:pointerdown", - (elementView: dia.ElementView, event: dia.Event) => { - event.preventDefault(); - const elementAsShape = elementView.model as ServiceEntityBlock; + //trigger highlighter when user drag element from stencil + graph.on("add", function (cell) { + const paperRepresentation = paper.findViewByModel(cell); - const isCollapsed = elementAsShape.get("isCollapsed"); - const originalAttrs = elementAsShape.get("dataToDisplay"); - - elementAsShape.appendColumns( - isCollapsed ? originalAttrs : originalAttrs.slice(0, 4), - false, - ); - elementAsShape.attr( - "toggleButton/xlink:href", - isCollapsed ? collapseButton : expandButton, - ); - - const bbox = elementAsShape.getBBox(); - - elementAsShape.attr("toggleButton/y", bbox.height - 24); - elementAsShape.attr("spacer/y", bbox.height - 33); - elementAsShape.attr("buttonBody/y", bbox.height - 32); - - elementAsShape.set("isCollapsed", !isCollapsed); - }, - ); - - paper.on("cell:pointerup", function (cellView) { - // We don't want a Halo if cellView is a Link or is a representation of an already existing instance that has strict_modifier set to false if ( - cellView.model instanceof dia.Link || - cellView.model.get("isBlockedFromEditing") - ) - return; - if (cellView.model.get("isBlockedFromEditing") || !editable) return; - const halo = createHalo( - graph, - paper, - cellView, - connectionRules, - updateInstancesToSend, - ); - - halo.render(); - }); - - paper.on("link:mouseenter", (linkView) => { - const source = linkView.model.source(); - const target = linkView.model.target(); - - const sourceCell = graph.getCell( - source.id as dia.Cell.ID, - ) as ServiceEntityBlock; - const targetCell = graph.getCell( - target.id as dia.Cell.ID, - ) as ServiceEntityBlock; - - if (!(sourceCell.getName()[0] === "_")) { - linkView.model.appendLabel({ - attrs: { - rect: { - fill: "none", - }, - text: { - text: sourceCell.getName(), - autoOrient: "target", - class: "joint-label-text", - }, - }, - position: { - distance: 1, - }, - }); - } - if (!(targetCell.getName()[0] === "_")) { - linkView.model.appendLabel({ - attrs: { - rect: { - fill: "none", - }, - text: { - text: targetCell.getName(), - autoOrient: "source", - class: "joint-label-text", - }, - }, - position: { - distance: 0, - }, - }); + cell.get("isEmbedded") && + !cell.get("embeddedTo") && + paperRepresentation + ) { + toggleLooseElement(paperRepresentation, EmbeddedEventEnum.ADD); } - if (linkView.model.get("isBlockedFromEditing") || !editable) return; - showLinkTools( - paper, - graph, - linkView, - updateInstancesToSend, - connectionRules, - ); }); - paper.on("link:mouseleave", (linkView: dia.LinkView) => { - linkView.removeTools(); - linkView.model.labels([]); - }); - - paper.on("link:connect", (linkView: dia.LinkView) => { - //only id values are stored in the linkView - const source = linkView.model.source(); - const target = linkView.model.target(); - - const sourceCell = graph.getCell( - source.id as dia.Cell.ID, - ) as ServiceEntityBlock; - const targetCell = graph.getCell( - target.id as dia.Cell.ID, - ) as ServiceEntityBlock; - - /** - * Function that checks if cell that we are connecting to is being the one storing information about said connection. - * @param elementCell cell that we checking - * @param connectingCell cell that is being connected to elementCell - * @returns boolean whether connections was set - */ - const wasConnectionDataAssigned = ( - elementCell: ServiceEntityBlock, - connectingCell: ServiceEntityBlock, - ): boolean => { - const cellRelations = elementCell.getRelations(); - const cellName = elementCell.getName(); - const connectingCellName = connectingCell.getName(); - - //if cell has Map that mean it can accept inter-service relations - if (cellRelations) { - const cellConnectionRule = connectionRules[cellName].find( - (rule) => rule.name === connectingCellName, - ); - - //if there is corresponding rule we can apply connection and update given service - if ( - cellConnectionRule && - cellConnectionRule.kind === TypeEnum.INTERSERVICE - ) { - elementCell.addRelation( - connectingCell.id as string, - cellConnectionRule.attributeName, - ); + //programmatically trigger link:connect event, when we connect elements not by user interaction + graph.on("link:connect", (link: dia.Link) => { + const linkView = paper.findViewByModel(link); - updateInstancesToSend(sourceCell, ActionEnum.UPDATE); - - return true; - } - } - - if ( - elementCell.get("isEmbedded") && - elementCell.get("embeddedTo") !== null - ) { - elementCell.set("embeddedTo", connectingCell.id); - toggleLooseElement(paper.findViewByModel(elementCell), "remove"); - updateInstancesToSend(elementCell, ActionEnum.UPDATE); - - return true; - } else { - return false; - } - }; - - const wasConnectionFromSourceSet = wasConnectionDataAssigned( - sourceCell, - targetCell, - ); - - if (!wasConnectionFromSourceSet) { - wasConnectionDataAssigned(targetCell, sourceCell); + if (linkView) { + paper.trigger("link:connect", linkView); } }); - paper.on("blank:pointerdown", (evt: dia.Event) => scroller.startPanning(evt)); - paper.on( - "blank:mousewheel", - (evt: dia.Event, ox: number, oy: number, delta: number) => { - evt.preventDefault(); - zoom(ox, oy, delta); - }, + "blank:pointerdown", + (evt: dia.Event) => evt && scroller.startPanning(evt), ); - paper.on( - "cell:mousewheel", - (_, evt: dia.Event, ox: number, oy: number, delta: number) => { - evt.preventDefault(); - zoom(ox, oy, delta); - }, - ); - - /** - * Function that zooms in/out the view of canvas - * @param {number} x - x coordinate - * @param {number} y - y coordinate - * @param {number} delta - the value that dictates how big the zoom has to be. - */ - function zoom(x: number, y: number, delta: number) { - scroller.zoom(delta * 0.05, { - min: 0.4, - max: 1.2, - grid: 0.05, - ox: x, - oy: y, - }); + if (canvasRef.current) { + canvasRef.current.appendChild(scroller.el); } + scroller.render().center(); + scroller.centerContent(); + + new ui.Tooltip({ + rootTarget: ".canvas", + target: "[data-tooltip]", + padding: 20, + }); paper.unfreeze(); @@ -367,17 +118,34 @@ export default function diagramInit( }, addInstance: ( - instance: InstanceWithRelations, services: ServiceModel[], - isMainInstance: boolean, + instance: InstanceWithRelations | null, ) => { - appendInstance(paper, graph, instance, services, isMainInstance); + let cells: ServiceEntityBlock[] = []; + + if (!instance) { + populateGraphWithDefault(graph, mainService); + + cells = graph + .getCells() + .filter( + (cell) => cell.get("type") !== "Link", + ) as ServiceEntityBlock[]; + } else { + cells = appendInstance(paper, graph, instance, services); - if (instance.coordinates) { - const parsedCoordinates = JSON.parse(instance.coordinates); + if ( + instance.instance.metadata && + instance.instance.metadata.coordinates + ) { + const parsedCoordinates = JSON.parse( + instance.instance.metadata.coordinates, + ); - applyCoordinatesToCells(graph, parsedCoordinates); + applyCoordinatesToCells(graph, parsedCoordinates); + } } + scroller.zoomToFit({ useModelGeometry: true, padding: 20, @@ -388,74 +156,73 @@ export default function diagramInit( maxScaleY: 1.2, }); - const jsonGraph = graph.toJSON(); - - return jsonGraph.cells as serializedCell[]; + return cells; }, - addEntity: ( - instance, - service, - addingCoreInstance, - isEmbedded, - holderName, - ) => { - const shape = appendEntity( - graph, - service, - instance, - addingCoreInstance, - isEmbedded, - holderName, - ); - - if (shape.get("isEmbedded")) { - toggleLooseElement(paper.findViewByModel(shape), "add"); - } - const shapeCoordinates = shape.getBBox(); - - scroller.center(shapeCoordinates.x, shapeCoordinates.y + 200); - - return shape; - }, editEntity: (cellView, serviceModel, attributeValues) => { //line below resolves issue that appendColumns did update values in the model, but visual representation wasn't updated cellView.model.set("items", []); - appendColumns( + updateAttributes( cellView.model as ServiceEntityBlock, - serviceModel.attributes.map((attr) => attr.name), + serviceModel.key_attributes || [], attributeValues, false, ); return cellView.model as ServiceEntityBlock; }, - zoom: (delta) => { - scroller.zoom(0.05 * delta, { min: 0.4, max: 1.2, grid: 0.05 }); - }, getCoordinates: () => getCellsCoordinates(graph), }; } export interface DiagramHandlers { + /** + * Removes the canvas. + * + * This function is responsible for cleaning up the canvas when it is no longer needed. + * removes the scroller and paper elements. + */ removeCanvas: () => void; + + /** + * Adds an instance to the canvas. + * + * This function is responsible for adding a fetched instance with all it's relations to the canvas or adds minimal default instance for the main service model. + * It creates a new elements for the instance, it's embedded entities and inter-service related entities, adds them to the graph, and returns the serialized cells of the graph. + * + * @param {ServiceModel[]} services - The array of service models to which the instance or it's ineter-service related instances belongs. + * @param {InstanceWithRelations} [instance] - The instance to be added to the canvas. If not provided, a default instance of main type will be created. + * + * @returns {ServiceEntityBlock[]} The created cells after adding the instance. + */ addInstance: ( - instance: InstanceWithRelations, services: ServiceModel[], - isMainInstance: boolean, - ) => serializedCell[]; - addEntity: ( - entity: InstanceAttributeModel, - service: ServiceModel, - addingCoreInstance: boolean, - isEmbedded: boolean, - embeddedTo: string, - ) => ServiceEntityBlock; + instance: InstanceWithRelations | null, + ) => ServiceEntityBlock[]; + + /** + * Edits an entity in the canvas. + * + * This function is responsible for updating an existing entity in the canvas. + * It modifies the entity's properties based on the provided changes, and returns the serialized cells of the graph. + * + * @param {dia.CellView} cellView - The view of the cell to be edited. + * @param {ServiceModel} serviceModel - the service model of the entity edited. + * @param {InstanceAttributeModel} attributeValues - An object containing the changes to be applied to the entity. + * + * @returns {ServiceEntityBlock} The updated entity block. + */ editEntity: ( cellView: dia.CellView, serviceModel: ServiceModel, attributeValues: InstanceAttributeModel, ) => ServiceEntityBlock; - zoom: (delta: 1 | -1) => void; + + /** + * + * This function is responsible for finding and returning the position where all elements are placed in the canvas. + * + * @returns {SavedCoordinates} The array of coordinates for all elements in the canvas. + */ getCoordinates: () => SavedCoordinates[]; } diff --git a/src/UI/Components/Diagram/interfaces.ts b/src/UI/Components/Diagram/interfaces.ts index 95ea167e1..a50580a6c 100644 --- a/src/UI/Components/Diagram/interfaces.ts +++ b/src/UI/Components/Diagram/interfaces.ts @@ -1,32 +1,52 @@ import { dia, g } from "@inmanta/rappid"; -import { ParsedNumber } from "@/Core"; +import { + EmbeddedEntity, + InstanceAttributeModel, + ParsedNumber, + ServiceModel, +} from "@/Core"; import { ServiceOrderItemAction, ServiceOrderItemConfig, } from "@/Slices/Orders/Core/Query"; +/** + * Enum representing types of actions possible to perform on entities. + */ enum ActionEnum { UPDATE = "update", CREATE = "create", DELETE = "delete", } +/** + * Interface representing data for a column for displayable attributes in the entity. + */ interface ColumnData { name: string; - [key: string]: string; + [key: string]: unknown; } +/** + * Interface representing options for a router. + */ interface RouterOptions { padding?: number; sourcePadding?: number; targetPadding?: number; } +/** + * Interface representing data for a dictionary dialog. + */ interface DictDialogData { title: string; value: unknown; } +/** + * Interface representing a rule. + */ interface Rule { name: string; lowerLimit: ParsedNumber | null | undefined; @@ -34,90 +54,44 @@ interface Rule { modifier: string; } +/** + * Interface representing an embedded rule, extending the base Rule interface. + */ interface EmbeddedRule extends Rule { kind: TypeEnum.EMBEDDED; } +/** + * Interface representing an inter-service rule, extending the base Rule interface. + */ interface InterServiceRule extends Rule { kind: TypeEnum.INTERSERVICE; attributeName: string; } -export enum TypeEnum { + +/** + * Enum representing the types of embedded events. + */ +enum EmbeddedEventEnum { + REMOVE = "remove", + ADD = "add", +} + +/** + * Enum representing the types of entities. + */ +enum TypeEnum { EMBEDDED = "Embedded", INTERSERVICE = "Inter-Service", } +/** + * Interface representing the rules for a connection. + */ interface ConnectionRules { [serviceName: string]: (InterServiceRule | EmbeddedRule)[]; } -interface serializedCell { - type: string; - source?: { - id: string; - }; - target?: { - id: string; - }; - z: number; - id: string; - attrs: { - headerLabel?: { - text: string; - }; - info?: { - preserveAspectRatio: string; - cursor: string; - x: string; - "xlink:href": string; - "data-tooltip": string; - y: number; - width: number; - height: number; - }; - header?: { - fill: string; - stroke: string; - }; - }; - columns?: unknown; - padding?: { - top: number; - bottom: number; - left: number; - right: number; - }; - size?: { - width: number; - height: number; - }; - itemMinLabelWidth?: number; - itemHeight?: number; - itemOffset?: number; - itemOverflow?: boolean; - isCollapsed?: boolean; - itemAboveViewSelector?: string; - itemBelowViewSelector?: string; - scrollTop: unknown; - itemButtonSize?: number; - itemIcon?: { - width: number; - height: number; - padding: number; - }; - position?: { - x: number; - y: number; - }; - angle?: number; - entityName?: string; - relatedTo?: Map; - isEmbedded?: boolean; - instanceAttributes?: Record; - holderName?: string; - embeddedTo?: string; -} - type relationId = string | null | undefined; //dia.LinkView & dia.Link doesn't have properties below in the model yet they are available to access and required to update labels @@ -128,6 +102,9 @@ interface LabelLinkView extends dia.LinkView { targetPoint: g.Rect; } +/** + * Interface representing saved coordinates. + */ interface SavedCoordinates { id: string | dia.Cell.ID; name: string; @@ -144,10 +121,59 @@ interface ComposerServiceOrderItem { service_entity: string; action: null | ServiceOrderItemAction; embeddedTo?: string | null; - relatedTo?: Map | null; + relatedTo?: Map | null; metadata?: Record | null; } +/** + * Interface representing the state of a stencil. + */ +interface StencilState { + [key: string]: { + min: ParsedNumber | undefined | null; + max: ParsedNumber | undefined | null; + current: number; + }; +} + +/** + * interface representing options for configuring a composer entity in the canvas. + */ +interface ComposerEntityOptions { + /** The service model or embedded entity associated with the composer entity. */ + serviceModel: ServiceModel | EmbeddedEntity; + + /** Indicates if the entity is a core entity. */ + isCore: boolean; + + /** Indicates if the entity is in edit mode. */ + isInEditMode: boolean; + + /** Optional attributes of the entity. */ + attributes?: InstanceAttributeModel; + + /** Optional flag indicating if the entity is embedded. */ + isEmbedded?: boolean; + + /** Optional name of the holder of the entity. */ + holderName?: string; + + /** Optional identifier of the entity to which this entity is embedded. */ + embeddedTo?: "string" | dia.Cell.ID; + + /** Optional flag indicating if the entity is blocked from editing. */ + isBlockedFromEditing?: boolean; + + /** Optional flag indicating if the entity cannot be removed. */ + cantBeRemoved?: boolean; + + /** Optional name of the stencil associated with the entity. */ + stencilName?: string; + + /** Optional identifier of the entity. */ + id?: string; +} + export { ActionEnum, ColumnData, @@ -156,9 +182,12 @@ export { InterServiceRule, EmbeddedRule, ConnectionRules, - serializedCell, relationId, LabelLinkView, SavedCoordinates, ComposerServiceOrderItem, + StencilState, + TypeEnum, + EmbeddedEventEnum, + ComposerEntityOptions, }; diff --git a/src/UI/Components/Diagram/paper/index.ts b/src/UI/Components/Diagram/paper/index.ts new file mode 100644 index 000000000..da7aefda8 --- /dev/null +++ b/src/UI/Components/Diagram/paper/index.ts @@ -0,0 +1 @@ +export * from "./paper"; diff --git a/src/UI/Components/Diagram/paper/paper.ts b/src/UI/Components/Diagram/paper/paper.ts new file mode 100644 index 000000000..5e2d4f9cc --- /dev/null +++ b/src/UI/Components/Diagram/paper/paper.ts @@ -0,0 +1,313 @@ +import { dia, shapes } from "@inmanta/rappid"; +import { anchorNamespace } from "../anchors"; +import createHalo from "../halo"; +import { + checkIfConnectionIsAllowed, + showLinkTools, + toggleLooseElement, +} from "../helpers"; +import collapseButton from "../icons/collapse-icon.svg"; +import expandButton from "../icons/expand-icon.svg"; +import { + ActionEnum, + ConnectionRules, + EmbeddedEventEnum, + TypeEnum, +} from "../interfaces"; +import { routerNamespace } from "../routers"; +import { Link, ServiceEntityBlock } from "../shapes"; + +/** + * Represents the ComposerPaper class. which initializes the JointJS paper object and sets up the event listeners. + * + * more info: https://docs.jointjs.com/api/dia/Paper/ + */ +export class ComposerPaper { + paper: dia.Paper; + + /** + * Creates an instance of ComposerPaper. + * @param {ConnectionRules} connectionRules - The connection rules. + * @param {dia.Graph} graph - The JointJS graph. + * @param {boolean} editable - Indicates if the paper is editable. + */ + constructor( + connectionRules: ConnectionRules, + graph: dia.Graph, + editable: boolean, + ) { + this.paper = new dia.Paper({ + model: graph, + width: 1000, + height: 1000, + gridSize: 1, + interactive: { linkMove: false }, + defaultConnectionPoint: { + name: "boundary", + args: { + extrapolate: true, + sticky: true, + }, + }, + defaultConnector: { name: "rounded" }, + async: true, + frozen: true, + sorting: dia.Paper.sorting.APPROX, + cellViewNamespace: shapes, + routerNamespace: routerNamespace, + defaultRouter: { name: "customRouter" }, + anchorNamespace: anchorNamespace, + defaultAnchor: { name: "customAnchor" }, + snapLinks: true, + linkPinning: false, + magnetThreshold: 0, + background: { color: "transparent" }, + highlighting: { + connecting: { + name: "addClass", + options: { + className: "column-connected", + }, + }, + }, + defaultLink: () => new Link(), + validateConnection: (sourceView, srcMagnet, targetView, tgtMagnet) => { + const baseValidators = + srcMagnet !== tgtMagnet && sourceView.cid !== targetView.cid; + + const sourceViewAsElement = graph + .getElements() + .find((element) => element.cid === sourceView.model.cid); + + //find sourceView as Element to get Neighbors and check if it's already connected to the target + if (sourceViewAsElement) { + const connectedElements = graph.getNeighbors(sourceViewAsElement); + const isConnected = connectedElements.find( + (connectedElement) => connectedElement.cid === targetView.model.cid, + ); + const isAllowed = checkIfConnectionIsAllowed( + graph, + targetView, + sourceView, + connectionRules, + ); + + return isConnected === undefined && isAllowed && baseValidators; + } + + return baseValidators; + }, + }); + + //Event that is triggered when user clicks on the cell's dictionary icon. It's used to open the dictionary modal. + this.paper.on( + "element:showDict", + (_elementView: dia.ElementView, event: dia.Event) => { + document.dispatchEvent( + new CustomEvent("openDictsModal", { + detail: event.target.parentElement.attributes.dict.value, + }), + ); + }, + ); + + //Event that is triggered when user clicks on the toggle button for the cells that have more than 4 attributes. It's used to collapse or expand the cell. + this.paper.on( + "element:toggleButton:pointerdown", + (elementView: dia.ElementView, event: dia.Event) => { + event.preventDefault(); + const elementAsShape = elementView.model as ServiceEntityBlock; + + const isCollapsed = elementAsShape.get("isCollapsed"); + const originalAttrs = elementAsShape.get("dataToDisplay"); + + elementAsShape.appendColumns( + isCollapsed ? originalAttrs : originalAttrs.slice(0, 4), + false, + ); + elementAsShape.attr( + "toggleButton/xlink:href", + isCollapsed ? collapseButton : expandButton, + ); + + const bbox = elementAsShape.getBBox(); + + elementAsShape.attr("toggleButton/y", bbox.height - 24); + elementAsShape.attr("spacer/y", bbox.height - 33); + elementAsShape.attr("buttonBody/y", bbox.height - 32); + + elementAsShape.set("isCollapsed", !isCollapsed); + }, + ); + + //Event that is triggered when user clicks on the blank space of the paper. It's used to clear the sidebar. + this.paper.on("blank:pointerdown", () => { + document.dispatchEvent( + new CustomEvent("sendCellToSidebar", { + detail: null, + }), + ); + }); + + //Event that is triggered when user clicks on the cell. It's used to send the cell data to the sidebar and create a Halo around the cell. with option to link it to another cell + this.paper.on("cell:pointerup", (cellView: dia.CellView) => { + //We don't want interaction at all if cellView is a Link + if (cellView.model instanceof dia.Link) { + return; + } + + document.dispatchEvent( + new CustomEvent("sendCellToSidebar", { + detail: cellView, + }), + ); + + const halo = createHalo(graph, this.paper, cellView, connectionRules); + + halo.render(); + }); + + //Event that is triggered when user clicks on the link. It's used to show the link tools and the link labels for connected cells + this.paper.on("link:mouseenter", (linkView: dia.LinkView) => { + const { model } = linkView; + const source = model.source(); + const target = model.target(); + + if (!source.id || !target.id) { + return; + } + + const sourceCell = graph.getCell(source.id) as ServiceEntityBlock; + const targetCell = graph.getCell(target.id) as ServiceEntityBlock; + + // source or target cell have name starting with "_" means that it shouldn't have label when hovering + if (!(sourceCell.getName()[0] === "_")) { + model.appendLabel({ + attrs: { + rect: { + fill: "none", + }, + text: { + text: sourceCell.getName(), + autoOrient: "target", + class: "joint-label-text", + }, + }, + position: { + distance: 1, + }, + }); + } + + if (!(targetCell.getName()[0] === "_")) { + model.appendLabel({ + attrs: { + rect: { + fill: "none", + }, + text: { + text: targetCell.getName(), + autoOrient: "source", + class: "joint-label-text", + }, + }, + position: { + distance: 0, + }, + }); + } + + if ( + model.get("isBlockedFromEditing") || + !editable || + !model.get("isRelationshipConnection") + ) { + return; + } + + showLinkTools(graph, linkView, connectionRules); + }); + + //Event that is triggered when user leaves the link. It's used to remove the link tools and the link labels for connected cells + this.paper.on("link:mouseleave", (linkView: dia.LinkView) => { + linkView.removeTools(); + linkView.model.labels([]); + }); + + //Event that is triggered when user drags the link from one cell to another. It's used to update the connection between the cells + this.paper.on("link:connect", (linkView: dia.LinkView) => { + const { model } = linkView; + //only id values are stored in the linkView + const source = model.source(); + const target = model.target(); + + if (!source.id || !target.id) { + return; + } + + const sourceCell = graph.getCell(source.id) as ServiceEntityBlock; + const targetCell = graph.getCell(target.id) as ServiceEntityBlock; + + /** + * Function that checks if cell that we are connecting to is being the one storing information about said connection. + * @param elementCell cell that we checking + * @param connectingCell cell that is being connected to elementCell + * @returns data assigned to ElementCell or null if it's not assigned + */ + const assignConnectionData = ( + elementCell: ServiceEntityBlock, + connectingCell: ServiceEntityBlock, + ) => { + const cellRelations = elementCell.getRelations(); + const cellName = elementCell.getName(); + const connectingCellName = connectingCell.getName(); + + //if cell has Map of relations that mean it can accept inter-service relations + if (cellRelations) { + const cellConnectionRule = connectionRules[cellName].find( + (rule) => rule.name === connectingCellName, + ); + + //if there is corresponding rule we can apply connection and update given service + if ( + cellConnectionRule && + cellConnectionRule.kind === TypeEnum.INTERSERVICE + ) { + elementCell.addRelation( + connectingCell.id, + cellConnectionRule.attributeName, + ); + model.set("isRelationshipConnection", true); + document.dispatchEvent( + new CustomEvent("updateServiceOrderItems", { + detail: { cell: sourceCell, action: ActionEnum.UPDATE }, + }), + ); + } + } + + if ( + elementCell.get("isEmbedded") && + elementCell.get("embeddedTo") !== null && + elementCell.get("holderName") === connectingCellName + ) { + elementCell.set("embeddedTo", connectingCell.id); + toggleLooseElement( + this.paper.findViewByModel(elementCell), + EmbeddedEventEnum.REMOVE, + ); + + document.dispatchEvent( + new CustomEvent("updateServiceOrderItems", { + detail: { cell: elementCell, action: ActionEnum.UPDATE }, + }), + ); + } + }; + + //as the connection between two cells is bidirectional we need attempt to assign data to both cells + assignConnectionData(sourceCell, targetCell); + assignConnectionData(targetCell, sourceCell); + }); + } +} diff --git a/src/UI/Components/Diagram/routers.ts b/src/UI/Components/Diagram/routers.ts index 79d1d1ffe..8ae0765fc 100644 --- a/src/UI/Components/Diagram/routers.ts +++ b/src/UI/Components/Diagram/routers.ts @@ -1,3 +1,6 @@ +/* istanbul ignore file */ +//It's a JointJS file that is hard to test with Jest due to the fact that JointJS base itself on native browser functions that aren't supported by Jest environement + import { dia, g, routers } from "@inmanta/rappid"; import { RouterOptions } from "./interfaces"; @@ -11,7 +14,7 @@ export const routerNamespace = { ...routers }; * @param {number} angle - The rotation of the element in degrees * @param {g.Point} anchor - Point object with x and y coordinates that represent anchor of given entity * @param {number} padding - padding of the Link - * @returns {g.Point} + * @returns {g.Point} - Point object with x and y coordinates */ function getOutsidePoint( bbox: g.Rect, @@ -47,7 +50,7 @@ const customRouter = function ( vertices: Array, routerOptions: RouterOptions, linkView: dia.LinkView, -) { +): g.Point[] { const link = linkView.model; const route: g.Point[] = []; // Target Point diff --git a/src/UI/Components/Diagram/shapes.ts b/src/UI/Components/Diagram/shapes.ts index 0cfb0904b..29fdf4dfd 100644 --- a/src/UI/Components/Diagram/shapes.ts +++ b/src/UI/Components/Diagram/shapes.ts @@ -142,7 +142,7 @@ export class ServiceEntityBlock extends shapes.standard.HeaderedRecord { ); this.attr(`itemLabel_${item.name}_value/cursor`, "pointer"); } else { - value.label = item.value; + value.label = String(item.value); if (item.value !== undefined && item.value !== null) { //reproduce internal formatting of the text base on actual dimensions, if text includes elipsis add Tooltip @@ -247,13 +247,13 @@ export class ServiceEntityBlock extends shapes.standard.HeaderedRecord { } } - getRelations(): Map | null { + getRelations(): Map | null { const relations = this.get("relatedTo"); - return relations ? relations : null; + return relations || null; } - addRelation(id: string, relationName: string): void { + addRelation(id: dia.Cell.ID, relationName: string): void { const currentRelation = this.getRelations(); if (currentRelation) { diff --git a/src/UI/Components/Diagram/stencil/helpers.test.ts b/src/UI/Components/Diagram/stencil/helpers.test.ts new file mode 100644 index 000000000..49c0cba68 --- /dev/null +++ b/src/UI/Components/Diagram/stencil/helpers.test.ts @@ -0,0 +1,107 @@ +import { containerModel, testApiInstanceModel } from "../Mocks"; +import { + createStencilElement, + transformEmbeddedToStencilElements, +} from "./helpers"; + +describe("createStencilElement", () => { + it("returns single instance of Stencil Element based on properties passed", () => { + const embeddedElementWithModel = createStencilElement( + "default", + containerModel.embedded_entities[0], + { + attrOne: "test_value", + attrTwo: "other_test_value", + }, + true, + "holderName", + ); + + expect(embeddedElementWithModel.attributes.name).toEqual("default"); + expect(embeddedElementWithModel.attributes.serviceModel).toStrictEqual( + containerModel.embedded_entities[0], + ); + expect(embeddedElementWithModel.attributes.holderName).toEqual( + "holderName", + ); + expect( + embeddedElementWithModel.attributes.instanceAttributes, + ).toStrictEqual({ + attrOne: "test_value", + attrTwo: "other_test_value", + }); + expect(embeddedElementWithModel.attributes.attrs?.body).toStrictEqual({ + width: 7, + height: 40, + x: 233, + d: "M 0 0 H calc(w) V calc(h) H 0 Z", + strokeWidth: 2, + fill: "#0066cc", + stroke: "none", + class: "body_default", + }); + expect(embeddedElementWithModel.attributes.attrs?.bodyTwo).toStrictEqual({ + width: 240, + height: 40, + fill: "#FFFFFF", + stroke: "none", + class: "bodyTwo_default", + }); + expect(embeddedElementWithModel.attributes.attrs?.label).toStrictEqual({ + x: "10", + textAnchor: "start", + fontFamily: "sans-serif", + fontSize: 12, + text: "default", + refX: undefined, + class: "text_default", + y: "calc(h/2)", + textVerticalAnchor: "middle", + fill: "#333333", + }); + }); +}); + +describe("transformEmbeddedToStencilElements", () => { + it("returns all Stencil Elements based on the Service Model passed", () => { + const result = transformEmbeddedToStencilElements({ + ...testApiInstanceModel, + name: "holderName", + embedded_entities: [ + { + ...containerModel.embedded_entities[0], + name: "embedded", + embedded_entities: [ + { + ...containerModel.embedded_entities[0], + name: "embedded-embedded", + }, + ], + }, + ], + }); + + expect(result.length).toEqual(2); + expect(result[0].attributes.name).toEqual("embedded"); + expect(result[0].attributes.serviceModel).toStrictEqual({ + ...containerModel.embedded_entities[0], + name: "embedded", + embedded_entities: [ + { + ...containerModel.embedded_entities[0], + name: "embedded-embedded", + }, + ], + }); + expect(result[0].attributes.holderName).toEqual("holderName"); + expect(result[0].attributes.instanceAttributes).toStrictEqual({}); + + expect(result[1].attributes.name).toEqual("embedded-embedded"); + expect(result[1].attributes.serviceModel).toStrictEqual({ + ...containerModel.embedded_entities[0], + name: "embedded-embedded", + }); + expect(result[1].attributes.holderName).toEqual("embedded"); + expect(result[1].attributes.instanceAttributes).toStrictEqual({}); + }); +}); diff --git a/src/UI/Components/Diagram/stencil/helpers.ts b/src/UI/Components/Diagram/stencil/helpers.ts new file mode 100644 index 000000000..8a1aedda9 --- /dev/null +++ b/src/UI/Components/Diagram/stencil/helpers.ts @@ -0,0 +1,105 @@ +import { shapes } from "@inmanta/rappid"; +import { v4 as uuidv4 } from "uuid"; +import { EmbeddedEntity, InstanceAttributeModel, ServiceModel } from "@/Core"; + +/** + * It recursively goes through embedded entities in the service model or embedded entity and creates stencil elements for each of them. + * Stencil Elements are the visual representation of the entities in the Stencil Sidebar + * + * @param {ServiceModel | EmbeddedEntity} service - The service model or embedded entity whose embedded entities are to be transformed. + * + * @returns {shapes.standard.Path[]} An array of stencil elements created from the embedded entities + */ +export const transformEmbeddedToStencilElements = ( + service: ServiceModel | EmbeddedEntity, +): shapes.standard.Path[] => { + return service.embedded_entities.flatMap((embedded_entity) => { + const stencilElement = createStencilElement( + embedded_entity.name, + embedded_entity, + {}, + true, + service.name, + ); + const nestedStencilElements = + transformEmbeddedToStencilElements(embedded_entity); + + return [stencilElement, ...nestedStencilElements]; + }); +}; + +/** + * Creates a stencil element with the given parameters. + * + * @param {string} name - The name of the stencil element. + * @param {EmbeddedEntity | ServiceModel} serviceModel - The embedded entity model associated with the entity that the stencil element represent. + * @param {InstanceAttributeModel} instanceAttributes - The instance attributes of the entity that the stencil element represent. + * @param {boolean} isEmbedded - A boolean indicating whether the entity that the stencil represent is embedded or not. Defaults to false. + * @param {string} holderName - The name of the holder of the element that the stencil element represent. Optional. + * + * @returns {shapes.standard.Path} An object representing the stencil element. + */ +export const createStencilElement = ( + name: string, + serviceModel: EmbeddedEntity | ServiceModel, + instanceAttributes: InstanceAttributeModel, + isEmbedded: boolean = false, + holderName?: string, +): shapes.standard.Path => { + let id = uuidv4(); + + if (instanceAttributes && instanceAttributes.id) { + id = instanceAttributes.id as string; + } + + return new shapes.standard.Path({ + type: "standard.Path", + size: { width: 240, height: 40 }, + name: name, + serviceModel, + instanceAttributes, + holderName, + disabled: false, + id, + attrs: { + body: { + class: "body_" + name, + width: 7, + height: 40, + x: 233, + fill: isEmbedded ? "#0066cc" : "#6753AC", + stroke: "none", + }, + bodyTwo: { + class: "bodyTwo_" + name, + width: 240, + height: 40, + fill: "#FFFFFF", + stroke: "none", + }, + label: { + class: "text_" + name, + refX: undefined, // reset the default + x: "10", + textAnchor: "start", + fontFamily: "sans-serif", + fontSize: 12, + text: name, + }, + }, + markup: [ + { + tagName: "rect", + selector: "bodyTwo", + }, + { + tagName: "rect", + selector: "body", + }, + { + tagName: "text", + selector: "label", + }, + ], + }); +}; diff --git a/src/UI/Components/Diagram/stencil/index.ts b/src/UI/Components/Diagram/stencil/index.ts new file mode 100644 index 000000000..4ee2eb85c --- /dev/null +++ b/src/UI/Components/Diagram/stencil/index.ts @@ -0,0 +1 @@ +export * from "./stencil"; diff --git a/src/UI/Components/Diagram/stencil/instanceStencil.ts b/src/UI/Components/Diagram/stencil/instanceStencil.ts new file mode 100644 index 000000000..55a5a598a --- /dev/null +++ b/src/UI/Components/Diagram/stencil/instanceStencil.ts @@ -0,0 +1,96 @@ +import { dia, ui } from "@inmanta/rappid"; +import { ServiceModel } from "@/Core"; +import { + CreateModifierHandler, + FieldCreator, + createFormState, +} from "../../ServiceInstanceForm"; +import { createComposerEntity } from "../actions"; +import { ActionEnum, EmbeddedEventEnum } from "../interfaces"; +import { transformEmbeddedToStencilElements } from "./helpers"; + +/** + * Class initializing the Service Instance Stencil Tab. + * This stencil tab is used to drag and drop the embedded entity elements onto the diagram. + */ +export class InstanceStencilTab { + stencil: ui.Stencil; + + /** + * Creates the Service Instance Stencil Tab. + * + * @param {HTMLElement} stencilElement - The HTML element to which the stencil will be appended. + * @param {ui.PaperScroller} scroller - The jointJS scroller associated with the stencil. + * @param {ServiceModel} service - The service model used to populate the stencil with corresponding Elements. + */ + constructor( + stencilElement: HTMLElement, + scroller: ui.PaperScroller, + service: ServiceModel, + ) { + this.stencil = new ui.Stencil({ + id: "instance-stencil", + paper: scroller, + width: 240, + scaleClones: true, + dropAnimation: true, + paperOptions: { + sorting: dia.Paper.sorting.NONE, + }, + canDrag: (cellView) => { + return !cellView.model.get("disabled"); + }, + dragStartClone: (cell: dia.Cell) => { + const serviceModel = cell.get("serviceModel"); + + const fieldCreator = new FieldCreator(new CreateModifierHandler()); + const fields = fieldCreator.attributesToFields(serviceModel.attributes); + + return createComposerEntity({ + serviceModel, + isCore: false, + isInEditMode: false, + attributes: createFormState(fields), + isEmbedded: true, + holderName: cell.get("holderName"), + }); + }, + dragEndClone: (el) => el.clone().set("items", el.get("items")), //cloned element loses key value pairs, so we need to set them again + layout: { + columns: 1, + rowHeight: "compact", + rowGap: 10, + horizontalAlign: "left", + marginY: 10, + // reset defaults + resizeToFit: false, + centre: false, + dx: 0, + dy: 0, + background: "#FFFFFF", + }, + }); + stencilElement.appendChild(this.stencil.el); + this.stencil.render(); + this.stencil.load(transformEmbeddedToStencilElements(service)); + + this.stencil.on("element:drop", (elementView) => { + if (elementView.model.get("isEmbedded")) { + document.dispatchEvent( + new CustomEvent("updateStencil", { + detail: { + name: elementView.model.get("entityName"), + action: EmbeddedEventEnum.ADD, + }, + }), + ); + } + + document.dispatchEvent( + new CustomEvent("updateServiceOrderItems", { + detail: { cell: elementView.model, action: ActionEnum.CREATE }, + }), + ); + }); + } +} diff --git a/src/UI/Components/Diagram/stencil/inventoryStencil.ts b/src/UI/Components/Diagram/stencil/inventoryStencil.ts new file mode 100644 index 000000000..e376744e7 --- /dev/null +++ b/src/UI/Components/Diagram/stencil/inventoryStencil.ts @@ -0,0 +1,145 @@ +import { dia, ui } from "@inmanta/rappid"; +import { ServiceModel } from "@/Core"; +import { Inventories } from "@/Data/Managers/V2/GETTERS/GetInventoryList"; +import { createComposerEntity } from "../actions"; +import { createStencilElement } from "./helpers"; + +const GRID_SIZE = 8; +const PADDING_S = GRID_SIZE; + +/** + * Class initializing the Service Inventory Stencil Tab. + * This stencil tab is used to drag and drop the inter-service related instances onto the diagram. + */ +export class InventoryStencilTab { + stencil: ui.Stencil; + + /** + * Creates the Service Inventory Stencil Tab. + * + * @param {HTMLElement} stencilElement - The HTML element to which the stencil will be appended. + * @param {ui.PaperScroller} scroller - The jointJS scroller associated with the stencil. + * @param {Inventories} serviceInventories - The service inventories used to populate the stencil with corresponding Elements. + */ + constructor( + stencilElement: HTMLElement, + scroller: ui.PaperScroller, + serviceInventories: Inventories, + serviceModels: ServiceModel[], + ) { + const groups = {}; + + //Create object with service names as keys and all of the service instances as StencilElements, to be used in the Stencil Sidebar + Object.keys(serviceInventories).forEach((serviceName) => { + const serviceModel = serviceModels.find( + (model) => model.name === serviceName, + ); + + if (!serviceModel) { + return; + } + + return (groups[serviceName] = serviceInventories[serviceName].map( + (instance) => { + const attributes = + instance.candidate_attributes || + instance.active_attributes || + undefined; + + const displayName = instance.service_identity_attribute_value + ? instance.service_identity_attribute_value + : instance.id; + + //add the instance id to the attributes object, to then pass it to the actual object on canvas + return createStencilElement(displayName, serviceModel, { + ...attributes, + id: instance.id, + }); + }, + )); + }); + + this.stencil = new ui.Stencil({ + id: "inventory-stencil", + testid: "inventory-stencil", + className: "joint-stencil hidden", + paper: scroller, + width: 240, + scaleClones: true, + dropAnimation: true, + marginTop: PADDING_S, + paperPadding: PADDING_S, + marginLeft: PADDING_S, + marginRight: PADDING_S, + paperOptions: { + sorting: dia.Paper.sorting.NONE, + }, + groups, + search: { + "*": ["attrs/label/text"], + "standard.Image": ["description"], + "standard.Path": ["description"], + }, + dragStartClone: (cell: dia.Cell) => { + const entity = createComposerEntity({ + serviceModel: cell.get("serviceModel"), + isCore: false, + isInEditMode: false, + attributes: cell.get("instanceAttributes"), + }); + + //set id to the one that is stored in the stencil which equal to the instance id + entity.set("id", cell.get("id")); + entity.set("isBlockedFromEditing", true); + entity.set("stencilName", cell.get("name")); + + return entity; + }, + dragEndClone: (el) => el.clone().set("id", el.get("id")), + layout: { + columns: 1, + rowHeight: "compact", + rowGap: 10, + marginY: 10, + horizontalAlign: "left", + // reset defaults + resizeToFit: false, + centre: false, + dx: 0, + dy: 10, + background: "#FFFFFF", + }, + }); + + stencilElement.appendChild(this.stencil.el); + this.stencil.render(); + + this.stencil.load(groups); + this.stencil.freeze(); //freeze by default as this tab is not active on init + + this.stencil.on("element:drop", (elementView) => { + const elements = [ + { + selector: `.body_${elementView.model.get("stencilName")}`, + className: "stencil_accent-disabled", + }, + { + selector: `.bodyTwo_${elementView.model.get("stencilName")}`, + className: "stencil_body-disabled", + }, + { + selector: `.text_${elementView.model.get("stencilName")}`, + className: "stencil_text-disabled", + }, + ]; + + elements.forEach(({ selector, className }) => { + const element = document.querySelector(selector); + + if (element) { + element.classList.add(className); + } + }); + }); + } +} diff --git a/src/UI/Components/Diagram/stencil/stencil.ts b/src/UI/Components/Diagram/stencil/stencil.ts new file mode 100644 index 000000000..ce91a9d42 --- /dev/null +++ b/src/UI/Components/Diagram/stencil/stencil.ts @@ -0,0 +1,123 @@ +import { dia, ui } from "@inmanta/rappid"; +import { ServiceModel } from "@/Core"; +import { Inventories } from "@/Data/Managers/V2/GETTERS/GetInventoryList"; +import { InstanceStencilTab } from "./instanceStencil"; +import { InventoryStencilTab } from "./inventoryStencil"; + +/** + * Class representing a stencil sidebar. + */ +export class StencilSidebar { + instanceTab: InstanceStencilTab; + inventoryTab: InventoryStencilTab; + tabsToolbar: ui.Toolbar; + toggleTabVisibility = ( + event: dia.Event, + tabOne: Tab, + tabTwo: Tab, + siblingOrder: "prev" | "next", + ) => { + if (event.target.classList.contains("-active")) { + return; + } + tabOne.stencil.el.classList.add("joint-hidden"); + tabOne.stencil.freeze(); + + tabTwo.stencil.el.classList.remove("joint-hidden"); + tabTwo.stencil.unfreeze(); + + event.target.classList.add("-active"); + + if (siblingOrder === "prev") { + event.target.previousSibling.classList.remove("-active"); + } else { + event.target.nextSibling.classList.remove("-active"); + } + }; + + /** + * Creates a stencil sidebar. + * + * @param {HTMLElement} stencilElement - The HTML element to which the sidebar elements will be appended. + * @param {ui.PaperScroller} scroller - The JointJS scroller associated with the stencil. + * @param {Inventories} serviceInventories - The service inventories used to create the inventory stencil tab. + * @param {ServiceModel} service - The service model used to create the instance stencil tab. + */ + constructor( + stencilElement: HTMLElement, + scroller: ui.PaperScroller, + serviceInventories: Inventories, + service: ServiceModel, + serviceModels: ServiceModel[], + ) { + this.instanceTab = new InstanceStencilTab( + stencilElement, + scroller, + service, + ); + this.inventoryTab = new InventoryStencilTab( + stencilElement, + scroller, + serviceInventories, + serviceModels, + ); + + this.tabsToolbar = new ui.Toolbar({ + id: "tabs-toolbar", + tools: [ + { + type: "button", + name: "new_tab", + text: "New", + id: "new-tab", + }, + { + type: "button", + name: "inventory_tab", + text: "Inventory", + id: "inventory-tab", + }, + ], + }); + + stencilElement.appendChild(this.tabsToolbar.el); + this.tabsToolbar.render(); + + const firstChild = this.tabsToolbar.el.firstElementChild; + + //adding active class to the first tab as a default, as Toolbar doesn't apply it when adding 'class' attribute to the tool object + if (firstChild) { + const targetElement = firstChild.firstElementChild; + + if (targetElement && targetElement.classList) { + targetElement.classList.add("-active"); + } + } + + this.tabsToolbar.on("new_tab:pointerclick", (event: dia.Event) => + this.toggleTabVisibility( + event, + this.inventoryTab, + this.instanceTab, + "next", + ), + ); + + this.tabsToolbar.on("inventory_tab:pointerclick", (event: dia.Event) => + this.toggleTabVisibility( + event, + this.instanceTab, + this.inventoryTab, + "prev", + ), + ); + } + + remove(): void { + this.instanceTab.stencil.remove(); + this.inventoryTab.stencil.remove(); + this.tabsToolbar.remove(); + } +} + +type Tab = InstanceStencilTab | InventoryStencilTab; diff --git a/src/UI/Components/Diagram/styles.ts b/src/UI/Components/Diagram/styles.ts index 92103a8b6..86b68c9ad 100644 --- a/src/UI/Components/Diagram/styles.ts +++ b/src/UI/Components/Diagram/styles.ts @@ -4,37 +4,90 @@ import linkBttn from "./icons/link-button.svg"; import removeBttn from "./icons/remove-button.svg"; export const CanvasWrapper = styled.div` + display: flex; width: 100%; height: calc(80vh - 140px); position: relative; background: var(--pf-v5-global--palette--black-200); margin: 0; overflow: hidden; - .canvas { + border: 1px solid var(--pf-v5-global--BackgroundColor--200); + + &.fullscreen { + position: fixed; top: 0; - bottom: 0; left: 0; - right: 0; - position: absolute; - background: var(--pf-v5-global--BackgroundColor--light-300); - * { - font-family: var(--pf-v5-global--FontFamily--monospace); + width: 100vw; + height: 100vh; + } + + #tabs-toolbar { + padding: 12px 0 0; + border: 0; + button { + width: 120px; + border-radius: 0; + border-color: transparent; + background: var(--pf-v5-global--BackgroundColor--200); + margin: 0; + justify-content: center; + + &:hover { + background: var(--pf-v5-global--palette--black-400); + } + + &.-active { + background: var(--pf-v5-global--BackgroundColor--100); + border-top: 2px solid var(--pf-v5-global--primary-color--100); + } } - .joint-element { - filter: drop-shadow( - 0.1rem 0.1rem 0.15rem - var(--pf-v5-global--BackgroundColor--dark-transparent-200) - ); + } + + .joint-stencil { + top: 52px; + border: 0; + + &.joint-hidden { + visibility: hidden; //note: display: none breaks the stencil-groups } - .joint-paper-background { - background: var(--pf-v5-global--BackgroundColor--light-300); + + .content { + padding: 12px 0; } - .source-arrowhead, - .target-arrowhead { - fill: var(--pf-v5-global--palette--black-500); - stroke-width: 1; + .stencil_body-disabled { + pointer-events: none; + fill: var(--pf-v5-global--disabled-color--200); } + + .stencil_text-disabled { + fill: var(--pf-v5-global--disabled-color--100); + } + + .stencil_accent-disabled { + fill: var(--pf-v5-global--disabled-color--100); + } + } + + .joint-element { + filter: drop-shadow( + 0.1rem 0.1rem 0.15rem + var(--pf-v5-global--BackgroundColor--dark-transparent-200) + ); + } + + .joint-stencil.searchable > .content { + top: 60px; + } + + .joint-stencil.joint-theme-default .search { + padding-left: 10px; + border: 1px solid var(--pf-v5-global--BackgroundColor--200); + border-bottom: 1px solid var(--pf-v5-global--palette--black-700); + } + + .joint-stencil.joint-theme-default .search-wrap { + padding: 10px; } // *** ui.Halo *** diff --git a/src/UI/Components/Diagram/testSetup.ts b/src/UI/Components/Diagram/testSetup.ts index 2ec435444..0e8d622e2 100644 --- a/src/UI/Components/Diagram/testSetup.ts +++ b/src/UI/Components/Diagram/testSetup.ts @@ -1,8 +1,17 @@ +/* istanbul ignore file */ /** * Defines objects for JointJS. * This function sets up mock implementations for various properties and methods used by JointJS library that aren't supported by default in the Jest environment. */ export const defineObjectsForJointJS = () => { + Object.defineProperty(document.documentElement, "requestFullscreen", { + writable: true, + value: jest.fn(), + }); + Object.defineProperty(document, "exitFullscreen", { + writable: true, + value: jest.fn(), + }); Object.defineProperty(window, "SVGAngle", { writable: true, value: jest.fn().mockImplementation(() => ({ diff --git a/src/UI/Components/Diagram/zoomHandler/index.ts b/src/UI/Components/Diagram/zoomHandler/index.ts new file mode 100644 index 000000000..57849420b --- /dev/null +++ b/src/UI/Components/Diagram/zoomHandler/index.ts @@ -0,0 +1 @@ +export * from "./zoomHandler"; diff --git a/src/UI/Components/Diagram/zoomHandler/zoomHandler.test.tsx b/src/UI/Components/Diagram/zoomHandler/zoomHandler.test.tsx new file mode 100644 index 000000000..9cf2d9ebb --- /dev/null +++ b/src/UI/Components/Diagram/zoomHandler/zoomHandler.test.tsx @@ -0,0 +1,98 @@ +import { act } from "react"; +import { dia, ui } from "@inmanta/rappid"; +import { fireEvent, screen } from "@testing-library/dom"; +import userEvent from "@testing-library/user-event"; +import { defineObjectsForJointJS } from "../testSetup"; +import { ZoomHandlerService } from "./zoomHandler"; + +// parts of the ZoomHandlerService that aren't covered are related to the getElementById() which isn't supported by jest, this part is covered by the E2E tests scenario 8.1 +describe("ZoomHandler", () => { + defineObjectsForJointJS(); + const canvas = document.createElement("div"); + const zoom = document.createElement("div"); + const wrapper = document.createElement("div"); + + const graph = new dia.Graph(); + const paper = new dia.Paper({ + el: canvas, + model: graph, + }); + const scroller = new ui.PaperScroller({ + paper, + }); + + const zoomHandler = new ZoomHandlerService(zoom, scroller); + + wrapper.appendChild(zoom); + document.body.appendChild(zoom); + it("should render zoom handler", () => { + expect(screen.getByTestId("zoomHandler")).toBeInTheDocument(); + }); + + it("should fire requestFullscreen() function when clicking fullscreen button", async () => { + //jest + jsdom doesn't implement the fullscreen API, they are mocked in the testSetup() + const fullScreenSpy = jest.spyOn( + document.documentElement, + "requestFullscreen", + ); + + const fullscreenButton = screen.getByTestId("fullscreen"); + + await act(async () => { + await userEvent.click(fullscreenButton); + }); + + expect(fullScreenSpy).toHaveBeenCalled(); + }); + + it("should fire exitFullscreen() function when clicking fullscreen button and the fullscreenElement exist", async () => { + //mock that fullscreen is active, by default it's null + Object.defineProperty(document, "fullscreenElement", { + writable: false, + value: {}, + }); + //jest + jsdom doesn't implement the fullscreen API, they are mocked in the testSetup() + const exitFullScreenSpy = jest.spyOn(document, "exitFullscreen"); + + const fullscreenButton = screen.getByTestId("fullscreen"); + + await act(async () => { + await userEvent.click(fullscreenButton); + }); + + expect(exitFullScreenSpy).toHaveBeenCalled(); + }); + + it("should fire scroller's function zoomToFit() when clicking fit-to-screen button", async () => { + //we aren't testing the zoomToFit function itself as that is part of JointJS which use logic that isn't supported by Jest + const zoomToFit = jest.spyOn(scroller, "zoomToFit"); + + const fitToScreenButton = screen.getByTestId("fit-to-screen"); + + await act(async () => { + await userEvent.click(fitToScreenButton); + }); + + expect(zoomToFit).toHaveBeenCalled(); + }); + + it("should update slider input & output element when slider is triggered", async () => { + const initialSliderOutput = screen.getByTestId("slider-output"); + + expect(initialSliderOutput).toHaveTextContent("100"); + + const sliderInput = screen.getByTestId("slider-input"); + + //userEvent doesn't have a interaction that allows sliding the slider, so we need to use fireEvent + fireEvent.change(sliderInput, { target: { value: 120 } }); + const sliderOutput = screen.getByTestId("slider-output"); + + expect(sliderOutput).toHaveTextContent("120"); + }); + + it("should remove zoomHandler from the dom when remove() method is fired", async () => { + zoomHandler.remove(); + + expect(screen.queryByTestId("zoomHandler")).not.toBeInTheDocument(); + }); +}); diff --git a/src/UI/Components/Diagram/zoomHandler/zoomHandler.ts b/src/UI/Components/Diagram/zoomHandler/zoomHandler.ts new file mode 100644 index 000000000..d8c5f3ba9 --- /dev/null +++ b/src/UI/Components/Diagram/zoomHandler/zoomHandler.ts @@ -0,0 +1,310 @@ +import { ui } from "@inmanta/rappid"; +import { words } from "@/UI/words"; +import exitFullscreen from "../icons/exit-fullscreen.svg"; +import fitToScreen from "../icons/fit-to-screen.svg"; +import requestFullscreen from "../icons/request-fullscreen.svg"; + +/** + * Interface for a button with an icon and a tooltip. + * + * This interface extends the ui.widgets.button interface and adds methods for setting the icon and the tooltip of the button. + */ +interface IconButton extends ui.widgets.button { + setIcon(icon: string): void; + setTooltip(tooltip: string): void; +} + +/** + * IconButton + * + * It extends the ui.widgets.button class and represents a button with an icon. + * It provides methods for setting the icon and the tooltip of the button. + * + */ +const IconButton = ui.widgets.button.extend({ + render: function () { + const size = this.options.size || 20; + const imageEl = document.createElement("img"); + + imageEl.style.width = `${size}px`; + imageEl.style.height = `${size}px`; + this.el.appendChild(imageEl); + this.setIcon(this.options.icon); + this.setTooltip(this.options.tooltip); + + return this; + }, + + /** + * Sets the icon of the element. + * + * This method is responsible for setting the source of the image element within the current element to the provided icon. + * + * @param {string} icon - The source of the icon to set. Defaults to an empty string, which will clear the current icon. + */ + setIcon: function (icon = "") { + this.el.querySelector("img").src = icon; + }, + + /** + * Sets the tooltip of the element. + * + * This method is responsible for setting the tooltip of the current element to the provided tooltip. + * It also sets the position of the tooltip. + * + * @param {string} tooltip - The text of the tooltip to set. Defaults to an empty string, which will clear the current tooltip. + * @param {string} direction - The position of the tooltip. Defaults to "right". + */ + setTooltip: function (tooltip = "", direction = "right") { + this.el.dataset.tooltip = tooltip; + this.el.dataset.tooltipPosition = direction; + }, +}); + +/** + * ZoomHandlerService class + * + * This class is responsible for managing the paning of the canvas. + * It provides methods for zoom to fit, zooming in/out in the canvas and fullscreen toggle. + * + * @class + * @prop {ui.Toolbar} toolbar - The toolbar object that contains the paning controls. + * @method constructor - Constructs a new instance of the ZoomHandlerService class. + * @param {HTMLElement} element - The HTML element that will contain the zoomHandler. + * @param {ui.PaperScroller} scroller - The scroller object that allows and zooming in the canvas. + */ +export class ZoomHandlerService { + toolbar: ui.Toolbar; + + constructor( + private element: HTMLElement, + private scroller: ui.PaperScroller, + ) { + this.toolbar = new ui.Toolbar({ + autoToggle: true, + references: { + paperScroller: scroller, + }, + tools: [ + { + type: "icon-button", + name: "fullscreen", + tooltip: words("instanceComposer.zoomHandler.fullscreen.toggle"), + attrs: { + button: { + "data-testid": "fullscreen", + }, + }, + }, + { + type: "icon-button", + name: "fit-to-screen", + tooltip: words("instanceComposer.zoomHandler.zoomToFit"), + attrs: { + button: { + "data-testid": "fit-to-screen", + }, + }, + }, + { + id: "zoomSlider", + type: "zoom-slider", + min: 0.2 * 100, + max: 5 * 100, + attrs: { + input: { + "data-tooltip": words("instanceComposer.zoomHandler.zoom"), + "data-tooltip-position": "bottom", + "data-testid": "slider-input", + }, + output: { + "data-testid": "slider-output", + }, + }, + }, + ], + widgetNamespace: { + ...ui.widgets, + iconButton: IconButton, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, //ui.widgets aren't aligned with widgetNamespace typing, that's a snippet from the JointJS typeScript demo + }); + + this.toolbar.render(); + this.toolbar.el.dataset.testid = "zoomHandler"; + this.updateFullscreenStyling(); //set the icon of the button as adding icons through object properties wasn't loading the icons properly + this.element.appendChild(this.toolbar.el); + + new ui.Tooltip({ + rootTarget: ".zoom-handler", + target: "[data-tooltip]", + padding: 16, + }); + + const fullscreenButton = this.toolbar.getWidgetByName( + "fit-to-screen", + ) as IconButton; + + fullscreenButton.setIcon(`${fitToScreen}`); //set the icon of the button as adding icons through object properties wasn't loading the icons properly + + this.toolbar.on("fit-to-screen:pointerclick", () => this.fitToScreen()); + this.toolbar.on("fullscreen:pointerclick", () => this.toggleFullscreen()); + + this.updateFullscreenStyling = this.updateFullscreenStyling.bind(this); + this.updateSliderOnInput = this.updateSliderOnInput.bind(this); + + document.addEventListener("fullscreenchange", this.updateFullscreenStyling); + + const zoomSlider = document.getElementById("zoomSlider"); + + if (zoomSlider) { + zoomSlider.addEventListener("input", this.updateSliderOnInput); + } + } + + /** + * Fits all of the canvas's elements to the screen. + * + * This method is responsible for adjusting the zoom level of the canvas so that it shows all it's elements on the screen. + * It uses the zoomToFit method of the scroller object, which is a joint.ui.PaperScroller. + */ + fitToScreen() { + this.scroller.zoomToFit({ useModelGeometry: true, padding: 20 }); + + const sliderWrapper = document.getElementById("zoomSlider"); + + if (!sliderWrapper) { + return; + } + + const slider = sliderWrapper.children[0]; + + if (!slider) { + return; + } + + this.updateSliderProgressBar(slider as HTMLInputElement); + } + + updateSliderOnInput(event) { + if (!event.target) { + return; + } + const slider = event.target as HTMLInputElement; + + this.updateSliderProgressBar(slider); + } + + /** + * Updates the progress bar of the zoom slider. + * + * This method is responsible for updating the background of the zoom slider to reflect the current zoom level as currently chromium based browsers don't support styling of the progressbar. + * The zoom level is calculated as a percentage of the slider's current value relative to its minimum and maximum values. + * + * @param {HTMLInputElement} slider - The zoom slider, which is an HTML input element. + */ + updateSliderProgressBar(slider: HTMLInputElement) { + const value = + ((Number(slider.value) - Number(slider.min)) / + (Number(slider.max) - Number(slider.min))) * + 100; + + slider.style.setProperty( + "--slider-background", + `linear-gradient(to right, var(--pf-v5-global--active-color--100) 0%, var(--pf-v5-global--active-color--100), ${value}% var(--pf-v5-global--palette--black-400) ${value}%, var(--pf-v5-global--palette--black-400) 100%)`, + ); + } + + /** + * Toggles the fullscreen mode of the document. + * + * This method is responsible for switching the document between fullscreen mode and normal mode. + */ + toggleFullscreen() { + if (!document.fullscreenElement) { + document.documentElement.requestFullscreen(); + } else if (document.exitFullscreen) { + document.exitFullscreen(); + } + } + + /** + * Changes the display style of an HTML element. + * + * This function is responsible for changing the display style of an HTML element. + * It uses the document.querySelector method to find the element and changes its display style. + * + * @param {string} selector - The CSS selector of the element to change. + * @param {string} display - The new display style for the element. + */ + changeDisplay(selector: string, display: string) { + const element: HTMLElement | null = document.querySelector(selector); + + if (element) { + element.style.display = display; + } + } + + /** + * Updates the state of the toolbar buttons. + * + * This method is responsible for toggling visual state of the button responsible for toggling full screen mode, + * as well as the display of the page components when full screen mode is toggled. + * + * @method + */ + updateFullscreenStyling() { + const fullscreenButton = this.toolbar.getWidgetByName( + "fullscreen", + ) as IconButton; + + const canvas = document.querySelector("#canvas-wrapper"); + const banners = document.querySelectorAll(".pf-v5-c-banner"); + + if (canvas) { + canvas.classList.toggle("fullscreen", !!document.fullscreenElement); + } + + if (banners) { + banners.forEach( + (el) => + ((el as HTMLElement).style.display = document.fullscreenElement + ? "none" + : "block"), + ); + } + + if (document.fullscreenElement) { + this.changeDisplay("#page-sidebar", "none"); + this.changeDisplay("#page-header", "none"); + + fullscreenButton.setIcon(`${exitFullscreen}`); + fullscreenButton.setTooltip( + words("instanceComposer.zoomHandler.fullscreen.exit"), + ); + } else { + this.changeDisplay("#page-sidebar", "flex"); + this.changeDisplay("#page-header", "grid"); + + fullscreenButton.setIcon(`${requestFullscreen}`); + fullscreenButton.setTooltip( + words("instanceComposer.zoomHandler.fullscreen.toggle"), + ); + } + } + + remove() { + this.toolbar.remove(); + + document.removeEventListener( + "fullscreenchange", + this.updateFullscreenStyling, + ); + + const zoomSlider = document.getElementById("zoomSlider"); + + if (zoomSlider) { + zoomSlider.removeEventListener("input", this.updateSliderOnInput); + } + } +} diff --git a/src/UI/Components/InstanceProvider/InstanceProvider.test.tsx b/src/UI/Components/InstanceProvider/InstanceProvider.test.tsx deleted file mode 100644 index 9a11e7fc3..000000000 --- a/src/UI/Components/InstanceProvider/InstanceProvider.test.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import React from "react"; -import { MemoryRouter } from "react-router-dom"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render, screen, waitFor } from "@testing-library/react"; -import { HttpResponse, delay, http } from "msw"; -import { setupServer } from "msw/node"; -import { dependencies } from "@/Test"; -import { DependencyProvider } from "@/UI/Dependency"; -import { testInstance, testService } from "../Diagram/Mock"; -import { defineObjectsForJointJS } from "../Diagram/testSetup"; -import { InstanceProvider } from "./InstanceProvider"; - -const setup = () => { - const queryClient = new QueryClient(); - - const component = ( - - - - - - - - ); - - return component; -}; - -export const server = setupServer(); - -// Establish API mocking before all tests. -beforeAll(() => { - defineObjectsForJointJS(); - server.listen(); -}); -// Reset any request handlers that we may add during the tests, -// so they don't affect other tests. -afterEach(() => server.resetHandlers()); -// Clean up after the tests are finished. -afterAll(() => server.close()); - -describe("UserManagementPage", () => { - it("should render LoadingView when there are no responses from the endpoints yet,", async () => { - server.use( - http.get("/lsm/v1/service_inventory", async () => { - await delay(1000); - - return HttpResponse.json({ - data: testInstance, - }); - }), - ); - render(setup()); - - await waitFor(() => { - expect(screen.getByText("Loading")).toBeInTheDocument(); - }); - - await waitFor(() => { - expect(screen.getByTestId("Composer-Container")).toBeInTheDocument(); - }); - }); - - it("should render the ErrorView when there is an error returned from the instance endpoint", async () => { - server.use( - http.get("/lsm/v1/service_inventory", async () => { - return HttpResponse.json({ message: "instance_fail" }, { status: 400 }); - }), - ); - render(setup()); - - await waitFor(() => { - expect(screen.getByTestId("ErrorView")).toBeInTheDocument(); - }); - - await waitFor(() => { - expect( - screen.getByText( - "The following error occured: Failed to fetch instance with id: id", - ), - ).toBeInTheDocument(); - }); - }); -}); diff --git a/src/UI/Components/InstanceProvider/InstanceProvider.tsx b/src/UI/Components/InstanceProvider/InstanceProvider.tsx deleted file mode 100644 index b87346f11..000000000 --- a/src/UI/Components/InstanceProvider/InstanceProvider.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import React, { useContext } from "react"; -import { ServiceModel } from "@/Core"; -import { useGetInstanceWithRelations } from "@/Data/Managers/V2/GETTERS/GetInstanceWithRelations"; -import { DependencyContext, words } from "@/UI"; -import { ErrorView, LoadingView } from "@/UI/Components"; -import Canvas from "@/UI/Components/Diagram/Canvas"; - -/** - * Renders the InstanceProvider component. - * It serves purpose to provide all the necessary data for the Canvas at the same time to avoid unnecessary rerenders, - * and extract the data fetching logic, out of the already busy component - * - * @param {ServiceModel[]} services - The list of service models. - * @param {string} mainServiceName - The name of the main service. - * @param {string} instanceId - The ID of the instance. - * @returns {JSX.Element} The rendered InstanceProvider component. - */ -export const InstanceProvider: React.FC<{ - label: string; - services: ServiceModel[]; - mainServiceName: string; - instanceId: string; - editable?: boolean; -}> = ({ services, mainServiceName, instanceId, editable = false }) => { - const { environmentHandler } = useContext(DependencyContext); - const environment = environmentHandler.useId(); - - const { data, isLoading, isError, error, refetch } = - useGetInstanceWithRelations(instanceId, environment).useOneTime(); - - if (isLoading) { - return ; - } - - if (isError) { - return ( - - ); - } - - return ( - - ); -}; diff --git a/src/UI/Components/InstanceProvider/index.ts b/src/UI/Components/InstanceProvider/index.ts deleted file mode 100644 index 345f2a6b2..000000000 --- a/src/UI/Components/InstanceProvider/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./InstanceProvider"; diff --git a/src/UI/Components/ServiceInstanceForm/Components/FieldInput.tsx b/src/UI/Components/ServiceInstanceForm/Components/FieldInput.tsx index 2f608a639..9f2fb9fe1 100644 --- a/src/UI/Components/ServiceInstanceForm/Components/FieldInput.tsx +++ b/src/UI/Components/ServiceInstanceForm/Components/FieldInput.tsx @@ -37,7 +37,7 @@ interface Props { } /** - * Type representing a function to update the state within the form. + * function to update the state within the form. * * @param {string} path - The path within the form state to update. * @param {unknown} value - The new value to set at the specified path. @@ -141,7 +141,7 @@ export const FieldInput: React.FC = ({ getUpdate(makePath(path, field.name), toOptionalBoolean(value)) } description={field.description} - key={field.name} + key={field.id || field.name} shouldBeDisabled={ field.isDisabled && get(originalState, makePath(path, field.name)) !== undefined && @@ -157,7 +157,7 @@ export const FieldInput: React.FC = ({ getUpdate(makePath(path, field.name), toOptionalBoolean(value)) } description={field.description} - key={field.name} + key={field.id || field.name} shouldBeDisabled={ field.isDisabled && get(originalState, makePath(path, field.name)) !== undefined && @@ -185,7 +185,7 @@ export const FieldInput: React.FC = ({ } placeholder={getPlaceholderForType(field.type)} typeHint={getTypeHintForType(field.type)} - key={field.name} + key={field.id || field.name} suggestions={suggestionsList} /> ); @@ -208,7 +208,7 @@ export const FieldInput: React.FC = ({ }} placeholder={getPlaceholderForType(field.type)} typeHint={getTypeHintForType(field.type)} - key={field.name} + key={field.id || field.name} isTextarea /> ); @@ -233,7 +233,7 @@ export const FieldInput: React.FC = ({ } placeholder={getPlaceholderForType(field.type)} typeHint={getTypeHintForType(field.type)} - key={field.name} + key={field.id || field.name} suggestions={suggestionsList} /> ); @@ -266,7 +266,7 @@ export const FieldInput: React.FC = ({ description={field.description} isOptional={field.isOptional} handleInputChange={getEnumUpdate} - key={field.name} + key={field.id || field.name} shouldBeDisabled={ field.isDisabled && get(originalState, makePath(path, field.name)) !== undefined && diff --git a/src/UI/Components/ServiceInstanceForm/Helpers/FieldCreator.spec.ts b/src/UI/Components/ServiceInstanceForm/Helpers/FieldCreator.spec.ts index 2bcfbd273..4d96c60be 100644 --- a/src/UI/Components/ServiceInstanceForm/Helpers/FieldCreator.spec.ts +++ b/src/UI/Components/ServiceInstanceForm/Helpers/FieldCreator.spec.ts @@ -76,6 +76,7 @@ test("GIVEN FieldCreator WHEN an attribute has the empty string as default value const fields = new FieldCreator(new CreateModifierHandler()).create({ attributes: attributes, embedded_entities: [], + inter_service_relations: [], }); expect(fields).toHaveLength(attributes.length); @@ -114,6 +115,7 @@ test("GIVEN FieldCreator WHEN an entity has validation_type 'enum' or 'enum?' TH const fields = new FieldCreator(new CreateModifierHandler()).create({ attributes: [enumAttribute, optionalEnumAttribute], embedded_entities: [], + inter_service_relations: [], }); expect(fields[0].isOptional).toBeFalsy(); @@ -175,6 +177,7 @@ test("GIVEN FieldCreator WHEN attributes are processed for edit form THEN the fi const fields = new FieldCreator(new CreateModifierHandler(), true).create({ attributes: attributesList, embedded_entities: [], + inter_service_relations: [], }); expect(fields).toHaveLength(attributesList.length); @@ -239,6 +242,7 @@ test.each` const fields = new FieldCreator(new CreateModifierHandler(), true).create({ attributes: [], embedded_entities: [embeddedEntity], + inter_service_relations: [], }); const entityFields = fields[0] as DictListField; @@ -276,6 +280,7 @@ test.each` const fields = new FieldCreator(new CreateModifierHandler(), true).create({ attributes: [], embedded_entities: [embeddedEntity], + inter_service_relations: [], }); const entityFields = fields[0] as DictListField; diff --git a/src/UI/Components/ServiceInstanceForm/ServiceInstanceForm.tsx b/src/UI/Components/ServiceInstanceForm/ServiceInstanceForm.tsx index 7bad4afff..5285a6d6d 100644 --- a/src/UI/Components/ServiceInstanceForm/ServiceInstanceForm.tsx +++ b/src/UI/Components/ServiceInstanceForm/ServiceInstanceForm.tsx @@ -45,7 +45,7 @@ interface Props { * @param {boolean} [isEdit=false] - Whether the form is in edit mode. Default is false. * @returns {InstanceAttributeModel} The calculated form state. */ -const getFormState = ( +export const getFormState = ( fields, apiVersion, originalAttributes, diff --git a/src/UI/Components/TreeTable/TreeTable.test.tsx b/src/UI/Components/TreeTable/TreeTable.test.tsx index a04cf5184..702707cb9 100644 --- a/src/UI/Components/TreeTable/TreeTable.test.tsx +++ b/src/UI/Components/TreeTable/TreeTable.test.tsx @@ -100,6 +100,7 @@ test("TreeTable with 1st level of attributes containing annotations should not r }, ], embedded_entities: [], + inter_service_relations: [], config: {}, lifecycle: { initial_state: "initial", diff --git a/src/UI/Root/Components/Header/Header.tsx b/src/UI/Root/Components/Header/Header.tsx index b88b29616..3e9658357 100644 --- a/src/UI/Root/Components/Header/Header.tsx +++ b/src/UI/Root/Components/Header/Header.tsx @@ -22,17 +22,35 @@ import { DocumentationLinks } from "./Actions/DocumentationLinks"; import { StatusButton } from "./Actions/StatusButton"; import { EnvSelectorWithProvider } from "./EnvSelector"; +/** + * Properties for the Header component. + * + * @interface + * @prop {boolean} noEnv - A flag indicating whether there is no environment selected. + * @prop {function} onNotificationsToggle - A function to be called when the notifications badge is clicked. + */ interface Props { noEnv: boolean; onNotificationsToggle(): void; } +/** + * Header component of the application. + * + * This component is responsible for rendering the header of the application. + * + * @component + * @props {Props} props - The properties that define the behavior of the header. + * @prop {boolean} props.noEnv - A flag indicating whether there is no environment selected. + * @prop {function} props.onNotificationsToggle - A function to be called when the notifications badge is clicked. + * @returns {React.FC } The rendered Header component. + */ export const Header: React.FC = ({ noEnv, onNotificationsToggle }) => { const { routeManager, environmentHandler } = useContext(DependencyContext); return ( <> - + = ({ noEnv, onNotificationsToggle }) => { ); }; +/** + * A styled ToolbarItem component. + */ const StyledToolbarItem = styled(ToolbarItem)` padding-left: 8px; padding-right: 8px; diff --git a/src/UI/Root/PrimaryPageManager.tsx b/src/UI/Root/PrimaryPageManager.tsx index 4457ea8a8..616327890 100644 --- a/src/UI/Root/PrimaryPageManager.tsx +++ b/src/UI/Root/PrimaryPageManager.tsx @@ -1,6 +1,6 @@ import React from "react"; import { PageManager, Page, RouteDictionary, PageDictionary } from "@/Core"; -import { InstanceComposerPage } from "@/Slices/InstanceComposer/UI"; +import { InstanceComposerPage } from "@/Slices/InstanceComposerCreator/UI"; import { InstanceComposerEditorPage } from "@/Slices/InstanceComposerEditor/UI"; import { InstanceComposerViewerPage } from "@/Slices/InstanceComposerViewer/UI"; import { OrderDetailsPage } from "@/Slices/OrderDetails/UI"; diff --git a/src/UI/Routing/Paths.ts b/src/UI/Routing/Paths.ts index 658e44d65..e890cd364 100644 --- a/src/UI/Routing/Paths.ts +++ b/src/UI/Routing/Paths.ts @@ -1,4 +1,5 @@ import { RouteKind } from "@/Core"; +import { InstanceComposer } from "@/Slices/InstanceComposerCreator"; import { InstanceComposerViewer } from "@/Slices/InstanceComposerViewer"; import { InstanceDetails } from "@/Slices/ServiceInstanceDetails"; import { AgentProcess } from "@S/AgentProcess"; @@ -19,7 +20,6 @@ import { EditInstance } from "@S/EditInstance"; import { Events } from "@S/Events"; import { Facts } from "@S/Facts"; import { Home } from "@S/Home"; -import { InstanceComposer } from "@S/InstanceComposer"; import { InstanceComposerEditor } from "@S/InstanceComposerEditor"; import { Notification } from "@S/Notification"; import { OrderDetails } from "@S/OrderDetails"; diff --git a/src/UI/Routing/PrimaryRouteManager.ts b/src/UI/Routing/PrimaryRouteManager.ts index ad62d419e..1656faec5 100644 --- a/src/UI/Routing/PrimaryRouteManager.ts +++ b/src/UI/Routing/PrimaryRouteManager.ts @@ -16,7 +16,7 @@ import { } from "@/Core"; import { Dashboard } from "@/Slices/Dashboard"; import { DuplicateInstance } from "@/Slices/DuplicateInstance"; -import { InstanceComposer } from "@/Slices/InstanceComposer"; +import { InstanceComposer } from "@/Slices/InstanceComposerCreator"; import { InstanceComposerEditor } from "@/Slices/InstanceComposerEditor"; import { InstanceComposerViewer } from "@/Slices/InstanceComposerViewer"; import { ServiceDetails } from "@/Slices/ServiceDetails"; diff --git a/src/UI/words.tsx b/src/UI/words.tsx index 5753f1418..3fc08d84a 100644 --- a/src/UI/words.tsx +++ b/src/UI/words.tsx @@ -22,6 +22,7 @@ const dict = { confirm: "Confirm", cancel: "Cancel", deploy: "Deploy", + save: "Save", yes: "Yes", no: "No", null: "null", @@ -33,6 +34,9 @@ const dict = { hideAll: "Hide All", showAll: "Show All", back: "Back", + edit: "Edit", + details: "Details", + remove: "Remove", username: "Username", password: "Password", @@ -165,27 +169,6 @@ const dict = { "inventory.form.placeholder.dict": '{"key": "value"}', "inventory.form.button": "Form", "inventory.editor.button": "JSON-Editor", - "inventory.instanceComposer.labelButtonTooltip": "Toggle connection labels", - "inventory.instanceComposer.addInstanceButtonTooltip": - "Add new instance to the canvas.", - "inventory.instanceComposer.orderDescription": - "Requested with Instance Composer", - "inventory.instanceComposer.errorMessage": "missing Instance Model", - "inventory.instanceComposer.editButton": "Edit in Composer", - "inventory.instanceComposer.showButton": "Show in Composer", - "inventory.instanceComposer.formModal.placeholder": "Choose a Service", - "inventory.instanceComposer.formModal.create.title": "Add Entity", - "inventory.instanceComposer.formModal.edit.title": "Edit Entity", - "inventory.instanceComposer.success": "The request got sent successfully", - "inventory.instanceComposer.success.title": "Instance Composed successfully", - "inventory.instanceComposer.failed.title": "Instance Composing failed", - "inventory.instanceComposer.dictModal": (valueName: string) => - `Values of ${valueName}`, - "inventory.instanceComposer.disabled": - "Your licence doesn't give you access to the Instance Composer, please contact support for more details.", - "inventory.instanceComposer.title": "Instance Composer", - "inventory.instanceComposer.title.edit": "Instance Composer Editor", - "inventory.instanceComposer.title.view": "Instance Composer Viewer", "inventory.deleteInstance.button": "Delete", "inventory.deleteInstance.failed": "Deleting instance failed", "inventory.deleteInstance.title": "Delete instance", @@ -217,6 +200,44 @@ const dict = { `Are you absolutely sure you want to change attribute from ${oldValue} to ${newValue}? This operation can corrupt the instance.`, "inventory.error.mermaid": "Error rendering Mermaid diagram", + /** + * Instance Composer text + */ + + "instanceComposer.noData.errorTitle": "No Data", + "instanceComposer.error.updateInstanceNotInMap": + "Updated instance is not in the map, if error persists please refresh the page.", + "instanceComposer.noData.errorMessage": (serviceId) => + `There is no data available to display for the given id: ${serviceId}`, + "instanceComposer.noServiceModel.errorTitle": "No Service Model", + "instanceComposer.noServiceModel.errorMessage": (serviceName) => + `There is no service model available for ${serviceName}`, + "instanceComposer.labelButtonTooltip": "Toggle connection labels", + "instanceComposer.addInstanceButtonTooltip": + "Add new instance to the canvas.", + "instanceComposer.orderDescription": "Requested with Instance Composer", + "instanceComposer.errorMessage.missingModel": + "The instance attribute model is missing", + "instanceComposer.editButton": "Edit in Composer", + "instanceComposer.showButton": "Show in Composer", + "instanceComposer.formModal.placeholder": "Choose a Service", + "instanceComposer.formModal.create.title": "Add Entity", + "instanceComposer.formModal.edit.title": "Edit Entity", + "instanceComposer.formModal.noAttributes": "There are no attributes to edit.", + "instanceComposer.success": "The request got sent successfully", + "instanceComposer.success.title": "Instance composed successfully", + "instanceComposer.failed.title": "Instance Composing failed", + "instanceComposer.dictModal": (valueName: string) => `Values of ${valueName}`, + "instanceComposer.disabled": + "Your license doesn't give you access to the Instance Composer, please contact support for more details.", + "instanceComposer.title": "Instance Composer", + "instanceComposer.title.edit": "Instance Composer Editor", + "instanceComposer.title.view": "Instance Composer Viewer", + "instanceComposer.zoomHandler.fullscreen.toggle": "Toggle full screen", + "instanceComposer.zoomHandler.fullscreen.exit": "Exit full screen", + "instanceComposer.zoomHandler.zoomToFit": "Fit to screen", + "instanceComposer.zoomHandler.zoom": "Slide to zoom", + /** * Service Instance Details text */