From 96fdf98490e45f2d8a576ec3ed7244761313620d Mon Sep 17 00:00:00 2001 From: axel7083 <42176370+axel7083@users.noreply.github.com> Date: Tue, 19 Sep 2023 10:58:46 +0200 Subject: [PATCH] feat: extend menus capabilities (#3947) * feat: extend menus capabilities Signed-off-by: axel7083 <42176370+axel7083@users.noreply.github.com> * revert: restoring [] Signed-off-by: axel7083 <42176370+axel7083@users.noreply.github.com> * fix: removing logic of sorting out non-serializable properties from args of ContributionActions Signed-off-by: axel7083 <42176370+axel7083@users.noreply.github.com> * test: adding test for ContributionActions Signed-off-by: axel7083 <42176370+axel7083@users.noreply.github.com> * fix: adding mock for windows.getContributedMenus Signed-off-by: axel7083 <42176370+axel7083@users.noreply.github.com> * refactor: moving utils functions to separate files Signed-off-by: axel7083 <42176370+axel7083@users.noreply.github.com> * fix: adding a mock to avoid a regression in test Signed-off-by: axel7083 <42176370+axel7083@users.noreply.github.com> * fix: adding copyright headers Signed-off-by: axel7083 <42176370+axel7083@users.noreply.github.com> --- packages/main/src/plugin/menu-registry.ts | 1 + .../renderer/src/lib/ContainerList.spec.ts | 3 + .../renderer/src/lib/ContainerList.svelte | 14 ++- .../src/lib/ContainerListCompose.spec.ts | 3 + .../src/lib/actions/ActionUtils.spec.ts | 89 ++++++++++++++ .../renderer/src/lib/actions/ActionUtils.ts | 53 +++++++++ .../lib/actions/ContributionActions.spec.ts | 110 ++++++++++++++++++ .../lib/actions/ContributionActions.svelte | 29 +++++ .../src/lib/compose/ComposeActions.svelte | 8 ++ .../src/lib/container/ContainerActions.svelte | 8 ++ .../src/lib/image/ImageActions.svelte | 36 ++---- .../renderer/src/lib/pod/PodActions.svelte | 8 ++ 12 files changed, 335 insertions(+), 27 deletions(-) create mode 100644 packages/renderer/src/lib/actions/ActionUtils.spec.ts create mode 100644 packages/renderer/src/lib/actions/ActionUtils.ts create mode 100644 packages/renderer/src/lib/actions/ContributionActions.spec.ts create mode 100644 packages/renderer/src/lib/actions/ContributionActions.svelte diff --git a/packages/main/src/plugin/menu-registry.ts b/packages/main/src/plugin/menu-registry.ts index c859d2717a5..9d099e7e221 100644 --- a/packages/main/src/plugin/menu-registry.ts +++ b/packages/main/src/plugin/menu-registry.ts @@ -25,6 +25,7 @@ export interface Menu { export enum MenuContext { DASHBOARD_IMAGE = 'dashboard/image', + DASHBOARD_CONTAINER = 'dashboard/container', } export class MenuRegistry { diff --git a/packages/renderer/src/lib/ContainerList.spec.ts b/packages/renderer/src/lib/ContainerList.spec.ts index 9f7d6670110..a77a0a1504c 100644 --- a/packages/renderer/src/lib/ContainerList.spec.ts +++ b/packages/renderer/src/lib/ContainerList.spec.ts @@ -29,6 +29,7 @@ import { providerInfos } from '../stores/providers'; const listContainersMock = vi.fn(); const getProviderInfosMock = vi.fn(); const listViewsMock = vi.fn(); +const getContributedMenusMock = vi.fn(); const deleteContainerMock = vi.fn(); const removePodMock = vi.fn(); @@ -44,6 +45,7 @@ beforeAll(() => { listPodsMock.mockImplementation(() => Promise.resolve([])); kubernetesListPodsMock.mockImplementation(() => Promise.resolve([])); listViewsMock.mockImplementation(() => Promise.resolve([])); + getContributedMenusMock.mockImplementation(() => Promise.resolve([])); (window as any).listViewsContributions = listViewsMock; (window as any).listContainers = listContainersMock; (window as any).listPods = listPodsMock; @@ -51,6 +53,7 @@ beforeAll(() => { (window as any).getProviderInfos = getProviderInfosMock; (window as any).removePod = removePodMock; (window as any).deleteContainer = deleteContainerMock; + (window as any).getContributedMenus = getContributedMenusMock; (window.events as unknown) = { receive: (_channel: string, func: any) => { diff --git a/packages/renderer/src/lib/ContainerList.svelte b/packages/renderer/src/lib/ContainerList.svelte index b4d63972d8d..7a5827d9af4 100644 --- a/packages/renderer/src/lib/ContainerList.svelte +++ b/packages/renderer/src/lib/ContainerList.svelte @@ -36,6 +36,7 @@ import { CONTAINER_LIST_VIEW } from './view/views'; import type { ViewInfoUI } from '../../../main/src/plugin/api/view-info'; import type { ContextUI } from './context/context'; import Button from './ui/Button.svelte'; +import { type Menu, MenuContext } from '../../../main/src/plugin/menu-registry'; const containerUtils = new ContainerUtils(); let openChoiceModal = false; @@ -220,6 +221,8 @@ let contextsUnsubscribe: Unsubscriber; let podUnsubscribe: Unsubscriber; let viewsUnsubscribe: Unsubscriber; let pods: PodInfo[]; +let contributedMenus: Menu[]; + onMount(async () => { // grab previous groups containerGroups = get(containerGroupsInfo); @@ -245,6 +248,8 @@ onMount(async () => { podUnsubscribe = podsInfos.subscribe(podInfos => { pods = podInfos; }); + + contributedMenus = await window.getContributedMenus(MenuContext.DASHBOARD_CONTAINER); }); function updateContainers(containers: ContainerInfo[], globalContext: ContextUI, viewContributions: ViewInfoUI[]) { @@ -525,7 +530,8 @@ function errorCallback(container: ContainerInfoUI, errorMessage: string): void { containers: [], kind: 'podman', }}" - dropdownMenu="{true}" /> + dropdownMenu="{true}" + contributions="{contributedMenus}" /> {/if} {#if containerGroup.type === ContainerGroupInfoTypeUI.COMPOSE && containerGroup.status && containerGroup.engineId && containerGroup.engineType} + composeGroupInProgressCallback(containerGroup.containers, flag, state)}" + contributions="{contributedMenus}" /> {/if} @@ -618,7 +625,8 @@ function errorCallback(container: ContainerInfoUI, errorMessage: string): void { errorCallback="{error => errorCallback(container, error)}" inProgressCallback="{(flag, state) => inProgressCallback(container, flag, state)}" container="{container}" - dropdownMenu="{true}" /> + dropdownMenu="{true}" + contributions="{contributedMenus}" /> diff --git a/packages/renderer/src/lib/ContainerListCompose.spec.ts b/packages/renderer/src/lib/ContainerListCompose.spec.ts index 13e41f24fd0..ed47f8a3a71 100644 --- a/packages/renderer/src/lib/ContainerListCompose.spec.ts +++ b/packages/renderer/src/lib/ContainerListCompose.spec.ts @@ -28,6 +28,7 @@ import { providerInfos } from '../stores/providers'; const listContainersMock = vi.fn(); const getProviderInfosMock = vi.fn(); +const getContributedMenusMock = vi.fn(); const listPodsMock = vi.fn(); @@ -49,8 +50,10 @@ beforeAll(() => { (window as any).deleteContainersByLabel = deleteContainersByLabelMock; const listViewsContributionsMock = vi.fn(); (window as any).listViewsContributions = listViewsContributionsMock; + (window as any).getContributedMenus = getContributedMenusMock; listViewsContributionsMock.mockResolvedValue([]); + getContributedMenusMock.mockImplementation(() => Promise.resolve([])); (window.events as unknown) = { receive: (_channel: string, func: any) => { diff --git a/packages/renderer/src/lib/actions/ActionUtils.spec.ts b/packages/renderer/src/lib/actions/ActionUtils.spec.ts new file mode 100644 index 00000000000..6a38dafe9ff --- /dev/null +++ b/packages/renderer/src/lib/actions/ActionUtils.spec.ts @@ -0,0 +1,89 @@ +/********************************************************************** + * Copyright (C) 2022 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import { test, expect } from 'vitest'; +import { removeNonSerializableProperties } from '/@/lib/actions/ActionUtils'; + +test('Object with single non serializable property', async () => { + expect( + removeNonSerializableProperties({ + nonSerializable: () => {}, + }), + ).toStrictEqual({}); +}); + +test('Array with single non serializable property', async () => { + expect(removeNonSerializableProperties([() => {}])).toStrictEqual([]); +}); + +test('Array with single non serializable and serializable property', async () => { + expect(removeNonSerializableProperties([() => {}, 'dummy'])).toStrictEqual(['dummy']); +}); + +test('Object with properties nested in object', async () => { + expect( + removeNonSerializableProperties({ + parent: { + nonSerializable: () => {}, + serializable: 'dummy', + }, + }), + ).toStrictEqual({ + parent: { + serializable: 'dummy', + }, + }); +}); + +test('Object with properties nested in array', async () => { + expect( + removeNonSerializableProperties({ + parent: [ + { + nonSerializable: () => {}, + serializable: 'dummy', + }, + ], + }), + ).toStrictEqual({ + parent: [ + { + serializable: 'dummy', + }, + ], + }); +}); + +test('Object with single non serializable property nested in array', async () => { + expect( + removeNonSerializableProperties({ + parent: [ + { + nonSerializable: () => {}, + serializable: 'dummy', + }, + ], + }), + ).toStrictEqual({ + parent: [ + { + serializable: 'dummy', + }, + ], + }); +}); diff --git a/packages/renderer/src/lib/actions/ActionUtils.ts b/packages/renderer/src/lib/actions/ActionUtils.ts new file mode 100644 index 00000000000..a4c69fe7d67 --- /dev/null +++ b/packages/renderer/src/lib/actions/ActionUtils.ts @@ -0,0 +1,53 @@ +/********************************************************************** + * Copyright (C) 2022 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +function isSerializable(value: any): boolean { + switch (typeof value) { + case 'string': + case 'number': + case 'boolean': + case 'object': + return true; + default: + return false; + } +} + +// Does not support circular properties +export function removeNonSerializableProperties(obj: T): T { + if (typeof obj !== 'object' || obj === undefined) { + return obj; + } + + if (Array.isArray(obj)) { + return obj.reduce((previousValue, currentValue) => { + if (isSerializable(currentValue)) return [...previousValue, removeNonSerializableProperties(currentValue)]; + return previousValue; + }, []); + } + + const result: Partial = {}; + + for (const key in obj) { + if (isSerializable(obj[key])) { + result[key] = removeNonSerializableProperties(obj[key]); + } + } + + return result as T; +} diff --git a/packages/renderer/src/lib/actions/ContributionActions.spec.ts b/packages/renderer/src/lib/actions/ContributionActions.spec.ts new file mode 100644 index 00000000000..d2d1c27bfb5 --- /dev/null +++ b/packages/renderer/src/lib/actions/ContributionActions.spec.ts @@ -0,0 +1,110 @@ +import '@testing-library/jest-dom/vitest'; +import { beforeAll, test, expect, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/svelte'; +import ContributionActions from '/@/lib/actions/ContributionActions.svelte'; + +const executeCommand = vi.fn(); + +beforeAll(() => { + (window as any).executeCommand = executeCommand; + executeCommand.mockImplementation(() => {}); + + (window.events as unknown) = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + receive: (_channel: string, func: any) => { + func(); + }, + }; +}); + +test('Expect no ListItemButtonIcon', async () => { + render(ContributionActions, { + args: [], + contributions: [], + onError: () => {}, + }); + const imgs = screen.queryAllByRole('img'); + expect(imgs).lengthOf(0); +}); + +test('Expect one ListItemButtonIcon', async () => { + render(ContributionActions, { + args: [], + contributions: [ + { + command: 'dummy.command', + title: 'dummy-title', + }, + ], + onError: () => {}, + dropdownMenu: true, + }); + const item = screen.getByText('dummy-title'); + expect(item).toBeInTheDocument(); +}); + +test('Expect executeCommand to be called', async () => { + render(ContributionActions, { + args: [], + contributions: [ + { + command: 'dummy.command', + title: 'dummy-title', + }, + ], + onError: () => {}, + dropdownMenu: true, + }); + const item = screen.getByText('dummy-title'); + + await fireEvent.click(item); + expect(executeCommand).toBeCalledWith('dummy.command'); +}); + +test('Expect executeCommand to be called with sanitize object', async () => { + render(ContributionActions, { + args: [ + { + nonSerializable: () => {}, + serializable: 'hello', + }, + ], + contributions: [ + { + command: 'dummy.command', + title: 'dummy-title', + }, + ], + onError: () => {}, + dropdownMenu: true, + }); + const item = screen.getByText('dummy-title'); + + await fireEvent.click(item); + expect(executeCommand).toBeCalledWith('dummy.command', { serializable: 'hello' }); +}); + +test('Expect executeCommand to be called with sanitize object nested', async () => { + render(ContributionActions, { + args: [ + { + parent: { + nonSerializable: () => {}, + serializable: 'hello', + }, + }, + ], + contributions: [ + { + command: 'dummy.command', + title: 'dummy-title', + }, + ], + onError: () => {}, + dropdownMenu: true, + }); + const item = screen.getByText('dummy-title'); + + await fireEvent.click(item); + expect(executeCommand).toBeCalledWith('dummy.command', { parent: { serializable: 'hello' } }); +}); diff --git a/packages/renderer/src/lib/actions/ContributionActions.svelte b/packages/renderer/src/lib/actions/ContributionActions.svelte new file mode 100644 index 00000000000..edc58e0f469 --- /dev/null +++ b/packages/renderer/src/lib/actions/ContributionActions.svelte @@ -0,0 +1,29 @@ + + +{#each contributions as menu} + +{/each} diff --git a/packages/renderer/src/lib/compose/ComposeActions.svelte b/packages/renderer/src/lib/compose/ComposeActions.svelte index eaeb8047a84..159deba87f2 100644 --- a/packages/renderer/src/lib/compose/ComposeActions.svelte +++ b/packages/renderer/src/lib/compose/ComposeActions.svelte @@ -6,10 +6,13 @@ import ListItemButtonIcon from '../ui/ListItemButtonIcon.svelte'; import DropdownMenu from '../ui/DropdownMenu.svelte'; import FlatMenu from '../ui/FlatMenu.svelte'; import type { ContainerInfoUI } from '../container/ContainerInfoUI'; +import type { Menu } from '../../../../main/src/plugin/menu-registry'; +import ContributionActions from '/@/lib/actions/ContributionActions.svelte'; export let compose: ComposeInfoUI; export let dropdownMenu = false; export let detailed = false; +export let contributions: Menu[] = []; export let inProgressCallback: (containers: ContainerInfoUI[], inProgress: boolean, state?: string) => void = () => {}; export let errorCallback: (erroMessage: string) => void = () => {}; @@ -124,4 +127,9 @@ if (dropdownMenu) { menu="{dropdownMenu}" detailed="{detailed}" icon="{faArrowsRotate}" /> + diff --git a/packages/renderer/src/lib/container/ContainerActions.svelte b/packages/renderer/src/lib/container/ContainerActions.svelte index fae4d3a2e7a..16620171f89 100644 --- a/packages/renderer/src/lib/container/ContainerActions.svelte +++ b/packages/renderer/src/lib/container/ContainerActions.svelte @@ -16,9 +16,12 @@ import { router } from 'tinro'; import ListItemButtonIcon from '../ui/ListItemButtonIcon.svelte'; import DropdownMenu from '../ui/DropdownMenu.svelte'; import FlatMenu from '../ui/FlatMenu.svelte'; +import type { Menu } from '../../../../main/src/plugin/menu-registry'; +import ContributionActions from '/@/lib/actions/ContributionActions.svelte'; export let container: ContainerInfoUI; export let dropdownMenu = false; export let detailed = false; +export let contributions: Menu[] = []; export let inProgressCallback: (inProgress: boolean, state?: string) => void = () => {}; export let errorCallback: (erroMessage: string) => void = () => {}; @@ -173,4 +176,9 @@ if (dropdownMenu) { menu="{dropdownMenu}" detailed="{detailed}" icon="{faArrowsRotate}" /> + diff --git a/packages/renderer/src/lib/image/ImageActions.svelte b/packages/renderer/src/lib/image/ImageActions.svelte index 8962b96ce5e..362b3ee4d95 100644 --- a/packages/renderer/src/lib/image/ImageActions.svelte +++ b/packages/renderer/src/lib/image/ImageActions.svelte @@ -1,12 +1,5 @@ @@ -109,13 +99,11 @@ if (dropdownMenu) { icon="{faLayerGroup}" /> {/if} - {#each contributions as menu} - - {/each} + {#if errorMessage}