mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-18 05:02:14 +01:00
add command palette
This commit is contained in:
405
e2e/command-palette.spec.ts
Normal file
405
e2e/command-palette.spec.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
8
package-lock.json
generated
8
package-lock.json
generated
@@ -33,7 +33,7 @@
|
|||||||
"parse-duration": "^2.0.1",
|
"parse-duration": "^2.0.1",
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
"radix-vue": "^1.9.6",
|
"radix-vue": "^1.9.6",
|
||||||
"reka-ui": "^2.2.0",
|
"reka-ui": "^2.7.0",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"vue-echarts": "^7.0.3"
|
"vue-echarts": "^7.0.3"
|
||||||
@@ -5419,9 +5419,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/reka-ui": {
|
"node_modules/reka-ui": {
|
||||||
"version": "2.2.0",
|
"version": "2.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.7.0.tgz",
|
||||||
"integrity": "sha512-eeRrLI4LwJ6dkdwks6KFNKGs0+beqZlHO3JMHen7THDTh+yJ5Z0KNwONmOhhV/0hZC2uJCEExgG60QPzGstkQg==",
|
"integrity": "sha512-m+XmxQN2xtFzBP3OAdIafKq7C8OETo2fqfxcIIxYmNN2Ch3r5oAf6yEYCIJg5tL/yJU2mHqF70dCCekUkrAnXA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@floating-ui/dom": "^1.6.13",
|
"@floating-ui/dom": "^1.6.13",
|
||||||
|
|||||||
@@ -66,7 +66,7 @@
|
|||||||
"parse-duration": "^2.0.1",
|
"parse-duration": "^2.0.1",
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
"radix-vue": "^1.9.6",
|
"radix-vue": "^1.9.6",
|
||||||
"reka-ui": "^2.2.0",
|
"reka-ui": "^2.7.0",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"vue-echarts": "^7.0.3"
|
"vue-echarts": "^7.0.3"
|
||||||
|
|||||||
@@ -0,0 +1,268 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, onUnmounted, computed } from 'vue';
|
||||||
|
import { router, usePage } from '@inertiajs/vue3';
|
||||||
|
import { CommandPalette } from '@/packages/ui/src/CommandPalette';
|
||||||
|
import { useCommandPalette } from '@/utils/useCommandPalette';
|
||||||
|
import { useProjectsStore } from '@/utils/useProjects';
|
||||||
|
import { useClientsStore } from '@/utils/useClients';
|
||||||
|
import { useTagsStore } from '@/utils/useTags';
|
||||||
|
import { useTimeEntriesMutations } from '@/utils/useTimeEntriesMutations';
|
||||||
|
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||||
|
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||||
|
import { canCreateProjects } from '@/utils/permissions';
|
||||||
|
import type {
|
||||||
|
CreateClientBody,
|
||||||
|
CreateProjectBody,
|
||||||
|
CreateTimeEntryBody,
|
||||||
|
Project,
|
||||||
|
Client,
|
||||||
|
Tag,
|
||||||
|
} from '@/packages/api/src';
|
||||||
|
import type { User } from '@/types/models';
|
||||||
|
import type { Role } from '@/types/jetstream';
|
||||||
|
|
||||||
|
// Import modals
|
||||||
|
import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';
|
||||||
|
import ClientCreateModal from '@/Components/Common/Client/ClientCreateModal.vue';
|
||||||
|
import TaskCreateModal from '@/Components/Common/Task/TaskCreateModal.vue';
|
||||||
|
import TagCreateModal from '@/packages/ui/src/Tag/TagCreateModal.vue';
|
||||||
|
import MemberInviteModal from '@/Components/Common/Member/MemberInviteModal.vue';
|
||||||
|
import TimeEntryCreateModal from '@/packages/ui/src/TimeEntry/TimeEntryCreateModal.vue';
|
||||||
|
|
||||||
|
// Import dropdowns for active timer selectors
|
||||||
|
import TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
|
||||||
|
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
|
||||||
|
|
||||||
|
// Dialog components for selectors
|
||||||
|
import DialogModal from '@/packages/ui/src/DialogModal.vue';
|
||||||
|
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||||
|
|
||||||
|
const {
|
||||||
|
isOpen,
|
||||||
|
searchTerm,
|
||||||
|
groups,
|
||||||
|
entityResults,
|
||||||
|
togglePalette,
|
||||||
|
showCreateProjectModal,
|
||||||
|
showCreateClientModal,
|
||||||
|
showCreateTaskModal,
|
||||||
|
showCreateTagModal,
|
||||||
|
showInviteMemberModal,
|
||||||
|
showCreateTimeEntryModal,
|
||||||
|
showProjectSelector,
|
||||||
|
showTaskSelector,
|
||||||
|
showTagsSelector,
|
||||||
|
currentTimeEntry,
|
||||||
|
updateTimer,
|
||||||
|
projects,
|
||||||
|
clients,
|
||||||
|
tasks,
|
||||||
|
tags,
|
||||||
|
} = useCommandPalette();
|
||||||
|
|
||||||
|
// Stores for creating entities
|
||||||
|
const projectsStore = useProjectsStore();
|
||||||
|
const clientsStore = useClientsStore();
|
||||||
|
const tagsStore = useTagsStore();
|
||||||
|
|
||||||
|
// Time entry mutations
|
||||||
|
const { createTimeEntry: createTimeEntryMutation } = useTimeEntriesMutations();
|
||||||
|
|
||||||
|
// Get available roles from page props (for member invite modal)
|
||||||
|
const page = usePage<{
|
||||||
|
availableRoles?: Role[];
|
||||||
|
auth: {
|
||||||
|
user: User;
|
||||||
|
};
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const availableRoles = computed(() => page.props.availableRoles ?? []);
|
||||||
|
|
||||||
|
// Active clients for dropdowns
|
||||||
|
const activeClients = computed(() => clients.value.filter((c) => !c.is_archived));
|
||||||
|
|
||||||
|
// Keyboard shortcut handler
|
||||||
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||||
|
e.preventDefault();
|
||||||
|
togglePalette();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Project creation
|
||||||
|
async function createProject(project: CreateProjectBody): Promise<Project | undefined> {
|
||||||
|
const openedFromCommandPalette = showCreateProjectModal.value;
|
||||||
|
const newProject = await projectsStore.createProject(project);
|
||||||
|
showCreateProjectModal.value = false;
|
||||||
|
if (newProject && openedFromCommandPalette) {
|
||||||
|
router.visit(route('projects.show', { project: newProject.id }));
|
||||||
|
}
|
||||||
|
return newProject;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createClient(client: CreateClientBody): Promise<Client | undefined> {
|
||||||
|
const openedFromCommandPalette = showCreateClientModal.value;
|
||||||
|
const newClient = await clientsStore.createClient(client);
|
||||||
|
if (newClient && openedFromCommandPalette) {
|
||||||
|
showCreateClientModal.value = false;
|
||||||
|
router.visit(route('clients'));
|
||||||
|
}
|
||||||
|
return newClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTag(name: string): Promise<Tag | undefined> {
|
||||||
|
const openedFromCommandPalette = showCreateTagModal.value;
|
||||||
|
const newTag = await tagsStore.createTag(name);
|
||||||
|
if (newTag && openedFromCommandPalette) {
|
||||||
|
showCreateTagModal.value = false;
|
||||||
|
router.visit(route('tags'));
|
||||||
|
}
|
||||||
|
return newTag;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTimeEntry(timeEntry: Omit<CreateTimeEntryBody, 'member_id'>) {
|
||||||
|
await createTimeEntryMutation(timeEntry);
|
||||||
|
showCreateTimeEntryModal.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleProjectTaskSelect() {
|
||||||
|
showProjectSelector.value = false;
|
||||||
|
showTaskSelector.value = false;
|
||||||
|
await updateTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTagsSelect() {
|
||||||
|
showTagsSelector.value = false;
|
||||||
|
await updateTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstProjectId = computed(() => projects.value[0]?.id ?? '');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- Command Palette Dialog -->
|
||||||
|
<CommandPalette
|
||||||
|
v-model:open="isOpen"
|
||||||
|
v-model:search-term="searchTerm"
|
||||||
|
:groups="groups"
|
||||||
|
:entity-results="entityResults" />
|
||||||
|
|
||||||
|
<!-- Project Create Modal -->
|
||||||
|
<ProjectCreateModal
|
||||||
|
v-model:show="showCreateProjectModal"
|
||||||
|
:create-project="createProject"
|
||||||
|
:create-client="createClient"
|
||||||
|
:clients="activeClients"
|
||||||
|
:currency="getOrganizationCurrencyString()"
|
||||||
|
:enable-estimated-time="isAllowedToPerformPremiumAction()" />
|
||||||
|
|
||||||
|
<!-- Client Create Modal -->
|
||||||
|
<ClientCreateModal v-model:show="showCreateClientModal" />
|
||||||
|
|
||||||
|
<!-- Task Create Modal -->
|
||||||
|
<TaskCreateModal
|
||||||
|
v-if="firstProjectId"
|
||||||
|
v-model:show="showCreateTaskModal"
|
||||||
|
:project-id="firstProjectId" />
|
||||||
|
|
||||||
|
<!-- Tag Create Modal -->
|
||||||
|
<TagCreateModal v-model:show="showCreateTagModal" :create-tag="createTag" />
|
||||||
|
|
||||||
|
<!-- Member Invite Modal -->
|
||||||
|
<MemberInviteModal v-model:show="showInviteMemberModal" :available-roles="availableRoles" />
|
||||||
|
|
||||||
|
<!-- Time Entry Create Modal -->
|
||||||
|
<TimeEntryCreateModal
|
||||||
|
v-model:show="showCreateTimeEntryModal"
|
||||||
|
:create-time-entry="createTimeEntry"
|
||||||
|
:create-project="createProject"
|
||||||
|
:create-client="createClient"
|
||||||
|
:create-tag="createTag"
|
||||||
|
:projects="projects"
|
||||||
|
:tasks="tasks"
|
||||||
|
:tags="tags"
|
||||||
|
:clients="activeClients"
|
||||||
|
:currency="getOrganizationCurrencyString()"
|
||||||
|
:enable-estimated-time="isAllowedToPerformPremiumAction()"
|
||||||
|
:can-create-project="canCreateProjects()" />
|
||||||
|
|
||||||
|
<!-- Project Selector Dialog for Active Timer -->
|
||||||
|
<DialogModal :show="showProjectSelector" closeable @close="showProjectSelector = false">
|
||||||
|
<template #title>Set Project</template>
|
||||||
|
<template #content>
|
||||||
|
<TimeTrackerProjectTaskDropdown
|
||||||
|
v-model:project="currentTimeEntry.project_id"
|
||||||
|
v-model:task="currentTimeEntry.task_id"
|
||||||
|
:projects="projects"
|
||||||
|
:tasks="tasks"
|
||||||
|
:clients="activeClients"
|
||||||
|
:create-project="createProject"
|
||||||
|
:create-client="createClient"
|
||||||
|
:can-create-project="canCreateProjects()"
|
||||||
|
:currency="getOrganizationCurrencyString()"
|
||||||
|
:enable-estimated-time="isAllowedToPerformPremiumAction()"
|
||||||
|
size="xlarge"
|
||||||
|
class="w-full" />
|
||||||
|
</template>
|
||||||
|
<template #footer>
|
||||||
|
<SecondaryButton @click="showProjectSelector = false"> Cancel </SecondaryButton>
|
||||||
|
<SecondaryButton class="ms-3" @click="handleProjectTaskSelect"> Save </SecondaryButton>
|
||||||
|
</template>
|
||||||
|
</DialogModal>
|
||||||
|
|
||||||
|
<!-- Task Selector Dialog for Active Timer -->
|
||||||
|
<DialogModal :show="showTaskSelector" closeable @close="showTaskSelector = false">
|
||||||
|
<template #title>Set Task</template>
|
||||||
|
<template #content>
|
||||||
|
<TimeTrackerProjectTaskDropdown
|
||||||
|
v-model:project="currentTimeEntry.project_id"
|
||||||
|
v-model:task="currentTimeEntry.task_id"
|
||||||
|
:projects="projects"
|
||||||
|
:tasks="tasks"
|
||||||
|
:clients="activeClients"
|
||||||
|
:create-project="createProject"
|
||||||
|
:create-client="createClient"
|
||||||
|
:can-create-project="canCreateProjects()"
|
||||||
|
:currency="getOrganizationCurrencyString()"
|
||||||
|
:enable-estimated-time="isAllowedToPerformPremiumAction()"
|
||||||
|
size="xlarge"
|
||||||
|
class="w-full" />
|
||||||
|
</template>
|
||||||
|
<template #footer>
|
||||||
|
<SecondaryButton @click="showTaskSelector = false"> Cancel </SecondaryButton>
|
||||||
|
<SecondaryButton class="ms-3" @click="handleProjectTaskSelect"> Save </SecondaryButton>
|
||||||
|
</template>
|
||||||
|
</DialogModal>
|
||||||
|
|
||||||
|
<!-- Tags Selector Dialog for Active Timer -->
|
||||||
|
<DialogModal :show="showTagsSelector" closeable @close="showTagsSelector = false">
|
||||||
|
<template #title>Set Tags</template>
|
||||||
|
<template #content>
|
||||||
|
<TagDropdown v-model="currentTimeEntry.tags" :tags="tags" :create-tag="createTag">
|
||||||
|
<template #trigger>
|
||||||
|
<div
|
||||||
|
class="w-full p-3 border border-card-border rounded-lg cursor-pointer hover:bg-tertiary transition">
|
||||||
|
<span
|
||||||
|
v-if="currentTimeEntry.tags.length === 0"
|
||||||
|
class="text-muted-foreground">
|
||||||
|
Click to select tags...
|
||||||
|
</span>
|
||||||
|
<span v-else> {{ currentTimeEntry.tags.length }} tag(s) selected </span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</TagDropdown>
|
||||||
|
</template>
|
||||||
|
<template #footer>
|
||||||
|
<SecondaryButton @click="showTagsSelector = false"> Cancel </SecondaryButton>
|
||||||
|
<SecondaryButton class="ms-3" @click="handleTagsSelect"> Save </SecondaryButton>
|
||||||
|
</template>
|
||||||
|
</DialogModal>
|
||||||
|
</template>
|
||||||
1
resources/js/Components/CommandPalette/index.ts
Normal file
1
resources/js/Components/CommandPalette/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default as CommandPaletteProvider } from './CommandPaletteProvider.vue';
|
||||||
@@ -38,7 +38,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
|||||||
v-bind="forwarded"
|
v-bind="forwarded"
|
||||||
:class="
|
:class="
|
||||||
cn(
|
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
|
props.class
|
||||||
)
|
)
|
||||||
">
|
">
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
|||||||
v-bind="forwarded"
|
v-bind="forwarded"
|
||||||
:class="
|
:class="
|
||||||
cn(
|
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
|
props.class
|
||||||
)
|
)
|
||||||
">
|
">
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
CreditCardIcon,
|
CreditCardIcon,
|
||||||
FolderIcon,
|
FolderIcon,
|
||||||
HomeIcon,
|
HomeIcon,
|
||||||
|
MagnifyingGlassIcon,
|
||||||
TagIcon,
|
TagIcon,
|
||||||
UserCircleIcon,
|
UserCircleIcon,
|
||||||
UserGroupIcon,
|
UserGroupIcon,
|
||||||
@@ -48,8 +49,13 @@ import { api } from '@/packages/api/src';
|
|||||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||||
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
|
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
|
||||||
import { twMerge } from 'tailwind-merge';
|
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 { openFeedback } from '@/utils/feedback';
|
||||||
|
import { CommandPaletteProvider } from '@/Components/CommandPalette';
|
||||||
|
import { useCommandPalette } from '@/utils/useCommandPalette';
|
||||||
|
|
||||||
|
const { openPalette } = useCommandPalette();
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
title: String,
|
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">
|
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">
|
||||||
<div class="flex flex-col h-full">
|
<div class="flex flex-col h-full">
|
||||||
<div class="border-b border-default-background-separator pb-2 flex justify-between">
|
<div
|
||||||
<OrganizationSwitcher class="w-full"></OrganizationSwitcher>
|
class="border-b border-default-background-separator pb-2 flex items-center gap-1">
|
||||||
<XMarkIcon class="w-8 lg:hidden" @click="showSidebarMenu = false"></XMarkIcon>
|
<div class="flex-1 min-w-0 overflow-hidden">
|
||||||
|
<OrganizationSwitcher></OrganizationSwitcher>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-7 w-7 flex-shrink-0"
|
||||||
|
data-testid="command_palette_button"
|
||||||
|
@click="openPalette">
|
||||||
|
<MagnifyingGlassIcon class="h-4 w-4 text-icon-default" />
|
||||||
|
</Button>
|
||||||
|
<XMarkIcon
|
||||||
|
class="w-8 lg:hidden flex-shrink-0"
|
||||||
|
@click="showSidebarMenu = false"></XMarkIcon>
|
||||||
</div>
|
</div>
|
||||||
<div class="border-b border-default-background-separator">
|
<div class="border-b border-default-background-separator">
|
||||||
<CurrentSidebarTimer></CurrentSidebarTimer>
|
<CurrentSidebarTimer></CurrentSidebarTimer>
|
||||||
@@ -255,14 +274,14 @@ const page = usePage<{
|
|||||||
:icon="Cog6ToothIcon"
|
:icon="Cog6ToothIcon"
|
||||||
:href="route('profile.show')"></NavigationSidebarItem>
|
:href="route('profile.show')"></NavigationSidebarItem>
|
||||||
|
|
||||||
<Button
|
<UiButton
|
||||||
v-if="page.props.has_services_extension"
|
v-if="page.props.has_services_extension"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="xs"
|
size="xs"
|
||||||
class="rounded-full ml-2 flex h-6 w-6 items-center text-xs text-icon-default justify-center"
|
class="rounded-full ml-2 flex h-6 w-6 items-center text-xs text-icon-default justify-center"
|
||||||
@click="openFeedback">
|
@click="openFeedback">
|
||||||
?
|
?
|
||||||
</Button>
|
</UiButton>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -275,7 +294,17 @@ const page = usePage<{
|
|||||||
<Bars3Icon
|
<Bars3Icon
|
||||||
class="w-7 text-text-secondary"
|
class="w-7 text-text-secondary"
|
||||||
@click="showSidebarMenu = !showSidebarMenu"></Bars3Icon>
|
@click="showSidebarMenu = !showSidebarMenu"></Bars3Icon>
|
||||||
<OrganizationSwitcher></OrganizationSwitcher>
|
<div class="flex items-center gap-1">
|
||||||
|
<OrganizationSwitcher></OrganizationSwitcher>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-7 w-7 shrink-0"
|
||||||
|
data-testid="command_palette_button_mobile"
|
||||||
|
@click="openPalette">
|
||||||
|
<MagnifyingGlassIcon class="h-4 w-4 text-icon-default" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Head :title="title" />
|
<Head :title="title" />
|
||||||
@@ -308,4 +337,5 @@ const page = usePage<{
|
|||||||
</div>
|
</div>
|
||||||
<NotificationContainer></NotificationContainer>
|
<NotificationContainer></NotificationContainer>
|
||||||
<UserTimezoneMismatchModal></UserTimezoneMismatchModal>
|
<UserTimezoneMismatchModal></UserTimezoneMismatchModal>
|
||||||
|
<CommandPaletteProvider></CommandPaletteProvider>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
139
resources/js/packages/ui/src/CommandPalette/CommandPalette.vue
Normal file
139
resources/js/packages/ui/src/CommandPalette/CommandPalette.vue
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, watch } from 'vue';
|
||||||
|
import { DialogRoot, DialogPortal, DialogOverlay, DialogContent } from 'reka-ui';
|
||||||
|
import {
|
||||||
|
Command as CommandRoot,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
CommandSeparator,
|
||||||
|
CommandShortcut,
|
||||||
|
} from '../command';
|
||||||
|
import { cn } from '../utils/cn';
|
||||||
|
import type {
|
||||||
|
CommandPaletteCommand,
|
||||||
|
CommandPaletteGroup,
|
||||||
|
EntitySearchResult,
|
||||||
|
} from './CommandPaletteTypes';
|
||||||
|
|
||||||
|
const open = defineModel<boolean>('open', { required: true });
|
||||||
|
const searchTerm = defineModel<string>('searchTerm', { default: '' });
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
groups: CommandPaletteGroup[];
|
||||||
|
entityResults?: EntitySearchResult[];
|
||||||
|
placeholder?: string;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
entityResults: () => [],
|
||||||
|
placeholder: 'Type a command or search...',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
select: [command: CommandPaletteCommand | EntitySearchResult];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// Non-empty groups for rendering
|
||||||
|
const nonEmptyGroups = computed(() => props.groups.filter((g) => g.commands.length > 0));
|
||||||
|
|
||||||
|
const hasEntityResults = computed(() => (props.entityResults?.length ?? 0) > 0);
|
||||||
|
|
||||||
|
const hasAnyGroups = computed(() => nonEmptyGroups.value.length > 0);
|
||||||
|
|
||||||
|
// Handle command selection
|
||||||
|
async function handleSelect(cmd: CommandPaletteCommand | EntitySearchResult) {
|
||||||
|
emit('select', cmd);
|
||||||
|
await cmd.action();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset search when dialog closes
|
||||||
|
watch(open, (isOpen) => {
|
||||||
|
if (!isOpen) {
|
||||||
|
searchTerm.value = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogRoot v-model:open="open">
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay
|
||||||
|
class="fixed inset-0 z-50 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0">
|
||||||
|
<div class="absolute inset-0 bg-default-background opacity-30" />
|
||||||
|
</DialogOverlay>
|
||||||
|
<div
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'fixed top-0 left-0 z-50 pointer-events-none w-screen h-screen flex items-start pt-6 md:pt-20 xl:pt-32 justify-center overflow-auto'
|
||||||
|
)
|
||||||
|
">
|
||||||
|
<DialogContent
|
||||||
|
class="pointer-events-auto bg-default-background w-full max-w-lg border border-border-tertiary shadow-lg sm:rounded-lg outline-none overflow-hidden p-0 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">
|
||||||
|
<CommandRoot
|
||||||
|
v-model:search-term="searchTerm"
|
||||||
|
class="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||||
|
<CommandInput :placeholder="placeholder" />
|
||||||
|
<CommandList>
|
||||||
|
<!-- Empty state -->
|
||||||
|
<div
|
||||||
|
v-if="searchTerm.length > 0 && !hasEntityResults && !hasAnyGroups"
|
||||||
|
class="py-6 text-center text-sm text-muted-foreground">
|
||||||
|
No results found.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Command Groups -->
|
||||||
|
<template v-for="(group, index) in nonEmptyGroups" :key="group.id">
|
||||||
|
<CommandSeparator v-if="index > 0" />
|
||||||
|
<CommandGroup :heading="group.heading">
|
||||||
|
<CommandItem
|
||||||
|
v-for="cmd in group.commands"
|
||||||
|
:key="cmd.id"
|
||||||
|
:value="cmd.id"
|
||||||
|
class="cursor-pointer"
|
||||||
|
@select="handleSelect(cmd)">
|
||||||
|
<component :is="cmd.icon" v-if="cmd.icon" />
|
||||||
|
<span>{{ cmd.label }}</span>
|
||||||
|
<span class="sr-only" aria-hidden="true">{{
|
||||||
|
cmd.keywords.join(' ')
|
||||||
|
}}</span>
|
||||||
|
<CommandShortcut v-if="cmd.shortcut">
|
||||||
|
{{ cmd.shortcut }}
|
||||||
|
</CommandShortcut>
|
||||||
|
</CommandItem>
|
||||||
|
</CommandGroup>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Entity Search Results -->
|
||||||
|
<template v-if="hasEntityResults">
|
||||||
|
<CommandSeparator v-if="hasAnyGroups" />
|
||||||
|
<CommandGroup heading="Search Results">
|
||||||
|
<CommandItem
|
||||||
|
v-for="cmd in entityResults"
|
||||||
|
:key="cmd.id"
|
||||||
|
:value="cmd.id"
|
||||||
|
class="cursor-pointer"
|
||||||
|
@select="handleSelect(cmd)">
|
||||||
|
<component :is="cmd.icon" v-if="cmd.icon" />
|
||||||
|
<span class="flex-1">{{ cmd.label }}</span>
|
||||||
|
<span class="sr-only" aria-hidden="true">{{
|
||||||
|
cmd.keywords.join(' ')
|
||||||
|
}}</span>
|
||||||
|
<span
|
||||||
|
v-if="cmd.badgeClass"
|
||||||
|
class="ml-2 rounded px-1.5 py-0.5 text-xs font-medium"
|
||||||
|
:class="cmd.badgeClass">
|
||||||
|
{{ cmd.entityType }}
|
||||||
|
</span>
|
||||||
|
</CommandItem>
|
||||||
|
</CommandGroup>
|
||||||
|
</template>
|
||||||
|
</CommandList>
|
||||||
|
</CommandRoot>
|
||||||
|
</DialogContent>
|
||||||
|
</div>
|
||||||
|
</DialogPortal>
|
||||||
|
</DialogRoot>
|
||||||
|
</template>
|
||||||
@@ -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 `<component :is="...">` accepts any object at runtime.
|
||||||
|
export interface CommandPaletteCommand {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
icon?: object;
|
||||||
|
keywords: string[];
|
||||||
|
action: () => void | Promise<void>;
|
||||||
|
shortcut?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommandPaletteGroup {
|
||||||
|
id: string;
|
||||||
|
heading: string;
|
||||||
|
commands: CommandPaletteCommand[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EntitySearchResult extends CommandPaletteCommand {
|
||||||
|
entityType: string;
|
||||||
|
color?: string;
|
||||||
|
badgeClass?: string;
|
||||||
|
}
|
||||||
6
resources/js/packages/ui/src/CommandPalette/index.ts
Normal file
6
resources/js/packages/ui/src/CommandPalette/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export { default as CommandPalette } from './CommandPalette.vue';
|
||||||
|
export type {
|
||||||
|
CommandPaletteCommand,
|
||||||
|
CommandPaletteGroup,
|
||||||
|
EntitySearchResult,
|
||||||
|
} from './CommandPaletteTypes';
|
||||||
@@ -170,7 +170,8 @@ function onSelectChange(checked: boolean) {
|
|||||||
|
|
||||||
<TimeTrackerStartStop
|
<TimeTrackerStartStop
|
||||||
:active="!!(timeEntry.start && !timeEntry.end)"
|
:active="!!(timeEntry.start && !timeEntry.end)"
|
||||||
class="opacity-20 flex group-hover:opacity-100 focus-visible:opacity-100"
|
variant="secondary"
|
||||||
|
class="opacity-60 flex group-hover:opacity-100 focus-visible:opacity-100"
|
||||||
@changed="onStartStopClick(timeEntry)"></TimeTrackerStartStop>
|
@changed="onStartStopClick(timeEntry)"></TimeTrackerStartStop>
|
||||||
<TimeEntryMoreOptionsDropdown
|
<TimeEntryMoreOptionsDropdown
|
||||||
:show-edit="false"
|
:show-edit="false"
|
||||||
|
|||||||
107
resources/js/packages/ui/src/command/Command.vue
Normal file
107
resources/js/packages/ui/src/command/Command.vue
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
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<ListboxRootProps & { class?: HTMLAttributes['class']; searchTerm?: string }>(),
|
||||||
|
{
|
||||||
|
modelValue: '',
|
||||||
|
searchTerm: '',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const emits = defineEmits<ListboxRootEmits & { 'update:searchTerm': [value: string] }>();
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class', 'searchTerm');
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||||
|
|
||||||
|
const allItems = ref<Map<string, string>>(new Map());
|
||||||
|
const allGroups = ref<Map<string, Set<string>>>(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<string, number>,
|
||||||
|
/** Set of groups with at least one visible item. */
|
||||||
|
groups: new Set() as Set<string>,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ListboxRoot
|
||||||
|
v-bind="forwarded"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground',
|
||||||
|
props.class
|
||||||
|
)
|
||||||
|
">
|
||||||
|
<slot />
|
||||||
|
</ListboxRoot>
|
||||||
|
</template>
|
||||||
51
resources/js/packages/ui/src/command/CommandGroup.vue
Normal file
51
resources/js/packages/ui/src/command/CommandGroup.vue
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ListboxGroupProps } from 'reka-ui';
|
||||||
|
import type { HTMLAttributes } from 'vue';
|
||||||
|
import { reactiveOmit } from '@vueuse/core';
|
||||||
|
import { ListboxGroup, ListboxGroupLabel, useId } from 'reka-ui';
|
||||||
|
import { computed, onMounted, onUnmounted } from 'vue';
|
||||||
|
import { cn } from '../utils/cn';
|
||||||
|
import { provideCommandGroupContext, useCommand } from '.';
|
||||||
|
|
||||||
|
const props = defineProps<
|
||||||
|
ListboxGroupProps & {
|
||||||
|
class?: HTMLAttributes['class'];
|
||||||
|
heading?: string;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class');
|
||||||
|
|
||||||
|
const { allGroups, filterState } = useCommand();
|
||||||
|
const id = useId();
|
||||||
|
|
||||||
|
const isRender = computed(() => (!filterState.search ? true : filterState.filtered.groups.has(id)));
|
||||||
|
|
||||||
|
provideCommandGroupContext({ id });
|
||||||
|
onMounted(() => {
|
||||||
|
if (!allGroups.value.has(id)) allGroups.value.set(id, new Set());
|
||||||
|
});
|
||||||
|
onUnmounted(() => {
|
||||||
|
allGroups.value.delete(id);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ListboxGroup
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
:id="id"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground',
|
||||||
|
props.class
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:hidden="isRender ? undefined : true">
|
||||||
|
<ListboxGroupLabel
|
||||||
|
v-if="heading"
|
||||||
|
class="px-2 py-1.5 text-xs font-medium text-muted-foreground">
|
||||||
|
{{ heading }}
|
||||||
|
</ListboxGroupLabel>
|
||||||
|
<slot />
|
||||||
|
</ListboxGroup>
|
||||||
|
</template>
|
||||||
41
resources/js/packages/ui/src/command/CommandInput.vue
Normal file
41
resources/js/packages/ui/src/command/CommandInput.vue
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ListboxFilterProps } from 'reka-ui';
|
||||||
|
import type { HTMLAttributes } from 'vue';
|
||||||
|
import { reactiveOmit } from '@vueuse/core';
|
||||||
|
import { Search } from 'lucide-vue-next';
|
||||||
|
import { ListboxFilter, useForwardProps } from 'reka-ui';
|
||||||
|
import { cn } from '../utils/cn';
|
||||||
|
import { useCommand } from '.';
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = defineProps<
|
||||||
|
ListboxFilterProps & {
|
||||||
|
class?: HTMLAttributes['class'];
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class');
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps);
|
||||||
|
|
||||||
|
const { filterState } = useCommand();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex items-center border-b border-border-tertiary px-3" cmdk-input-wrapper>
|
||||||
|
<Search class="mr-1.5 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
<ListboxFilter
|
||||||
|
v-bind="{ ...forwardedProps, ...$attrs }"
|
||||||
|
v-model="filterState.search"
|
||||||
|
auto-focus
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'flex h-10 w-full rounded-md bg-transparent py-3 text-sm border-none outline-none ring-0 focus:border-none focus:outline-none focus:ring-0 placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
props.class
|
||||||
|
)
|
||||||
|
" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
78
resources/js/packages/ui/src/command/CommandItem.vue
Normal file
78
resources/js/packages/ui/src/command/CommandItem.vue
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ListboxItemEmits, ListboxItemProps } from 'reka-ui';
|
||||||
|
import type { HTMLAttributes } from 'vue';
|
||||||
|
import { reactiveOmit, useCurrentElement } from '@vueuse/core';
|
||||||
|
import { ListboxItem, useForwardPropsEmits, useId } from 'reka-ui';
|
||||||
|
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||||
|
import { cn } from '../utils/cn';
|
||||||
|
import { useCommand, useCommandGroup } from '.';
|
||||||
|
|
||||||
|
const props = defineProps<ListboxItemProps & { class?: HTMLAttributes['class'] }>();
|
||||||
|
const emits = defineEmits<ListboxItemEmits>();
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class');
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||||
|
|
||||||
|
const id = useId();
|
||||||
|
const { filterState, allItems, allGroups } = useCommand();
|
||||||
|
const groupContext = useCommandGroup();
|
||||||
|
|
||||||
|
const isRender = computed(() => {
|
||||||
|
if (!filterState.search) {
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
const filteredCurrentItem = filterState.filtered.items.get(id);
|
||||||
|
// If the filtered items is undefined means not in the all times map yet
|
||||||
|
// Do the first render to add into the map
|
||||||
|
if (filteredCurrentItem === undefined) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check with filter
|
||||||
|
return filteredCurrentItem > 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const itemRef = ref();
|
||||||
|
const currentElement = useCurrentElement(itemRef);
|
||||||
|
onMounted(() => {
|
||||||
|
if (!(currentElement.value instanceof HTMLElement)) return;
|
||||||
|
|
||||||
|
// textValue to perform filter
|
||||||
|
allItems.value.set(id, currentElement.value.textContent ?? props?.value!.toString());
|
||||||
|
|
||||||
|
const groupId = groupContext?.id;
|
||||||
|
if (groupId) {
|
||||||
|
if (!allGroups.value.has(groupId)) {
|
||||||
|
allGroups.value.set(groupId, new Set([id]));
|
||||||
|
} else {
|
||||||
|
allGroups.value.get(groupId)?.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
onUnmounted(() => {
|
||||||
|
allItems.value.delete(id);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ListboxItem
|
||||||
|
v-if="isRender"
|
||||||
|
v-bind="forwarded"
|
||||||
|
:id="id"
|
||||||
|
ref="itemRef"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'relative flex cursor-default gap-1.5 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground [&_svg]:text-icon-default data-[highlighted]:[&_svg]:text-icon-active data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0',
|
||||||
|
props.class
|
||||||
|
)
|
||||||
|
"
|
||||||
|
@select="
|
||||||
|
() => {
|
||||||
|
filterState.search = '';
|
||||||
|
}
|
||||||
|
">
|
||||||
|
<slot />
|
||||||
|
</ListboxItem>
|
||||||
|
</template>
|
||||||
23
resources/js/packages/ui/src/command/CommandList.vue
Normal file
23
resources/js/packages/ui/src/command/CommandList.vue
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ListboxContentProps } from 'reka-ui';
|
||||||
|
import type { HTMLAttributes } from 'vue';
|
||||||
|
import { reactiveOmit } from '@vueuse/core';
|
||||||
|
import { ListboxContent, useForwardProps } from 'reka-ui';
|
||||||
|
import { cn } from '../utils/cn';
|
||||||
|
|
||||||
|
const props = defineProps<ListboxContentProps & { class?: HTMLAttributes['class'] }>();
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class');
|
||||||
|
|
||||||
|
const forwarded = useForwardProps(delegatedProps);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ListboxContent
|
||||||
|
v-bind="forwarded"
|
||||||
|
:class="cn('max-h-[300px] overflow-y-auto overflow-x-hidden', props.class)">
|
||||||
|
<div role="presentation">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</ListboxContent>
|
||||||
|
</template>
|
||||||
17
resources/js/packages/ui/src/command/CommandSeparator.vue
Normal file
17
resources/js/packages/ui/src/command/CommandSeparator.vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SeparatorProps } from 'reka-ui';
|
||||||
|
import type { HTMLAttributes } from 'vue';
|
||||||
|
import { reactiveOmit } from '@vueuse/core';
|
||||||
|
import { Separator } from 'reka-ui';
|
||||||
|
import { cn } from '../utils/cn';
|
||||||
|
|
||||||
|
const props = defineProps<SeparatorProps & { class?: HTMLAttributes['class'] }>();
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Separator v-bind="delegatedProps" :class="cn('-mx-1 h-px bg-border', props.class)">
|
||||||
|
<slot />
|
||||||
|
</Separator>
|
||||||
|
</template>
|
||||||
14
resources/js/packages/ui/src/command/CommandShortcut.vue
Normal file
14
resources/js/packages/ui/src/command/CommandShortcut.vue
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue';
|
||||||
|
import { cn } from '../utils/cn';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class'];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span :class="cn('ml-auto text-xs tracking-widest text-muted-foreground', props.class)">
|
||||||
|
<slot />
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
27
resources/js/packages/ui/src/command/index.ts
Normal file
27
resources/js/packages/ui/src/command/index.ts
Normal file
@@ -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<Map<string, string>>;
|
||||||
|
allGroups: Ref<Map<string, Set<string>>>;
|
||||||
|
filterState: {
|
||||||
|
search: string;
|
||||||
|
filtered: {
|
||||||
|
count: number;
|
||||||
|
items: Map<string, number>;
|
||||||
|
groups: Set<string>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}>('Command');
|
||||||
|
|
||||||
|
export const [useCommandGroup, provideCommandGroupContext] = createContext<{
|
||||||
|
id?: string;
|
||||||
|
}>('CommandGroup');
|
||||||
@@ -43,7 +43,13 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './tool
|
|||||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from './accordion/index';
|
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from './accordion/index';
|
||||||
import { Popover, PopoverContent, PopoverTrigger, PopoverAnchor } from './popover/index';
|
import { Popover, PopoverContent, PopoverTrigger, PopoverAnchor } from './popover/index';
|
||||||
import { RangeCalendar } from './range-calendar/index';
|
import { RangeCalendar } from './range-calendar/index';
|
||||||
|
import { CommandPalette } from './CommandPalette/index';
|
||||||
export type { ActivityPeriod } from './FullCalendar/idleStatusPlugin';
|
export type { ActivityPeriod } from './FullCalendar/idleStatusPlugin';
|
||||||
|
export type {
|
||||||
|
CommandPaletteCommand,
|
||||||
|
CommandPaletteGroup,
|
||||||
|
EntitySearchResult,
|
||||||
|
} from './CommandPalette/index';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
money,
|
money,
|
||||||
@@ -89,4 +95,5 @@ export {
|
|||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
PopoverAnchor,
|
PopoverAnchor,
|
||||||
RangeCalendar,
|
RangeCalendar,
|
||||||
|
CommandPalette,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ body {
|
|||||||
--foreground: var(--color-text-primary);
|
--foreground: var(--color-text-primary);
|
||||||
--card: var(--theme-color-card-background);
|
--card: var(--theme-color-card-background);
|
||||||
--card-foreground: var(--color-text-primary);
|
--card-foreground: var(--color-text-primary);
|
||||||
--popover: var(--color-bg-tertiary);
|
--popover: var(--color-bg-secondary);
|
||||||
--popover-foreground: var(--color-text-primary);
|
--popover-foreground: var(--color-text-primary);
|
||||||
--primary: var(--color-bg-primary);
|
--primary: var(--color-bg-primary);
|
||||||
--primary-foreground: var(--color-text-primary);
|
--primary-foreground: var(--color-text-primary);
|
||||||
|
|||||||
486
resources/js/utils/commandPaletteCommands.ts
Normal file
486
resources/js/utils/commandPaletteCommands.ts
Normal file
@@ -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<void>;
|
||||||
|
shortcut?: string;
|
||||||
|
permission?: () => boolean;
|
||||||
|
condition?: () => boolean;
|
||||||
|
priority: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GROUP_PRIORITIES: Record<CommandGroup, number> = {
|
||||||
|
timer: 1000,
|
||||||
|
'active-timer': 900,
|
||||||
|
navigation: 500,
|
||||||
|
create: 400,
|
||||||
|
organization: 300,
|
||||||
|
theme: 200,
|
||||||
|
entity: 100,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createNavigationCommands(
|
||||||
|
navigate: (route: string, params?: Record<string, string>) => 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<void>;
|
||||||
|
stopTimer: () => Promise<void>;
|
||||||
|
openCreateTimeEntryModal: () => void;
|
||||||
|
continueLastEntry: () => Promise<void>;
|
||||||
|
},
|
||||||
|
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;
|
||||||
|
}
|
||||||
552
resources/js/utils/useCommandPalette.ts
Normal file
552
resources/js/utils/useCommandPalette.ts
Normal file
@@ -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<string, string> = {
|
||||||
|
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<string, typeof FolderIcon> = {
|
||||||
|
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<string, string>) {
|
||||||
|
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<Record<string, Command[]>>(() => {
|
||||||
|
const allCommands: Command[] = [
|
||||||
|
...timerCommands.value,
|
||||||
|
...activeTimerCommands.value,
|
||||||
|
...navigationCommands.value,
|
||||||
|
...createCommands.value,
|
||||||
|
...organizationCommands.value,
|
||||||
|
...themeCommands.value,
|
||||||
|
];
|
||||||
|
|
||||||
|
const grouped: Record<string, Command[]> = {};
|
||||||
|
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<CommandPaletteGroup[]>(() =>
|
||||||
|
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<EntitySearchResult[]>(() => {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user