add command palette

This commit is contained in:
Gregor Vostrak
2026-01-27 18:29:40 +01:00
parent 3fb75ec3d5
commit 672c243c91
24 changed files with 2292 additions and 16 deletions

405
e2e/command-palette.spec.ts Normal file
View 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
View File

@@ -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",

View File

@@ -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"

View File

@@ -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>

View File

@@ -0,0 +1 @@
export { default as CommandPaletteProvider } from './CommandPaletteProvider.vue';

View File

@@ -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
)
">

View File

@@ -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
)
">

View File

@@ -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>

View 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>

View File

@@ -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;
}

View File

@@ -0,0 +1,6 @@
export { default as CommandPalette } from './CommandPalette.vue';
export type {
CommandPaletteCommand,
CommandPaletteGroup,
EntitySearchResult,
} from './CommandPaletteTypes';

View File

@@ -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"

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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');

View File

@@ -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,
};

View File

@@ -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);

View 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;
}

View 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,
};
}