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
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' } });
});
60 changes: 60 additions & 0 deletions packages/renderer/src/lib/actions/ContributionActions.svelte
@@ -0,0 +1,60 @@
<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';

function isSerializable(value: any): boolean {
switch (typeof value) {
case 'string':
case 'number':
case 'boolean':
case 'object':
return true;
default:
return false;
}
}

function removeNonSerializableProperties<T>(obj: T): T {
if (typeof obj !== 'object' || obj === undefined) {
return obj;
}

if (Array.isArray(obj)) {
return obj.map(item => removeNonSerializableProperties(item)) as any;
}

const result: Partial<T> = {};

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

return result as T;
}
axel7083 marked this conversation as resolved.
Show resolved Hide resolved

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}
8 changes: 8 additions & 0 deletions packages/renderer/src/lib/compose/ComposeActions.svelte
Expand Up @@ -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 = () => {};
Expand Down Expand Up @@ -124,4 +127,9 @@ if (dropdownMenu) {
menu="{dropdownMenu}"
detailed="{detailed}"
icon="{faArrowsRotate}" />
<ContributionActions
args="{[compose]}"
dropdownMenu="{dropdownMenu}"
contributions="{contributions}"
onError="{errorCallback}" />
</svelte:component>
8 changes: 8 additions & 0 deletions packages/renderer/src/lib/container/ContainerActions.svelte
Expand Up @@ -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 = () => {};
Expand Down Expand Up @@ -173,4 +176,9 @@ if (dropdownMenu) {
menu="{dropdownMenu}"
detailed="{detailed}"
icon="{faArrowsRotate}" />
<ContributionActions
args="{[container]}"
dropdownMenu="{dropdownMenu}"
contributions="{contributions}"
onError="{errorCallback}" />
</svelte:component>
36 changes: 12 additions & 24 deletions packages/renderer/src/lib/image/ImageActions.svelte
@@ -1,19 +1,13 @@
<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';
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;
Expand Down Expand Up @@ -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;
Expand All @@ -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}" />
Expand Down Expand Up @@ -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">
Expand Down
8 changes: 8 additions & 0 deletions packages/renderer/src/lib/pod/PodActions.svelte
Expand Up @@ -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 = () => {};
Expand Down Expand Up @@ -126,4 +129,9 @@ if (dropdownMenu) {
detailed="{detailed}"
icon="{faArrowsRotate}" />
{/if}
<ContributionActions
args="{[pod]}"
dropdownMenu="{dropdownMenu}"
contributions="{contributions}"
onError="{errorCallback}" />
</svelte:component>