diff --git a/src/features/application-menu/main/menu-items/get-composite/find-composite/find-composite.test.ts b/src/features/application-menu/main/menu-items/get-composite/find-composite/find-composite.test.ts new file mode 100644 index 0000000000..6fbca5cf5e --- /dev/null +++ b/src/features/application-menu/main/menu-items/get-composite/find-composite/find-composite.test.ts @@ -0,0 +1,52 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { Composite } from "../get-composite"; +import getComposite from "../get-composite"; +import { findComposite } from "./find-composite"; + +describe("find-composite", () => { + let composite: Composite<{ id: string; parentId?: string; someProperty: string }>; + + beforeEach(() => { + composite = getComposite({ + source: [ + { id: "some-root-id", someProperty: "some-value" }, + { id: "some-child-id", parentId: "some-root-id", someProperty: "some-value" }, + { id: "some-irrelevant-grandchild-id", parentId: "some-child-id", someProperty: "some-value" }, + { id: "some-grandchild-id", parentId: "some-child-id", someProperty: "some-value" }, + ], + + rootId: "some-root-id", + }); + }); + + it("when finding root using path, does so", () => { + const actual = findComposite("some-root-id")(composite); + + expect(actual?.id).toBe("some-root-id"); + }); + + it("when finding child using path, does so", () => { + const actual = findComposite("some-root-id.some-child-id")(composite); + + expect(actual?.id).toBe("some-child-id"); + }); + + it("when finding grandchild using path, does so", () => { + const actual = findComposite( + "some-root-id.some-child-id.some-grandchild-id", + )(composite); + + expect(actual?.id).toBe("some-grandchild-id"); + }); + + it("when finding with non existing path, returns undefined", () => { + const actual = findComposite("some-root-id.some-non-existing-path")( + composite, + ); + + expect(actual).toBe(undefined); + }); +}); diff --git a/src/features/application-menu/main/menu-items/get-composite/find-composite/find-composite.ts b/src/features/application-menu/main/menu-items/get-composite/find-composite/find-composite.ts new file mode 100644 index 0000000000..048e7e6eb0 --- /dev/null +++ b/src/features/application-menu/main/menu-items/get-composite/find-composite/find-composite.ts @@ -0,0 +1,11 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { Composite } from "../get-composite"; +import { normalizeComposite } from "../normalize-composite/normalize-composite"; + +export const findComposite = + (path: string) => + (composite: Composite): Composite | undefined => + new Map(normalizeComposite(composite)).get(path); diff --git a/src/features/application-menu/main/menu-items/get-composite/get-composite-paths/get-composite-paths.test.ts b/src/features/application-menu/main/menu-items/get-composite/get-composite-paths/get-composite-paths.test.ts new file mode 100644 index 0000000000..34e188639f --- /dev/null +++ b/src/features/application-menu/main/menu-items/get-composite/get-composite-paths/get-composite-paths.test.ts @@ -0,0 +1,61 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import getComposite from "../get-composite"; +import { getCompositePaths } from "./get-composite-paths"; + +describe("get-composite-paths", () => { + it("given composite with ordered children, returns ordered paths", () => { + const someRootItem = { + id: "some-root-id", + }; + + const someItem1 = { + id: "some-id-1", + parentId: "some-root-id", + orderNumber: 1, + }; + + const someItem2 = { + id: "some-id-2", + parentId: "some-root-id", + orderNumber: 2, + }; + + const someChildItem1 = { + id: "some-child-id-1", + parentId: "some-id-1", + orderNumber: 1, + }; + + const someChildItem2 = { + id: "some-child-id-2", + parentId: "some-id-1", + orderNumber: 2, + }; + + const items = [ + someRootItem, + // Note: not in order yet. + someItem2, + someItem1, + someChildItem2, + someChildItem1, + ]; + + const composite = getComposite({ + source: items, + }); + + const actual = getCompositePaths(composite); + + expect(actual).toEqual([ + "some-root-id", + "some-root-id.some-id-1", + "some-root-id.some-id-1.some-child-id-1", + "some-root-id.some-id-1.some-child-id-2", + "some-root-id.some-id-2", + ]); + }); +}); diff --git a/src/features/application-menu/main/menu-items/get-composite/get-composite-paths/get-composite-paths.ts b/src/features/application-menu/main/menu-items/get-composite/get-composite-paths/get-composite-paths.ts new file mode 100644 index 0000000000..f977cbcacd --- /dev/null +++ b/src/features/application-menu/main/menu-items/get-composite/get-composite-paths/get-composite-paths.ts @@ -0,0 +1,28 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { pipeline } from "@ogre-tools/fp"; +import { flatMap } from "lodash/fp"; +import type { Composite } from "../get-composite"; + +export const getCompositePaths = ( + composite: Composite, + previousPath: string[] = [], +): string[] => { + const currentPath = [...previousPath, composite.id]; + + const currentPathString = currentPath.join("."); + + return [ + currentPathString, + + ...pipeline( + composite.children, + + flatMap((childComposite) => + getCompositePaths(childComposite, currentPath), + ), + ), + ]; +}; diff --git a/src/features/application-menu/main/menu-items/get-composite/get-composite.test.ts b/src/features/application-menu/main/menu-items/get-composite/get-composite.test.ts new file mode 100644 index 0000000000..ac11602a60 --- /dev/null +++ b/src/features/application-menu/main/menu-items/get-composite/get-composite.test.ts @@ -0,0 +1,380 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { sortBy } from "lodash/fp"; +import getComposite from "./get-composite"; +import { getCompositePaths } from "./get-composite-paths/get-composite-paths"; + +describe("get-composite", () => { + it("given items and a specified root id, creates a composite", () => { + const someRootItem = { + someId: "some-root-id", + someParentId: undefined, + someProperty: "some-root-content", + }; + + const someItem = { + someId: "some-id", + someParentId: "some-root-id", + someProperty: "some-content", + }; + + const someNestedItem = { + someId: "some-nested-id", + someParentId: "some-id", + someProperty: "some-nested-content", + }; + + const items = [someRootItem, someItem, someNestedItem]; + + const composite = getComposite({ + source: items, + rootId: "some-root-id", + getId: (x) => x.someId, + getParentId: (x) => x.someParentId, + }); + + expect(composite).toEqual({ + id: "some-root-id", + value: someRootItem, + + children: [ + { + id: "some-id", + parentId: "some-root-id", + value: someItem, + + children: [ + { + id: "some-nested-id", + parentId: "some-id", + value: someNestedItem, + children: [], + }, + ], + }, + ], + }); + }); + + it("given items and an unspecified root id and single item without parent as root, creates a composite", () => { + const someRootItem = { + someId: "some-root-id", + someProperty: "some-root-content", + // Notice: no "someParentId" makes this the root. + someParentId: undefined, + }; + + const someItem = { + someId: "some-id", + someParentId: "some-root-id", + someProperty: "some-content", + }; + + const someNestedItem = { + someId: "some-nested-id", + someParentId: "some-id", + someProperty: "some-nested-content", + }; + + const items = [someRootItem, someItem, someNestedItem]; + + const composite = getComposite({ + source: items, + // Notice: no root id + // rootId: "some-root-id", + getId: (x) => x.someId, + getParentId: (x) => x.someParentId, + }); + + expect(composite).toEqual({ + id: "some-root-id", + value: someRootItem, + + children: [ + { + id: "some-id", + parentId: "some-root-id", + value: someItem, + + children: [ + { + id: "some-nested-id", + parentId: "some-id", + value: someNestedItem, + children: [], + }, + ], + }, + ], + }); + }); + + it("given items and an unspecified root id and multiple items without parent as root, throws", () => { + const someRootItem = { + someId: "some-root-id", + // Notice: no "someParentId" makes this a root. + someParentId: undefined, + }; + + const someOtherRootItem = { + someId: "some-other-root-id", + // Notice: no "someParentId" makes also this a root. + someParentId: undefined, + }; + + const items = [someRootItem, someOtherRootItem]; + + expect(() => { + getComposite({ + source: items, + // Notice: no root id + // rootId: "some-root-id", + getId: (x) => x.someId, + getParentId: (x) => x.someParentId, + }); + }).toThrow( + 'Tried to get a composite, but multiple roots where encountered: "some-root-id", "some-other-root-id"', + ); + }); + + it("given non-unique ids, throws", () => { + const someItem = { + someId: "some-id", + someParentId: "irrelevant", + }; + + const someOtherItem = { + someId: "some-id", + someParentId: "irrelevant", + }; + + const items = [someItem, someOtherItem]; + + expect(() => { + getComposite({ + source: items, + rootId: "irrelevant", + getId: (x) => x.someId, + getParentId: (x) => x.someParentId, + }); + }).toThrow( + 'Tried to get a composite but encountered non-unique ids: "some-id"', + ); + }); + + it("given missing parent ids, throws", () => { + const someItem = { + someId: "some-id", + someParentId: undefined, + }; + + const someItemWithMissingParentId = { + someId: "some-other-id", + someParentId: "some-missing-id", + }; + + const items = [someItem, someItemWithMissingParentId]; + + expect(() => { + getComposite({ + source: items, + rootId: "irrelevant", + getId: (x) => x.someId, + getParentId: (x) => x.someParentId, + }); + }).toThrow( + 'Tried to get a composite but encountered missing parent ids: "some-missing-id"', + ); + }); + + it("given undefined ids, throws", () => { + const root = { + someParentId: undefined, + someId: "some-root", + }; + + const someItem = { + someParentId: "some-root", + someId: undefined, + }; + + const someOtherItem = { + someParentId: "some-root", + someId: undefined, + }; + + const items = [root, someItem, someOtherItem]; + + expect(() => { + getComposite({ + source: items, + rootId: "some-root", + getId: (x) => x.someId as any, + getParentId: (x) => x.someParentId, + }); + }).toThrow("Tried to get a composite but encountered 2 undefined ids"); + }); + + it("given items with default properties for id and parentId, creates a composite", () => { + const someRootItem = { + id: "some-root-id", + }; + + const someItem = { + id: "some-id", + parentId: "some-root-id", + }; + + const someNestedItem = { + id: "some-nested-id", + parentId: "some-id", + }; + + const items = [someRootItem, someItem, someNestedItem]; + + const composite = getComposite({ + source: items, + // Notice: no need for functions + // getId: (x) => x.id, + // getParentId: (x) => x.parentId, + }); + + expect(composite).toEqual({ + id: "some-root-id", + value: someRootItem, + + children: [ + { + id: "some-id", + parentId: "some-root-id", + value: someItem, + + children: [ + { + id: "some-nested-id", + parentId: "some-id", + value: someNestedItem, + children: [], + }, + ], + }, + ], + }); + }); + + it("given explicitly ordered items, creates a composite with ordered children", () => { + const someRootItem = { + id: "some-root-id", + someOrderNumber: 1, + }; + + const someItem1 = { + id: "some-id-1", + parentId: "some-root-id", + someOrderNumber: 1, + }; + + const someItem2 = { + id: "some-id-2", + parentId: "some-root-id", + someOrderNumber: 2, + }; + + const someChildItem1 = { + id: "some-child-id-1", + parentId: "some-id-1", + someOrderNumber: 1, + }; + + const someChildItem2 = { + id: "some-child-id-2", + parentId: "some-id-1", + someOrderNumber: 2, + }; + + const items = [ + someRootItem, + // Note: not in order yet. + someItem2, + someItem1, + someChildItem2, + someChildItem1, + ]; + + const composite = getComposite({ + source: items, + // Note: this is the explicit function to order a composite's children. + getOrderedChildren: (things) => + sortBy((thing) => thing.someOrderNumber, things), + }); + + const orderedPaths = getCompositePaths(composite); + + expect(orderedPaths).toEqual([ + "some-root-id", + "some-root-id.some-id-1", + "some-root-id.some-id-1.some-child-id-1", + "some-root-id.some-id-1.some-child-id-2", + "some-root-id.some-id-2", + ]); + }); + + it("given implicitly ordered items, creates a composite with ordered children", () => { + const someRootItem = { + id: "some-root-id", + orderNumber: 1, + }; + + const someItem1 = { + id: "some-id-1", + parentId: "some-root-id", + orderNumber: 1, + }; + + const someItem2 = { + id: "some-id-2", + parentId: "some-root-id", + orderNumber: 2, + }; + + const someChildItem1 = { + id: "some-child-id-1", + parentId: "some-id-1", + orderNumber: 1, + }; + + const someChildItem2 = { + id: "some-child-id-2", + parentId: "some-id-1", + orderNumber: 2, + }; + + const items = [ + someRootItem, + // Note: not in order yet. + someItem2, + someItem1, + someChildItem2, + someChildItem1, + ]; + + const composite = getComposite({ + source: items, + // Note: without explicit getOrderedChildren for ordering, implicit default value of "orderNumber" will be used, if it exists. + // getOrderedChildren: things => sortBy(thing => thing.orderNumber, things), + }); + + const orderedPaths = getCompositePaths(composite); + + expect(orderedPaths).toEqual([ + "some-root-id", + "some-root-id.some-id-1", + "some-root-id.some-id-1.some-child-id-1", + "some-root-id.some-id-1.some-child-id-2", + "some-root-id.some-id-2", + ]); + }); +}); diff --git a/src/features/application-menu/main/menu-items/get-composite/get-composite.ts b/src/features/application-menu/main/menu-items/get-composite/get-composite.ts new file mode 100644 index 0000000000..1c188e7951 --- /dev/null +++ b/src/features/application-menu/main/menu-items/get-composite/get-composite.ts @@ -0,0 +1,120 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { pipeline } from "@ogre-tools/fp"; +import { + countBy, + filter, + toPairs, + nth, + map, + uniq, + without, + compact, + get, + sortBy, +} from "lodash/fp"; + +export interface Composite { + id: string; + parentId: string | undefined; + value: T; + children: Composite[]; +} + +export default ({ + source, + rootId, + getId = get("id"), + getParentId = get("parentId"), + getOrderedChildren = (things: T[]) => sortBy("orderNumber", things), +}: { + source: T[]; + rootId?: string; + getId?: (thing: T) => string; + getParentId?: (thing: T) => string | undefined; + getOrderedChildren?: (things: T[]) => T[]; +}) => { + const undefinedIds = pipeline( + source, + filter((x) => getId(x) === undefined), + ); + + if(undefinedIds.length) { + throw new Error( + `Tried to get a composite but encountered ${undefinedIds.length} undefined ids`, + ); + } + + const duplicateIds = pipeline( + source, + countBy(getId), + toPairs, + filter(([, count]) => count > 1), + map(nth(0)), + ); + + if (duplicateIds.length) { + throw new Error( + `Tried to get a composite but encountered non-unique ids: "${duplicateIds + .map((x) => String(x)) + .join('", "')}"`, + ); + } + + const allIds = pipeline(source, map(getId)); + + const allParentIds = pipeline(source, map(getParentId), uniq, compact); + + const unknownParentIds = without(allIds, allParentIds); + + if (unknownParentIds.length) { + throw new Error( + `Tried to get a composite but encountered missing parent ids: "${unknownParentIds + .map((x) => String(x)) + .join('", "')}"`, + ); + } + + const toComposite = (thing: T): Composite => { + const thingId = getId(thing); + + return { + id: thingId, + parentId: getParentId(thing), + value: thing, + + children: pipeline( + source, + + filter((childThing) => { + const parentId = getParentId(childThing); + + return parentId !== undefined && parentId === thingId; + }), + + getOrderedChildren, + + map(toComposite), + ), + }; + }; + + const isRootId = rootId + ? (thing: T) => getId(thing) === rootId + : (thing: T) => getParentId(thing) === undefined; + + const roots = source.filter(isRootId); + + if (roots.length > 1) { + throw new Error( + `Tried to get a composite, but multiple roots where encountered: "${roots + .map(getId) + .join('", "')}"`, + ); + } + + return toComposite(roots[0]); +}; diff --git a/src/features/application-menu/main/menu-items/get-composite/normalize-composite/normalize-composite.test.ts b/src/features/application-menu/main/menu-items/get-composite/normalize-composite/normalize-composite.test.ts new file mode 100644 index 0000000000..a3b137b262 --- /dev/null +++ b/src/features/application-menu/main/menu-items/get-composite/normalize-composite/normalize-composite.test.ts @@ -0,0 +1,44 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { normalizeComposite } from "./normalize-composite"; +import getComposite from "../get-composite"; + +describe("normalize-composite", () => { + it("given a composite, flattens it to path and composite", () => { + const someRootItem = { + id: "some-root-id", + parentId: undefined, + }; + + const someItem = { + id: "some-id", + parentId: "some-root-id", + }; + + const someNestedItem = { + id: "some-child-id", + parentId: "some-id", + }; + + const items = [someRootItem, someItem, someNestedItem]; + + const composite = getComposite({ + source: items, + }); + + const actual = normalizeComposite(composite); + + expect(actual).toEqual([ + ["some-root-id", expect.objectContaining({ value: someRootItem })], + + ["some-root-id.some-id", expect.objectContaining({ value: someItem })], + + [ + "some-root-id.some-id.some-child-id", + expect.objectContaining({ value: someNestedItem }), + ], + ]); + }); +}); diff --git a/src/features/application-menu/main/menu-items/get-composite/normalize-composite/normalize-composite.ts b/src/features/application-menu/main/menu-items/get-composite/normalize-composite/normalize-composite.ts new file mode 100644 index 0000000000..f785a18a19 --- /dev/null +++ b/src/features/application-menu/main/menu-items/get-composite/normalize-composite/normalize-composite.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { Composite } from "../get-composite"; + +export const normalizeComposite = ( + composite: Composite, + previousPath: string[] = [], +): (readonly [path: string, composite: Composite])[] => { + const currentPath = [...previousPath, composite.id]; + + const pathAndCompositeTuple = [currentPath.join("."), composite] as const; + + return [ + pathAndCompositeTuple, + + ...composite.children.flatMap((x) => normalizeComposite(x, currentPath)), + ]; +};