mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-13 10:42:16 +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",
|
||||
"pinia": "^2.1.7",
|
||||
"radix-vue": "^1.9.6",
|
||||
"reka-ui": "^2.2.0",
|
||||
"reka-ui": "^2.7.0",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"vue-echarts": "^7.0.3"
|
||||
@@ -5419,9 +5419,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/reka-ui": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.2.0.tgz",
|
||||
"integrity": "sha512-eeRrLI4LwJ6dkdwks6KFNKGs0+beqZlHO3JMHen7THDTh+yJ5Z0KNwONmOhhV/0hZC2uJCEExgG60QPzGstkQg==",
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.7.0.tgz",
|
||||
"integrity": "sha512-m+XmxQN2xtFzBP3OAdIafKq7C8OETo2fqfxcIIxYmNN2Ch3r5oAf6yEYCIJg5tL/yJU2mHqF70dCCekUkrAnXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
"parse-duration": "^2.0.1",
|
||||
"pinia": "^2.1.7",
|
||||
"radix-vue": "^1.9.6",
|
||||
"reka-ui": "^2.2.0",
|
||||
"reka-ui": "^2.7.0",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"vue-echarts": "^7.0.3"
|
||||
|
||||
@@ -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"
|
||||
:class="
|
||||
cn(
|
||||
'bg-default-background grid w-full max-w-lg border border-border-tertiary shadow-lg duration-200 sm:rounded-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||
'pointer-events-auto bg-default-background grid w-full max-w-lg border border-border-tertiary shadow-lg duration-200 sm:rounded-lg outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||
props.class
|
||||
)
|
||||
">
|
||||
|
||||
@@ -32,7 +32,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn(
|
||||
'z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
'z-50 min-w-32 overflow-hidden rounded-md border border-border-secondary bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
props.class
|
||||
)
|
||||
">
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
CreditCardIcon,
|
||||
FolderIcon,
|
||||
HomeIcon,
|
||||
MagnifyingGlassIcon,
|
||||
TagIcon,
|
||||
UserCircleIcon,
|
||||
UserGroupIcon,
|
||||
@@ -48,8 +49,13 @@ import { api } from '@/packages/api/src';
|
||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { Button } from '@/packages/ui/src';
|
||||
import { Button as UiButton } from '@/packages/ui/src';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { openFeedback } from '@/utils/feedback';
|
||||
import { CommandPaletteProvider } from '@/Components/CommandPalette';
|
||||
import { useCommandPalette } from '@/utils/useCommandPalette';
|
||||
|
||||
const { openPalette } = useCommandPalette();
|
||||
|
||||
defineProps({
|
||||
title: String,
|
||||
@@ -114,9 +120,22 @@ const page = usePage<{
|
||||
}"
|
||||
class="flex-shrink-0 h-screen hidden fixed w-[230px] 2xl:w-[250px] px-2.5 2xl:px-3 py-4 lg:flex flex-col justify-between">
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="border-b border-default-background-separator pb-2 flex justify-between">
|
||||
<OrganizationSwitcher class="w-full"></OrganizationSwitcher>
|
||||
<XMarkIcon class="w-8 lg:hidden" @click="showSidebarMenu = false"></XMarkIcon>
|
||||
<div
|
||||
class="border-b border-default-background-separator pb-2 flex items-center gap-1">
|
||||
<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 class="border-b border-default-background-separator">
|
||||
<CurrentSidebarTimer></CurrentSidebarTimer>
|
||||
@@ -255,14 +274,14 @@ const page = usePage<{
|
||||
:icon="Cog6ToothIcon"
|
||||
:href="route('profile.show')"></NavigationSidebarItem>
|
||||
|
||||
<Button
|
||||
<UiButton
|
||||
v-if="page.props.has_services_extension"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
class="rounded-full ml-2 flex h-6 w-6 items-center text-xs text-icon-default justify-center"
|
||||
@click="openFeedback">
|
||||
?
|
||||
</Button>
|
||||
</UiButton>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -275,7 +294,17 @@ const page = usePage<{
|
||||
<Bars3Icon
|
||||
class="w-7 text-text-secondary"
|
||||
@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>
|
||||
|
||||
<Head :title="title" />
|
||||
@@ -308,4 +337,5 @@ const page = usePage<{
|
||||
</div>
|
||||
<NotificationContainer></NotificationContainer>
|
||||
<UserTimezoneMismatchModal></UserTimezoneMismatchModal>
|
||||
<CommandPaletteProvider></CommandPaletteProvider>
|
||||
</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
|
||||
: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>
|
||||
<TimeEntryMoreOptionsDropdown
|
||||
: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 { Popover, PopoverContent, PopoverTrigger, PopoverAnchor } from './popover/index';
|
||||
import { RangeCalendar } from './range-calendar/index';
|
||||
import { CommandPalette } from './CommandPalette/index';
|
||||
export type { ActivityPeriod } from './FullCalendar/idleStatusPlugin';
|
||||
export type {
|
||||
CommandPaletteCommand,
|
||||
CommandPaletteGroup,
|
||||
EntitySearchResult,
|
||||
} from './CommandPalette/index';
|
||||
|
||||
export {
|
||||
money,
|
||||
@@ -89,4 +95,5 @@ export {
|
||||
PopoverTrigger,
|
||||
PopoverAnchor,
|
||||
RangeCalendar,
|
||||
CommandPalette,
|
||||
};
|
||||
|
||||
@@ -187,7 +187,7 @@ body {
|
||||
--foreground: var(--color-text-primary);
|
||||
--card: var(--theme-color-card-background);
|
||||
--card-foreground: var(--color-text-primary);
|
||||
--popover: var(--color-bg-tertiary);
|
||||
--popover: var(--color-bg-secondary);
|
||||
--popover-foreground: var(--color-text-primary);
|
||||
--primary: var(--color-bg-primary);
|
||||
--primary-foreground: var(--color-text-primary);
|
||||
|
||||
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