diff --git a/src/frontend/package.json b/src/frontend/package.json
index 81ddc7dc07..fc25530c64 100644
--- a/src/frontend/package.json
+++ b/src/frontend/package.json
@@ -27,6 +27,7 @@
"@mantine/hooks": "<7",
"@mantine/modals": "<7",
"@mantine/notifications": "<7",
+ "@mantine/spotlight": "<7",
"@naisutech/react-tree": "^3.1.0",
"@sentry/react": "^7.109.0",
"@tabler/icons-react": "^3.1.0",
diff --git a/src/frontend/src/components/buttons/SpotlightButton.tsx b/src/frontend/src/components/buttons/SpotlightButton.tsx
new file mode 100644
index 0000000000..c524e0b947
--- /dev/null
+++ b/src/frontend/src/components/buttons/SpotlightButton.tsx
@@ -0,0 +1,15 @@
+import { t } from '@lingui/macro';
+import { ActionIcon } from '@mantine/core';
+import { spotlight } from '@mantine/spotlight';
+import { IconCommand } from '@tabler/icons-react';
+
+/**
+ * A button which opens the quick command modal
+ */
+export function SpotlightButton() {
+ return (
+ spotlight.open()} title={t`Open spotlight`}>
+
+
+ );
+}
diff --git a/src/frontend/src/components/items/MenuLinks.tsx b/src/frontend/src/components/items/MenuLinks.tsx
index c75cc565d5..e9e9b10772 100644
--- a/src/frontend/src/components/items/MenuLinks.tsx
+++ b/src/frontend/src/components/items/MenuLinks.tsx
@@ -16,6 +16,10 @@ export interface MenuLinkItem {
docchildren?: React.ReactNode;
}
+export type menuItemsCollection = {
+ [key: string]: MenuLinkItem;
+};
+
function ConditionalDocTooltip({
item,
children
diff --git a/src/frontend/src/components/nav/Header.tsx b/src/frontend/src/components/nav/Header.tsx
index 755db08a8d..1c344c1d82 100644
--- a/src/frontend/src/components/nav/Header.tsx
+++ b/src/frontend/src/components/nav/Header.tsx
@@ -2,7 +2,7 @@ import { ActionIcon, Container, Group, Indicator, Tabs } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { IconBell, IconSearch } from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
-import { useState } from 'react';
+import { useEffect, useState } from 'react';
import { useMatch, useNavigate, useParams } from 'react-router-dom';
import { api } from '../../App';
@@ -10,7 +10,9 @@ import { navTabs as mainNavTabs } from '../../defaults/links';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { InvenTreeStyle } from '../../globalStyle';
import { apiUrl } from '../../states/ApiState';
+import { useLocalState } from '../../states/LocalState';
import { ScanButton } from '../buttons/ScanButton';
+import { SpotlightButton } from '../buttons/SpotlightButton';
import { MainMenu } from './MainMenu';
import { NavHoverMenu } from './NavHoverMenu';
import { NavigationDrawer } from './NavigationDrawer';
@@ -19,8 +21,12 @@ import { SearchDrawer } from './SearchDrawer';
export function Header() {
const { classes } = InvenTreeStyle();
+ const [setNavigationOpen, navigationOpen] = useLocalState((state) => [
+ state.setNavigationOpen,
+ state.navigationOpen
+ ]);
const [navDrawerOpened, { open: openNavDrawer, close: closeNavDrawer }] =
- useDisclosure(false);
+ useDisclosure(navigationOpen);
const [
searchDrawerOpened,
{ open: openSearchDrawer, close: closeSearchDrawer }
@@ -59,6 +65,18 @@ export function Header() {
refetchOnWindowFocus: false
});
+ // Sync Navigation Drawer state with zustand
+ useEffect(() => {
+ if (navigationOpen === navDrawerOpened) return;
+ setNavigationOpen(navDrawerOpened);
+ }, [navDrawerOpened]);
+
+ useEffect(() => {
+ if (navigationOpen === navDrawerOpened) return;
+ if (navigationOpen) openNavDrawer();
+ else closeNavDrawer();
+ }, [navigationOpen]);
+
return (
@@ -80,6 +98,7 @@ export function Header() {
+
{
export default function LayoutComponent() {
const { classes } = InvenTreeStyle();
+ const navigate = useNavigate();
+ const location = useLocation();
+
+ const defaultactions = getActions(navigate);
+ const [actions, setActions] = useState(defaultactions);
+ const [customActions, setCustomActions] = useState(false);
+
+ function actionsAreChanging(change: []) {
+ if (change.length > defaultactions.length) setCustomActions(true);
+ setActions(change);
+ }
+ useEffect(() => {
+ if (customActions) {
+ setActions(defaultactions);
+ setCustomActions(false);
+ }
+ }, [location]);
return (
-
-
-
-
-
-
-
-
+ }
+ searchPlaceholder={t`Search...`}
+ shortcut={['mod + K', '/']}
+ nothingFoundMessage={t`Nothing found...`}
+ >
+
+
+
+
+
+
+
+
+
);
}
diff --git a/src/frontend/src/components/nav/NavHoverMenu.tsx b/src/frontend/src/components/nav/NavHoverMenu.tsx
index a6026956ff..499f3c493e 100644
--- a/src/frontend/src/components/nav/NavHoverMenu.tsx
+++ b/src/frontend/src/components/nav/NavHoverMenu.tsx
@@ -20,6 +20,8 @@ import { useLocalState } from '../../states/LocalState';
import { InvenTreeLogo } from '../items/InvenTreeLogo';
import { MenuLinks } from '../items/MenuLinks';
+const onlyItems = Object.values(menuItems);
+
export function NavHoverMenu({
openDrawer: openDrawer
}: {
@@ -85,7 +87,7 @@ export function NavHoverMenu({
mx="-md"
color={theme.colorScheme === 'dark' ? 'dark.5' : 'gray.1'}
/>
-
+
diff --git a/src/frontend/src/components/nav/NavigationDrawer.tsx b/src/frontend/src/components/nav/NavigationDrawer.tsx
index 7bb8c90fa1..c68eacf100 100644
--- a/src/frontend/src/components/nav/NavigationDrawer.tsx
+++ b/src/frontend/src/components/nav/NavigationDrawer.tsx
@@ -18,6 +18,7 @@ import { MenuLinkItem, MenuLinks } from '../items/MenuLinks';
// TODO @matmair #1: implement plugin loading and menu item generation see #5269
const plugins: MenuLinkItem[] = [];
+const onlyItems = Object.values(menuItems);
export function NavigationDrawer({
opened,
@@ -60,7 +61,7 @@ function DrawerContent() {
{t`Pages`}
-
+
{plugins.length > 0 ? (
<>
diff --git a/src/frontend/src/contexts/LanguageContext.tsx b/src/frontend/src/contexts/LanguageContext.tsx
index 7a93f39baa..467f82627f 100644
--- a/src/frontend/src/contexts/LanguageContext.tsx
+++ b/src/frontend/src/contexts/LanguageContext.tsx
@@ -101,6 +101,7 @@ export function LanguageContext({ children }: { children: JSX.Element }) {
// Clear out cached table column names
useLocalState.getState().clearTableColumnNames();
})
+ /* istanbul ignore next */
.catch((err) => {
console.error('Failed loading translations', err);
if (isMounted.current) setLoadedState('error');
@@ -115,6 +116,7 @@ export function LanguageContext({ children }: { children: JSX.Element }) {
return ;
}
+ /* istanbul ignore next */
if (loadedState === 'error') {
return (
diff --git a/src/frontend/src/defaults/actions.tsx b/src/frontend/src/defaults/actions.tsx
new file mode 100644
index 0000000000..4ffac3055f
--- /dev/null
+++ b/src/frontend/src/defaults/actions.tsx
@@ -0,0 +1,59 @@
+import { t } from '@lingui/macro';
+import type { SpotlightAction } from '@mantine/spotlight';
+import { IconHome, IconLink, IconPointer } from '@tabler/icons-react';
+import { NavigateFunction } from 'react-router-dom';
+
+import { useLocalState } from '../states/LocalState';
+import { aboutInvenTree, docLinks, licenseInfo, serverInfo } from './links';
+import { menuItems } from './menuItems';
+
+export function getActions(navigate: NavigateFunction) {
+ const setNavigationOpen = useLocalState((state) => state.setNavigationOpen);
+
+ const actions: SpotlightAction[] = [
+ {
+ title: t`Home`,
+ description: `Go to the home page`,
+ onTrigger: () => navigate(menuItems.home.link),
+ icon:
+ },
+ {
+ title: t`Dashboard`,
+ description: t`Go to the InvenTree dashboard`,
+ onTrigger: () => navigate(menuItems.dashboard.link),
+ icon:
+ },
+ {
+ title: t`Documentation`,
+ description: t`Visit the documentation to learn more about InvenTree`,
+ onTrigger: () => (window.location.href = docLinks.faq),
+ icon:
+ },
+ {
+ title: t`About InvenTree`,
+ description: t`About the InvenTree org`,
+ onTrigger: () => aboutInvenTree(),
+ icon:
+ },
+ {
+ title: t`Server Information`,
+ description: t`About this Inventree instance`,
+ onTrigger: () => serverInfo(),
+ icon:
+ },
+ {
+ title: t`License Information`,
+ description: t`Licenses for dependencies of the service`,
+ onTrigger: () => licenseInfo(),
+ icon:
+ },
+ {
+ title: t`Open Navigation`,
+ description: t`Open the main navigation menu`,
+ onTrigger: () => setNavigationOpen(true),
+ icon:
+ }
+ ];
+
+ return actions;
+}
diff --git a/src/frontend/src/defaults/links.tsx b/src/frontend/src/defaults/links.tsx
index 48f99e5b2f..5bf2839689 100644
--- a/src/frontend/src/defaults/links.tsx
+++ b/src/frontend/src/defaults/links.tsx
@@ -71,7 +71,7 @@ export const navDocLinks: DocumentationLinkItem[] = [
}
];
-function serverInfo() {
+export function serverInfo() {
return openContextModal({
modal: 'info',
title: (
@@ -84,7 +84,7 @@ function serverInfo() {
});
}
-function aboutInvenTree() {
+export function aboutInvenTree() {
return openContextModal({
modal: 'about',
title: (
@@ -96,7 +96,8 @@ function aboutInvenTree() {
innerProps: {}
});
}
-function licenseInfo() {
+
+export function licenseInfo() {
return openContextModal({
modal: 'license',
title: (
diff --git a/src/frontend/src/defaults/menuItems.tsx b/src/frontend/src/defaults/menuItems.tsx
index abc57e13a9..e713553217 100644
--- a/src/frontend/src/defaults/menuItems.tsx
+++ b/src/frontend/src/defaults/menuItems.tsx
@@ -1,75 +1,75 @@
import { Trans } from '@lingui/macro';
-import { MenuLinkItem } from '../components/items/MenuLinks';
+import { menuItemsCollection } from '../components/items/MenuLinks';
import { IS_DEV_OR_DEMO } from '../main';
-export const menuItems: MenuLinkItem[] = [
- {
+export const menuItems: menuItemsCollection = {
+ home: {
id: 'home',
text: Home,
link: '/',
highlight: true
},
- {
+ profile: {
id: 'profile',
text: Account settings,
link: '/settings/user',
doctext: User attributes and design settings.
},
- {
+ scan: {
id: 'scan',
text: Scanning,
link: '/scan',
doctext: View for interactive scanning and multiple actions.,
highlight: true
},
- {
+ dashboard: {
id: 'dashboard',
text: Dashboard,
link: '/dashboard'
},
- {
+ parts: {
id: 'parts',
text: Parts,
link: '/part/'
},
- {
+ stock: {
id: 'stock',
text: Stock,
link: '/stock'
},
- {
+ build: {
id: 'build',
text: Build,
link: '/build/'
},
- {
+ purchasing: {
id: 'purchasing',
text: Purchasing,
link: '/purchasing/'
},
- {
+ sales: {
id: 'sales',
text: Sales,
link: '/sales/'
},
- {
+ 'settings-system': {
id: 'settings-system',
text: System Settings,
link: '/settings/system'
},
- {
+ 'settings-admin': {
id: 'settings-admin',
text: Admin Center,
link: '/settings/admin'
}
-];
+};
if (IS_DEV_OR_DEMO) {
- menuItems.push({
+ menuItems['playground'] = {
id: 'playground',
text: Playground,
link: '/playground',
highlight: true
- });
+ };
}
diff --git a/src/frontend/src/pages/Index/Playground.tsx b/src/frontend/src/pages/Index/Playground.tsx
index 3bbf0f527d..bdeabf7ef8 100644
--- a/src/frontend/src/pages/Index/Playground.tsx
+++ b/src/frontend/src/pages/Index/Playground.tsx
@@ -2,6 +2,8 @@ import { Trans } from '@lingui/macro';
import { Button, Card, Stack, TextInput } from '@mantine/core';
import { Group, Text } from '@mantine/core';
import { Accordion } from '@mantine/core';
+import { spotlight } from '@mantine/spotlight';
+import { IconAlien } from '@tabler/icons-react';
import { ReactNode, useMemo, useState } from 'react';
import { OptionsApiForm } from '../../components/forms/ApiForm';
@@ -167,6 +169,38 @@ function StatusLabelPlayground() {
);
}
+// Sample for spotlight actions
+function SpotlighPlayground() {
+ return (
+
+ );
+}
+
/** Construct a simple accordion group with title and content */
function PlaygroundArea({
title,
@@ -207,6 +241,10 @@ export default function Playground() {
title="Status labels"
content={}
/>
+ }
+ />
>
);
diff --git a/src/frontend/src/states/LocalState.tsx b/src/frontend/src/states/LocalState.tsx
index 24044d0ce5..9686b0640f 100644
--- a/src/frontend/src/states/LocalState.tsx
+++ b/src/frontend/src/states/LocalState.tsx
@@ -31,6 +31,8 @@ interface LocalStateProps {
clearTableColumnNames: () => void;
detailDrawerStack: number;
addDetailDrawer: (value: number | false) => void;
+ navigationOpen: boolean;
+ setNavigationOpen: (value: boolean) => void;
}
export const useLocalState = create()(
@@ -87,6 +89,11 @@ export const useLocalState = create()(
detailDrawerStack:
value === false ? 0 : get().detailDrawerStack + value
});
+ },
+ // navigation
+ navigationOpen: false,
+ setNavigationOpen: (value) => {
+ set({ navigationOpen: value });
}
}),
{
diff --git a/src/frontend/tests/baseFixtures.ts b/src/frontend/tests/baseFixtures.ts
index e9aec65fb8..4ff985c33b 100644
--- a/src/frontend/tests/baseFixtures.ts
+++ b/src/frontend/tests/baseFixtures.ts
@@ -1,11 +1,22 @@
import { test as baseTest } from '@playwright/test';
import * as crypto from 'crypto';
import * as fs from 'fs';
+import os from 'os';
import * as path from 'path';
const istanbulCLIOutput = path.join(process.cwd(), '.nyc_output');
export const classicUrl = 'http://127.0.0.1:8000';
+let platform = os.platform();
+let systemKeyVar;
+if (platform === 'darwin') {
+ systemKeyVar = 'Meta';
+} else {
+ systemKeyVar = 'Control';
+}
+/* metaKey is the local action key (used for spotlight for example) */
+export const systemKey = systemKeyVar;
+
export function generateUUID(): string {
return crypto.randomBytes(16).toString('hex');
}
diff --git a/src/frontend/tests/pui_command.spec.ts b/src/frontend/tests/pui_command.spec.ts
new file mode 100644
index 0000000000..893252f53e
--- /dev/null
+++ b/src/frontend/tests/pui_command.spec.ts
@@ -0,0 +1,147 @@
+import { expect, systemKey, test } from './baseFixtures.js';
+
+test('PUI - Quick Command', async ({ page }) => {
+ await page.goto('./platform/');
+ await expect(page).toHaveTitle('InvenTree');
+ await page.waitForURL('**/platform/');
+ await page.getByLabel('username').fill('allaccess');
+ await page.getByLabel('password').fill('nolimits');
+ await page.getByRole('button', { name: 'Log in' }).click();
+ await page.waitForURL('**/platform');
+ await page.goto('./platform/');
+
+ await expect(page).toHaveTitle('InvenTree');
+ await page.waitForURL('**/platform/');
+ await page
+ .getByRole('heading', { name: 'Welcome to your Dashboard,' })
+ .click();
+ await page.waitForTimeout(500);
+
+ // Open Spotlight with Keyboard Shortcut
+ await page.locator('body').press(`${systemKey}+k`);
+ await page.waitForTimeout(200);
+ await page
+ .getByRole('button', { name: 'Dashboard Go to the InvenTree dashboard' })
+ .click();
+ await page
+ .locator('div')
+ .filter({ hasText: /^Dashboard$/ })
+ .click();
+ await page.waitForURL('**/platform/dashboard');
+
+ // Open Spotlight with Button
+ await page.getByRole('button', { name: 'Open spotlight' }).click();
+ await page.getByRole('button', { name: 'Home Go to the home page' }).click();
+ await page
+ .getByRole('heading', { name: 'Welcome to your Dashboard,' })
+ .click();
+ await page.waitForURL('**/platform');
+
+ // Open Spotlight with Keyboard Shortcut and Search
+ await page.locator('body').press(`${systemKey}+k`);
+ await page.waitForTimeout(200);
+ await page.getByPlaceholder('Search...').fill('Dashboard');
+ await page.getByPlaceholder('Search...').press('Tab');
+ await page.getByPlaceholder('Search...').press('Enter');
+ await page.waitForURL('**/platform/dashboard');
+});
+
+test('PUI - Quick Command - no keys', async ({ page }) => {
+ await page.goto('./platform/');
+ await expect(page).toHaveTitle('InvenTree');
+ await page.waitForURL('**/platform/');
+ await page.getByLabel('username').fill('allaccess');
+ await page.getByLabel('password').fill('nolimits');
+ await page.getByRole('button', { name: 'Log in' }).click();
+ await page.waitForURL('**/platform');
+
+ await expect(page).toHaveTitle('InvenTree');
+ await page.waitForURL('**/platform');
+ // wait for the page to load - 0.5s
+ await page.waitForTimeout(500);
+
+ // Open Spotlight with Button
+ await page.getByRole('button', { name: 'Open spotlight' }).click();
+ await page.getByRole('button', { name: 'Home Go to the home page' }).click();
+ await page
+ .getByRole('heading', { name: 'Welcome to your Dashboard,' })
+ .click();
+ await page.waitForURL('**/platform');
+
+ // Use navigation menu
+ await page.getByRole('button', { name: 'Open spotlight' }).click();
+ await page
+ .getByRole('button', { name: 'Open Navigation Open the main' })
+ .click();
+ // assert the nav headers are visible
+ await page.getByRole('heading', { name: 'Navigation' }).waitFor();
+ await page.getByRole('heading', { name: 'Pages' }).waitFor();
+ await page.getByRole('heading', { name: 'Documentation' }).waitFor();
+ await page.getByRole('heading', { name: 'About' }).waitFor();
+
+ await page.keyboard.press('Escape');
+
+ // use server info
+ await page.getByRole('button', { name: 'Open spotlight' }).click();
+ await page
+ .getByRole('button', {
+ name: 'Server Information About this Inventree instance'
+ })
+ .click();
+ await page.getByRole('cell', { name: 'Instance Name' }).waitFor();
+ await page.getByRole('button', { name: 'Dismiss' }).click();
+
+ await page.waitForURL('**/platform');
+
+ // use license info
+ await page.getByRole('button', { name: 'Open spotlight' }).click();
+ await page
+ .getByRole('button', {
+ name: 'License Information Licenses for dependencies of the service'
+ })
+ .click();
+ await page.getByText('License Information').first().waitFor();
+ await page.getByRole('tab', { name: 'backend Packages' }).waitFor();
+
+ await page.getByLabel('License Information').getByRole('button').click();
+
+ // use about
+ await page.getByRole('button', { name: 'Open spotlight' }).click();
+ await page
+ .getByRole('button', { name: 'About InvenTree About the InvenTree org' })
+ .click();
+ await page.getByText('This information is only').waitFor();
+
+ await page.getByLabel('About InvenTree').getByRole('button').click();
+
+ // use documentation
+ await page.getByRole('button', { name: 'Open spotlight' }).click();
+ await page
+ .getByRole('button', {
+ name: 'Documentation Visit the documentation to learn more about InvenTree'
+ })
+ .click();
+ await page.waitForURL('https://docs.inventree.org/**');
+
+ // Test addition of new actions
+ await page.goto('./platform/playground');
+ await page
+ .locator('div')
+ .filter({ hasText: /^Playground$/ })
+ .waitFor();
+ await page.getByRole('button', { name: 'Spotlight actions' }).click();
+ await page.getByRole('button', { name: 'Register extra actions' }).click();
+ await page.getByPlaceholder('Search...').fill('secret');
+ await page.getByRole('button', { name: 'Secret action It was' }).click();
+ await page.getByRole('button', { name: 'Open spotlight' }).click();
+ await page.getByPlaceholder('Search...').fill('Another secret action');
+ await page
+ .getByRole('button', {
+ name: 'Another secret action You can register multiple actions with just one command'
+ })
+ .click();
+ await page.getByRole('tab', { name: 'Home' }).click();
+ await page.getByRole('button', { name: 'Open spotlight' }).click();
+ await page.getByPlaceholder('Search...').fill('secret');
+ await page.getByText('Nothing found...').click();
+});
diff --git a/src/frontend/yarn.lock b/src/frontend/yarn.lock
index 3926e5b20a..c43253600c 100644
--- a/src/frontend/yarn.lock
+++ b/src/frontend/yarn.lock
@@ -1156,6 +1156,13 @@
"@mantine/utils" "6.0.21"
react-transition-group "4.4.2"
+"@mantine/spotlight@<7":
+ version "6.0.21"
+ resolved "https://registry.yarnpkg.com/@mantine/spotlight/-/spotlight-6.0.21.tgz#98f507bd3429fee1f2b57ad5ef9f88d1d8d8ff32"
+ integrity sha512-xJqF2Vpn8s6I4mSF+iCi7IzqL8iaqbvq0RcYlF1usLZYW2HrArX31s1r11DmzqM1PIuBQUhquW8jUXx/MZy3oA==
+ dependencies:
+ "@mantine/utils" "6.0.21"
+
"@mantine/styles@6.0.21":
version "6.0.21"
resolved "https://registry.yarnpkg.com/@mantine/styles/-/styles-6.0.21.tgz#8ea097fc76cbb3ed55f5cfd719d2f910aff5031b"