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>
This commit is contained in:
axel7083 2023-09-19 10:58:46 +02:00 committed by GitHub
parent f40c48ed5d
commit 96fdf98490
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 335 additions and 27 deletions

View file

@ -25,6 +25,7 @@ export interface Menu {
export enum MenuContext {
DASHBOARD_IMAGE = 'dashboard/image',
DASHBOARD_CONTAINER = 'dashboard/container',
}
export class MenuRegistry {

View file

@ -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) => {

View file

@ -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}
<ComposeActions
@ -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>
@ -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>

View file

@ -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) => {

View file

@ -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',
},
],
});
});

View file

@ -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<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;
}

View file

@ -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' } });
});

View file

@ -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}

View file

@ -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}" />
<ContributionActions
args="{[compose]}"
dropdownMenu="{dropdownMenu}"
contributions="{contributions}"
onError="{errorCallback}" />
</svelte:component>

View file

@ -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}" />
<ContributionActions
args="{[container]}"
dropdownMenu="{dropdownMenu}"
contributions="{contributions}"
onError="{errorCallback}" />
</svelte:component>

View file

@ -1,12 +1,5 @@
<script lang="ts">
import {
faArrowUp,
faEllipsisVertical,
faLayerGroup,
faPlay,
faTrash,
faEdit,
} from '@fortawesome/free-solid-svg-icons';
import { faArrowUp, faLayerGroup, faPlay, faTrash, faEdit } from '@fortawesome/free-solid-svg-icons';
import type { ImageInfoUI } from './ImageInfoUI';
import { router } from 'tinro';
import ListItemButtonIcon from '../ui/ListItemButtonIcon.svelte';
@ -14,6 +7,7 @@ import DropdownMenu from '../ui/DropdownMenu.svelte';
import FlatMenu from '../ui/FlatMenu.svelte';
import { runImageInfo } from '../../stores/run-image-store';
import type { Menu } from '../../../../main/src/plugin/menu-registry';
import ContributionActions from '/@/lib/actions/ContributionActions.svelte';
export let onPushImage: (imageInfo: ImageInfoUI) => void;
export let onRenameImage: (imageInfo: ImageInfoUI) => void;
@ -54,15 +48,6 @@ async function showLayersImage(): Promise<void> {
router.goto(`/images/${image.id}/${image.engineId}/${image.base64RepoTag}/history`);
}
async function executeContribution(menu: Menu): Promise<void> {
try {
await window.executeCommand(menu.command, image);
} catch (err) {
errorTitle = `Error while executing ${menu.title}`;
errorMessage = String(err);
}
}
// If dropdownMenu = true, we'll change style to the imported dropdownMenu style
// otherwise, leave blank.
let actionsStyle: typeof DropdownMenu | typeof FlatMenu;
@ -71,6 +56,11 @@ if (dropdownMenu) {
} else {
actionsStyle = FlatMenu;
}
function onError(error: string): void {
errorTitle = 'Something went wrong.';
errorMessage = error;
}
</script>
<ListItemButtonIcon title="Run Image" onClick="{() => runImage(image)}" detailed="{detailed}" icon="{faPlay}" />
@ -109,13 +99,11 @@ if (dropdownMenu) {
icon="{faLayerGroup}" />
{/if}
{#each contributions as menu}
<ListItemButtonIcon
title="{menu.title}"
onClick="{() => executeContribution(menu)}"
menu="{dropdownMenu}"
icon="{faEllipsisVertical}" />
{/each}
<ContributionActions
args="{[image]}"
dropdownMenu="{dropdownMenu}"
contributions="{contributions}"
onError="{onError}" />
{#if errorMessage}
<div class="modal fixed w-full h-full top-0 left-0 flex items-center justify-center p-8 lg:p-0 z-50" tabindex="-1">

View file

@ -5,10 +5,13 @@ 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 pod: PodInfoUI;
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 = () => {};
@ -126,4 +129,9 @@ if (dropdownMenu) {
detailed="{detailed}"
icon="{faArrowsRotate}" />
{/if}
<ContributionActions
args="{[pod]}"
dropdownMenu="{dropdownMenu}"
contributions="{contributions}"
onError="{errorCallback}" />
</svelte:component>