Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: extend menus capabilities #3947

Merged
merged 9 commits into from Sep 19, 2023
1 change: 1 addition & 0 deletions packages/main/src/plugin/menu-registry.ts
Expand Up @@ -24,6 +24,7 @@ export interface Menu {

export enum MenuContext {
DASHBOARD_IMAGE = 'dashboard/image',
DASHBOARD_CONTAINER = 'dashboard/container',
}

export class MenuRegistry {
Expand Down
3 changes: 3 additions & 0 deletions packages/renderer/src/lib/ContainerList.spec.ts
Expand Up @@ -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();
Expand All @@ -44,13 +45,15 @@ 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;
(window as any).kubernetesListPods = kubernetesListPodsMock;
(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) => {
Expand Down
14 changes: 11 additions & 3 deletions packages/renderer/src/lib/ContainerList.svelte
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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[]) {
Expand Down Expand Up @@ -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}
<ComposeActions
Expand All @@ -538,7 +544,8 @@ function errorCallback(container: ContainerInfoUI, errorMessage: string): void {
}}"
dropdownMenu="{true}"
inProgressCallback="{(containers, flag, state) =>
composeGroupInProgressCallback(containerGroup.containers, flag, state)}" />
composeGroupInProgressCallback(containerGroup.containers, flag, state)}"
contributions="{contributedMenus}" />
{/if}
</td>
</tr>
Expand Down Expand Up @@ -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}" />
</div>
</div>
</td>
Expand Down
3 changes: 3 additions & 0 deletions packages/renderer/src/lib/ContainerListCompose.spec.ts
Expand Up @@ -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();

Expand All @@ -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) => {
Expand Down
89 changes: 89 additions & 0 deletions 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',
},
],
});
});
53 changes: 53 additions & 0 deletions 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 {
axel7083 marked this conversation as resolved.
Show resolved Hide resolved
switch (typeof value) {
case 'string':
case 'number':
case 'boolean':
case 'object':
return true;
default:
return false;
}
}

// Does not support circular properties
export function removeNonSerializableProperties<T>(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<T> = {};

for (const key in obj) {
if (isSerializable(obj[key])) {
result[key] = removeNonSerializableProperties(obj[key]);
}
}

return result as T;
}
110 changes: 110 additions & 0 deletions 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' } });
});
29 changes: 29 additions & 0 deletions packages/renderer/src/lib/actions/ContributionActions.svelte
@@ -0,0 +1,29 @@
<script lang="ts">
import type { Menu } from '../../../../main/src/plugin/menu-registry';
import { faEllipsisVertical } from '@fortawesome/free-solid-svg-icons';
import ListItemButtonIcon from '../ui/ListItemButtonIcon.svelte';
import { removeNonSerializableProperties } from '/@/lib/actions/ActionUtils';

export let args: unknown[];

export let dropdownMenu = false;
export let contributions: Menu[] = [];

export let onError: (errorMessage: string) => void;

async function executeContribution(menu: Menu): Promise<void> {
try {
await window.executeCommand(menu.command, ...removeNonSerializableProperties(args));
} catch (err) {
onError(`Error while executing ${menu.title}: ${String(err)}`);
}
}
</script>

{#each contributions as menu}
<ListItemButtonIcon
title="{menu.title}"
onClick="{() => executeContribution(menu)}"
menu="{dropdownMenu}"
icon="{faEllipsisVertical}" />
{/each}