diff --git a/.semgrepignore b/.semgrepignore index 1c19645e3..5b374e053 100644 --- a/.semgrepignore +++ b/.semgrepignore @@ -4,6 +4,7 @@ vuu/src/main/resources/www/ws-example.html vuu/src/main/scala/org/finos/vuu/provider/simulation/SimulatedBigInstrumentsProvider.scala vuu-ui/packages/vuu-data/src/array-data-source/group-utils.ts vuu-ui/packages/vuu-datagrid-extras/src/column-expression-input/column-language-parser/walkExpressionTree.ts +vuu-ui/packages/vuu-layout/src/layout-persistence/RemoteLayoutPersistenceManager.ts vuu-ui/packages/vuu-popups/src/menu/useContextMenu.tsx vuu-ui/packages/vuu-table-extras/src/cell-edit-validators/PatternValidator.ts vuu-ui/packages/vuu-ui-controls/src/list/Highlighter.tsx diff --git a/layout-server/src/main/java/org/finos/vuu/layoutserver/CorsConfig.java b/layout-server/src/main/java/org/finos/vuu/layoutserver/CorsConfig.java new file mode 100644 index 000000000..7950487b4 --- /dev/null +++ b/layout-server/src/main/java/org/finos/vuu/layoutserver/CorsConfig.java @@ -0,0 +1,15 @@ +package org.finos.vuu.layoutserver; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class CorsConfig implements WebMvcConfigurer { + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") + .allowedOrigins("http://127.0.0.1:5173") + .allowedMethods("GET", "POST", "PUT", "DELETE"); + } +} diff --git a/vuu-ui/packages/vuu-layout/src/layout-persistence/LayoutPersistenceManager.ts b/vuu-ui/packages/vuu-layout/src/layout-persistence/LayoutPersistenceManager.ts index 54fbf9d54..55ac2cfce 100644 --- a/vuu-ui/packages/vuu-layout/src/layout-persistence/LayoutPersistenceManager.ts +++ b/vuu-ui/packages/vuu-layout/src/layout-persistence/LayoutPersistenceManager.ts @@ -1,5 +1,5 @@ import { LayoutJSON } from "@finos/vuu-layout"; -import { LayoutMetadata } from "@finos/vuu-shell"; +import { LayoutMetadata, LayoutMetadataDto } from "@finos/vuu-shell"; export interface LayoutPersistenceManager { /** @@ -10,7 +10,7 @@ export interface LayoutPersistenceManager { * * @returns Unique identifier assigned to the saved layout */ - createLayout: (metadata: Omit, layout: LayoutJSON) => Promise; + createLayout: (metadata: LayoutMetadataDto, layout: LayoutJSON) => Promise; /** * Overwrites an existing layout and its corresponding metadata with the provided information @@ -19,7 +19,7 @@ export interface LayoutPersistenceManager { * @param metadata - Metadata describing the new layout to overwrite with * @param layout - Full JSON representation of the new layout to overwrite with */ - updateLayout: (id: string, metadata: Omit, layout: LayoutJSON) => Promise; + updateLayout: (id: string, metadata: LayoutMetadataDto, layout: LayoutJSON) => Promise; /** * Deletes an existing layout and its corresponding metadata diff --git a/vuu-ui/packages/vuu-layout/src/layout-persistence/LocalLayoutPersistenceManager.ts b/vuu-ui/packages/vuu-layout/src/layout-persistence/LocalLayoutPersistenceManager.ts index ec62b70d7..95cdb3735 100644 --- a/vuu-ui/packages/vuu-layout/src/layout-persistence/LocalLayoutPersistenceManager.ts +++ b/vuu-ui/packages/vuu-layout/src/layout-persistence/LocalLayoutPersistenceManager.ts @@ -1,18 +1,24 @@ -import { Layout, LayoutMetadata, WithId } from "@finos/vuu-shell"; -import { LayoutJSON, LayoutPersistenceManager } from "@finos/vuu-layout"; -import { getLocalEntity, saveLocalEntity } from "@finos/vuu-filters"; -import { getUniqueId } from "@finos/vuu-utils"; +import { + Layout, + LayoutMetadata, + LayoutMetadataDto, + WithId, +} from "@finos/vuu-shell"; +import { formatDate, getUniqueId } from "@finos/vuu-utils"; import { defaultLayout } from "./data"; +import { LayoutPersistenceManager } from "./LayoutPersistenceManager"; +import { LayoutJSON } from "../layout-reducer"; +import { getLocalEntity, saveLocalEntity } from "@finos/vuu-filters"; const metadataSaveLocation = "layouts/metadata"; const layoutsSaveLocation = "layouts/layouts"; export class LocalLayoutPersistenceManager implements LayoutPersistenceManager { createLayout( - metadata: Omit, + metadata: LayoutMetadataDto, layout: LayoutJSON - ): Promise { + ): Promise { return new Promise((resolve) => { console.log( `Saving layout as ${metadata.name} to group ${metadata.group}...` @@ -21,14 +27,17 @@ export class LocalLayoutPersistenceManager implements LayoutPersistenceManager { Promise.all([this.loadLayouts(), this.loadMetadata()]).then( ([existingLayouts, existingMetadata]) => { const id = getUniqueId(); - this.appendAndPersist( + const newMetadata: LayoutMetadata = { + ...metadata, id, - metadata, - layout, - existingLayouts, - existingMetadata + created: formatDate(new Date(), "dd.mm.yyyy"), + }; + + this.saveLayoutsWithMetadata( + [...existingLayouts, { id, json: layout }], + [...existingMetadata, newMetadata] ); - resolve(id); + resolve(newMetadata); } ); }); @@ -36,18 +45,20 @@ export class LocalLayoutPersistenceManager implements LayoutPersistenceManager { updateLayout( id: string, - newMetadata: Omit, + newMetadata: LayoutMetadataDto, newLayout: LayoutJSON ): Promise { return new Promise((resolve, reject) => { this.validateIds(id) .then(() => Promise.all([this.loadLayouts(), this.loadMetadata()])) .then(([existingLayouts, existingMetadata]) => { - const layouts = existingLayouts.filter((layout) => layout.id !== id); - const metadata = existingMetadata.filter( - (metadata) => metadata.id !== id + const updatedLayouts = existingLayouts.map((layout) => + layout.id === id ? { ...layout, json: newLayout } : layout ); - this.appendAndPersist(id, newMetadata, newLayout, layouts, metadata); + const updatedMetadata = existingMetadata.map((metadata) => + metadata.id === id ? { ...metadata, ...newMetadata } : metadata + ); + this.saveLayoutsWithMetadata(updatedLayouts, updatedMetadata); resolve(); }) .catch((e) => reject(e)); @@ -75,10 +86,14 @@ export class LocalLayoutPersistenceManager implements LayoutPersistenceManager { this.validateId(id, "layout") .then(() => this.loadLayouts()) .then((existingLayouts) => { - const layouts = existingLayouts.find( + const foundLayout = existingLayouts.find( (layout) => layout.id === id - ) as Layout; - resolve(layouts.json); + ); + if (foundLayout) { + resolve(foundLayout.json); + } else { + reject(new Error(`no layout found matching id ${id}`)); + } }) .catch((e) => reject(e)); }); @@ -123,19 +138,6 @@ export class LocalLayoutPersistenceManager implements LayoutPersistenceManager { }); } - private appendAndPersist( - newId: string, - newMetadata: Omit, - newLayout: LayoutJSON, - existingLayouts: Layout[], - existingMetadata: LayoutMetadata[] - ) { - existingLayouts.push({ id: newId, json: newLayout }); - existingMetadata.push({ id: newId, ...newMetadata }); - - this.saveLayoutsWithMetadata(existingLayouts, existingMetadata); - } - private saveLayoutsWithMetadata( layouts: Layout[], metadata: LayoutMetadata[] diff --git a/vuu-ui/packages/vuu-layout/src/layout-persistence/RemoteLayoutPersistenceManager.ts b/vuu-ui/packages/vuu-layout/src/layout-persistence/RemoteLayoutPersistenceManager.ts new file mode 100644 index 000000000..7b9816247 --- /dev/null +++ b/vuu-ui/packages/vuu-layout/src/layout-persistence/RemoteLayoutPersistenceManager.ts @@ -0,0 +1,146 @@ +import { LayoutMetadata, LayoutMetadataDto } from "@finos/vuu-shell"; +import { LayoutPersistenceManager } from "./LayoutPersistenceManager"; +import { LayoutJSON } from "../layout-reducer"; +import { defaultLayout } from "./data"; + +const DEFAULT_SERVER_BASE_URL = "http://127.0.0.1:8081/api"; + +const baseURL = process.env.LAYOUT_BASE_URL ?? DEFAULT_SERVER_BASE_URL; +const metadataSaveLocation = "layouts/metadata"; +const layoutsSaveLocation = "layouts"; + +export type CreateLayoutResponseDto = { metadata: LayoutMetadata }; +export type GetLayoutResponseDto = { definition: LayoutJSON }; + +export class RemoteLayoutPersistenceManager + implements LayoutPersistenceManager +{ + createLayout( + metadata: LayoutMetadataDto, + layout: LayoutJSON + ): Promise { + return new Promise((resolve, reject) => + fetch(`${baseURL}/${layoutsSaveLocation}`, { + headers: { + "Content-Type": "application/json", + }, + method: "POST", + body: JSON.stringify({ + metadata, + definition: JSON.stringify(layout), + }), + }) + .then((response) => { + if (!response.ok) { + reject(new Error(response.statusText)); + } + response.json().then(({ metadata }: CreateLayoutResponseDto) => { + if (!metadata) { + reject(new Error("Response did not contain valid metadata")); + } + resolve(metadata); + }); + }) + .catch((error: Error) => { + reject(error); + }) + ); + } + + updateLayout( + id: string, + metadata: LayoutMetadataDto, + newLayoutJson: LayoutJSON + ): Promise { + return new Promise((resolve, reject) => + fetch(`${baseURL}/${layoutsSaveLocation}/${id}`, { + method: "PUT", + body: JSON.stringify({ + metadata, + layout: newLayoutJson, + }), + }) + .then((response) => { + if (!response.ok) { + reject(new Error(response.statusText)); + } + resolve(); + }) + .catch((error: Error) => { + reject(error); + }) + ); + } + + deleteLayout(id: string): Promise { + return new Promise((resolve, reject) => + fetch(`${baseURL}/${layoutsSaveLocation}/${id}`, { + method: "DELETE", + }) + .then((response) => { + if (!response.ok) { + reject(new Error(response.statusText)); + } + resolve(); + }) + .catch((error: Error) => { + reject(error); + }) + ); + } + + loadLayout(id: string): Promise { + return new Promise((resolve, reject) => { + fetch(`${baseURL}/${layoutsSaveLocation}/${id}`, { + method: "GET", + }) + .then((response) => { + if (!response.ok) { + reject(new Error(response.statusText)); + } + response.json().then(({ definition }: GetLayoutResponseDto) => { + if (!definition) { + reject(new Error("Response did not contain a valid layout")); + } + resolve(definition); + }); + }) + .catch((error: Error) => { + reject(error); + }); + }); + } + + loadMetadata(): Promise { + return new Promise((resolve, reject) => + fetch(`${baseURL}/${metadataSaveLocation}`, { + method: "GET", + }) + .then((response) => { + if (!response.ok) { + reject(new Error(response.statusText)); + } + response.json().then((metadata: LayoutMetadata[]) => { + if (!metadata) { + reject(new Error("Response did not contain valid metadata")); + } + resolve(metadata); + }); + }) + .catch((error: Error) => { + reject(error); + }) + ); + } + + saveApplicationLayout(layout: LayoutJSON): Promise { + // TODO POST api/layouts/application #71 + console.log(layout); + return new Promise((resolve) => resolve()); + } + + loadApplicationLayout(): Promise { + // TODO GET api/layouts/application #71 + return new Promise((resolve) => resolve(defaultLayout)); + } +} diff --git a/vuu-ui/packages/vuu-layout/src/layout-persistence/index.ts b/vuu-ui/packages/vuu-layout/src/layout-persistence/index.ts index 10bbeed00..c00fe72d9 100644 --- a/vuu-ui/packages/vuu-layout/src/layout-persistence/index.ts +++ b/vuu-ui/packages/vuu-layout/src/layout-persistence/index.ts @@ -1,4 +1,5 @@ export * from "./data"; export * from "./LayoutPersistenceManager"; export * from "./LocalLayoutPersistenceManager"; +export * from './RemoteLayoutPersistenceManager'; export * from "./useLayoutContextMenuItems"; diff --git a/vuu-ui/packages/vuu-layout/src/layout-persistence/useLayoutContextMenuItems.tsx b/vuu-ui/packages/vuu-layout/src/layout-persistence/useLayoutContextMenuItems.tsx index e68effaa6..0ff129ab4 100644 --- a/vuu-ui/packages/vuu-layout/src/layout-persistence/useLayoutContextMenuItems.tsx +++ b/vuu-ui/packages/vuu-layout/src/layout-persistence/useLayoutContextMenuItems.tsx @@ -1,5 +1,5 @@ import { - LayoutMetadata, + LayoutMetadataDto, SaveLayoutPanel, useLayoutManager, } from "@finos/vuu-shell"; @@ -21,7 +21,7 @@ export const useLayoutContextMenuItems = () => { }, []); const handleSave = useCallback( - (layoutMetadata: Omit) => { + (layoutMetadata: LayoutMetadataDto) => { saveLayout(layoutMetadata); setDialogContent(undefined); }, diff --git a/vuu-ui/packages/vuu-layout/test/layout-persistence/LocalLayoutPersistenceManager.test.ts b/vuu-ui/packages/vuu-layout/test/layout-persistence/LocalLayoutPersistenceManager.test.ts index a94ccc200..d8eced8ab 100644 --- a/vuu-ui/packages/vuu-layout/test/layout-persistence/LocalLayoutPersistenceManager.test.ts +++ b/vuu-ui/packages/vuu-layout/test/layout-persistence/LocalLayoutPersistenceManager.test.ts @@ -1,12 +1,11 @@ import "../global-mocks"; -import { Layout, LayoutMetadata } from "@finos/vuu-shell"; +import { Layout, LayoutMetadata, LayoutMetadataDto } from "@finos/vuu-shell"; import { afterEach, describe, expect, it, vi } from "vitest"; import { LocalLayoutPersistenceManager } from "../../src/layout-persistence"; import { LayoutJSON } from "../../src/layout-reducer"; -import { - getLocalEntity, - saveLocalEntity, -} from "../../../vuu-filters/src/local-config"; +import { getLocalEntity, saveLocalEntity } from "@finos/vuu-filters"; +import { formatDate } from "@finos/vuu-utils"; +import { expectPromiseRejectsWithError } from "./utils"; vi.mock("@finos/vuu-filters", async () => { return { @@ -29,13 +28,15 @@ const persistenceManager = new LocalLayoutPersistenceManager(); const existingId = "existing_id"; +const newDate = formatDate(new Date(), "dd.mm.yyyy"); + const existingMetadata: LayoutMetadata = { id: existingId, name: "Existing Layout", group: "Group 1", screenshot: "screenshot", user: "vuu user", - date: "01/01/2023", + created: newDate, }; const existingLayout: Layout = { @@ -43,12 +44,19 @@ const existingLayout: Layout = { json: { type: "t0" }, }; -const metadataToAdd: Omit = { +const metadataToAdd: LayoutMetadataDto = { name: "New Layout", group: "Group 1", screenshot: "screenshot", user: "vuu user", - date: "26/09/2023", +}; + +const metadataToUpdate: Omit = { + name: "New Layout", + group: "Group 1", + screenshot: "screenshot", + user: "vuu user", + created: newDate, }; const layoutToAdd: LayoutJSON = { @@ -63,8 +71,8 @@ afterEach(() => { }); describe("createLayout", () => { - it("persists to local storage with a unique ID", async () => { - const returnedId = await persistenceManager.createLayout( + it("persists to local storage with a unique ID and current date", async () => { + const { id, created } = await persistenceManager.createLayout( metadataToAdd, layoutToAdd ); @@ -75,14 +83,16 @@ describe("createLayout", () => { const expectedMetadata: LayoutMetadata = { ...metadataToAdd, - id: returnedId, + id, + created, }; const expectedLayout: Layout = { json: layoutToAdd, - id: returnedId, + id, }; + expect(created).toEqual(newDate); expect(persistedMetadata).toEqual([expectedMetadata]); expect(persistedLayout).toEqual([expectedLayout]); }); @@ -91,11 +101,11 @@ describe("createLayout", () => { saveLocalEntity(metadataSaveLocation, [existingMetadata]); saveLocalEntity(layoutsSaveLocation, [existingLayout]); - const returnedId = await persistenceManager.createLayout( + const { id, created } = await persistenceManager.createLayout( metadataToAdd, layoutToAdd ); - expect(returnedId).not.toEqual(existingId); + expect(id).not.toEqual(existingId); const persistedMetadata = getLocalEntity(metadataSaveLocation); @@ -103,12 +113,13 @@ describe("createLayout", () => { const expectedMetadata: LayoutMetadata = { ...metadataToAdd, - id: returnedId, + id, + created, }; const expectedLayout: Layout = { json: layoutToAdd, - id: returnedId, + id, }; expect(persistedMetadata).toEqual([existingMetadata, expectedMetadata]); @@ -123,7 +134,7 @@ describe("updateLayout", () => { await persistenceManager.updateLayout( existingId, - metadataToAdd, + metadataToUpdate, layoutToAdd ); @@ -132,7 +143,7 @@ describe("updateLayout", () => { const persistedLayout = getLocalEntity(layoutsSaveLocation); const expectedMetadata: LayoutMetadata = { - ...metadataToAdd, + ...metadataToUpdate, id: existingId, }; @@ -148,9 +159,13 @@ describe("updateLayout", () => { it("errors if there is no metadata in local storage with requested ID ", async () => { saveLocalEntity(layoutsSaveLocation, [existingLayout]); - expectError( + expectPromiseRejectsWithError( () => - persistenceManager.updateLayout(existingId, metadataToAdd, layoutToAdd), + persistenceManager.updateLayout( + existingId, + metadataToUpdate, + layoutToAdd + ), `No metadata with ID ${existingId}` ); }); @@ -158,9 +173,13 @@ describe("updateLayout", () => { it("errors if there is no layout in local storage with requested ID ", async () => { saveLocalEntity(metadataSaveLocation, [existingMetadata]); - expectError( + expectPromiseRejectsWithError( () => - persistenceManager.updateLayout(existingId, metadataToAdd, layoutToAdd), + persistenceManager.updateLayout( + existingId, + metadataToUpdate, + layoutToAdd + ), `No layout with ID ${existingId}` ); }); @@ -168,11 +187,11 @@ describe("updateLayout", () => { it("errors if there is no metadata or layout in local storage with requested ID ", async () => { const requestedId = "non_existent_id"; - expectError( + expectPromiseRejectsWithError( () => persistenceManager.updateLayout( requestedId, - metadataToAdd, + metadataToUpdate, layoutToAdd ), `No metadata with ID ${requestedId}; No layout with ID ${requestedId}` @@ -183,9 +202,13 @@ describe("updateLayout", () => { saveLocalEntity(metadataSaveLocation, [existingMetadata, existingMetadata]); saveLocalEntity(layoutsSaveLocation, [existingLayout]); - expectError( + expectPromiseRejectsWithError( () => - persistenceManager.updateLayout(existingId, metadataToAdd, layoutToAdd), + persistenceManager.updateLayout( + existingId, + metadataToUpdate, + layoutToAdd + ), `Non-unique metadata with ID ${existingId}` ); }); @@ -194,9 +217,13 @@ describe("updateLayout", () => { saveLocalEntity(metadataSaveLocation, [existingMetadata]); saveLocalEntity(layoutsSaveLocation, [existingLayout, existingLayout]); - expectError( + expectPromiseRejectsWithError( () => - persistenceManager.updateLayout(existingId, metadataToAdd, layoutToAdd), + persistenceManager.updateLayout( + existingId, + metadataToUpdate, + layoutToAdd + ), `Non-unique layout with ID ${existingId}` ); }); @@ -205,9 +232,13 @@ describe("updateLayout", () => { saveLocalEntity(metadataSaveLocation, [existingMetadata, existingMetadata]); saveLocalEntity(layoutsSaveLocation, [existingLayout, existingLayout]); - expectError( + expectPromiseRejectsWithError( () => - persistenceManager.updateLayout(existingId, metadataToAdd, layoutToAdd), + persistenceManager.updateLayout( + existingId, + metadataToUpdate, + layoutToAdd + ), `Non-unique metadata with ID ${existingId}; Non-unique layout with ID ${existingId}` ); }); @@ -215,9 +246,13 @@ describe("updateLayout", () => { it("errors if there are multiple metadata entries and no layouts in local storage with requested ID ", async () => { saveLocalEntity(metadataSaveLocation, [existingMetadata, existingMetadata]); - expectError( + expectPromiseRejectsWithError( () => - persistenceManager.updateLayout(existingId, metadataToAdd, layoutToAdd), + persistenceManager.updateLayout( + existingId, + metadataToUpdate, + layoutToAdd + ), `Non-unique metadata with ID ${existingId}; No layout with ID ${existingId}` ); }); @@ -225,9 +260,13 @@ describe("updateLayout", () => { it("errors if there are no metadata entries and multiple layouts in local storage with requested ID ", async () => { saveLocalEntity(layoutsSaveLocation, [existingLayout, existingLayout]); - expectError( + expectPromiseRejectsWithError( () => - persistenceManager.updateLayout(existingId, metadataToAdd, layoutToAdd), + persistenceManager.updateLayout( + existingId, + metadataToUpdate, + layoutToAdd + ), `No metadata with ID ${existingId}; Non-unique layout with ID ${existingId}` ); }); @@ -251,7 +290,7 @@ describe("deleteLayout", () => { it("errors if there is no metadata in local storage with requested ID ", async () => { saveLocalEntity(layoutsSaveLocation, [existingLayout]); - expectError( + expectPromiseRejectsWithError( () => persistenceManager.deleteLayout(existingId), `No metadata with ID ${existingId}` ); @@ -260,7 +299,7 @@ describe("deleteLayout", () => { it("errors if there is no layout in local storage with requested ID ", async () => { saveLocalEntity(metadataSaveLocation, [existingMetadata]); - expectError( + expectPromiseRejectsWithError( () => persistenceManager.deleteLayout(existingId), `No layout with ID ${existingId}` ); @@ -269,7 +308,7 @@ describe("deleteLayout", () => { it("errors if there is no metadata or layout in local storage with requested ID ", async () => { const requestedId = "non_existent_id"; - expectError( + expectPromiseRejectsWithError( () => persistenceManager.deleteLayout(requestedId), `No metadata with ID ${requestedId}; No layout with ID ${requestedId}` ); @@ -279,7 +318,7 @@ describe("deleteLayout", () => { saveLocalEntity(metadataSaveLocation, [existingMetadata, existingMetadata]); saveLocalEntity(layoutsSaveLocation, [existingLayout]); - expectError( + expectPromiseRejectsWithError( () => persistenceManager.deleteLayout(existingId), `Non-unique metadata with ID ${existingId}` ); @@ -289,7 +328,7 @@ describe("deleteLayout", () => { saveLocalEntity(metadataSaveLocation, [existingMetadata]); saveLocalEntity(layoutsSaveLocation, [existingLayout, existingLayout]); - expectError( + expectPromiseRejectsWithError( () => persistenceManager.deleteLayout(existingId), `Non-unique layout with ID ${existingId}` ); @@ -299,7 +338,7 @@ describe("deleteLayout", () => { saveLocalEntity(metadataSaveLocation, [existingMetadata, existingMetadata]); saveLocalEntity(layoutsSaveLocation, [existingLayout, existingLayout]); - expectError( + expectPromiseRejectsWithError( () => persistenceManager.deleteLayout(existingId), `Non-unique metadata with ID ${existingId}; Non-unique layout with ID ${existingId}` ); @@ -308,7 +347,7 @@ describe("deleteLayout", () => { it("errors if there are multiple metadata entries and no layouts in local storage with requested ID ", async () => { saveLocalEntity(metadataSaveLocation, [existingMetadata, existingMetadata]); - expectError( + expectPromiseRejectsWithError( () => persistenceManager.deleteLayout(existingId), `Non-unique metadata with ID ${existingId}; No layout with ID ${existingId}` ); @@ -317,7 +356,7 @@ describe("deleteLayout", () => { it("errors if there are no metadata entries and multiple layouts in local storage with requested ID ", async () => { saveLocalEntity(layoutsSaveLocation, [existingLayout, existingLayout]); - expectError( + expectPromiseRejectsWithError( () => persistenceManager.deleteLayout(existingId), `No metadata with ID ${existingId}; Non-unique layout with ID ${existingId}` ); @@ -345,7 +384,7 @@ describe("loadLayout", () => { it("errors if there is no layout in local storage with requested ID ", async () => { saveLocalEntity(metadataSaveLocation, [existingMetadata]); - expectError( + expectPromiseRejectsWithError( () => persistenceManager.loadLayout(existingId), `No layout with ID ${existingId}` ); @@ -354,7 +393,7 @@ describe("loadLayout", () => { it("errors if there is no metadata or layout in local storage with requested ID ", async () => { const requestedId = "non_existent_id"; - expectError( + expectPromiseRejectsWithError( () => persistenceManager.loadLayout(requestedId), `No layout with ID ${requestedId}` ); @@ -373,7 +412,7 @@ describe("loadLayout", () => { saveLocalEntity(metadataSaveLocation, [existingMetadata]); saveLocalEntity(layoutsSaveLocation, [existingLayout, existingLayout]); - expectError( + expectPromiseRejectsWithError( () => persistenceManager.loadLayout(existingId), `Non-unique layout with ID ${existingId}` ); @@ -383,7 +422,7 @@ describe("loadLayout", () => { saveLocalEntity(metadataSaveLocation, [existingMetadata, existingMetadata]); saveLocalEntity(layoutsSaveLocation, [existingLayout, existingLayout]); - expectError( + expectPromiseRejectsWithError( () => persistenceManager.loadLayout(existingId), `Non-unique layout with ID ${existingId}` ); @@ -392,7 +431,7 @@ describe("loadLayout", () => { it("errors if there are multiple metadata entries and no layouts in local storage with requested ID ", async () => { saveLocalEntity(metadataSaveLocation, [existingMetadata, existingMetadata]); - expectError( + expectPromiseRejectsWithError( () => persistenceManager.loadLayout(existingId), `No layout with ID ${existingId}` ); @@ -401,7 +440,7 @@ describe("loadLayout", () => { it("errors if there are no metadata entries and multiple layouts in local storage with requested ID ", async () => { saveLocalEntity(layoutsSaveLocation, [existingLayout, existingLayout]); - expectError( + expectPromiseRejectsWithError( () => persistenceManager.loadLayout(existingId), `Non-unique layout with ID ${existingId}` ); @@ -428,8 +467,4 @@ describe("loadMetadata", () => { it("returns empty array if no metadata is persisted", async () => { expect(await persistenceManager.loadMetadata()).toEqual([]); }); -}); - -const expectError = (f: () => Promise, message: string) => { - expect(f).rejects.toStrictEqual(new Error(message)); -}; +}); \ No newline at end of file diff --git a/vuu-ui/packages/vuu-layout/test/layout-persistence/RemoteLayoutPersistenceManager.test.ts b/vuu-ui/packages/vuu-layout/test/layout-persistence/RemoteLayoutPersistenceManager.test.ts new file mode 100644 index 000000000..213d1e6a9 --- /dev/null +++ b/vuu-ui/packages/vuu-layout/test/layout-persistence/RemoteLayoutPersistenceManager.test.ts @@ -0,0 +1,304 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + GetLayoutResponseDto, + CreateLayoutResponseDto, + RemoteLayoutPersistenceManager, +} from "../../src/layout-persistence/RemoteLayoutPersistenceManager"; +import { LayoutMetadata, LayoutMetadataDto } from "@finos/vuu-shell"; +import { LayoutJSON } from "../../src/layout-reducer"; +import { v4 as uuidv4 } from "uuid"; +import { expectPromiseRejectsWithError } from "./utils"; + +const persistence = new RemoteLayoutPersistenceManager(); +const mockFetch = vi.fn(); + +global.fetch = mockFetch; + +const metadata: LayoutMetadata = { + id: "0001", + name: "layout 1", + group: "group 1", + screenshot: "screenshot", + user: "username", + created: "01.01.2000", +}; + +const metadataToAdd: LayoutMetadataDto = { + name: "layout 1", + group: "group 1", + screenshot: "screenshot", + user: "username", +}; + +const layout: LayoutJSON = { + type: "View", +}; + +const uniqueId = uuidv4(); +const dateString = new Date().toISOString(); +const fetchError = new Error("Something went wrong with your request"); + +type FetchResponse = { + json?: () => Promise; + ok: boolean; + statusText?: string; +}; + +describe("RemoteLayoutPersistenceManager", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("createLayout", () => { + const responseJSON: CreateLayoutResponseDto = { + metadata: { + ...metadataToAdd, + id: uniqueId, + created: dateString, + }, + }; + + it("resolves with metadata when fetch resolves, response is ok and contains metadata", () => { + const fetchResponse: FetchResponse = { + json: () => new Promise((resolve) => resolve(responseJSON)), + ok: true, + }; + + mockFetch.mockResolvedValue(fetchResponse); + + const result = persistence.createLayout(metadataToAdd, layout); + + expect(result).resolves.toStrictEqual(responseJSON.metadata); + }); + + it("rejects with error when response is not ok", () => { + const errorMessage = "Not Found"; + + const fetchResponse: FetchResponse = { + json: () => new Promise((resolve) => resolve(responseJSON)), + ok: false, + statusText: errorMessage, + }; + + mockFetch.mockResolvedValue(fetchResponse); + + expectPromiseRejectsWithError( + () => persistence.createLayout(metadata, layout), + errorMessage + ); + }); + + it("rejects with error when metadata in response is falsey", () => { + const fetchResponse: FetchResponse = { + json: () => new Promise((resolve) => resolve({})), + ok: true, + }; + + mockFetch.mockResolvedValue(fetchResponse); + + expectPromiseRejectsWithError( + () => persistence.createLayout(metadata, layout), + "Response did not contain valid metadata" + ); + }); + + it("rejects with error when fetch rejects", () => { + mockFetch.mockRejectedValue(fetchError); + + expectPromiseRejectsWithError( + () => persistence.createLayout(metadata, layout), + fetchError.message + ); + }); + }); + + describe("updateLayout", () => { + it("resolves when fetch resolves and response is ok", () => { + const fetchResponse: FetchResponse = { + ok: true, + }; + + mockFetch.mockResolvedValue(fetchResponse); + + const result = persistence.updateLayout(uniqueId, metadata, layout); + + expect(result).resolves.toBe(undefined); + }); + + it("rejects with error when response is not ok", () => { + const errorMessage = "Not Found"; + + const fetchResponse: FetchResponse = { + ok: false, + statusText: errorMessage, + }; + + mockFetch.mockResolvedValue(fetchResponse); + + expectPromiseRejectsWithError( + () => persistence.updateLayout(uniqueId, metadata, layout), + errorMessage + ); + }); + + it("rejects with error when fetch rejects", () => { + mockFetch.mockRejectedValue(fetchError); + + expectPromiseRejectsWithError( + () => persistence.updateLayout(uniqueId, metadata, layout), + fetchError.message + ); + }); + }); + + describe("deleteLayout", () => { + it("resolves when fetch resolves and response is ok", () => { + const fetchResponse: FetchResponse = { + ok: true, + }; + + mockFetch.mockResolvedValue(fetchResponse); + + const result = persistence.deleteLayout(uniqueId); + + expect(result).resolves.toBe(undefined); + }); + + it("rejects with error when response is not ok", () => { + const errorMessage = "Not Found"; + + const fetchResponse: FetchResponse = { + ok: false, + statusText: errorMessage, + }; + + mockFetch.mockResolvedValue(fetchResponse); + + expectPromiseRejectsWithError( + () => persistence.deleteLayout(uniqueId), + errorMessage + ); + }); + + it("rejects with error when fetch rejects", () => { + mockFetch.mockRejectedValue(fetchError); + + expectPromiseRejectsWithError( + () => persistence.deleteLayout(uniqueId), + fetchError.message + ); + }); + }); + + describe("loadMetadata", () => { + it("resolves with array of metadata when response is ok", () => { + const responseJson = [metadata]; + + const fetchResponse: FetchResponse = { + json: () => new Promise((resolve) => resolve(responseJson)), + ok: true, + }; + + mockFetch.mockResolvedValue(fetchResponse); + + const result = persistence.loadMetadata(); + + expect(result).resolves.toBe(responseJson); + }); + + it("rejects with error when response is not ok", () => { + const errorMessage = "Not Found"; + + const fetchResponse: FetchResponse = { + json: () => new Promise((resolve) => resolve()), + ok: false, + statusText: errorMessage, + }; + + mockFetch.mockResolvedValue(fetchResponse); + + expectPromiseRejectsWithError( + () => persistence.loadMetadata(), + errorMessage + ); + }); + + it("rejects with error when metadata is falsey in response", () => { + const fetchResponse: FetchResponse = { + json: () => new Promise((resolve) => resolve()), + ok: true, + }; + + mockFetch.mockResolvedValue(fetchResponse); + + expectPromiseRejectsWithError( + () => persistence.loadMetadata(), + "Response did not contain valid metadata" + ); + }); + + it("rejects with error when fetch rejects", () => { + mockFetch.mockRejectedValue(fetchError); + + expectPromiseRejectsWithError( + () => persistence.loadMetadata(), + fetchError.message + ); + }); + }); + + describe("loadLayout", () => { + it("resolves with array of metadata when response is ok", () => { + const fetchResponse: FetchResponse = { + json: () => new Promise((resolve) => resolve({ definition: layout })), + ok: true, + }; + + mockFetch.mockResolvedValue(fetchResponse); + + const result = persistence.loadLayout(uniqueId); + + expect(result).resolves.toBe(layout); + }); + + it("rejects with error when response is not ok", () => { + const errorMessage = "Not Found"; + + const fetchResponse: FetchResponse = { + json: () => new Promise((resolve) => resolve({})), + ok: false, + statusText: errorMessage, + }; + + mockFetch.mockResolvedValue(fetchResponse); + + expectPromiseRejectsWithError( + () => persistence.loadLayout(uniqueId), + errorMessage + ); + }); + + it("rejects with error when definition is falsey in response", () => { + const fetchResponse: FetchResponse = { + json: () => new Promise((resolve) => resolve({})), + ok: true, + }; + + mockFetch.mockResolvedValue(fetchResponse); + + expectPromiseRejectsWithError( + () => persistence.loadLayout(uniqueId), + "Response did not contain a valid layout" + ); + }); + + it("rejects with error when fetch rejects", () => { + mockFetch.mockRejectedValue(fetchError); + + expectPromiseRejectsWithError( + () => persistence.loadLayout(uniqueId), + fetchError.message + ); + }); + }); +}); diff --git a/vuu-ui/packages/vuu-layout/test/layout-persistence/utils.ts b/vuu-ui/packages/vuu-layout/test/layout-persistence/utils.ts new file mode 100644 index 000000000..1dff87194 --- /dev/null +++ b/vuu-ui/packages/vuu-layout/test/layout-persistence/utils.ts @@ -0,0 +1,8 @@ +import { expect } from "vitest"; + +export const expectPromiseRejectsWithError = ( + f: () => Promise, + message: string +) => { + expect(f).rejects.toStrictEqual(new Error(message)); +}; diff --git a/vuu-ui/packages/vuu-shell/src/layout-management/LayoutList.tsx b/vuu-ui/packages/vuu-shell/src/layout-management/LayoutList.tsx index f4e931ed7..ae5514f07 100644 --- a/vuu-ui/packages/vuu-shell/src/layout-management/LayoutList.tsx +++ b/vuu-ui/packages/vuu-shell/src/layout-management/LayoutList.tsx @@ -41,20 +41,20 @@ export const LayoutsList = (props: HTMLAttributes) => { source={Object.entries(layoutsByGroup)} ListItem={({ item }) => { if (!item) return <> - const [groupName, layouts] = item + const [groupName, layoutMetadata] = item return <>
{groupName}
- {layouts.map(layout => + {layoutMetadata.map((metadata) =>
handleLoadLayout(layout?.id)} + key={metadata?.id} + onClick={() => handleLoadLayout(metadata?.id)} > - +
-
{layout?.name}
+
{metadata?.name}
-
{`${layout?.user}, ${layout?.date}`}
+
{`${metadata?.user}, ${metadata?.created}`}
diff --git a/vuu-ui/packages/vuu-shell/src/layout-management/SaveLayoutPanel.tsx b/vuu-ui/packages/vuu-shell/src/layout-management/SaveLayoutPanel.tsx index 513a5d05f..b0cf1cc54 100644 --- a/vuu-ui/packages/vuu-shell/src/layout-management/SaveLayoutPanel.tsx +++ b/vuu-ui/packages/vuu-shell/src/layout-management/SaveLayoutPanel.tsx @@ -1,8 +1,8 @@ import { ChangeEvent, useEffect, useState } from "react"; import { Input, Button, FormField, FormFieldLabel, Text } from "@salt-ds/core"; import { ComboBox, Checkbox, RadioButton } from "@finos/vuu-ui-controls"; -import { formatDate, takeScreenshot } from "@finos/vuu-utils"; -import { LayoutMetadata } from "./layoutTypes"; +import { takeScreenshot } from "@finos/vuu-utils"; +import { LayoutMetadataDto } from "./layoutTypes"; import "./SaveLayoutPanel.css"; @@ -19,8 +19,8 @@ type RadioValue = (typeof radioValues)[number]; type SaveLayoutPanelProps = { onCancel: () => void; - onSave: (layoutMetadata: Omit) => void; - componentId?: string; + onSave: (layoutMetadata: LayoutMetadataDto) => void; + componentId?: string }; export const SaveLayoutPanel = (props: SaveLayoutPanelProps) => { @@ -45,10 +45,9 @@ export const SaveLayoutPanel = (props: SaveLayoutPanelProps) => { name: layoutName, group, screenshot: screenshot ?? "", - user: "User", - date: formatDate(new Date(), "dd.mm.yyyy"), - }); - }; + user: "User" + }) + } return (
diff --git a/vuu-ui/packages/vuu-shell/src/layout-management/layoutTypes.ts b/vuu-ui/packages/vuu-shell/src/layout-management/layoutTypes.ts index 96e0441cc..8b2bf1dd3 100644 --- a/vuu-ui/packages/vuu-shell/src/layout-management/layoutTypes.ts +++ b/vuu-ui/packages/vuu-shell/src/layout-management/layoutTypes.ts @@ -9,9 +9,11 @@ export interface LayoutMetadata extends WithId { group: string; screenshot: string; user: string; - date: string; + created: string; } +export type LayoutMetadataDto = Omit; + export interface Layout extends WithId { json: LayoutJSON; } diff --git a/vuu-ui/packages/vuu-shell/src/layout-management/useLayoutManager.tsx b/vuu-ui/packages/vuu-shell/src/layout-management/useLayoutManager.tsx index e32023989..02b0a6bcd 100644 --- a/vuu-ui/packages/vuu-shell/src/layout-management/useLayoutManager.tsx +++ b/vuu-ui/packages/vuu-shell/src/layout-management/useLayoutManager.tsx @@ -8,26 +8,29 @@ import React, { import { LayoutJSON, LocalLayoutPersistenceManager, + RemoteLayoutPersistenceManager, resolveJSONPath, } from "@finos/vuu-layout"; -import { LayoutMetadata } from "./layoutTypes"; +import { LayoutMetadata, LayoutMetadataDto } from "./layoutTypes"; import { defaultLayout } from "@finos/vuu-layout/"; -const persistenceManager = new LocalLayoutPersistenceManager(); +const local = process.env.LOCAL ?? true; + +const persistenceManager = local ? new LocalLayoutPersistenceManager() : new RemoteLayoutPersistenceManager(); export const LayoutManagementContext = React.createContext<{ - layoutMetadata: LayoutMetadata[]; - saveLayout: (n: Omit) => void; - applicationLayout: LayoutJSON; - saveApplicationLayout: (layout: LayoutJSON) => void; - loadLayoutById: (id: string) => void; + layoutMetadata: LayoutMetadata[], + saveLayout: (n: LayoutMetadataDto) => void, + applicationLayout: LayoutJSON, + saveApplicationLayout: (layout: LayoutJSON) => void, + loadLayoutById: (id: string) => void }>({ layoutMetadata: [], - saveLayout: () => undefined, + saveLayout: () => null, applicationLayout: defaultLayout, - saveApplicationLayout: () => undefined, - loadLayoutById: () => defaultLayout, -}); + saveApplicationLayout: () => null, + loadLayoutById: () => defaultLayout +}) type LayoutManagementProviderProps = { children: JSX.Element | JSX.Element[]; @@ -58,8 +61,12 @@ export const LayoutManagementProvider = ( }); persistenceManager.loadApplicationLayout().then((layout) => { setApplicationLayout(layout); - }); - }, [setApplicationLayout]); + }) + .catch((error: Error) => { + //TODO: Show error toaster + console.error("Error occurred while retrieving metadata", error) + }) + }, [setApplicationLayout]) const saveApplicationLayout = useCallback( (layout: LayoutJSON) => { @@ -69,23 +76,18 @@ export const LayoutManagementProvider = ( [setApplicationLayout] ); - const saveLayout = useCallback((metadata: Omit) => { - const layoutToSave = resolveJSONPath( - applicationLayoutRef.current, - "#main-tabs.ACTIVE_CHILD" - ); + const saveLayout = useCallback((metadata:LayoutMetadataDto) => { - if (layoutToSave) { - persistenceManager - .createLayout(metadata, layoutToSave) - .then((generatedId) => { - const newMetadata: LayoutMetadata = { - ...metadata, - id: generatedId, - }; + const layoutToSave = resolveJSONPath(applicationLayoutRef.current, "#main-tabs.ACTIVE_CHILD"); - setLayoutMetadata((prev) => [...prev, newMetadata]); - }); + if (layoutToSave) { + persistenceManager.createLayout(metadata, layoutToSave).then((metadata) => { + setLayoutMetadata(prev => [...prev, metadata]); + //TODO: Show success toast + }).catch((error: Error) => { + //TODO: Show error toaster + console.error("Error occurred while saving layout", error) + }) } //TODO else{ show error message} }, []); diff --git a/vuu-ui/showcase/vite.config.js b/vuu-ui/showcase/vite.config.js index 81ce7f139..1b5859152 100644 --- a/vuu-ui/showcase/vite.config.js +++ b/vuu-ui/showcase/vite.config.js @@ -8,6 +8,7 @@ export default defineConfig({ }, define: { "process.env.NODE_DEBUG": false, + "process.env.LOCAL": true, }, esbuild: { jsx: `automatic`,