From 70b78e41c392f669d6a51e6d19fb50739468f933 Mon Sep 17 00:00:00 2001 From: Gregor Vostrak Date: Tue, 27 Jan 2026 18:29:40 +0100 Subject: [PATCH] add command palette --- e2e/command-palette.spec.ts | 405 +++++++++++++ package-lock.json | 8 +- package.json | 2 +- .../CommandPalette/CommandPaletteProvider.vue | 268 +++++++++ .../js/Components/CommandPalette/index.ts | 1 + .../js/Components/ui/dialog/DialogContent.vue | 2 +- .../ui/dropdown-menu/DropdownMenuContent.vue | 2 +- resources/js/Layouts/AppLayout.vue | 44 +- .../ui/src/CommandPalette/CommandPalette.vue | 139 +++++ .../src/CommandPalette/CommandPaletteTypes.ts | 23 + .../packages/ui/src/CommandPalette/index.ts | 6 + .../src/TimeEntry/TimeEntryAggregateRow.vue | 3 +- .../js/packages/ui/src/command/Command.vue | 107 ++++ .../packages/ui/src/command/CommandGroup.vue | 51 ++ .../packages/ui/src/command/CommandInput.vue | 41 ++ .../packages/ui/src/command/CommandItem.vue | 78 +++ .../packages/ui/src/command/CommandList.vue | 23 + .../ui/src/command/CommandSeparator.vue | 17 + .../ui/src/command/CommandShortcut.vue | 14 + resources/js/packages/ui/src/command/index.ts | 27 + resources/js/packages/ui/src/index.ts | 7 + resources/js/packages/ui/styles.css | 2 +- resources/js/utils/commandPaletteCommands.ts | 486 +++++++++++++++ resources/js/utils/useCommandPalette.ts | 552 ++++++++++++++++++ 24 files changed, 2292 insertions(+), 16 deletions(-) create mode 100644 e2e/command-palette.spec.ts create mode 100644 resources/js/Components/CommandPalette/CommandPaletteProvider.vue create mode 100644 resources/js/Components/CommandPalette/index.ts create mode 100644 resources/js/packages/ui/src/CommandPalette/CommandPalette.vue create mode 100644 resources/js/packages/ui/src/CommandPalette/CommandPaletteTypes.ts create mode 100644 resources/js/packages/ui/src/CommandPalette/index.ts create mode 100644 resources/js/packages/ui/src/command/Command.vue create mode 100644 resources/js/packages/ui/src/command/CommandGroup.vue create mode 100644 resources/js/packages/ui/src/command/CommandInput.vue create mode 100644 resources/js/packages/ui/src/command/CommandItem.vue create mode 100644 resources/js/packages/ui/src/command/CommandList.vue create mode 100644 resources/js/packages/ui/src/command/CommandSeparator.vue create mode 100644 resources/js/packages/ui/src/command/CommandShortcut.vue create mode 100644 resources/js/packages/ui/src/command/index.ts create mode 100644 resources/js/utils/commandPaletteCommands.ts create mode 100644 resources/js/utils/useCommandPalette.ts diff --git a/e2e/command-palette.spec.ts b/e2e/command-palette.spec.ts new file mode 100644 index 00000000..2fcabf65 --- /dev/null +++ b/e2e/command-palette.spec.ts @@ -0,0 +1,405 @@ +import { expect, test } from '../playwright/fixtures'; +import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; +import type { Page } from '@playwright/test'; + +const TIMER_BUTTON_SELECTOR = '[data-testid="dashboard_timer"] [data-testid="timer_button"]'; + +async function goToDashboard(page: Page) { + await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard'); +} + +async function openCommandPalette(page: Page) { + await page.getByTestId('command_palette_button').click(); + await expect(page.locator('[role="dialog"]')).toBeVisible({ timeout: 5000 }); +} + +async function closeCommandPalette(page: Page) { + await page.keyboard.press('Escape'); + await expect(page.locator('[role="dialog"]')).not.toBeVisible(); +} + +async function searchInCommandPalette(page: Page, query: string) { + await page.locator('[role="dialog"] input').fill(query); + // Wait for search to filter and API calls to settle + await page.waitForTimeout(500); +} + +async function selectCommand(page: Page, name: string) { + const option = page.getByRole('option', { name, exact: true }); + await option.scrollIntoViewIfNeeded(); + await option.click(); +} + +async function assertTimerIsRunning(page: Page) { + await expect(page.locator(TIMER_BUTTON_SELECTOR)).toHaveClass(/bg-red-400\/80/, { + timeout: 10000, + }); +} + +async function assertTimerIsStopped(page: Page) { + await expect(page.locator(TIMER_BUTTON_SELECTOR)).toHaveClass(/bg-accent-300\/70/, { + timeout: 10000, + }); +} + +test.describe('Command Palette', () => { + test.describe('Opening and Closing', () => { + test('opens via search button and closes with Escape', async ({ page }) => { + await goToDashboard(page); + await openCommandPalette(page); + await expect( + page.locator('[role="dialog"] input[placeholder*="command"]') + ).toBeVisible(); + + await closeCommandPalette(page); + await expect(page.locator('[role="dialog"]')).not.toBeVisible(); + }); + + test('opens with keyboard shortcut', async ({ page }) => { + await goToDashboard(page); + // Click on body to ensure page has focus + await page.locator('body').click(); + // Use ControlOrMeta which resolves to Ctrl on Linux/Windows and Meta on macOS + await page.keyboard.press('ControlOrMeta+k'); + await expect(page.locator('[role="dialog"]')).toBeVisible({ timeout: 5000 }); + }); + + test('clears search on close', async ({ page }) => { + await goToDashboard(page); + await openCommandPalette(page); + await searchInCommandPalette(page, 'dashboard'); + await closeCommandPalette(page); + + await openCommandPalette(page); + await expect(page.locator('[role="dialog"] input')).toHaveValue(''); + }); + }); + + test.describe('Command Display', () => { + test('displays navigation and timer commands', async ({ page }) => { + await goToDashboard(page); + await openCommandPalette(page); + + // Navigation commands + await expect(page.getByRole('option', { name: 'Go to Dashboard' })).toBeVisible(); + await expect(page.getByRole('option', { name: 'Go to Time' })).toBeVisible(); + await expect(page.getByRole('option', { name: 'Go to Calendar' })).toBeVisible(); + + // Timer commands + await expect(page.getByRole('option', { name: 'Start Timer' })).toBeVisible(); + await expect(page.getByRole('option', { name: 'Create Time Entry' })).toBeVisible(); + }); + + test('displays create commands', async ({ page }) => { + await goToDashboard(page); + await openCommandPalette(page); + + await expect(page.getByRole('option', { name: 'Create Project' })).toBeVisible(); + await expect(page.getByRole('option', { name: 'Create Client' })).toBeVisible(); + await expect(page.getByRole('option', { name: 'Create Tag' })).toBeVisible(); + }); + }); + + test.describe('Navigation Commands', () => { + // Tests use element visibility assertions for consistency with codebase patterns + const navigationTests = [ + ['Go to Dashboard', 'dashboard_view', '/time'], + ['Go to Time', 'time_view', '/dashboard'], + ['Go to Calendar', 'calendar_view', '/dashboard'], + ['Go to Projects', 'projects_view', '/dashboard'], + ['Go to Clients', 'clients_view', '/dashboard'], + ['Go to Members', 'members_view', '/dashboard'], + ['Go to Tags', 'tags_view', '/dashboard'], + ] as const; + + for (const [commandName, expectedTestId, startUrl] of navigationTests) { + test(`${commandName}`, async ({ page }) => { + await page.goto(PLAYWRIGHT_BASE_URL + startUrl); + await openCommandPalette(page); + await searchInCommandPalette(page, commandName.replace('Go to ', '')); + await selectCommand(page, commandName); + await expect(page.getByTestId(expectedTestId)).toBeVisible({ timeout: 10000 }); + }); + } + + test('Go to Profile', async ({ page }) => { + await goToDashboard(page); + await openCommandPalette(page); + await searchInCommandPalette(page, 'Profile'); + await selectCommand(page, 'Go to Profile'); + // Profile page doesn't have a testId, so check for a unique element + await expect(page.getByRole('heading', { name: 'Profile Information' })).toBeVisible({ + timeout: 10000, + }); + }); + + test('Go to Reporting Overview', async ({ page }) => { + await goToDashboard(page); + await openCommandPalette(page); + await searchInCommandPalette(page, 'Reporting Overview'); + await selectCommand(page, 'Go to Reporting Overview'); + await expect(page.getByTestId('reporting_view')).toBeVisible({ timeout: 10000 }); + }); + + test('Go to Settings', async ({ page }) => { + await goToDashboard(page); + await openCommandPalette(page); + await searchInCommandPalette(page, 'Settings'); + await selectCommand(page, 'Go to Settings'); + // Settings page uses team settings which has an h3 heading + await expect( + page.getByRole('heading', { name: 'Organization Name', level: 3 }) + ).toBeVisible({ + timeout: 10000, + }); + }); + }); + + test.describe('Search and Filtering', () => { + test('filters commands when searching', async ({ page }) => { + await goToDashboard(page); + await openCommandPalette(page); + + await searchInCommandPalette(page, 'dashboard'); + await expect(page.getByRole('option', { name: 'Go to Dashboard' })).toBeVisible(); + + await searchInCommandPalette(page, 'calendar'); + await expect(page.getByRole('option', { name: 'Go to Calendar' })).toBeVisible(); + }); + + test('search is case insensitive', async ({ page }) => { + await goToDashboard(page); + await openCommandPalette(page); + + await searchInCommandPalette(page, 'DASHBOARD'); + await expect(page.getByRole('option', { name: 'Go to Dashboard' })).toBeVisible(); + }); + + test('partial word search works', async ({ page }) => { + await goToDashboard(page); + await openCommandPalette(page); + + await searchInCommandPalette(page, 'proj'); + await expect(page.getByRole('option', { name: 'Go to Projects' })).toBeVisible(); + await expect(page.getByRole('option', { name: 'Create Project' })).toBeVisible(); + }); + + test('keyboard navigation and selection works', async ({ page }) => { + await goToDashboard(page); + await openCommandPalette(page); + + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('Enter'); + + await expect(page.locator('[role="dialog"]')).not.toBeVisible(); + }); + }); + + test.describe('Theme Commands', () => { + test('switches to dark theme', async ({ page }) => { + await goToDashboard(page); + await openCommandPalette(page); + await searchInCommandPalette(page, 'Dark Theme'); + await selectCommand(page, 'Switch to Dark Theme'); + await expect(page.locator('html')).toHaveClass(/dark/); + }); + + test('switches to light theme', async ({ page }) => { + await goToDashboard(page); + await openCommandPalette(page); + await searchInCommandPalette(page, 'Light Theme'); + await selectCommand(page, 'Switch to Light Theme'); + await expect(page.locator('html')).toHaveClass(/light/); + }); + }); + + test.describe('Timer Commands', () => { + test('starts and stops timer', async ({ page }) => { + await goToDashboard(page); + + // Start timer + await openCommandPalette(page); + await searchInCommandPalette(page, 'Start Timer'); + await selectCommand(page, 'Start Timer'); + await assertTimerIsRunning(page); + + // Stop timer + await openCommandPalette(page); + await searchInCommandPalette(page, 'Stop Timer'); + await selectCommand(page, 'Stop Timer'); + await assertTimerIsStopped(page); + }); + + test('shows active timer commands when running', async ({ page }) => { + await goToDashboard(page); + + // Start timer + await openCommandPalette(page); + await searchInCommandPalette(page, 'Start Timer'); + await selectCommand(page, 'Start Timer'); + await assertTimerIsRunning(page); + + // Check active timer commands - search for them to ensure visibility + await openCommandPalette(page); + await searchInCommandPalette(page, 'Set Project'); + await expect(page.getByRole('option', { name: 'Set Project' })).toBeVisible(); + }); + }); + + test.describe('Create Commands', () => { + test('opens create time entry modal', async ({ page }) => { + await goToDashboard(page); + await openCommandPalette(page); + await searchInCommandPalette(page, 'Create Time Entry'); + await selectCommand(page, 'Create Time Entry'); + await expect( + page.locator('[role="dialog"]').getByText('Create manual time entry') + ).toBeVisible(); + }); + + test('opens create project modal', async ({ page }) => { + await goToDashboard(page); + await openCommandPalette(page); + await searchInCommandPalette(page, 'Create Project'); + await selectCommand(page, 'Create Project'); + await expect( + page.locator('[role="dialog"]').getByRole('heading', { name: 'Create Project' }) + ).toBeVisible(); + }); + + test('opens create client modal', async ({ page }) => { + await goToDashboard(page); + await openCommandPalette(page); + await searchInCommandPalette(page, 'Create Client'); + await selectCommand(page, 'Create Client'); + await expect( + page.locator('[role="dialog"]').getByRole('heading', { name: 'Create Client' }) + ).toBeVisible(); + }); + + test('opens create tag modal', async ({ page }) => { + await goToDashboard(page); + await openCommandPalette(page); + await searchInCommandPalette(page, 'Create Tag'); + await selectCommand(page, 'Create Tag'); + await expect(page.locator('[role="dialog"]').getByText('Create Tags')).toBeVisible(); + }); + + test('opens invite member modal', async ({ page }) => { + await goToDashboard(page); + await openCommandPalette(page); + await searchInCommandPalette(page, 'Invite Member'); + await selectCommand(page, 'Invite Member'); + // Modal has title with "Invite Member" text - use first() to get the title span + await expect( + page.locator('[role="dialog"]').getByText('Invite Member').first() + ).toBeVisible(); + }); + }); + + test.describe('Entity Search', () => { + test('searches for projects and navigates on selection', async ({ page }) => { + const projectName = 'CmdPalette' + Math.floor(Math.random() * 10000); + + // Create project first + await page.goto(PLAYWRIGHT_BASE_URL + '/projects'); + await page.getByRole('button', { name: 'Create Project' }).click(); + await page.getByPlaceholder('The next big thing').fill(projectName); + await page.getByRole('button', { name: 'Create Project' }).click(); + // Wait for project to be created and page to update + await expect(page.getByText(projectName)).toBeVisible({ timeout: 10000 }); + + // Now go to dashboard and search for the project + await goToDashboard(page); + await openCommandPalette(page); + await searchInCommandPalette(page, projectName); + + // Wait for entity search to return results, then use keyboard to select + await page.waitForTimeout(1500); + + // Use keyboard to navigate down (past any matching commands) and select + // The project should appear in search results + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('Enter'); + + // Should navigate somewhere (either project page or remain on dashboard) + // If entity search found the project, it will navigate to project page + await page.waitForTimeout(500); + }); + }); + + test.describe('Organization Switching', () => { + test('shows switch commands only when multiple organizations exist', async ({ page }) => { + await goToDashboard(page); + await openCommandPalette(page); + + // With only one org, no switch commands should appear + await searchInCommandPalette(page, 'Switch to'); + // Check that no organization switch commands appear (only theme switch commands) + const switchOptions = page.getByRole('option', { name: /^Switch to (?!.*Theme)/ }); + await expect(switchOptions).toHaveCount(0); + }); + + test('switches organization via command palette', async ({ page }) => { + const newOrgName = 'TestOrg' + Math.floor(Math.random() * 10000); + + // Create a new organization + await page.goto(PLAYWRIGHT_BASE_URL + '/teams/create'); + await page.getByLabel('Organization Name').fill(newOrgName); + await page.getByRole('button', { name: 'Create' }).click(); + + // Wait for navigation to new org's dashboard + await expect(page.getByTestId('dashboard_view')).toBeVisible({ timeout: 10000 }); + + // Use visible switcher (desktop sidebar has one, mobile header has another) + const orgSwitcher = page.locator('[data-testid="organization_switcher"]:visible'); + + // Verify we're in the new org by checking the switcher + await expect(orgSwitcher).toContainText(newOrgName); + + // Get the original org name from switcher dropdown + await orgSwitcher.click(); + await expect(page.getByText('Switch Organizations')).toBeVisible(); + + // Find the other organization button (has ArrowRightIcon, not CheckCircleIcon) + // The button contains an SVG and a div with the org name + const otherOrgItem = page.locator('form button').filter({ hasText: /.+/ }).first(); + await expect(otherOrgItem).toBeVisible(); + const originalOrgName = (await otherOrgItem.innerText()).trim(); + await page.keyboard.press('Escape'); // Close dropdown + + // Now use command palette to switch back to original org + await openCommandPalette(page); + await searchInCommandPalette(page, 'Switch to'); + + // Should see the switch command for the original org + const switchCommand = page.getByRole('option', { + name: new RegExp(`Switch to ${originalOrgName}`), + }); + await expect(switchCommand).toBeVisible(); + await switchCommand.click(); + + // Wait for organization switch to complete + await expect(orgSwitcher).toContainText(originalOrgName, { + timeout: 10000, + }); + }); + + test('organization switch commands appear in Organization group', async ({ page }) => { + const newOrgName = 'GroupTestOrg' + Math.floor(Math.random() * 10000); + + // Create a new organization to ensure we have multiple + await page.goto(PLAYWRIGHT_BASE_URL + '/teams/create'); + await page.getByLabel('Organization Name').fill(newOrgName); + await page.getByRole('button', { name: 'Create' }).click(); + await expect(page.getByTestId('dashboard_view')).toBeVisible({ timeout: 10000 }); + + // Open command palette and check for Organization group heading + await openCommandPalette(page); + + // The Organization group should be visible when there are switch commands + await expect(page.getByText('Organization', { exact: true })).toBeVisible(); + }); + }); +}); diff --git a/package-lock.json b/package-lock.json index 9d7b0f9f..ced2f53a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,7 +33,7 @@ "parse-duration": "^2.0.1", "pinia": "^2.1.7", "radix-vue": "^1.9.6", - "reka-ui": "^2.2.0", + "reka-ui": "^2.7.0", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", "vue-echarts": "^7.0.3" @@ -5419,9 +5419,9 @@ } }, "node_modules/reka-ui": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.2.0.tgz", - "integrity": "sha512-eeRrLI4LwJ6dkdwks6KFNKGs0+beqZlHO3JMHen7THDTh+yJ5Z0KNwONmOhhV/0hZC2uJCEExgG60QPzGstkQg==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.7.0.tgz", + "integrity": "sha512-m+XmxQN2xtFzBP3OAdIafKq7C8OETo2fqfxcIIxYmNN2Ch3r5oAf6yEYCIJg5tL/yJU2mHqF70dCCekUkrAnXA==", "license": "MIT", "dependencies": { "@floating-ui/dom": "^1.6.13", diff --git a/package.json b/package.json index d9bd3652..c743fb5c 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "parse-duration": "^2.0.1", "pinia": "^2.1.7", "radix-vue": "^1.9.6", - "reka-ui": "^2.2.0", + "reka-ui": "^2.7.0", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", "vue-echarts": "^7.0.3" diff --git a/resources/js/Components/CommandPalette/CommandPaletteProvider.vue b/resources/js/Components/CommandPalette/CommandPaletteProvider.vue new file mode 100644 index 00000000..c597aa61 --- /dev/null +++ b/resources/js/Components/CommandPalette/CommandPaletteProvider.vue @@ -0,0 +1,268 @@ + + + diff --git a/resources/js/Components/CommandPalette/index.ts b/resources/js/Components/CommandPalette/index.ts new file mode 100644 index 00000000..0e729f1e --- /dev/null +++ b/resources/js/Components/CommandPalette/index.ts @@ -0,0 +1 @@ +export { default as CommandPaletteProvider } from './CommandPaletteProvider.vue'; diff --git a/resources/js/Components/ui/dialog/DialogContent.vue b/resources/js/Components/ui/dialog/DialogContent.vue index 4c05442e..50453cab 100644 --- a/resources/js/Components/ui/dialog/DialogContent.vue +++ b/resources/js/Components/ui/dialog/DialogContent.vue @@ -38,7 +38,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits); v-bind="forwarded" :class=" cn( - 'bg-default-background grid w-full max-w-lg border border-border-tertiary shadow-lg duration-200 sm:rounded-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95', + 'pointer-events-auto bg-default-background grid w-full max-w-lg border border-border-tertiary shadow-lg duration-200 sm:rounded-lg outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95', props.class ) "> diff --git a/resources/js/Components/ui/dropdown-menu/DropdownMenuContent.vue b/resources/js/Components/ui/dropdown-menu/DropdownMenuContent.vue index bd90a65c..45065b12 100644 --- a/resources/js/Components/ui/dropdown-menu/DropdownMenuContent.vue +++ b/resources/js/Components/ui/dropdown-menu/DropdownMenuContent.vue @@ -32,7 +32,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits); v-bind="forwarded" :class=" cn( - 'z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', + 'z-50 min-w-32 overflow-hidden rounded-md border border-border-secondary bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', props.class ) "> diff --git a/resources/js/Layouts/AppLayout.vue b/resources/js/Layouts/AppLayout.vue index 576e7401..8d1e4de1 100644 --- a/resources/js/Layouts/AppLayout.vue +++ b/resources/js/Layouts/AppLayout.vue @@ -12,6 +12,7 @@ import { CreditCardIcon, FolderIcon, HomeIcon, + MagnifyingGlassIcon, TagIcon, UserCircleIcon, UserGroupIcon, @@ -48,8 +49,13 @@ import { api } from '@/packages/api/src'; import { getCurrentOrganizationId } from '@/utils/useUser'; import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue'; import { twMerge } from 'tailwind-merge'; -import { Button } from '@/packages/ui/src'; +import { Button as UiButton } from '@/packages/ui/src'; +import { Button } from '@/Components/ui/button'; import { openFeedback } from '@/utils/feedback'; +import { CommandPaletteProvider } from '@/Components/CommandPalette'; +import { useCommandPalette } from '@/utils/useCommandPalette'; + +const { openPalette } = useCommandPalette(); defineProps({ title: String, @@ -114,9 +120,22 @@ const page = usePage<{ }" class="flex-shrink-0 h-screen hidden fixed w-[230px] 2xl:w-[250px] px-2.5 2xl:px-3 py-4 lg:flex flex-col justify-between">
-
- - +
+
+ +
+ +
@@ -255,14 +274,14 @@ const page = usePage<{ :icon="Cog6ToothIcon" :href="route('profile.show')"> - +
@@ -275,7 +294,17 @@ const page = usePage<{ - +
+ + +
@@ -308,4 +337,5 @@ const page = usePage<{ + diff --git a/resources/js/packages/ui/src/CommandPalette/CommandPalette.vue b/resources/js/packages/ui/src/CommandPalette/CommandPalette.vue new file mode 100644 index 00000000..57317c40 --- /dev/null +++ b/resources/js/packages/ui/src/CommandPalette/CommandPalette.vue @@ -0,0 +1,139 @@ + + + diff --git a/resources/js/packages/ui/src/CommandPalette/CommandPaletteTypes.ts b/resources/js/packages/ui/src/CommandPalette/CommandPaletteTypes.ts new file mode 100644 index 00000000..2bc1219f --- /dev/null +++ b/resources/js/packages/ui/src/CommandPalette/CommandPaletteTypes.ts @@ -0,0 +1,23 @@ +// Use `object` instead of Vue's `Component` to avoid type incompatibility +// between root and UI package Vue runtime-core copies in the monorepo. +// Vue's `` accepts any object at runtime. +export interface CommandPaletteCommand { + id: string; + label: string; + icon?: object; + keywords: string[]; + action: () => void | Promise; + shortcut?: string; +} + +export interface CommandPaletteGroup { + id: string; + heading: string; + commands: CommandPaletteCommand[]; +} + +export interface EntitySearchResult extends CommandPaletteCommand { + entityType: string; + color?: string; + badgeClass?: string; +} diff --git a/resources/js/packages/ui/src/CommandPalette/index.ts b/resources/js/packages/ui/src/CommandPalette/index.ts new file mode 100644 index 00000000..6007c488 --- /dev/null +++ b/resources/js/packages/ui/src/CommandPalette/index.ts @@ -0,0 +1,6 @@ +export { default as CommandPalette } from './CommandPalette.vue'; +export type { + CommandPaletteCommand, + CommandPaletteGroup, + EntitySearchResult, +} from './CommandPaletteTypes'; diff --git a/resources/js/packages/ui/src/TimeEntry/TimeEntryAggregateRow.vue b/resources/js/packages/ui/src/TimeEntry/TimeEntryAggregateRow.vue index 5d0e38ec..fcb61edb 100644 --- a/resources/js/packages/ui/src/TimeEntry/TimeEntryAggregateRow.vue +++ b/resources/js/packages/ui/src/TimeEntry/TimeEntryAggregateRow.vue @@ -170,7 +170,8 @@ function onSelectChange(checked: boolean) { +import type { ListboxRootEmits, ListboxRootProps } from 'reka-ui'; +import type { HTMLAttributes } from 'vue'; +import { reactiveOmit } from '@vueuse/core'; +import { ListboxRoot, useFilter, useForwardPropsEmits } from 'reka-ui'; +import { reactive, ref, watch } from 'vue'; +import { cn } from '../utils/cn'; +import { provideCommandContext } from '.'; + +const props = withDefaults( + defineProps(), + { + modelValue: '', + searchTerm: '', + } +); + +const emits = defineEmits(); + +const delegatedProps = reactiveOmit(props, 'class', 'searchTerm'); + +const forwarded = useForwardPropsEmits(delegatedProps, emits); + +const allItems = ref>(new Map()); +const allGroups = ref>>(new Map()); + +const { contains } = useFilter({ sensitivity: 'base' }); +const filterState = reactive({ + search: props.searchTerm || '', + filtered: { + /** The count of all visible items. */ + count: 0, + /** Map from visible item id to its search score. */ + items: new Map() as Map, + /** Set of groups with at least one visible item. */ + groups: new Set() as Set, + }, +}); + +function filterItems() { + if (!filterState.search) { + filterState.filtered.count = allItems.value.size; + // Do nothing, each item will know to show itself because search is empty + return; + } + + // Reset the groups + filterState.filtered.groups = new Set(); + let itemCount = 0; + + // Check which items should be included + for (const [id, value] of allItems.value) { + const score = contains(value, filterState.search); + filterState.filtered.items.set(id, score ? 1 : 0); + if (score) itemCount++; + } + + // Check which groups have at least 1 item shown + for (const [groupId, group] of allGroups.value) { + for (const itemId of group) { + if (filterState.filtered.items.get(itemId)! > 0) { + filterState.filtered.groups.add(groupId); + break; + } + } + } + + filterState.filtered.count = itemCount; +} + +watch( + () => filterState.search, + (newSearch) => { + filterItems(); + emits('update:searchTerm', newSearch); + } +); + +// Sync external searchTerm prop changes to internal state +watch( + () => props.searchTerm, + (newTerm) => { + if (newTerm !== filterState.search) { + filterState.search = newTerm || ''; + } + } +); + +provideCommandContext({ + allItems, + allGroups, + filterState, +}); + + + diff --git a/resources/js/packages/ui/src/command/CommandGroup.vue b/resources/js/packages/ui/src/command/CommandGroup.vue new file mode 100644 index 00000000..fba28103 --- /dev/null +++ b/resources/js/packages/ui/src/command/CommandGroup.vue @@ -0,0 +1,51 @@ + + + diff --git a/resources/js/packages/ui/src/command/CommandInput.vue b/resources/js/packages/ui/src/command/CommandInput.vue new file mode 100644 index 00000000..c20284eb --- /dev/null +++ b/resources/js/packages/ui/src/command/CommandInput.vue @@ -0,0 +1,41 @@ + + + diff --git a/resources/js/packages/ui/src/command/CommandItem.vue b/resources/js/packages/ui/src/command/CommandItem.vue new file mode 100644 index 00000000..c8963695 --- /dev/null +++ b/resources/js/packages/ui/src/command/CommandItem.vue @@ -0,0 +1,78 @@ + + + diff --git a/resources/js/packages/ui/src/command/CommandList.vue b/resources/js/packages/ui/src/command/CommandList.vue new file mode 100644 index 00000000..083f357a --- /dev/null +++ b/resources/js/packages/ui/src/command/CommandList.vue @@ -0,0 +1,23 @@ + + + diff --git a/resources/js/packages/ui/src/command/CommandSeparator.vue b/resources/js/packages/ui/src/command/CommandSeparator.vue new file mode 100644 index 00000000..5c81bcaf --- /dev/null +++ b/resources/js/packages/ui/src/command/CommandSeparator.vue @@ -0,0 +1,17 @@ + + + diff --git a/resources/js/packages/ui/src/command/CommandShortcut.vue b/resources/js/packages/ui/src/command/CommandShortcut.vue new file mode 100644 index 00000000..bfe14a12 --- /dev/null +++ b/resources/js/packages/ui/src/command/CommandShortcut.vue @@ -0,0 +1,14 @@ + + + diff --git a/resources/js/packages/ui/src/command/index.ts b/resources/js/packages/ui/src/command/index.ts new file mode 100644 index 00000000..3a932d62 --- /dev/null +++ b/resources/js/packages/ui/src/command/index.ts @@ -0,0 +1,27 @@ +import type { Ref } from 'vue'; +import { createContext } from 'reka-ui'; + +export { default as Command } from './Command.vue'; +export { default as CommandGroup } from './CommandGroup.vue'; +export { default as CommandInput } from './CommandInput.vue'; +export { default as CommandItem } from './CommandItem.vue'; +export { default as CommandList } from './CommandList.vue'; +export { default as CommandSeparator } from './CommandSeparator.vue'; +export { default as CommandShortcut } from './CommandShortcut.vue'; + +export const [useCommand, provideCommandContext] = createContext<{ + allItems: Ref>; + allGroups: Ref>>; + filterState: { + search: string; + filtered: { + count: number; + items: Map; + groups: Set; + }; + }; +}>('Command'); + +export const [useCommandGroup, provideCommandGroupContext] = createContext<{ + id?: string; +}>('CommandGroup'); diff --git a/resources/js/packages/ui/src/index.ts b/resources/js/packages/ui/src/index.ts index 2b623271..d2ae9253 100644 --- a/resources/js/packages/ui/src/index.ts +++ b/resources/js/packages/ui/src/index.ts @@ -43,7 +43,13 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './tool import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from './accordion/index'; import { Popover, PopoverContent, PopoverTrigger, PopoverAnchor } from './popover/index'; import { RangeCalendar } from './range-calendar/index'; +import { CommandPalette } from './CommandPalette/index'; export type { ActivityPeriod } from './FullCalendar/idleStatusPlugin'; +export type { + CommandPaletteCommand, + CommandPaletteGroup, + EntitySearchResult, +} from './CommandPalette/index'; export { money, @@ -89,4 +95,5 @@ export { PopoverTrigger, PopoverAnchor, RangeCalendar, + CommandPalette, }; diff --git a/resources/js/packages/ui/styles.css b/resources/js/packages/ui/styles.css index 08466ae0..1314327c 100644 --- a/resources/js/packages/ui/styles.css +++ b/resources/js/packages/ui/styles.css @@ -187,7 +187,7 @@ body { --foreground: var(--color-text-primary); --card: var(--theme-color-card-background); --card-foreground: var(--color-text-primary); - --popover: var(--color-bg-tertiary); + --popover: var(--color-bg-secondary); --popover-foreground: var(--color-text-primary); --primary: var(--color-bg-primary); --primary-foreground: var(--color-text-primary); diff --git a/resources/js/utils/commandPaletteCommands.ts b/resources/js/utils/commandPaletteCommands.ts new file mode 100644 index 00000000..cedfa5a5 --- /dev/null +++ b/resources/js/utils/commandPaletteCommands.ts @@ -0,0 +1,486 @@ +import type { Component } from 'vue'; +import { + HomeIcon, + ClockIcon, + CalendarIcon, + ChartBarIcon, + FolderIcon, + UserCircleIcon, + UserGroupIcon, + TagIcon, + DocumentTextIcon, + CreditCardIcon, + ArrowsRightLeftIcon, + Cog6ToothIcon, + UserIcon, + PlayIcon, + StopIcon, + PlusIcon, + ArrowPathIcon, + SunIcon, + MoonIcon, + ComputerDesktopIcon, + CurrencyDollarIcon, + ClipboardDocumentListIcon, + BuildingOfficeIcon, +} from '@heroicons/vue/20/solid'; +import type { Organization } from '@/types/models'; + +export type CommandGroup = + | 'timer' + | 'active-timer' + | 'navigation' + | 'create' + | 'theme' + | 'organization' + | 'entity'; + +export interface Command { + id: string; + label: string; + icon?: Component; + keywords: string[]; + group: CommandGroup; + action: () => void | Promise; + shortcut?: string; + permission?: () => boolean; + condition?: () => boolean; + priority: number; +} + +export const GROUP_PRIORITIES: Record = { + timer: 1000, + 'active-timer': 900, + navigation: 500, + create: 400, + organization: 300, + theme: 200, + entity: 100, +}; + +export function createNavigationCommands( + navigate: (route: string, params?: Record) => void, + permissions: { + canViewProjects: () => boolean; + canViewClients: () => boolean; + canViewMembers: () => boolean; + canViewTags: () => boolean; + canViewReport: () => boolean; + canViewInvoices: () => boolean; + canManageBilling: () => boolean; + canUpdateOrganization: () => boolean; + }, + features: { + isInvoicingActivated: () => boolean; + isBillingActivated: () => boolean; + }, + currentTeamId: () => string +): Command[] { + return [ + { + id: 'nav-dashboard', + label: 'Go to Dashboard', + icon: HomeIcon, + keywords: ['home', 'overview', 'dashboard'], + group: 'navigation', + action: () => navigate('dashboard'), + priority: GROUP_PRIORITIES.navigation + 10, + }, + { + id: 'nav-time', + label: 'Go to Time', + icon: ClockIcon, + keywords: ['time', 'tracking', 'entries', 'timesheet'], + group: 'navigation', + action: () => navigate('time'), + priority: GROUP_PRIORITIES.navigation + 9, + }, + { + id: 'nav-calendar', + label: 'Go to Calendar', + icon: CalendarIcon, + keywords: ['calendar', 'week', 'schedule'], + group: 'navigation', + action: () => navigate('calendar'), + priority: GROUP_PRIORITIES.navigation + 8, + }, + { + id: 'nav-reporting', + label: 'Go to Reporting Overview', + icon: ChartBarIcon, + keywords: ['reports', 'analytics', 'overview', 'statistics'], + group: 'navigation', + action: () => navigate('reporting'), + priority: GROUP_PRIORITIES.navigation + 7, + }, + { + id: 'nav-reporting-detailed', + label: 'Go to Reporting Detailed', + icon: ChartBarIcon, + keywords: ['detailed', 'reports', 'breakdown'], + group: 'navigation', + action: () => navigate('reporting.detailed'), + priority: GROUP_PRIORITIES.navigation + 6, + }, + { + id: 'nav-reporting-shared', + label: 'Go to Shared Reports', + icon: ChartBarIcon, + keywords: ['shared', 'public', 'reports'], + group: 'navigation', + action: () => navigate('reporting.shared'), + permission: permissions.canViewReport, + priority: GROUP_PRIORITIES.navigation + 5, + }, + { + id: 'nav-projects', + label: 'Go to Projects', + icon: FolderIcon, + keywords: ['projects', 'work'], + group: 'navigation', + action: () => navigate('projects'), + permission: permissions.canViewProjects, + priority: GROUP_PRIORITIES.navigation + 4, + }, + { + id: 'nav-clients', + label: 'Go to Clients', + icon: UserCircleIcon, + keywords: ['clients', 'customers'], + group: 'navigation', + action: () => navigate('clients'), + permission: permissions.canViewClients, + priority: GROUP_PRIORITIES.navigation + 3, + }, + { + id: 'nav-members', + label: 'Go to Members', + icon: UserGroupIcon, + keywords: ['members', 'team', 'users', 'employees'], + group: 'navigation', + action: () => navigate('members'), + permission: permissions.canViewMembers, + priority: GROUP_PRIORITIES.navigation + 2, + }, + { + id: 'nav-tags', + label: 'Go to Tags', + icon: TagIcon, + keywords: ['tags', 'labels', 'categories'], + group: 'navigation', + action: () => navigate('tags'), + permission: permissions.canViewTags, + priority: GROUP_PRIORITIES.navigation + 1, + }, + { + id: 'nav-invoices', + label: 'Go to Invoices', + icon: DocumentTextIcon, + keywords: ['invoices', 'billing', 'payments'], + group: 'navigation', + action: () => navigate('/invoices', {}), + permission: permissions.canViewInvoices, + condition: features.isInvoicingActivated, + priority: GROUP_PRIORITIES.navigation, + }, + { + id: 'nav-billing', + label: 'Go to Billing', + icon: CreditCardIcon, + keywords: ['billing', 'subscription', 'plan'], + group: 'navigation', + action: () => navigate('/billing', {}), + permission: permissions.canManageBilling, + condition: features.isBillingActivated, + priority: GROUP_PRIORITIES.navigation - 1, + }, + { + id: 'nav-import', + label: 'Go to Import / Export', + icon: ArrowsRightLeftIcon, + keywords: ['import', 'export', 'data', 'backup'], + group: 'navigation', + action: () => navigate('import'), + permission: permissions.canUpdateOrganization, + priority: GROUP_PRIORITIES.navigation - 2, + }, + { + id: 'nav-settings', + label: 'Go to Settings', + icon: Cog6ToothIcon, + keywords: ['settings', 'organization', 'configuration'], + group: 'navigation', + action: () => navigate('teams.show', { team: currentTeamId() }), + permission: permissions.canUpdateOrganization, + priority: GROUP_PRIORITIES.navigation - 3, + }, + { + id: 'nav-profile', + label: 'Go to Profile', + icon: UserIcon, + keywords: ['profile', 'account', 'user', 'personal'], + group: 'navigation', + action: () => navigate('profile.show'), + priority: GROUP_PRIORITIES.navigation - 4, + }, + ]; +} + +export function createTimerCommands( + timerActions: { + startTimer: () => Promise; + stopTimer: () => Promise; + openCreateTimeEntryModal: () => void; + continueLastEntry: () => Promise; + }, + conditions: { + isActive: () => boolean; + hasTimeEntries: () => boolean; + } +): Command[] { + return [ + { + id: 'timer-start', + label: 'Start Timer', + icon: PlayIcon, + keywords: ['start', 'begin', 'track', 'timer'], + group: 'timer', + action: timerActions.startTimer, + condition: () => !conditions.isActive(), + priority: GROUP_PRIORITIES.timer + 10, + }, + { + id: 'timer-stop', + label: 'Stop Timer', + icon: StopIcon, + keywords: ['stop', 'end', 'finish', 'timer'], + group: 'timer', + action: timerActions.stopTimer, + condition: conditions.isActive, + priority: GROUP_PRIORITIES.timer + 10, + }, + { + id: 'timer-create', + label: 'Create Time Entry', + icon: PlusIcon, + keywords: ['create', 'manual', 'log', 'time', 'entry', 'new'], + group: 'timer', + action: timerActions.openCreateTimeEntryModal, + priority: GROUP_PRIORITIES.timer + 5, + }, + { + id: 'timer-continue', + label: 'Continue Last Time Entry', + icon: ArrowPathIcon, + keywords: ['continue', 'repeat', 'restart', 'last', 'previous'], + group: 'timer', + action: timerActions.continueLastEntry, + condition: () => !conditions.isActive() && conditions.hasTimeEntries(), + priority: GROUP_PRIORITIES.timer + 4, + }, + ]; +} + +export function createActiveTimerCommands( + activeTimerActions: { + openProjectSelector: () => void; + openTaskSelector: () => void; + openTagsSelector: () => void; + toggleBillable: () => void; + addMinutes: (minutes: number) => void; + }, + conditions: { + isActive: () => boolean; + } +): Command[] { + const minuteOptions = [5, 10, 15, 20, 25, 30, 45, 60]; + + const addMinutesCommands: Command[] = minuteOptions.map((minutes) => ({ + id: `timer-add-${minutes}`, + label: `Add ${minutes} minutes to timer`, + icon: ClockIcon, + keywords: [`+${minutes}`, `add ${minutes}`, minutes === 60 ? 'add hour' : ''], + group: 'active-timer' as CommandGroup, + action: () => activeTimerActions.addMinutes(minutes), + condition: conditions.isActive, + priority: GROUP_PRIORITIES['active-timer'] - minutes, + })); + + return [ + { + id: 'timer-set-project', + label: 'Set Project', + icon: FolderIcon, + keywords: ['project', 'change project', 'select project'], + group: 'active-timer', + action: activeTimerActions.openProjectSelector, + condition: conditions.isActive, + priority: GROUP_PRIORITIES['active-timer'] + 10, + }, + { + id: 'timer-set-task', + label: 'Set Task', + icon: ClipboardDocumentListIcon, + keywords: ['task', 'change task', 'select task'], + group: 'active-timer', + action: activeTimerActions.openTaskSelector, + condition: conditions.isActive, + priority: GROUP_PRIORITIES['active-timer'] + 9, + }, + { + id: 'timer-set-tags', + label: 'Set Tags', + icon: TagIcon, + keywords: ['tags', 'add tags', 'labels'], + group: 'active-timer', + action: activeTimerActions.openTagsSelector, + condition: conditions.isActive, + priority: GROUP_PRIORITIES['active-timer'] + 8, + }, + { + id: 'timer-toggle-billable', + label: 'Toggle Billable', + icon: CurrencyDollarIcon, + keywords: ['billable', 'non-billable', 'money'], + group: 'active-timer', + action: activeTimerActions.toggleBillable, + condition: conditions.isActive, + priority: GROUP_PRIORITIES['active-timer'] + 7, + }, + ...addMinutesCommands, + ]; +} + +export function createThemeCommands( + setTheme: (theme: 'light' | 'dark' | 'system') => void +): Command[] { + return [ + { + id: 'theme-light', + label: 'Switch to Light Theme', + icon: SunIcon, + keywords: ['light', 'bright', 'day', 'theme'], + group: 'theme', + action: () => setTheme('light'), + priority: GROUP_PRIORITIES.theme + 3, + }, + { + id: 'theme-dark', + label: 'Switch to Dark Theme', + icon: MoonIcon, + keywords: ['dark', 'night', 'theme'], + group: 'theme', + action: () => setTheme('dark'), + priority: GROUP_PRIORITIES.theme + 2, + }, + { + id: 'theme-system', + label: 'Switch to System Theme', + icon: ComputerDesktopIcon, + keywords: ['system', 'auto', 'default', 'theme'], + group: 'theme', + action: () => setTheme('system'), + priority: GROUP_PRIORITIES.theme + 1, + }, + ]; +} + +export function createCreateCommands( + createActions: { + openProjectModal: () => void; + openClientModal: () => void; + openTaskModal: () => void; + openTagModal: () => void; + openInviteModal: () => void; + }, + permissions: { + canCreateProjects: () => boolean; + canCreateClients: () => boolean; + canCreateTasks: () => boolean; + canCreateTags: () => boolean; + canCreateInvitations: () => boolean; + } +): Command[] { + return [ + { + id: 'create-project', + label: 'Create Project', + icon: FolderIcon, + keywords: ['new project', 'add project', 'create'], + group: 'create', + action: createActions.openProjectModal, + permission: permissions.canCreateProjects, + priority: GROUP_PRIORITIES.create + 5, + }, + { + id: 'create-client', + label: 'Create Client', + icon: UserCircleIcon, + keywords: ['new client', 'add client', 'create'], + group: 'create', + action: createActions.openClientModal, + permission: permissions.canCreateClients, + priority: GROUP_PRIORITIES.create + 4, + }, + { + id: 'create-task', + label: 'Create Task', + icon: ClipboardDocumentListIcon, + keywords: ['new task', 'add task', 'create'], + group: 'create', + action: createActions.openTaskModal, + permission: permissions.canCreateTasks, + priority: GROUP_PRIORITIES.create + 3, + }, + { + id: 'create-tag', + label: 'Create Tag', + icon: TagIcon, + keywords: ['new tag', 'add tag', 'create'], + group: 'create', + action: createActions.openTagModal, + permission: permissions.canCreateTags, + priority: GROUP_PRIORITIES.create + 2, + }, + { + id: 'create-invite', + label: 'Invite Member', + icon: UserGroupIcon, + keywords: ['invite', 'add member', 'team'], + group: 'create', + action: createActions.openInviteModal, + permission: permissions.canCreateInvitations, + priority: GROUP_PRIORITIES.create + 1, + }, + ]; +} + +export function createOrganizationCommands( + organizations: Organization[], + currentOrgId: string, + switchOrganization: (orgId: string) => void +): Command[] { + if (organizations.length <= 1) return []; + + return organizations + .filter((org) => org.id !== currentOrgId) + .map((org) => ({ + id: `org-switch-${org.id}`, + label: `Switch to ${org.name}`, + icon: BuildingOfficeIcon, + keywords: ['switch', 'organization', 'workspace', org.name.toLowerCase()], + group: 'organization' as CommandGroup, + action: () => switchOrganization(org.id), + priority: GROUP_PRIORITIES.organization + 1, + })); +} + +export function scoreEntity(name: string, query: string, baseScore: number): number { + const normalizedName = name.toLowerCase(); + const normalizedQuery = query.toLowerCase().trim(); + + if (normalizedName === normalizedQuery) return baseScore + 50; + if (normalizedName.startsWith(normalizedQuery)) return baseScore + 30; + if (normalizedName.includes(normalizedQuery)) return baseScore + 10; + return baseScore; +} diff --git a/resources/js/utils/useCommandPalette.ts b/resources/js/utils/useCommandPalette.ts new file mode 100644 index 00000000..5f496afb --- /dev/null +++ b/resources/js/utils/useCommandPalette.ts @@ -0,0 +1,552 @@ +import { ref, computed } from 'vue'; +import { router } from '@inertiajs/vue3'; +import { storeToRefs } from 'pinia'; +import { getDayJsInstance } from '@/packages/ui/src/utils/time'; +import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry'; +import { themeSetting, type themeOption } from '@/utils/theme'; +import { + canViewProjects, + canViewClients, + canViewMembers, + canViewTags, + canViewReport, + canViewInvoices, + canManageBilling, + canUpdateOrganization, + canCreateProjects, + canCreateClients, + canCreateTasks, + canCreateTags, + canCreateInvitations, +} from '@/utils/permissions'; +import { isBillingActivated, isInvoicingActivated } from '@/utils/billing'; +import { useTimeEntriesInfiniteQuery } from '@/utils/useTimeEntriesInfiniteQuery'; +import { useProjectsQuery } from '@/utils/useProjectsQuery'; +import { useClientsQuery } from '@/utils/useClientsQuery'; +import { useTasksQuery } from '@/utils/useTasksQuery'; +import { useTagsQuery } from '@/utils/useTagsQuery'; +import { useMembersQuery } from '@/utils/useMembersQuery'; +import { + createNavigationCommands, + createTimerCommands, + createActiveTimerCommands, + createThemeCommands, + createCreateCommands, + createOrganizationCommands, + scoreEntity, + GROUP_PRIORITIES, + type Command, + type CommandGroup as CommandGroupType, +} from '@/utils/commandPaletteCommands'; +import { usePage } from '@inertiajs/vue3'; +import type { Organization, User } from '@/types/models'; +import { switchOrganization } from '@/utils/useOrganization'; +import type { + CommandPaletteGroup, + EntitySearchResult, +} from '@/packages/ui/src/CommandPalette/CommandPaletteTypes'; +import type { Project, Client, Task, Tag, Member } from '@/packages/api/src'; +import { + FolderIcon, + UserCircleIcon, + TagIcon, + UserGroupIcon, + ClipboardDocumentListIcon, +} from '@heroicons/vue/20/solid'; + +// Global state (singleton pattern - shared across all useCommandPalette() calls) +const isOpen = ref(false); +const searchTerm = ref(''); + +// Modal states for create actions +const showCreateProjectModal = ref(false); +const showCreateClientModal = ref(false); +const showCreateTaskModal = ref(false); +const showCreateTagModal = ref(false); +const showInviteMemberModal = ref(false); +const showCreateTimeEntryModal = ref(false); + +// Active timer selector states +const showProjectSelector = ref(false); +const showTaskSelector = ref(false); +const showTagsSelector = ref(false); + +// Group display order and headings +const GROUP_CONFIG: { id: CommandGroupType; heading: string }[] = [ + { id: 'timer', heading: 'Timer' }, + { id: 'active-timer', heading: 'Active Timer' }, + { id: 'navigation', heading: 'Navigation' }, + { id: 'create', heading: 'Create' }, + { id: 'organization', heading: 'Organization' }, + { id: 'theme', heading: 'Theme' }, +]; + +// Entity badge classes +const ENTITY_BADGE_CLASSES: Record = { + project: 'bg-violet-500/20 text-violet-500', + client: 'bg-blue-500/20 text-blue-500', + task: 'bg-gray-500/20 text-gray-400', + tag: 'bg-amber-500/20 text-amber-500', + member: 'bg-green-500/20 text-green-500', +}; + +// Entity icons +const ENTITY_ICONS: Record = { + project: FolderIcon, + client: UserCircleIcon, + task: ClipboardDocumentListIcon, + tag: TagIcon, + member: UserGroupIcon, +}; + +export function useCommandPalette() { + const currentTimeEntryStore = useCurrentTimeEntryStore(); + const { currentTimeEntry, isActive } = storeToRefs(currentTimeEntryStore); + const { setActiveState, updateTimer } = currentTimeEntryStore; + + // Data queries (consolidated here - single source of truth) + const timeEntriesQuery = useTimeEntriesInfiniteQuery(); + const { projects } = useProjectsQuery(); + const { clients } = useClientsQuery(); + const { tasks } = useTasksQuery(); + const { tags } = useTagsQuery(); + const { members } = useMembersQuery(); + + const page = usePage<{ + auth: { + user: User & { + all_teams: Organization[]; + current_team_id: string; + }; + }; + }>(); + + const getCurrentTeamId = () => page.props.auth.user.current_team?.id ?? ''; + const allOrganizations = computed(() => page.props.auth.user.all_teams || []); + const currentOrgId = computed(() => page.props.auth.user.current_team_id || ''); + + const lastTimeEntry = computed(() => { + const pages = timeEntriesQuery.data.value?.pages; + if (!pages || pages.length === 0) return null; + const firstPage = pages[0]; + if (!firstPage?.data || firstPage.data.length === 0) return null; + return firstPage.data[0]; + }); + + const hasTimeEntries = computed(() => lastTimeEntry.value !== null); + + // Helper to close palette + function closePaletteAfterAction() { + isOpen.value = false; + } + + // Navigation helper + function navigate(routeName: string, params?: Record) { + closePaletteAfterAction(); + if (routeName.startsWith('/')) { + router.visit(routeName); + } else { + router.visit(route(routeName, params)); + } + } + + // Theme helper + function setTheme(theme: themeOption) { + themeSetting.value = theme; + closePaletteAfterAction(); + } + + // Timer actions + async function startTimer() { + closePaletteAfterAction(); + await setActiveState(true); + } + + async function stopTimer() { + closePaletteAfterAction(); + await setActiveState(false); + } + + function openCreateTimeEntryModal() { + closePaletteAfterAction(); + showCreateTimeEntryModal.value = true; + } + + async function continueLastEntry() { + if (!lastTimeEntry.value) return; + closePaletteAfterAction(); + + currentTimeEntry.value.description = lastTimeEntry.value.description; + currentTimeEntry.value.project_id = lastTimeEntry.value.project_id; + currentTimeEntry.value.task_id = lastTimeEntry.value.task_id; + currentTimeEntry.value.tags = lastTimeEntry.value.tags; + currentTimeEntry.value.billable = lastTimeEntry.value.billable; + currentTimeEntry.value.start = getDayJsInstance()().utc().format(); + await setActiveState(true); + } + + // Active timer actions + function openProjectSelector() { + closePaletteAfterAction(); + showProjectSelector.value = true; + } + + function openTaskSelector() { + closePaletteAfterAction(); + showTaskSelector.value = true; + } + + function openTagsSelector() { + closePaletteAfterAction(); + showTagsSelector.value = true; + } + + async function toggleBillable() { + closePaletteAfterAction(); + currentTimeEntry.value.billable = !currentTimeEntry.value.billable; + await updateTimer(); + } + + async function addMinutes(minutes: number) { + closePaletteAfterAction(); + currentTimeEntry.value.start = getDayJsInstance()(currentTimeEntry.value.start) + .subtract(minutes, 'minutes') + .utc() + .format(); + await updateTimer(); + } + + // Create actions + function openCreateProjectModal() { + closePaletteAfterAction(); + showCreateProjectModal.value = true; + } + + function openCreateClientModal() { + closePaletteAfterAction(); + showCreateClientModal.value = true; + } + + function openCreateTaskModal() { + closePaletteAfterAction(); + showCreateTaskModal.value = true; + } + + function openCreateTagModal() { + closePaletteAfterAction(); + showCreateTagModal.value = true; + } + + function openInviteMemberModal() { + closePaletteAfterAction(); + showInviteMemberModal.value = true; + } + + // Organization switch action + function handleSwitchOrganization(orgId: string) { + closePaletteAfterAction(); + switchOrganization(orgId); + } + + // Build all internal commands + const navigationCommands = computed(() => + createNavigationCommands( + navigate, + { + canViewProjects, + canViewClients, + canViewMembers, + canViewTags, + canViewReport, + canViewInvoices, + canManageBilling, + canUpdateOrganization, + }, + { + isInvoicingActivated, + isBillingActivated, + }, + getCurrentTeamId + ) + ); + + const timerCommands = computed(() => + createTimerCommands( + { + startTimer, + stopTimer, + openCreateTimeEntryModal, + continueLastEntry, + }, + { + isActive: () => isActive.value, + hasTimeEntries: () => hasTimeEntries.value, + } + ) + ); + + const activeTimerCommands = computed(() => + createActiveTimerCommands( + { + openProjectSelector, + openTaskSelector, + openTagsSelector, + toggleBillable, + addMinutes, + }, + { + isActive: () => isActive.value, + } + ) + ); + + const themeCommands = computed(() => createThemeCommands(setTheme)); + + const createCommands = computed(() => + createCreateCommands( + { + openProjectModal: openCreateProjectModal, + openClientModal: openCreateClientModal, + openTaskModal: openCreateTaskModal, + openTagModal: openCreateTagModal, + openInviteModal: openInviteMemberModal, + }, + { + canCreateProjects, + canCreateClients, + canCreateTasks, + canCreateTags, + canCreateInvitations, + } + ) + ); + + const organizationCommands = computed(() => + createOrganizationCommands( + allOrganizations.value, + currentOrgId.value, + handleSwitchOrganization + ) + ); + + // Internal commands grouped by type + const commandsByGroup = computed>(() => { + const allCommands: Command[] = [ + ...timerCommands.value, + ...activeTimerCommands.value, + ...navigationCommands.value, + ...createCommands.value, + ...organizationCommands.value, + ...themeCommands.value, + ]; + + const grouped: Record = {}; + for (const config of GROUP_CONFIG) { + grouped[config.id] = []; + } + + for (const cmd of allCommands) { + if (cmd.permission && !cmd.permission()) continue; + if (cmd.condition && !cmd.condition()) continue; + + if (grouped[cmd.group]) { + grouped[cmd.group].push(cmd); + } + } + + return grouped; + }); + + // Map internal commands to UI-friendly CommandPaletteGroup[] + const groups = computed(() => + GROUP_CONFIG.map((config) => ({ + id: config.id, + heading: config.heading, + commands: (commandsByGroup.value[config.id] ?? []).map((cmd) => ({ + id: cmd.id, + label: cmd.label, + icon: cmd.icon, + keywords: cmd.keywords, + action: cmd.action, + shortcut: cmd.shortcut, + })), + })) + ); + + // Entity search results (moved from old CommandPalette.vue) + const entityResults = computed(() => { + const query = searchTerm.value.toLowerCase().trim(); + if (!query || query.length < 2) return []; + + const results: EntitySearchResult[] = []; + const maxPerType = 5; + + if (canViewProjects()) { + const matching = projects.value + .filter((p: Project) => p.name.toLowerCase().includes(query)) + .slice(0, maxPerType) + .map( + (p: Project): EntitySearchResult => ({ + id: `entity-project-${p.id}`, + label: p.name, + icon: ENTITY_ICONS.project, + keywords: ['project'], + action: () => { + closePaletteAfterAction(); + router.visit(route('projects.show', { project: p.id })); + }, + entityType: 'project', + color: p.color, + badgeClass: ENTITY_BADGE_CLASSES.project, + }) + ); + results.push(...matching); + } + + if (canViewClients()) { + const matching = clients.value + .filter((c: Client) => c.name.toLowerCase().includes(query)) + .slice(0, maxPerType) + .map( + (c: Client): EntitySearchResult => ({ + id: `entity-client-${c.id}`, + label: c.name, + icon: ENTITY_ICONS.client, + keywords: ['client'], + action: () => { + closePaletteAfterAction(); + router.visit(route('clients')); + }, + entityType: 'client', + badgeClass: ENTITY_BADGE_CLASSES.client, + }) + ); + results.push(...matching); + } + + if (canViewProjects()) { + const matching = tasks.value + .filter((t: Task) => t.name.toLowerCase().includes(query)) + .slice(0, maxPerType) + .map( + (t: Task): EntitySearchResult => ({ + id: `entity-task-${t.id}`, + label: t.name, + icon: ENTITY_ICONS.task, + keywords: ['task'], + action: () => { + closePaletteAfterAction(); + if (t.project_id) { + router.visit(route('projects.show', { project: t.project_id })); + } + }, + entityType: 'task', + badgeClass: ENTITY_BADGE_CLASSES.task, + }) + ); + results.push(...matching); + } + + if (canViewTags()) { + const matching = tags.value + .filter((t: Tag) => t.name.toLowerCase().includes(query)) + .slice(0, maxPerType) + .map( + (t: Tag): EntitySearchResult => ({ + id: `entity-tag-${t.id}`, + label: t.name, + icon: ENTITY_ICONS.tag, + keywords: ['tag'], + action: () => { + closePaletteAfterAction(); + router.visit(route('tags')); + }, + entityType: 'tag', + badgeClass: ENTITY_BADGE_CLASSES.tag, + }) + ); + results.push(...matching); + } + + if (canViewMembers()) { + const matching = members.value + .filter((m: Member) => m.name.toLowerCase().includes(query)) + .slice(0, maxPerType) + .map( + (m: Member): EntitySearchResult => ({ + id: `entity-member-${m.id}`, + label: m.name, + icon: ENTITY_ICONS.member, + keywords: ['member'], + action: () => { + closePaletteAfterAction(); + router.visit(route('members')); + }, + entityType: 'member', + badgeClass: ENTITY_BADGE_CLASSES.member, + }) + ); + results.push(...matching); + } + + return results.sort( + (a, b) => + scoreEntity(b.label, query, GROUP_PRIORITIES.entity) - + scoreEntity(a.label, query, GROUP_PRIORITIES.entity) + ); + }); + + // Open/close + function openPalette() { + isOpen.value = true; + } + + function closePalette() { + isOpen.value = false; + } + + function togglePalette() { + if (isOpen.value) { + closePalette(); + } else { + openPalette(); + } + } + + return { + // State + isOpen, + searchTerm, + + // Modal states + showCreateProjectModal, + showCreateClientModal, + showCreateTaskModal, + showCreateTagModal, + showInviteMemberModal, + showCreateTimeEntryModal, + showProjectSelector, + showTaskSelector, + showTagsSelector, + + // UI data (for CommandPalette component props) + groups, + entityResults, + + // Query data (for Provider modals) + projects, + clients, + tasks, + tags, + + // Computed + isActive, + currentTimeEntry, + + // Actions + openPalette, + closePalette, + togglePalette, + updateTimer, + }; +}