Skip to content

Commit

Permalink
feat: allow to show/hide navigation items (#8322)
Browse files Browse the repository at this point in the history
* feat: allow to show/hide navigation items

fixes podman-desktop/podman-desktop#7758
Signed-off-by: Florent Benoit <[email protected]>
  • Loading branch information
benoitf authored Aug 2, 2024
1 parent 5205368 commit faef17d
Show file tree
Hide file tree
Showing 13 changed files with 627 additions and 24 deletions.
14 changes: 13 additions & 1 deletion packages/main/src/mainWindow.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**********************************************************************
* Copyright (C) 2022 Red Hat, Inc.
* Copyright (C) 2022-2024 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.
Expand All @@ -24,11 +24,13 @@ import { app, autoUpdater, BrowserWindow, ipcMain, Menu, nativeTheme, screen } f
import contextMenu from 'electron-context-menu';
import { aboutMenuItem } from 'electron-util/main';

import { NavigationItemsMenuBuilder } from './navigation-items-menu-builder.js';
import { OpenDevTools } from './open-dev-tools.js';
import type { ConfigurationRegistry } from './plugin/configuration-registry.js';
import { isLinux, isMac, stoppedExtensions } from './util.js';

const openDevTools = new OpenDevTools();
let navigationItemsMenuBuilder: NavigationItemsMenuBuilder;

async function createWindow(): Promise<BrowserWindow> {
const INITIAL_APP_WIDTH = 1050;
Expand Down Expand Up @@ -104,10 +106,17 @@ async function createWindow(): Promise<BrowserWindow> {
ipcMain.on('configuration-registry', (_, data) => {
configurationRegistry = data;

navigationItemsMenuBuilder = new NavigationItemsMenuBuilder(configurationRegistry);

// open dev tools (if required)
openDevTools.open(browserWindow, configurationRegistry);
});

// receive the navigation items
ipcMain.handle('navigation:sendNavigationItems', (_, data) => {
navigationItemsMenuBuilder?.receiveNavigationItems(data);
});

// receive the message because an update is in progress and we need to quit the app
let quitAfterUpdate = false;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand Down Expand Up @@ -196,6 +205,9 @@ async function createWindow(): Promise<BrowserWindow> {
return [];
}
},
append: (_defaultActions, parameters) => {
return navigationItemsMenuBuilder?.buildNavigationMenu(parameters) ?? [];
},
});

// Add help/about menu entry
Expand Down
167 changes: 167 additions & 0 deletions packages/main/src/navigation-items-menu-builder.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
/**********************************************************************
* Copyright (C) 2024 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 type { BrowserWindow, ContextMenuParams, MenuItem, MenuItemConstructorOptions } from 'electron';
import { beforeEach, describe, expect, test, vi } from 'vitest';

import { NavigationItemsMenuBuilder } from './navigation-items-menu-builder.js';
import type { ConfigurationRegistry } from './plugin/configuration-registry.js';

let navigationItemsMenuBuilder: TestNavigationItemsMenuBuilder;

const getConfigurationMock = vi.fn();
const configurationRegistryMock = {
getConfiguration: getConfigurationMock,
updateConfigurationValue: vi.fn(),
} as unknown as ConfigurationRegistry;

const browserWindowMock = {
webContents: {},
} as unknown as BrowserWindow;

class TestNavigationItemsMenuBuilder extends NavigationItemsMenuBuilder {
buildHideMenuItem(linkText: string): MenuItemConstructorOptions | undefined {
return super.buildHideMenuItem(linkText);
}
buildNavigationToggleMenuItems(): MenuItemConstructorOptions[] {
return super.buildNavigationToggleMenuItems();
}
}

beforeEach(() => {
vi.resetAllMocks();
navigationItemsMenuBuilder = new TestNavigationItemsMenuBuilder(configurationRegistryMock);
});

describe('buildHideMenuItem', async () => {
test('build hide item', async () => {
getConfigurationMock.mockReturnValue({ get: () => [] } as unknown as ConfigurationRegistry);

const menu = navigationItemsMenuBuilder.buildHideMenuItem('Hello');
expect(menu?.label).toBe(`Hide 'Hello'`);
expect(menu?.click).toBeDefined();
expect(menu?.visible).toBe(true);

// click on the menu
menu?.click?.({} as MenuItem, browserWindowMock, {} as unknown as KeyboardEvent);

expect(getConfigurationMock).toBeCalled();
// if clicking it should send the item to the configuration as being disabled
expect(configurationRegistryMock.updateConfigurationValue).toBeCalledWith(
'navbar.disabledItems',
['Hello'],
'DEFAULT',
);
});

test('should not create a menu item if in excluded list', async () => {
getConfigurationMock.mockReturnValue({ get: () => [] } as unknown as ConfigurationRegistry);

const menu = navigationItemsMenuBuilder.buildHideMenuItem('Accounts');
expect(menu).toBeUndefined();
});
});

describe('buildNavigationToggleMenuItems', async () => {
test('build navigation toggle menu items', async () => {
getConfigurationMock.mockReturnValue({ get: () => ['existing'] } as unknown as ConfigurationRegistry);

// send 3 items, two being visible, one being hidden
navigationItemsMenuBuilder.receiveNavigationItems([
{ name: 'A & A', visible: true },
{ name: 'B', visible: false },
{ name: 'C', visible: true },
]);

const menu = navigationItemsMenuBuilder.buildNavigationToggleMenuItems();

// 4 items (first one being a separator)
expect(menu.length).toBe(4);

// check the first item is a separator
expect(menu[0].type).toBe('separator');

// label should be escaped as we have an &
expect(menu[1].label).toBe('A && A');
expect(menu[1].checked).toBe(true);
expect(menu[2].label).toBe('B');
expect(menu[2].checked).toBe(false);
expect(menu[3].label).toBe('C');
expect(menu[3].checked).toBe(true);

// click on the A item
menu[1].click?.({} as MenuItem, browserWindowMock, {} as unknown as KeyboardEvent);

expect(getConfigurationMock).toBeCalled();
// if clicking it should send the item to the configuration as being disabled
expect(configurationRegistryMock.updateConfigurationValue).toBeCalledWith(
'navbar.disabledItems',
// item A & A should not be escaped
['existing', 'A & A'],
'DEFAULT',
);

// reset the calls
vi.mocked(configurationRegistryMock.updateConfigurationValue).mockClear();

// click on the B item should unhide it so disabled items should be empty
menu[2].click?.({} as MenuItem, browserWindowMock, {} as unknown as KeyboardEvent);
expect(configurationRegistryMock.updateConfigurationValue).toBeCalledWith(
'navbar.disabledItems',
['existing'],
'DEFAULT',
);
});
});

describe('buildNavigationMenu', async () => {
test('no items if no linktext', async () => {
const parameters = {} as unknown as ContextMenuParams;

const menu = navigationItemsMenuBuilder.buildNavigationMenu(parameters);

expect(menu).toStrictEqual([]);
});

test('no items if outside of range of navbar', async () => {
const parameters = {
linkText: 'outside',
x: 0,
y: 0,
} as unknown as ContextMenuParams;

const menu = navigationItemsMenuBuilder.buildNavigationMenu(parameters);

expect(menu).toStrictEqual([]);
});

test('should call the build if inside range of navbar', async () => {
const spyMock = vi.spyOn(navigationItemsMenuBuilder, 'buildHideMenuItem');
spyMock.mockReturnValue({} as MenuItemConstructorOptions);
const parameters = {
linkText: 'inside',
x: 30,
y: 100,
} as unknown as ContextMenuParams;

const menu = navigationItemsMenuBuilder.buildNavigationMenu(parameters);

expect(menu.length).toBe(1);
expect(spyMock).toBeCalledWith('inside');
});
});
124 changes: 124 additions & 0 deletions packages/main/src/navigation-items-menu-builder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/**********************************************************************
* Copyright (C) 2024 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 type { ContextMenuParams, MenuItemConstructorOptions } from 'electron';

import type { ConfigurationRegistry } from './plugin/configuration-registry.js';
import { CONFIGURATION_DEFAULT_SCOPE } from './plugin/configuration-registry-constants.js';

// items that can't be hidden
const EXCLUDED_ITEMS = ['Accounts', 'Settings'];

// This class is responsible of creating the items to hide a given selected item of the left navigation bar
// and also display a list of all items with the ability to toggle the visibility of each item.
export class NavigationItemsMenuBuilder {
private navigationItems: { name: string; visible: boolean }[] = [];

constructor(private configurationRegistry: ConfigurationRegistry) {}

receiveNavigationItems(data: { name: string; visible: boolean }[]): void {
this.navigationItems = data;
}

protected async updateNavbarHiddenItem(itemName: string, visible: boolean): Promise<void> {
// grab the disabled items, and add the new one
const configuration = this.configurationRegistry.getConfiguration('navbar');
let items = configuration.get<string[]>('disabledItems', []);

if (visible) {
items = items.filter(i => i !== itemName);
} else if (!items.includes(itemName)) {
items.push(itemName);
}
await this.configurationRegistry.updateConfigurationValue(
'navbar.disabledItems',
items,
CONFIGURATION_DEFAULT_SCOPE,
);
}

protected escapeLabel(label: string): string {
return label.replace('&', '&&');
}

protected buildHideMenuItem(linkText: string): MenuItemConstructorOptions | undefined {
const rawItemName = linkText;

// need to filter any counter from the item name
// it's at the end with parenthesis like itemName (2)
const itemName = rawItemName.replace(/\s\(\d+\)$/, '');

if (EXCLUDED_ITEMS.includes(itemName)) {
return undefined;
}

// on electron, need to esccape the & character to show it
const itemDisplayName = this.escapeLabel(itemName);

const item: MenuItemConstructorOptions = {
label: `Hide '${itemDisplayName}'`,
visible: true,
click: (): void => {
// flag the item as being disabled
this.updateNavbarHiddenItem(itemName, false).catch((e: unknown) => console.error('error disabling item', e));
},
};
return item;
}

protected buildNavigationToggleMenuItems(): MenuItemConstructorOptions[] {
const items: MenuItemConstructorOptions[] = [];

// add all navigation items to be able to show/hide them
const menuForNavItems: Electron.MenuItemConstructorOptions[] = this.navigationItems.map(item => ({
label: this.escapeLabel(item.name),
type: 'checkbox',
checked: item.visible,
click: (): void => {
// send the item to the frontend to show/hide it
this.updateNavbarHiddenItem(item.name, !item.visible).catch((e: unknown) =>
console.error('error disabling item', e),
);
},
}));
if (menuForNavItems.length > 0) {
// add separator
items.push({ type: 'separator' });
// add all items
items.push(...menuForNavItems);
}

return items;
}

buildNavigationMenu(parameters: ContextMenuParams): MenuItemConstructorOptions[] {
const items: MenuItemConstructorOptions[] = [];
// allow to hide the item being selected
if (parameters.linkText && parameters.x < 48 && parameters.y > 76) {
const menu = this.buildHideMenuItem(parameters.linkText);
if (menu) {
items.push(menu);
}
}
if (parameters.x < 48) {
// add all navigation items to be able to show/hide them
items.push(...this.buildNavigationToggleMenuItems());
}
return items;
}
}
4 changes: 4 additions & 0 deletions packages/main/src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ import { downloadGuideList } from './learning-center/learning-center.js';
import { LibpodApiInit } from './libpod-api-enable/libpod-api-init.js';
import type { MessageBoxOptions, MessageBoxReturnValue } from './message-box.js';
import { MessageBox } from './message-box.js';
import { NavigationItemsInit } from './navigation-items-init.js';
import { NotificationRegistry } from './notification-registry.js';
import { OnboardingRegistry } from './onboarding-registry.js';
import { OpenDevToolsInit } from './open-devtools-init.js';
Expand Down Expand Up @@ -569,6 +570,9 @@ export class PluginSystem {
const terminalInit = new TerminalInit(configurationRegistry);
terminalInit.init();

const navigationItems = new NavigationItemsInit(configurationRegistry);
navigationItems.init();

// only in development mode
if (import.meta.env.DEV) {
const openDevToolsInit = new OpenDevToolsInit(configurationRegistry);
Expand Down
Loading

0 comments on commit faef17d

Please sign in to comment.