diff --git a/src/behaviours/cluster/namespaces/__snapshots__/edit-namespace-from-new-tab.test.tsx.snap b/src/behaviours/cluster/namespaces/__snapshots__/edit-namespace-from-new-tab.test.tsx.snap
new file mode 100644
index 0000000000..3ae620ccf6
--- /dev/null
+++ b/src/behaviours/cluster/namespaces/__snapshots__/edit-namespace-from-new-tab.test.tsx.snap
@@ -0,0 +1,11084 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`cluster/namespaces - edit namespace from new tab when navigating to namespaces when namespaces resolve when clicking the context menu for a namespace when clicking to edit namespace renders 1`] = `
+
+
+
+`;
+
+exports[`cluster/namespaces - edit namespace from new tab when navigating to namespaces when namespaces resolve when clicking the context menu for a namespace when clicking to edit namespace when call for namespace resolves with failure renders 1`] = `
+
+
+
+`;
+
+exports[`cluster/namespaces - edit namespace from new tab when navigating to namespaces when namespaces resolve when clicking the context menu for a namespace when clicking to edit namespace when call for namespace resolves with namespace given change in configuration renders 1`] = `
+
+
+
+`;
+
+exports[`cluster/namespaces - edit namespace from new tab when navigating to namespaces when namespaces resolve when clicking the context menu for a namespace when clicking to edit namespace when call for namespace resolves with namespace given clicking the context menu for second namespace, when clicking to edit namespace renders 1`] = `
+
+
+
+`;
+
+exports[`cluster/namespaces - edit namespace from new tab when navigating to namespaces when namespaces resolve when clicking the context menu for a namespace when clicking to edit namespace when call for namespace resolves with namespace given clicking the context menu for second namespace, when clicking to edit namespace when second namespace resolves renders 1`] = `
+
+
+
+`;
+
+exports[`cluster/namespaces - edit namespace from new tab when navigating to namespaces when namespaces resolve when clicking the context menu for a namespace when clicking to edit namespace when call for namespace resolves with namespace given clicking the context menu for second namespace, when clicking to edit namespace when second namespace resolves when clicking dock tab for the first namespace renders 1`] = `
+
+
+
+`;
+
+exports[`cluster/namespaces - edit namespace from new tab when navigating to namespaces when namespaces resolve when clicking the context menu for a namespace when clicking to edit namespace when call for namespace resolves with namespace given invalid change in configuration renders 1`] = `
+
+
+
+`;
+
+exports[`cluster/namespaces - edit namespace from new tab when navigating to namespaces when namespaces resolve when clicking the context menu for a namespace when clicking to edit namespace when call for namespace resolves with namespace given no changes in the configuration, when selecting to save renders 1`] = `
+
+
+
+`;
+
+exports[`cluster/namespaces - edit namespace from new tab when navigating to namespaces when namespaces resolve when clicking the context menu for a namespace when clicking to edit namespace when call for namespace resolves with namespace given no changes in the configuration, when selecting to save when saving resolves with failure renders 1`] = `
+
+
+
+`;
+
+exports[`cluster/namespaces - edit namespace from new tab when navigating to namespaces when namespaces resolve when clicking the context menu for a namespace when clicking to edit namespace when call for namespace resolves with namespace given no changes in the configuration, when selecting to save when saving resolves with success renders 1`] = `
+
+
+
+`;
+
+exports[`cluster/namespaces - edit namespace from new tab when navigating to namespaces when namespaces resolve when clicking the context menu for a namespace when clicking to edit namespace when call for namespace resolves with namespace renders 1`] = `
+
+
+
+`;
+
+exports[`cluster/namespaces - edit namespace from new tab when navigating to namespaces when namespaces resolve when clicking the context menu for a namespace when clicking to edit namespace when call for namespace resolves with namespace when selecting to cancel renders 1`] = `
+
+
+
+`;
+
+exports[`cluster/namespaces - edit namespace from new tab when navigating to namespaces when namespaces resolve when clicking the context menu for a namespace when clicking to edit namespace when call for namespace resolves with namespace when selecting to save and close renders 1`] = `
+
+
+
+`;
+
+exports[`cluster/namespaces - edit namespace from new tab when navigating to namespaces when namespaces resolve when clicking the context menu for a namespace when clicking to edit namespace when call for namespace resolves with namespace when selecting to save and close when saving resolves with failure renders 1`] = `
+
+
+
+`;
+
+exports[`cluster/namespaces - edit namespace from new tab when navigating to namespaces when namespaces resolve when clicking the context menu for a namespace when clicking to edit namespace when call for namespace resolves with namespace when selecting to save and close when saving resolves with success renders 1`] = `
+
+
+
+`;
+
+exports[`cluster/namespaces - edit namespace from new tab when navigating to namespaces when namespaces resolve when clicking the context menu for a namespace when clicking to edit namespace when call for namespace resolves without namespace renders 1`] = `
+
+
+
+`;
diff --git a/src/behaviours/cluster/namespaces/__snapshots__/edit-namespace-from-previously-opened-tab.test.tsx.snap b/src/behaviours/cluster/namespaces/__snapshots__/edit-namespace-from-previously-opened-tab.test.tsx.snap
new file mode 100644
index 0000000000..a828a810de
--- /dev/null
+++ b/src/behaviours/cluster/namespaces/__snapshots__/edit-namespace-from-previously-opened-tab.test.tsx.snap
@@ -0,0 +1,1123 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`cluster/namespaces - edit namespaces from previously opened tab given tab was previously opened, when application is started renders 1`] = `
+
+
+
+`;
+
+exports[`cluster/namespaces - edit namespaces from previously opened tab given tab was previously opened, when application is started when call for namespace resolves with namespace renders 1`] = `
+
+
+
+`;
diff --git a/src/behaviours/cluster/namespaces/edit-namespace-from-new-tab.test.tsx b/src/behaviours/cluster/namespaces/edit-namespace-from-new-tab.test.tsx
new file mode 100644
index 0000000000..ec8a07bc21
--- /dev/null
+++ b/src/behaviours/cluster/namespaces/edit-namespace-from-new-tab.test.tsx
@@ -0,0 +1,992 @@
+/**
+ * Copyright (c) OpenLens Authors. All rights reserved.
+ * Licensed under MIT License. See LICENSE in root directory for more information.
+ */
+import type { DiContainer } from "@ogre-tools/injectable";
+import type { RenderResult } from "@testing-library/react";
+import { fireEvent } from "@testing-library/react";
+import type { ApplicationBuilder } from "../../../renderer/components/test-utils/get-application-builder";
+import { getApplicationBuilder } from "../../../renderer/components/test-utils/get-application-builder";
+import navigateToNamespacesInjectable from "../../../common/front-end-routing/routes/cluster/namespaces/navigate-to-namespaces.injectable";
+import React from "react";
+import createEditResourceTabInjectable from "../../../renderer/components/dock/edit-resource/edit-resource-tab.injectable";
+import { KubeObject } from "../../../common/k8s-api/kube-object";
+import getRandomIdForEditResourceTabInjectable from "../../../renderer/components/dock/edit-resource/get-random-id-for-edit-resource-tab.injectable";
+import type { AsyncFnMock } from "@async-fn/jest";
+import asyncFn from "@async-fn/jest";
+import type { CallForResource } from "../../../renderer/components/dock/edit-resource/edit-resource-model/call-for-resource/call-for-resource.injectable";
+import callForResourceInjectable from "../../../renderer/components/dock/edit-resource/edit-resource-model/call-for-resource/call-for-resource.injectable";
+import type { CallForPatchResource } from "../../../renderer/components/dock/edit-resource/edit-resource-model/call-for-patch-resource/call-for-patch-resource.injectable";
+import callForPatchResourceInjectable from "../../../renderer/components/dock/edit-resource/edit-resource-model/call-for-patch-resource/call-for-patch-resource.injectable";
+import dockStoreInjectable from "../../../renderer/components/dock/dock/store.injectable";
+import { Namespace } from "../../../common/k8s-api/endpoints";
+import showSuccessNotificationInjectable from "../../../renderer/components/notifications/show-success-notification.injectable";
+import showErrorNotificationInjectable from "../../../renderer/components/notifications/show-error-notification.injectable";
+import readJsonFileInjectable from "../../../common/fs/read-json-file.injectable";
+import directoryForLensLocalStorageInjectable from "../../../common/directory-for-lens-local-storage/directory-for-lens-local-storage.injectable";
+import hostedClusterIdInjectable from "../../../renderer/cluster-frame-context/hosted-cluster-id.injectable";
+import { controlWhenStoragesAreReady } from "../../../renderer/utils/create-storage/storages-are-ready";
+
+jest.mock("../../../renderer/components/tooltip/withTooltip", () => ({
+ withTooltip:
+ (Target: any) =>
+ ({ tooltip, ...props }: any) => {
+ if (tooltip) {
+ const testId = props["data-testid"];
+
+ return (
+ <>
+
+
+ {tooltip.children || tooltip}
+
+ >
+ );
+ }
+
+ return ;
+ },
+}));
+
+describe("cluster/namespaces - edit namespace from new tab", () => {
+ let builder: ApplicationBuilder;
+ let callForNamespaceMock: AsyncFnMock;
+ let callForPatchNamespaceMock: AsyncFnMock;
+ let showSuccessNotificationMock: jest.Mock;
+ let showErrorNotificationMock: jest.Mock;
+ let storagesAreReady: () => Promise;
+
+ beforeEach(() => {
+ builder = getApplicationBuilder();
+
+ builder.setEnvironmentToClusterFrame();
+
+ callForNamespaceMock = asyncFn();
+ callForPatchNamespaceMock = asyncFn();
+
+ showSuccessNotificationMock = jest.fn();
+ showErrorNotificationMock = jest.fn();
+
+ builder.beforeApplicationStart(({ rendererDi }) => {
+ rendererDi.override(
+ directoryForLensLocalStorageInjectable,
+ () => "/some-directory-for-lens-local-storage",
+ );
+
+ rendererDi.override(hostedClusterIdInjectable, () => "some-cluster-id");
+
+ storagesAreReady = controlWhenStoragesAreReady(rendererDi);
+
+ rendererDi.override(
+ showSuccessNotificationInjectable,
+ () => showSuccessNotificationMock,
+ );
+
+ rendererDi.override(
+ showErrorNotificationInjectable,
+ () => showErrorNotificationMock,
+ );
+
+ rendererDi.override(getRandomIdForEditResourceTabInjectable, () =>
+ jest
+ .fn(() => "some-irrelevant-random-id")
+ .mockReturnValueOnce("some-first-tab-id")
+ .mockReturnValueOnce("some-second-tab-id"),
+ );
+
+ rendererDi.override(callForResourceInjectable, () => async (selfLink: string) => {
+ if (
+ [
+ "/apis/some-api-version/namespaces/some-uid",
+ "/apis/some-api-version/namespaces/some-other-uid",
+ ].includes(selfLink)
+ ) {
+ return await callForNamespaceMock(selfLink);
+ }
+
+ return undefined;
+ });
+
+ rendererDi.override(callForPatchResourceInjectable, () => async (namespace, ...args) => {
+ if (
+ [
+ "/apis/some-api-version/namespaces/some-uid",
+ "/apis/some-api-version/namespaces/some-other-uid",
+ ].includes(namespace.selfLink)
+ ) {
+ return await callForPatchNamespaceMock(namespace, ...args);
+ }
+
+ return undefined;
+ });
+ });
+
+ builder.allowKubeResource("namespaces");
+ });
+
+ describe("when navigating to namespaces", () => {
+ let rendered: RenderResult;
+ let rendererDi: DiContainer;
+
+ beforeEach(async () => {
+ rendered = await builder.render();
+
+ await storagesAreReady();
+
+ rendererDi = builder.dis.rendererDi;
+
+ const navigateToNamespaces = rendererDi.inject(
+ navigateToNamespacesInjectable,
+ );
+
+ navigateToNamespaces();
+
+ const dockStore = rendererDi.inject(dockStoreInjectable);
+
+ // TODO: Make TerminalWindow unit testable to allow realistic behaviour
+ dockStore.closeTab("terminal");
+ });
+
+ // TODO: Implement skipped tests when loading of resources can be tested
+ xit("renders", () => {
+ expect(rendered.baseElement).toMatchSnapshot();
+ });
+
+ xit("calls for namespaces", () => {
+
+ });
+
+ xit("shows spinner", () => {
+
+ });
+
+ describe("when namespaces resolve", () => {
+ beforeEach(() => {
+
+ });
+
+ xit("renders", () => {
+ expect(rendered.baseElement).toMatchSnapshot();
+ });
+
+ xit("does not show spinner anymore", () => {
+
+ });
+
+ describe("when clicking the context menu for a namespace", () => {
+ beforeEach(() => {
+
+ });
+
+ xit("renders", () => {
+ expect(rendered.baseElement).toMatchSnapshot();
+ });
+
+ xit("does not show edit resource tab yet", () => {
+
+ });
+
+ describe("when clicking to edit namespace", () => {
+ beforeEach(() => {
+ // TODO: Make implementation match the description (tests above)
+ const namespaceStub = KubeObject.create(someNamespaceDataStub);
+
+ const createEditResourceTab = rendererDi.inject(createEditResourceTabInjectable);
+
+ createEditResourceTab(namespaceStub);
+ });
+
+ it("renders", () => {
+ expect(rendered.baseElement).toMatchSnapshot();
+ });
+
+ it("shows dock tab for editing namespace", () => {
+ expect(
+ rendered.getByTestId("dock-tab-for-some-first-tab-id"),
+ ).toBeInTheDocument();
+ });
+
+ it("shows spinner in the dock tab", () => {
+ expect(
+ rendered.getByTestId("edit-resource-tab-spinner"),
+ ).toBeInTheDocument();
+ });
+
+ it("calls for namespace", () => {
+ expect(callForNamespaceMock).toHaveBeenCalledWith(
+ "/apis/some-api-version/namespaces/some-uid",
+ );
+ });
+
+ describe("when call for namespace resolves with namespace", () => {
+ let someNamespace: Namespace;
+
+ beforeEach(async () => {
+ someNamespace = new Namespace({
+ apiVersion: "some-api-version",
+ kind: "Namespace",
+
+ metadata: {
+ uid: "some-uid",
+ name: "some-name",
+ resourceVersion: "some-resource-version",
+ selfLink: "/apis/some-api-version/namespaces/some-uid",
+ somePropertyToBeRemoved: "some-value",
+ somePropertyToBeChanged: "some-old-value",
+ },
+ });
+
+ await callForNamespaceMock.resolve({
+ callWasSuccessful: true,
+ response: someNamespace,
+ });
+ });
+
+ it("renders", () => {
+ expect(rendered.baseElement).toMatchSnapshot();
+ });
+
+ it("does not show spinner anymore", () => {
+ expect(
+ rendered.queryByTestId("edit-resource-tab-spinner"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("has the configuration in editor", () => {
+ const input = rendered.getByTestId(
+ "monaco-editor-for-some-first-tab-id",
+ ) as HTMLTextAreaElement;
+
+ expect(input.value).toBe(`apiVersion: some-api-version
+kind: Namespace
+metadata:
+ uid: some-uid
+ name: some-name
+ resourceVersion: some-resource-version
+ selfLink: /apis/some-api-version/namespaces/some-uid
+ somePropertyToBeRemoved: some-value
+ somePropertyToBeChanged: some-old-value
+`);
+ });
+
+ describe("given no changes in the configuration, when selecting to save", () => {
+ beforeEach(() => {
+ const saveButton = rendered.getByTestId(
+ "save-edit-resource-from-tab-for-some-first-tab-id",
+ );
+
+ fireEvent.click(saveButton);
+ });
+
+ it("renders", () => {
+ expect(rendered.baseElement).toMatchSnapshot();
+ });
+
+ it("calls for save with empty values", () => {
+ expect(callForPatchNamespaceMock).toHaveBeenCalledWith(
+ someNamespace,
+ [],
+ );
+ });
+
+ it("shows spinner", () => {
+ expect(
+ rendered.getByTestId("saving-edit-resource-from-tab-for-some-first-tab-id"),
+ ).toBeInTheDocument();
+ });
+
+ it("save button is disabled", () => {
+ const saveButton = rendered.getByTestId(
+ "save-edit-resource-from-tab-for-some-first-tab-id",
+ );
+
+ expect(saveButton).toHaveAttribute("disabled");
+ });
+
+ it("save and close button is disabled", () => {
+ const saveButton = rendered.getByTestId(
+ "save-and-close-edit-resource-from-tab-for-some-first-tab-id",
+ );
+
+ expect(saveButton).toHaveAttribute("disabled");
+ });
+
+ describe("when saving resolves with success", () => {
+ beforeEach(async () => {
+ await callForPatchNamespaceMock.resolve({
+ callWasSuccessful: true,
+ response: { name: "some-name", kind: "Namespace" },
+ });
+ });
+
+ it("renders", () => {
+ expect(rendered.baseElement).toMatchSnapshot();
+ });
+
+ it("does not show spinner anymore", () => {
+ expect(
+ rendered.queryByTestId("saving-edit-resource-from-tab-for-some-first-tab-id"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("save button is enabled", () => {
+ const saveButton = rendered.getByTestId(
+ "save-edit-resource-from-tab-for-some-first-tab-id",
+ );
+
+ expect(saveButton).not.toHaveAttribute("disabled");
+ });
+
+ it("save and close button is enabled", () => {
+ const saveButton = rendered.getByTestId(
+ "save-and-close-edit-resource-from-tab-for-some-first-tab-id",
+ );
+
+ expect(saveButton).not.toHaveAttribute("disabled");
+ });
+
+ it("shows success notification", () => {
+ expect(showSuccessNotificationMock).toHaveBeenCalled();
+ });
+
+ it("does not show error notification", () => {
+ expect(showErrorNotificationMock).not.toHaveBeenCalled();
+ });
+
+ it("does not close the dock tab", () => {
+ expect(
+ rendered.getByTestId("dock-tab-for-some-first-tab-id"),
+ ).toBeInTheDocument();
+ });
+ });
+
+ describe("when saving resolves with failure", () => {
+ beforeEach(async () => {
+ await callForPatchNamespaceMock.resolve({
+ callWasSuccessful: false,
+ error: "some-error",
+ });
+ });
+
+ it("renders", () => {
+ expect(rendered.baseElement).toMatchSnapshot();
+ });
+
+ it("does not show spinner anymore", () => {
+ expect(
+ rendered.queryByTestId("edit-resource-tab-spinner"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("save button is enabled", () => {
+ const saveButton = rendered.getByTestId(
+ "save-edit-resource-from-tab-for-some-first-tab-id",
+ );
+
+ expect(saveButton).not.toHaveAttribute("disabled");
+ });
+
+ it("save and close button is enabled", () => {
+ const saveButton = rendered.getByTestId(
+ "save-and-close-edit-resource-from-tab-for-some-first-tab-id",
+ );
+
+ expect(saveButton).not.toHaveAttribute("disabled");
+ });
+
+ it("does not show success notification", () => {
+ expect(showSuccessNotificationMock).not.toHaveBeenCalled();
+ });
+
+ it("shows error notification", () => {
+ expect(showErrorNotificationMock).toHaveBeenCalled();
+ });
+
+ it("does not close the dock tab", () => {
+ expect(
+ rendered.getByTestId("dock-tab-for-some-first-tab-id"),
+ ).toBeInTheDocument();
+ });
+ });
+ });
+
+ describe("when selecting to save and close", () => {
+ beforeEach(() => {
+ const saveButton = rendered.getByTestId(
+ "save-and-close-edit-resource-from-tab-for-some-first-tab-id",
+ );
+
+ fireEvent.click(saveButton);
+ });
+
+ it("renders", () => {
+ expect(rendered.baseElement).toMatchSnapshot();
+ });
+
+ it("does not close the tab yet", () => {
+ expect(
+ rendered.getByTestId("dock-tab-for-some-first-tab-id"),
+ ).toBeInTheDocument();
+ });
+
+ describe("when saving resolves with success", () => {
+ beforeEach(async () => {
+ await callForPatchNamespaceMock.resolve({
+ callWasSuccessful: true,
+ response: { name: "some-name", kind: "Namespace" },
+ });
+ });
+
+ it("renders", () => {
+ expect(rendered.baseElement).toMatchSnapshot();
+ });
+
+ it("closes the dock tab", () => {
+ expect(
+ rendered.queryByTestId("dock-tab-for-some-first-tab-id"),
+ ).not.toBeInTheDocument();
+ });
+ });
+
+ describe("when saving resolves with failure", () => {
+ beforeEach(async () => {
+ await callForPatchNamespaceMock.resolve({
+ callWasSuccessful: false,
+ error: "Some error",
+ });
+ });
+
+ it("renders", () => {
+ expect(rendered.baseElement).toMatchSnapshot();
+ });
+
+ // TODO: Not doable at the moment because info panel controls closing of the tab
+ xit("does not close the dock tab", () => {
+ expect(
+ rendered.getByTestId("dock-tab-for-some-first-tab-id"),
+ ).toBeInTheDocument();
+ });
+ });
+ });
+
+ describe("when selecting to cancel", () => {
+ beforeEach(() => {
+ const cancelButton = rendered.getByTestId(
+ "cancel-edit-resource-from-tab-for-some-first-tab-id",
+ );
+
+ fireEvent.click(cancelButton);
+ });
+
+ it("renders", () => {
+ expect(rendered.baseElement).toMatchSnapshot();
+ });
+
+ it("does not have dock tab anymore", () => {
+ expect(
+ rendered.queryByTestId("dock-tab-for-some-first-tab-id"),
+ ).not.toBeInTheDocument();
+ });
+ });
+
+ describe("given change in configuration", () => {
+ beforeEach(() => {
+ const input = rendered.getByTestId(
+ "monaco-editor-for-some-first-tab-id",
+ ) as HTMLInputElement;
+
+ fireEvent.change(input, {
+ target: {
+ value: `apiVersion: some-api-version
+kind: Namespace
+metadata:
+ uid: some-uid
+ name: some-name
+ resourceVersion: some-resource-version
+ selfLink: /apis/some-api-version/namespaces/some-uid
+ somePropertyToBeChanged: some-changed-value
+ someAddedProperty: some-new-value
+`,
+ },
+ });
+ });
+
+ it("renders", () => {
+ expect(rendered.baseElement).toMatchSnapshot();
+ });
+
+ it("has the changed configuration in editor", () => {
+ const input = rendered.getByTestId(
+ "monaco-editor-for-some-first-tab-id",
+ ) as HTMLTextAreaElement;
+
+ expect(input.value).toBe(`apiVersion: some-api-version
+kind: Namespace
+metadata:
+ uid: some-uid
+ name: some-name
+ resourceVersion: some-resource-version
+ selfLink: /apis/some-api-version/namespaces/some-uid
+ somePropertyToBeChanged: some-changed-value
+ someAddedProperty: some-new-value
+`);
+ });
+
+ it("stores the changed configuration", async () => {
+ const readJsonFile = rendererDi.inject(
+ readJsonFileInjectable,
+ );
+
+ const actual = (await readJsonFile(
+ "/some-directory-for-lens-local-storage/some-cluster-id.json",
+ )) as any;
+
+ expect(
+ actual.edit_resource_store["some-first-tab-id"],
+ ).toEqual({
+ resource: "/apis/some-api-version/namespaces/some-uid",
+ firstDraft: `apiVersion: some-api-version
+kind: Namespace
+metadata:
+ uid: some-uid
+ name: some-name
+ resourceVersion: some-resource-version
+ selfLink: /apis/some-api-version/namespaces/some-uid
+ somePropertyToBeRemoved: some-value
+ somePropertyToBeChanged: some-old-value
+`,
+ draft: `apiVersion: some-api-version
+kind: Namespace
+metadata:
+ uid: some-uid
+ name: some-name
+ resourceVersion: some-resource-version
+ selfLink: /apis/some-api-version/namespaces/some-uid
+ somePropertyToBeChanged: some-changed-value
+ someAddedProperty: some-new-value
+`,
+ });
+ });
+
+ describe("when selecting to save", () => {
+ beforeEach(() => {
+ const saveButton = rendered.getByTestId(
+ "save-edit-resource-from-tab-for-some-first-tab-id",
+ );
+
+ fireEvent.click(saveButton);
+ });
+
+ it("calls for save with changed configuration", () => {
+ expect(callForPatchNamespaceMock).toHaveBeenCalledWith(
+ someNamespace,
+ [
+ {
+ op: "remove",
+ path: "/metadata/somePropertyToBeRemoved",
+ },
+ {
+ op: "add",
+ path: "/metadata/someAddedProperty",
+ value: "some-new-value",
+ },
+ {
+ op: "replace",
+ path: "/metadata/somePropertyToBeChanged",
+ value: "some-changed-value",
+ },
+ ],
+ );
+ });
+
+ it("given save resolves and another change in configuration, when saving, calls for save with changed configuration", async () => {
+ await callForPatchNamespaceMock.resolve({
+ callWasSuccessful: true,
+
+ response: {
+ name: "some-name",
+ kind: "Namespace",
+ },
+ });
+
+ const input = rendered.getByTestId(
+ "monaco-editor-for-some-first-tab-id",
+ ) as HTMLInputElement;
+
+ fireEvent.change(input, {
+ target: {
+ value: `apiVersion: some-api-version
+kind: Namespace
+metadata:
+ uid: some-uid
+ name: some-name
+ resourceVersion: some-resource-version
+ selfLink: /apis/some-api-version/namespaces/some-uid
+ somePropertyToBeChanged: some-changed-value
+ someAddedProperty: some-new-value
+ someOtherAddedProperty: some-other-new-value
+`,
+ },
+ });
+
+
+ callForPatchNamespaceMock.mockClear();
+
+ const saveButton = rendered.getByTestId(
+ "save-edit-resource-from-tab-for-some-first-tab-id",
+ );
+
+ fireEvent.click(saveButton);
+
+ expect(callForPatchNamespaceMock).toHaveBeenCalledWith(
+ someNamespace,
+ [
+ {
+ op: "add",
+ path: "/metadata/someOtherAddedProperty",
+ value: "some-other-new-value",
+ },
+ ],
+ );
+ });
+ });
+ });
+
+ describe("given invalid change in configuration", () => {
+ beforeEach(() => {
+ const input = rendered.getByTestId(
+ "monaco-editor-for-some-first-tab-id",
+ ) as HTMLInputElement;
+
+ fireEvent.change(input, {
+ target: {
+ value: "@some-invalid-configuration@",
+ },
+ });
+ });
+
+ it("renders", () => {
+ expect(rendered.baseElement).toMatchSnapshot();
+ });
+
+ it("has the changed configuration in editor", () => {
+ const input = rendered.getByTestId(
+ "monaco-editor-for-some-first-tab-id",
+ ) as HTMLTextAreaElement;
+
+ expect(input.value).toBe(`@some-invalid-configuration@`);
+ });
+
+ it("save button is disabled", () => {
+ const saveButton = rendered.getByTestId(
+ "save-edit-resource-from-tab-for-some-first-tab-id",
+ );
+
+ expect(saveButton).toHaveAttribute("disabled");
+ });
+
+ it("save and close button is disabled", () => {
+ const saveButton = rendered.getByTestId(
+ "save-and-close-edit-resource-from-tab-for-some-first-tab-id",
+ );
+
+ expect(saveButton).toHaveAttribute("disabled");
+ });
+
+ describe("when valid change in configuration", () => {
+
+ beforeEach(() => {
+ const input = rendered.getByTestId(
+ "monaco-editor-for-some-first-tab-id",
+ ) as HTMLInputElement;
+
+
+ fireEvent.change(input, {
+ target: {
+ value: `apiVersion: some-api-version
+kind: Namespace
+metadata:
+ uid: some-uid
+ name: some-name
+ resourceVersion: some-resource-version
+ selfLink: /apis/some-api-version/namespaces/some-uid
+`,
+ },
+ });
+
+ });
+
+ it("save button is enabled", () => {
+ const saveButton = rendered.getByTestId(
+ "save-edit-resource-from-tab-for-some-first-tab-id",
+ );
+
+ expect(saveButton).not.toHaveAttribute("disabled");
+ });
+
+ it("save and close button is enabled", () => {
+ const saveButton = rendered.getByTestId(
+ "save-and-close-edit-resource-from-tab-for-some-first-tab-id",
+ );
+
+ expect(saveButton).not.toHaveAttribute("disabled");
+ });
+ });
+
+ });
+
+ describe("given clicking the context menu for second namespace, when clicking to edit namespace", () => {
+ beforeEach(() => {
+ callForNamespaceMock.mockClear();
+
+ // TODO: Make implementation match the description
+ const namespaceStub = KubeObject.create(someOtherNamespaceDataStub);
+
+ const createEditResourceTab = rendererDi.inject(createEditResourceTabInjectable);
+
+ createEditResourceTab(namespaceStub);
+ });
+
+ it("renders", () => {
+ expect(rendered.baseElement).toMatchSnapshot();
+ });
+
+ it("shows dock tab for editing second namespace", () => {
+ expect(
+ rendered.getByTestId("dock-tab-content-for-some-second-tab-id"),
+ ).toBeInTheDocument();
+ });
+
+ it("still has dock tab for first namespace", () => {
+ expect(
+ rendered.getByTestId("dock-tab-for-some-first-tab-id"),
+ ).toBeInTheDocument();
+ });
+
+ it("shows spinner in the dock tab", () => {
+ expect(
+ rendered.getByTestId("edit-resource-tab-spinner"),
+ ).toBeInTheDocument();
+ });
+
+ it("calls for second namespace", () => {
+ expect(callForNamespaceMock).toHaveBeenCalledWith(
+ "/apis/some-api-version/namespaces/some-other-uid",
+ );
+ });
+
+ describe("when second namespace resolves", () => {
+ let someOtherNamespace: Namespace;
+
+ beforeEach(async () => {
+ someOtherNamespace = new Namespace({
+ apiVersion: "some-api-version",
+ kind: "Namespace",
+
+ metadata: {
+ uid: "some-other-uid",
+ name: "some-other-name",
+ resourceVersion: "some-resource-version",
+ selfLink:
+ "/apis/some-api-version/namespaces/some-other-uid",
+ },
+ });
+
+ await callForNamespaceMock.resolve({
+ callWasSuccessful: true,
+ response: someOtherNamespace,
+ });
+ });
+
+ it("renders", () => {
+ expect(rendered.baseElement).toMatchSnapshot();
+ });
+
+ it("has the configuration in editor", () => {
+ const input = rendered.getByTestId(
+ "monaco-editor-for-some-second-tab-id",
+ ) as HTMLTextAreaElement;
+
+ expect(input.value).toBe(`apiVersion: some-api-version
+kind: Namespace
+metadata:
+ uid: some-other-uid
+ name: some-other-name
+ resourceVersion: some-resource-version
+ selfLink: /apis/some-api-version/namespaces/some-other-uid
+`);
+ });
+
+ it("when selecting to save, calls for save of second namespace", () => {
+ callForPatchNamespaceMock.mockClear();
+
+ const saveButton = rendered.getByTestId(
+ "save-edit-resource-from-tab-for-some-second-tab-id",
+ );
+
+ fireEvent.click(saveButton);
+
+ expect(callForPatchNamespaceMock).toHaveBeenCalledWith(
+ someOtherNamespace,
+ [],
+ );
+ });
+
+ describe("when clicking dock tab for the first namespace", () => {
+ beforeEach(() => {
+ callForNamespaceMock.mockClear();
+
+ const tab = rendered.getByTestId("dock-tab-for-some-first-tab-id");
+
+ fireEvent.click(tab);
+ });
+
+ it("renders", () => {
+ expect(rendered.baseElement).toMatchSnapshot();
+ });
+
+ it("shows dock tab for editing first namespace", () => {
+ expect(
+ rendered.getByTestId("dock-tab-content-for-some-first-tab-id"),
+ ).toBeInTheDocument();
+ });
+
+ it("still has dock tab for second namespace", () => {
+ expect(
+ rendered.getByTestId("dock-tab-for-some-second-tab-id"),
+ ).toBeInTheDocument();
+ });
+
+ it("does not show spinner in the dock tab", () => {
+ expect(
+ rendered.queryByTestId("edit-resource-tab-spinner"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("does not call for namespace", () => {
+ expect(callForNamespaceMock).not.toHaveBeenCalled();
+ });
+
+ it("has configuration in the editor", () => {
+ const input = rendered.getByTestId(
+ "monaco-editor-for-some-first-tab-id",
+ ) as HTMLTextAreaElement;
+
+ expect(input.value).toBe(`apiVersion: some-api-version
+kind: Namespace
+metadata:
+ uid: some-uid
+ name: some-name
+ resourceVersion: some-resource-version
+ selfLink: /apis/some-api-version/namespaces/some-uid
+ somePropertyToBeRemoved: some-value
+ somePropertyToBeChanged: some-old-value
+`);
+ });
+
+ it("when selecting to save, calls for save of first namespace", () => {
+ callForPatchNamespaceMock.mockClear();
+
+ const saveButton = rendered.getByTestId(
+ "save-edit-resource-from-tab-for-some-first-tab-id",
+ );
+
+ fireEvent.click(saveButton);
+
+ expect(callForPatchNamespaceMock).toHaveBeenCalledWith(
+ someNamespace,
+ [],
+ );
+ });
+ });
+ });
+ });
+ });
+
+ describe("when call for namespace resolves without namespace", () => {
+ beforeEach(async () => {
+ await callForNamespaceMock.resolve({
+ callWasSuccessful: true,
+ response: undefined,
+ });
+ });
+
+ it("renders", () => {
+ expect(rendered.baseElement).toMatchSnapshot();
+ });
+
+ it("still shows the dock tab for editing namespace", () => {
+ expect(
+ rendered.getByTestId("dock-tab-for-some-first-tab-id"),
+ ).toBeInTheDocument();
+ });
+
+ it("shows error message", () => {
+ expect(
+ rendered.getByTestId("dock-tab-content-for-some-first-tab-id"),
+ ).toHaveTextContent("Resource not found");
+ });
+
+ it("does not show error notification", () => {
+ expect(showErrorNotificationMock).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("when call for namespace resolves with failure", () => {
+ beforeEach(async () => {
+ await callForNamespaceMock.resolve({
+ callWasSuccessful: false,
+ error: "some-error",
+ });
+ });
+
+ it("renders", () => {
+ expect(rendered.baseElement).toMatchSnapshot();
+ });
+
+ it("still shows the dock tab for editing namespace", () => {
+ expect(
+ rendered.getByTestId("dock-tab-for-some-first-tab-id"),
+ ).toBeInTheDocument();
+ });
+
+ it("shows error message", () => {
+ expect(
+ rendered.getByTestId("dock-tab-content-for-some-first-tab-id"),
+ ).toHaveTextContent("Resource not found");
+ });
+
+ it("shows error notification", () => {
+ expect(showErrorNotificationMock).toHaveBeenCalled();
+ });
+ });
+ });
+ });
+ });
+ });
+});
+
+const someNamespaceDataStub = {
+ apiVersion: "some-api-version",
+ kind: "Namespace",
+ metadata: {
+ uid: "some-uid",
+ name: "some-name",
+ resourceVersion: "some-resource-version",
+ selfLink: "/apis/some-api-version/namespaces/some-uid",
+ },
+};
+
+const someOtherNamespaceDataStub = {
+ apiVersion: "some-api-version",
+ kind: "Namespace",
+ metadata: {
+ uid: "some-other-uid",
+ name: "some-other-name",
+ resourceVersion: "some-resource-version",
+ selfLink: "/apis/some-api-version/namespaces/some-other-uid",
+ },
+};
diff --git a/src/behaviours/cluster/namespaces/edit-namespace-from-previously-opened-tab.test.tsx b/src/behaviours/cluster/namespaces/edit-namespace-from-previously-opened-tab.test.tsx
new file mode 100644
index 0000000000..721ed64679
--- /dev/null
+++ b/src/behaviours/cluster/namespaces/edit-namespace-from-previously-opened-tab.test.tsx
@@ -0,0 +1,168 @@
+/**
+ * Copyright (c) OpenLens Authors. All rights reserved.
+ * Licensed under MIT License. See LICENSE in root directory for more information.
+ */
+import type { RenderResult } from "@testing-library/react";
+import type { ApplicationBuilder } from "../../../renderer/components/test-utils/get-application-builder";
+import { getApplicationBuilder } from "../../../renderer/components/test-utils/get-application-builder";
+import React from "react";
+import type { AsyncFnMock } from "@async-fn/jest";
+import asyncFn from "@async-fn/jest";
+import type { CallForResource } from "../../../renderer/components/dock/edit-resource/edit-resource-model/call-for-resource/call-for-resource.injectable";
+import callForResourceInjectable from "../../../renderer/components/dock/edit-resource/edit-resource-model/call-for-resource/call-for-resource.injectable";
+import directoryForLensLocalStorageInjectable from "../../../common/directory-for-lens-local-storage/directory-for-lens-local-storage.injectable";
+import hostedClusterIdInjectable from "../../../renderer/cluster-frame-context/hosted-cluster-id.injectable";
+import { controlWhenStoragesAreReady } from "../../../renderer/utils/create-storage/storages-are-ready";
+import writeJsonFileInjectable from "../../../common/fs/write-json-file.injectable";
+import { TabKind } from "../../../renderer/components/dock/dock/store";
+import { Namespace } from "../../../common/k8s-api/endpoints";
+
+jest.mock("../../../renderer/components/tooltip/withTooltip", () => ({
+ withTooltip:
+ (Target: any) =>
+ ({ tooltip, ...props }: any) => {
+ if (tooltip) {
+ const testId = props["data-testid"];
+
+ return (
+ <>
+
+
+ {tooltip.children || tooltip}
+
+ >
+ );
+ }
+
+ return ;
+ },
+}));
+
+describe("cluster/namespaces - edit namespaces from previously opened tab", () => {
+ let builder: ApplicationBuilder;
+ let callForNamespaceMock: AsyncFnMock;
+ let storagesAreReady: () => Promise;
+
+ beforeEach(() => {
+ builder = getApplicationBuilder();
+
+ builder.setEnvironmentToClusterFrame();
+
+ callForNamespaceMock = asyncFn();
+
+ builder.beforeApplicationStart(({ rendererDi }) => {
+ rendererDi.override(
+ directoryForLensLocalStorageInjectable,
+ () => "/some-directory-for-lens-local-storage",
+ );
+
+ rendererDi.override(hostedClusterIdInjectable, () => "some-cluster-id");
+
+ storagesAreReady = controlWhenStoragesAreReady(rendererDi);
+
+ rendererDi.override(callForResourceInjectable, () => callForNamespaceMock);
+ });
+
+ builder.allowKubeResource("namespaces");
+ });
+
+ describe("given tab was previously opened, when application is started", () => {
+ let rendered: RenderResult;
+
+ beforeEach(async () => {
+ const writeJsonFile = builder.dis.rendererDi.inject(writeJsonFileInjectable);
+
+ writeJsonFile(
+ "/some-directory-for-lens-local-storage/some-cluster-id.json",
+ {
+ dock: {
+ height: 300,
+ tabs: [
+ {
+ id: "some-first-tab-id",
+ kind: TabKind.EDIT_RESOURCE,
+ title: "Namespace: some-namespace",
+ pinned: false,
+ },
+ ],
+
+ isOpen: true,
+ },
+
+ edit_resource_store: {
+ "some-first-tab-id": {
+ resource: "/apis/some-api-version/namespaces/some-uid",
+ draft: "some-saved-configuration",
+ },
+ },
+ },
+ );
+
+ rendered = await builder.render();
+
+ await storagesAreReady();
+ });
+
+ fit("renders", () => {
+ expect(rendered.baseElement).toMatchSnapshot();
+ });
+
+ fit("shows dock tab for editing namespace", () => {
+ expect(
+ rendered.getByTestId("dock-tab-for-some-first-tab-id"),
+ ).toBeInTheDocument();
+ });
+
+ fit("shows spinner in the dock tab", () => {
+ expect(
+ rendered.getByTestId("edit-resource-tab-spinner"),
+ ).toBeInTheDocument();
+ });
+
+ fit("calls for namespace", () => {
+ expect(callForNamespaceMock).toHaveBeenCalledWith(
+ "/apis/some-api-version/namespaces/some-uid",
+ );
+ });
+
+ describe("when call for namespace resolves with namespace", () => {
+ let someNamespace: Namespace;
+
+ beforeEach(async () => {
+ someNamespace = new Namespace({
+ apiVersion: "some-api-version",
+ kind: "Namespace",
+
+ metadata: {
+ uid: "some-uid",
+ name: "some-name",
+ resourceVersion: "some-resource-version",
+ selfLink: "/apis/some-api-version/namespaces/some-uid",
+ somePropertyToBeRemoved: "some-value",
+ somePropertyToBeChanged: "some-old-value",
+ },
+ });
+
+ await callForNamespaceMock.resolve({
+ callWasSuccessful: true,
+ response: someNamespace,
+ });
+ });
+
+ fit("renders", () => {
+ expect(rendered.baseElement).toMatchSnapshot();
+ });
+
+ fit("has the saved configuration in editor", () => {
+ const input = rendered.getByTestId(
+ "monaco-editor-for-some-first-tab-id",
+ ) as HTMLTextAreaElement;
+
+ expect(input.value).toBe("some-saved-configuration");
+ });
+ });
+ });
+});
diff --git a/src/common/cluster/is-allowed-resource.ts b/src/common/cluster/is-allowed-resource.ts
new file mode 100644
index 0000000000..7a6a392f78
--- /dev/null
+++ b/src/common/cluster/is-allowed-resource.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 { KubeResource } from "../rbac";
+import { apiResourceRecord, apiResources } from "../rbac";
+
+export const isAllowedResource = (allowedResources: string[]) => (kind: string): boolean => {
+ if ((kind as KubeResource) in apiResourceRecord) {
+ return allowedResources.includes(kind);
+ }
+
+ const apiResource = apiResources.find(resource => resource.kind === kind);
+
+ if (apiResource) {
+ return allowedResources.includes(apiResource.apiName);
+ }
+
+ return true; // allowed by default for other resources
+};
diff --git a/src/renderer/components/dock/edit-resource/edit-resource-model/call-for-patch-resource/call-for-patch-resource.global-override-for-injectable.ts b/src/renderer/components/dock/edit-resource/edit-resource-model/call-for-patch-resource/call-for-patch-resource.global-override-for-injectable.ts
new file mode 100644
index 0000000000..ca999211f5
--- /dev/null
+++ b/src/renderer/components/dock/edit-resource/edit-resource-model/call-for-patch-resource/call-for-patch-resource.global-override-for-injectable.ts
@@ -0,0 +1,12 @@
+/**
+ * Copyright (c) OpenLens Authors. All rights reserved.
+ * Licensed under MIT License. See LICENSE in root directory for more information.
+ */
+import { getGlobalOverride } from "../../../../../../common/test-utils/get-global-override";
+import callForPatchResourceInjectable from "./call-for-patch-resource.injectable";
+
+export default getGlobalOverride(callForPatchResourceInjectable, () => () => {
+ throw new Error(
+ "Tried to call patching of kube resource without explicit override.",
+ );
+});
diff --git a/src/renderer/components/dock/edit-resource/edit-resource-model/call-for-patch-resource/call-for-patch-resource.injectable.ts b/src/renderer/components/dock/edit-resource/edit-resource-model/call-for-patch-resource/call-for-patch-resource.injectable.ts
new file mode 100644
index 0000000000..097ef1ccf3
--- /dev/null
+++ b/src/renderer/components/dock/edit-resource/edit-resource-model/call-for-patch-resource/call-for-patch-resource.injectable.ts
@@ -0,0 +1,49 @@
+/**
+ * Copyright (c) OpenLens Authors. All rights reserved.
+ * Licensed under MIT License. See LICENSE in root directory for more information.
+ */
+import { getInjectable } from "@ogre-tools/injectable";
+import type { AsyncResult } from "../../../../../../common/utils/async-result";
+import apiManagerInjectable from "../../../../../../common/k8s-api/api-manager/manager.injectable";
+import type { JsonPatch } from "../../../../../../common/k8s-api/kube-object.store";
+import type { KubeObject } from "../../../../../../common/k8s-api/kube-object";
+import assert from "assert";
+import { getErrorMessage } from "../../../../../../common/utils/get-error-message";
+
+export type CallForPatchResource = (
+ item: KubeObject,
+ patch: JsonPatch
+) => Promise>;
+
+const callForPatchResourceInjectable = getInjectable({
+ id: "call-for-patch-resource",
+ instantiate: (di): CallForPatchResource => {
+ const apiManager = di.inject(apiManagerInjectable);
+
+ return async (item, patch) => {
+ const store = apiManager.getStore(item.selfLink);
+
+ assert(store);
+
+ let kubeObject: KubeObject;
+
+ try {
+ kubeObject = await store.patch(item, patch);
+ } catch (e: any) {
+ return {
+ callWasSuccessful: false,
+ error: getErrorMessage(e),
+ };
+ }
+
+ return {
+ callWasSuccessful: true,
+ response: { name: kubeObject.getName(), kind: kubeObject.kind },
+ };
+ };
+ },
+
+ causesSideEffects: true,
+});
+
+export default callForPatchResourceInjectable;
diff --git a/src/renderer/components/dock/edit-resource/edit-resource-model/call-for-resource/call-for-resource.global-override-for-injectable.ts b/src/renderer/components/dock/edit-resource/edit-resource-model/call-for-resource/call-for-resource.global-override-for-injectable.ts
new file mode 100644
index 0000000000..a4e768da9a
--- /dev/null
+++ b/src/renderer/components/dock/edit-resource/edit-resource-model/call-for-resource/call-for-resource.global-override-for-injectable.ts
@@ -0,0 +1,12 @@
+/**
+ * Copyright (c) OpenLens Authors. All rights reserved.
+ * Licensed under MIT License. See LICENSE in root directory for more information.
+ */
+import { getGlobalOverride } from "../../../../../../common/test-utils/get-global-override";
+import callForResourceInjectable from "./call-for-resource.injectable";
+
+export default getGlobalOverride(callForResourceInjectable, () => () => {
+ throw new Error(
+ "Tried to call for kube resource without explicit override.",
+ );
+});
diff --git a/src/renderer/components/dock/edit-resource/edit-resource-model/call-for-resource/call-for-resource.injectable.ts b/src/renderer/components/dock/edit-resource/edit-resource-model/call-for-resource/call-for-resource.injectable.ts
new file mode 100644
index 0000000000..347c207ae4
--- /dev/null
+++ b/src/renderer/components/dock/edit-resource/edit-resource-model/call-for-resource/call-for-resource.injectable.ts
@@ -0,0 +1,48 @@
+/**
+ * Copyright (c) OpenLens Authors. All rights reserved.
+ * Licensed under MIT License. See LICENSE in root directory for more information.
+ */
+import { getInjectable } from "@ogre-tools/injectable";
+import type { KubeObject } from "../../../../../../common/k8s-api/kube-object";
+import { parseKubeApi } from "../../../../../../common/k8s-api/kube-api-parse";
+import getKubeApiFromPathInjectable from "../../../../../../common/k8s-api/kube-api/get-kube-api-from-path.injectable";
+import type { AsyncResult } from "../../../../../../common/utils/async-result";
+import { getErrorMessage } from "../../../../../../common/utils/get-error-message";
+
+export type CallForResource = (
+ selfLink: string
+) => Promise>;
+
+const callForResourceInjectable = getInjectable({
+ id: "call-for-resource",
+
+ instantiate: (di): CallForResource => {
+ const getKubeApiFromPath = di.inject(getKubeApiFromPathInjectable);
+
+ return async (apiPath: string) => {
+ const api = getKubeApiFromPath(apiPath);
+ const parsed = parseKubeApi(apiPath);
+
+ if (!api || !parsed.name) {
+ return { callWasSuccessful: false, error: "Invalid API path" };
+ }
+
+ let resource: KubeObject | null;
+
+ try {
+ resource = await api.get({
+ name: parsed.name,
+ namespace: parsed.namespace,
+ });
+ } catch (e) {
+ return { callWasSuccessful: false, error: getErrorMessage(e) };
+ }
+
+ return { callWasSuccessful: true, response: resource || undefined };
+ };
+ },
+
+ causesSideEffects: true,
+});
+
+export default callForResourceInjectable;
diff --git a/src/renderer/components/dock/edit-resource/edit-resource-model/edit-resource-model.injectable.tsx b/src/renderer/components/dock/edit-resource/edit-resource-model/edit-resource-model.injectable.tsx
new file mode 100644
index 0000000000..f6e3d09d63
--- /dev/null
+++ b/src/renderer/components/dock/edit-resource/edit-resource-model/edit-resource-model.injectable.tsx
@@ -0,0 +1,183 @@
+/**
+ * Copyright (c) OpenLens Authors. All rights reserved.
+ * Licensed under MIT License. See LICENSE in root directory for more information.
+ */
+import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
+import type { CallForResource } from "./call-for-resource/call-for-resource.injectable";
+import callForResourceInjectable from "./call-for-resource/call-for-resource.injectable";
+import { waitUntilDefined } from "../../../../../common/utils";
+import editResourceTabStoreInjectable from "../store.injectable";
+import type { EditingResource, EditResourceTabStore } from "../store";
+import { action, computed, observable, runInAction } from "mobx";
+import type { KubeObject } from "../../../../../common/k8s-api/kube-object";
+import yaml from "js-yaml";
+import assert from "assert";
+import type { CallForPatchResource } from "./call-for-patch-resource/call-for-patch-resource.injectable";
+import callForPatchResourceInjectable from "./call-for-patch-resource/call-for-patch-resource.injectable";
+import { createPatch } from "rfc6902";
+import type { ShowNotification } from "../../../notifications";
+import showSuccessNotificationInjectable from "../../../notifications/show-success-notification.injectable";
+import React from "react";
+import showErrorNotificationInjectable from "../../../notifications/show-error-notification.injectable";
+
+const editResourceModelInjectable = getInjectable({
+ id: "edit-resource-model",
+
+ instantiate: async (di, tabId: string) => {
+ const store = di.inject(editResourceTabStoreInjectable);
+
+ const model = new EditResourceModel({
+ callForResource: di.inject(callForResourceInjectable),
+ callForPatchResource: di.inject(callForPatchResourceInjectable),
+ showSuccessNotification: di.inject(showSuccessNotificationInjectable),
+ showErrorNotification: di.inject(showErrorNotificationInjectable),
+ store,
+ tabId,
+
+ waitForEditingResource: () =>
+ waitUntilDefined(() => store.getData(tabId)),
+ });
+
+ await model.load();
+
+ return model;
+ },
+
+ lifecycle: lifecycleEnum.keyedSingleton({
+ getInstanceKey: (di, tabId: string) => tabId,
+ }),
+});
+
+export default editResourceModelInjectable;
+
+interface Dependencies {
+ callForResource: CallForResource;
+ callForPatchResource: CallForPatchResource;
+ waitForEditingResource: () => Promise;
+ showSuccessNotification: ShowNotification;
+ showErrorNotification: ShowNotification;
+ store: EditResourceTabStore;
+ tabId: string;
+}
+
+export class EditResourceModel {
+ constructor(private dependencies: Dependencies) {
+ }
+
+ readonly configuration = {
+ value: computed(() => this.editingResource.draft || this.editingResource.firstDraft || ""),
+
+ onChange: action((value: string) => {
+ this.editingResource.draft = value;
+ this.configuration.error.value.set("");
+ }),
+
+ error: {
+ value: observable.box(""),
+
+ onChange: action((error: string) => {
+ this.configuration.error.value.set(error);
+ }),
+ },
+ };
+
+ @observable private _resource: KubeObject | undefined;
+
+ @computed get shouldShowErrorAboutNoResource() {
+ return !this._resource;
+ }
+
+ @computed get resource() {
+ assert(this._resource, "Resource does not have data");
+
+ return this._resource;
+ }
+
+ @computed get editingResource() {
+ const resource = this.dependencies.store.getData(this.dependencies.tabId);
+
+ assert(resource, "Resource is not present in the store");
+
+ return resource;
+ }
+
+ @computed private get selfLink() {
+ return this.editingResource.resource;
+ }
+
+ load = async () => {
+ await this.dependencies.waitForEditingResource();
+
+ const result = await this.dependencies.callForResource(this.selfLink);
+
+ if (!result.callWasSuccessful) {
+ this.dependencies.showErrorNotification(
+ `Loading resource failed: ${result.error}`,
+ );
+
+ return;
+ }
+
+ const resource = result.response;
+
+ runInAction(() => {
+ this._resource = resource;
+ });
+
+ if (!resource) {
+ return;
+ }
+
+ runInAction(() => {
+ this.editingResource.firstDraft = yaml.dump(resource.toPlainObject());
+ });
+ };
+
+ get namespace() {
+ return this.resource.metadata.namespace || "default";
+ }
+
+ get name() {
+ return this.resource.metadata.name;
+ }
+
+ get kind() {
+ return this.resource.kind;
+ }
+
+ save = async () => {
+ const currentValue = this.configuration.value.get();
+ const currentVersion = yaml.load(currentValue);
+ const firstVersion = yaml.load(this.editingResource.firstDraft ?? currentValue);
+ const patches = createPatch(firstVersion, currentVersion);
+
+ const result = await this.dependencies.callForPatchResource(this.resource, patches);
+
+ if (!result.callWasSuccessful) {
+ this.dependencies.showErrorNotification(
+
+ Failed to save resource:
+ {" "}
+ {result.error}
+
,
+ );
+
+ return;
+ }
+
+ const { kind, name } = result.response;
+
+ this.dependencies.showSuccessNotification(
+
+ {kind}
+ {" "}
+ {name}
+ {" updated."}
+
,
+ );
+
+ runInAction(() => {
+ this.editingResource.firstDraft = currentValue;
+ });
+ };
+}
diff --git a/src/renderer/components/dock/edit-resource/edit-resource-tab.injectable.ts b/src/renderer/components/dock/edit-resource/edit-resource-tab.injectable.ts
index 5d9382e4ce..f8f11b824a 100644
--- a/src/renderer/components/dock/edit-resource/edit-resource-tab.injectable.ts
+++ b/src/renderer/components/dock/edit-resource/edit-resource-tab.injectable.ts
@@ -10,13 +10,15 @@ import type { DockStore, DockTabCreateSpecific, TabId } from "../dock/store";
import { TabKind } from "../dock/store";
import type { EditResourceTabStore } from "./store";
import { runInAction } from "mobx";
+import getRandomIdForEditResourceTabInjectable from "./get-random-id-for-edit-resource-tab.injectable";
interface Dependencies {
dockStore: DockStore;
editResourceStore: EditResourceTabStore;
+ getRandomId: () => string;
}
-const createEditResourceTab = ({ dockStore, editResourceStore }: Dependencies) => (object: KubeObject, tabParams: DockTabCreateSpecific = {}): TabId => {
+const createEditResourceTab = ({ dockStore, editResourceStore, getRandomId }: Dependencies) => (object: KubeObject, tabParams: DockTabCreateSpecific = {}): TabId => {
// use existing tab if already opened
const tabId = editResourceStore.getTabIdByResource(object);
@@ -30,6 +32,7 @@ const createEditResourceTab = ({ dockStore, editResourceStore }: Dependencies) =
return runInAction(() => {
const tab = dockStore.createTab(
{
+ id: getRandomId(),
title: `${object.kind}: ${object.getName()}`,
...tabParams,
kind: TabKind.EDIT_RESOURCE,
@@ -51,6 +54,7 @@ const createEditResourceTabInjectable = getInjectable({
instantiate: (di) => createEditResourceTab({
dockStore: di.inject(dockStoreInjectable),
editResourceStore: di.inject(editResourceTabStoreInjectable),
+ getRandomId: di.inject(getRandomIdForEditResourceTabInjectable),
}),
});
diff --git a/src/renderer/components/dock/edit-resource/get-random-id-for-edit-resource-tab.injectable.ts b/src/renderer/components/dock/edit-resource/get-random-id-for-edit-resource-tab.injectable.ts
new file mode 100644
index 0000000000..e71330d261
--- /dev/null
+++ b/src/renderer/components/dock/edit-resource/get-random-id-for-edit-resource-tab.injectable.ts
@@ -0,0 +1,13 @@
+/**
+ * Copyright (c) OpenLens Authors. All rights reserved.
+ * Licensed under MIT License. See LICENSE in root directory for more information.
+ */
+import { getInjectable } from "@ogre-tools/injectable";
+import getRandomIdInjectable from "../../../../common/utils/get-random-id.injectable";
+
+const getRandomIdForEditResourceTabInjectable = getInjectable({
+ id: "get-random-id-for-edit-resource-tab",
+ instantiate: (di) => di.inject(getRandomIdInjectable),
+});
+
+export default getRandomIdForEditResourceTabInjectable;
diff --git a/src/renderer/components/dock/edit-resource/store.injectable.ts b/src/renderer/components/dock/edit-resource/store.injectable.ts
index 3a8126abf3..dbf4f44a6d 100644
--- a/src/renderer/components/dock/edit-resource/store.injectable.ts
+++ b/src/renderer/components/dock/edit-resource/store.injectable.ts
@@ -5,14 +5,12 @@
import { getInjectable } from "@ogre-tools/injectable";
import { EditResourceTabStore } from "./store";
import createStorageInjectable from "../../../utils/create-storage/create-storage.injectable";
-import apiManagerInjectable from "../../../../common/k8s-api/api-manager/manager.injectable";
const editResourceTabStoreInjectable = getInjectable({
id: "edit-resource-tab-store",
instantiate: (di) => new EditResourceTabStore({
createStorage: di.inject(createStorageInjectable),
- apiManager: di.inject(apiManagerInjectable),
}),
});
diff --git a/src/renderer/components/dock/edit-resource/store.ts b/src/renderer/components/dock/edit-resource/store.ts
index 97890db272..13a2d30ad9 100644
--- a/src/renderer/components/dock/edit-resource/store.ts
+++ b/src/renderer/components/dock/edit-resource/store.ts
@@ -5,10 +5,7 @@
import type { DockTabStoreDependencies } from "../dock-tab-store/dock-tab.store";
import { DockTabStore } from "../dock-tab-store/dock-tab.store";
-import type { TabId } from "../dock/store";
import type { KubeObject } from "../../../../common/k8s-api/kube-object";
-import type { ApiManager } from "../../../../common/k8s-api/api-manager";
-import type { KubeObjectStore } from "../../../../common/k8s-api/kube-object.store";
export interface EditingResource {
resource: string; // resource path, e.g. /api/v1/namespaces/default
@@ -16,50 +13,14 @@ export interface EditingResource {
firstDraft?: string;
}
-export interface EditResourceTabStoreDependencies extends DockTabStoreDependencies {
- readonly apiManager: ApiManager;
-}
-
export class EditResourceTabStore extends DockTabStore {
- constructor(protected readonly dependencies: EditResourceTabStoreDependencies) {
+ constructor(protected readonly dependencies: DockTabStoreDependencies) {
super(dependencies, {
storageKey: "edit_resource_store",
});
}
- protected finalizeDataForSave({ draft, ...data }: EditingResource): EditingResource {
- return data; // skip saving draft to local-storage
- }
-
- isReady(tabId: TabId) {
- return super.isReady(tabId) && Boolean(this.getResource(tabId)); // ready to edit resource
- }
-
- getStore(tabId: TabId): KubeObjectStore | undefined {
- const apiPath = this.getResourcePath(tabId);
-
- return apiPath
- ? this.dependencies.apiManager.getStore(apiPath)
- : undefined;
- }
-
- getResource(tabId: TabId): KubeObject | undefined {
- const apiPath = this.getResourcePath(tabId);
-
- return apiPath
- ? this.dependencies.apiManager.getStore(apiPath)?.getByPath(apiPath)
- : undefined;
- }
-
- getResourcePath(tabId: TabId): string | undefined {
- return this.getData(tabId)?.resource;
- }
-
getTabIdByResource(object: KubeObject): string | undefined {
return this.findTabIdFromData(({ resource }) => object.selfLink === resource);
}
-
- clearInitialDraft(tabId: TabId): void {
- delete this.getData(tabId)?.firstDraft;
- }
}
diff --git a/src/renderer/components/dock/edit-resource/view.tsx b/src/renderer/components/dock/edit-resource/view.tsx
index 73f2077152..35714d2d24 100644
--- a/src/renderer/components/dock/edit-resource/view.tsx
+++ b/src/renderer/components/dock/edit-resource/view.tsx
@@ -4,169 +4,82 @@
*/
import React from "react";
-import { autorun, makeObservable, observable } from "mobx";
-import { disposeOnUnmount, observer } from "mobx-react";
-import yaml from "js-yaml";
-import type { DockTab, TabId } from "../dock/store";
-import type { EditingResource, EditResourceTabStore } from "./store";
+import { observer } from "mobx-react";
+import type { DockTab } from "../dock/store";
+import { Spinner } from "../../spinner";
+import { withInjectables } from "@ogre-tools/injectable-react";
+import type { EditResourceModel } from "./edit-resource-model/edit-resource-model.injectable";
+import editResourceModelInjectable from "./edit-resource-model/edit-resource-model.injectable";
+import { EditorPanel } from "../editor-panel";
import { InfoPanel } from "../info-panel";
import { Badge } from "../../badge";
-import { EditorPanel } from "../editor-panel";
-import { Spinner } from "../../spinner";
-import type { KubeObject } from "../../../../common/k8s-api/kube-object";
-import { createPatch } from "rfc6902";
-import { withInjectables } from "@ogre-tools/injectable-react";
-import editResourceTabStoreInjectable from "./store.injectable";
-import { noop, onceDefined } from "../../../utils";
-import closeDockTabInjectable from "../dock/close-dock-tab.injectable";
-import type { KubeObjectStore } from "../../../../common/k8s-api/kube-object.store";
+import { Notice } from "../../+extensions/notice";
export interface EditResourceProps {
tab: DockTab;
}
interface Dependencies {
- editResourceStore: EditResourceTabStore;
- closeTab: (tabId: TabId) => void;
+ model: EditResourceModel;
}
-interface SaveDraftArgs {
- tabData: EditingResource;
- resource: KubeObject;
- store: KubeObjectStore;
-}
-
-@observer
-class NonInjectedEditResource extends React.Component {
- @observable error = "";
- @observable draft = "";
-
- constructor(props: EditResourceProps & Dependencies) {
- super(props);
- makeObservable(this);
- }
-
- componentDidMount(): void {
- disposeOnUnmount(this, [
- onceDefined(
- () => {
- const tabData = this.tabData;
- const resource = this.resource;
-
- if (tabData && resource) {
- return { tabData, resource };
- }
-
- return undefined;
- },
- ({ tabData, resource }) => {
- if (typeof tabData.draft === "string") {
- this.draft = tabData.draft;
- } else {
- this.draft = tabData.firstDraft = yaml.dump(resource.toPlainObject());
- }
- },
- ),
- autorun(() => {
- const store = this.store;
- const tabData = this.tabData;
- const resource = this.resource;
-
- if (!resource && store && tabData) {
- if (store.isLoaded) {
- // auto-close tab when resource removed from store
- this.props.closeTab(this.props.tab.id);
- } else if (!store.isLoading) {
- // preload resource for editing
- store.loadFromPath(tabData.resource).catch(noop);
- }
- }
- }),
- ]);
- }
-
- get tabId() {
- return this.props.tab.id;
- }
-
- get store() {
- return this.props.editResourceStore.getStore(this.props.tab.id);
- }
-
- get resource() {
- return this.props.editResourceStore.getResource(this.tabId);
- }
-
- get tabData() {
- return this.props.editResourceStore.getData(this.tabId);
- }
-
- async save({ resource, store, tabData }: SaveDraftArgs) {
- if (this.error) {
- return null;
- }
-
- const currentVersion = yaml.load(this.draft);
- const firstVersion = yaml.load(tabData.firstDraft ?? this.draft);
- const patches = createPatch(firstVersion, currentVersion);
- const updatedResource = await store.patch(resource, patches);
-
- this.props.editResourceStore.clearInitialDraft(this.tabId);
-
- return (
-
- {updatedResource.kind}
- {" "}
- {updatedResource.getName()}
- {" updated."}
-
- );
- }
-
- render() {
- const { tabId, error, draft, tabData, resource, store } = this;
-
- if (!tabData || !resource || !store) {
- return ;
- }
-
+const NonInjectedEditResource = observer(
+ ({ model, tab: { id: tabId }}: EditResourceProps & Dependencies) => {
return (
-
this.save({ resource, store, tabData })}
- submitLabel="Save"
- submittingMessage="Applying.."
- controls={(
-
- Kind:
-
- Name:
-
- Namespace:
-
-
- )}
- />
- {
- this.error = "";
- this.draft = tabData.draft = draft;
- }}
- onError={error => this.error = String(error)}
- />
+ {model.shouldShowErrorAboutNoResource && (
+
+ Resource not found
+
+ )}
+
+ {!model.shouldShowErrorAboutNoResource && (
+ <>
+
+ Kind:
+
+ Name:
+
+ Namespace:
+
+
+ )}
+ />
+
+ >
+ )}
);
- }
-}
+ },
+);
-export const EditResource = withInjectables(NonInjectedEditResource, {
- getProps: (di, props) => ({
- editResourceStore: di.inject(editResourceTabStoreInjectable),
- closeTab: di.inject(closeDockTabInjectable),
- ...props,
- }),
-});
+export const EditResource = withInjectables(
+ NonInjectedEditResource,
+ {
+ getPlaceholder: () => (
+
+ ),
+
+ getProps: async (di, props) => ({
+ model: await di.inject(editResourceModelInjectable, props.tab.id),
+ ...props,
+ }),
+ },
+);
diff --git a/src/renderer/components/dock/info-panel.tsx b/src/renderer/components/dock/info-panel.tsx
index 22ecea94ec..48a53a0308 100644
--- a/src/renderer/components/dock/info-panel.tsx
+++ b/src/renderer/components/dock/info-panel.tsx
@@ -39,6 +39,7 @@ export interface OptionalProps {
showNotifications?: boolean;
showStatusPanel?: boolean;
submitTestId?: string;
+ submitAndCloseTestId?: string;
cancelTestId?: string;
submittingTestId?: string;
}
@@ -167,6 +168,7 @@ class NonInjectedInfoPanel extends Component {
label={`${submitLabel} & Close`}
onClick={submitAndClose}
disabled={isDisabled}
+ data-testid={this.props.submitAndCloseTestId}
/>
)}
>
diff --git a/src/renderer/components/test-utils/get-application-builder.tsx b/src/renderer/components/test-utils/get-application-builder.tsx
index 995e1cb54e..da07584816 100644
--- a/src/renderer/components/test-utils/get-application-builder.tsx
+++ b/src/renderer/components/test-utils/get-application-builder.tsx
@@ -5,7 +5,7 @@
import type { LensRendererExtension } from "../../../extensions/lens-renderer-extension";
import rendererExtensionsInjectable from "../../../extensions/renderer-extensions.injectable";
import currentlyInClusterFrameInjectable from "../../routes/currently-in-cluster-frame.injectable";
-import type { IObservableArray, ObservableSet } from "mobx";
+import type { ObservableSet } from "mobx";
import { computed, observable, runInAction } from "mobx";
import React from "react";
import { Router } from "react-router";
@@ -36,7 +36,6 @@ import clusterFrameContextInjectable from "../../cluster-frame-context/cluster-f
import startMainApplicationInjectable from "../../../main/start-main-application/start-main-application.injectable";
import startFrameInjectable from "../../start-frame/start-frame.injectable";
import type { NamespaceStore } from "../+namespaces/store";
-import namespaceStoreInjectable from "../+namespaces/store.injectable";
import historyInjectable from "../../navigation/history.injectable";
import type { MinimalTrayMenuItem } from "../../../main/tray/electron-tray/electron-tray.injectable";
import electronTrayInjectable from "../../../main/tray/electron-tray/electron-tray.injectable";
@@ -61,6 +60,8 @@ import { ClusterFrame } from "../../frames/cluster-frame/cluster-frame";
import hostedClusterIdInjectable from "../../cluster-frame-context/hosted-cluster-id.injectable";
import activeKubernetesClusterInjectable from "../../cluster-frame-context/active-kubernetes-cluster.injectable";
import { catalogEntityFromCluster } from "../../../main/cluster-manager";
+import namespaceStoreInjectable from "../+namespaces/store.injectable";
+import { isAllowedResource } from "../../../common/cluster/is-allowed-resource";
type Callback = (dis: DiContainers) => void | Promise;
@@ -208,7 +209,7 @@ export const getApplicationBuilder = () => {
},
}));
- let allowedResourcesState: IObservableArray;
+ let allowedResourcesState: KubeResource[];
let rendered: RenderResult;
const enableExtensionsFor = (
@@ -398,16 +399,27 @@ export const getApplicationBuilder = () => {
const clusterStub = {
accessibleNamespaces: [],
+ isAllowedResource: isAllowedResource(allowedResourcesState),
} as unknown as Cluster;
rendererDi.override(activeKubernetesClusterInjectable, () =>
computed(() => catalogEntityFromCluster(clusterStub)),
);
+ // TODO: Figure out a way to remove this stub.
const namespaceStoreStub = {
+ isLoaded: true,
contextNamespaces: [],
+ contextItems: [],
+ api: {
+ kind: "Namespace",
+ },
items: [],
selectNamespaces: () => {},
+ getByPath: () => undefined,
+ pickOnlySelected: () => [],
+ isSelectedAll: () => false,
+ getTotalCount: () => 0,
} as unknown as NamespaceStore;
const clusterFrameContextFake = new ClusterFrameContext(