Skip to content

Commit 672c243

Browse files
committed
add command palette
1 parent 3fb75ec commit 672c243

24 files changed

+2292
-16
lines changed

e2e/command-palette.spec.ts

Lines changed: 405 additions & 0 deletions
Large diffs are not rendered by default.

package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@
6666
"parse-duration": "^2.0.1",
6767
"pinia": "^2.1.7",
6868
"radix-vue": "^1.9.6",
69-
"reka-ui": "^2.2.0",
69+
"reka-ui": "^2.7.0",
7070
"tailwind-merge": "^2.6.0",
7171
"tailwindcss-animate": "^1.0.7",
7272
"vue-echarts": "^7.0.3"
Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
<script setup lang="ts">
2+
import { onMounted, onUnmounted, computed } from 'vue';
3+
import { router, usePage } from '@inertiajs/vue3';
4+
import { CommandPalette } from '@/packages/ui/src/CommandPalette';
5+
import { useCommandPalette } from '@/utils/useCommandPalette';
6+
import { useProjectsStore } from '@/utils/useProjects';
7+
import { useClientsStore } from '@/utils/useClients';
8+
import { useTagsStore } from '@/utils/useTags';
9+
import { useTimeEntriesMutations } from '@/utils/useTimeEntriesMutations';
10+
import { getOrganizationCurrencyString } from '@/utils/money';
11+
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
12+
import { canCreateProjects } from '@/utils/permissions';
13+
import type {
14+
CreateClientBody,
15+
CreateProjectBody,
16+
CreateTimeEntryBody,
17+
Project,
18+
Client,
19+
Tag,
20+
} from '@/packages/api/src';
21+
import type { User } from '@/types/models';
22+
import type { Role } from '@/types/jetstream';
23+
24+
// Import modals
25+
import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';
26+
import ClientCreateModal from '@/Components/Common/Client/ClientCreateModal.vue';
27+
import TaskCreateModal from '@/Components/Common/Task/TaskCreateModal.vue';
28+
import TagCreateModal from '@/packages/ui/src/Tag/TagCreateModal.vue';
29+
import MemberInviteModal from '@/Components/Common/Member/MemberInviteModal.vue';
30+
import TimeEntryCreateModal from '@/packages/ui/src/TimeEntry/TimeEntryCreateModal.vue';
31+
32+
// Import dropdowns for active timer selectors
33+
import TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
34+
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
35+
36+
// Dialog components for selectors
37+
import DialogModal from '@/packages/ui/src/DialogModal.vue';
38+
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
39+
40+
const {
41+
isOpen,
42+
searchTerm,
43+
groups,
44+
entityResults,
45+
togglePalette,
46+
showCreateProjectModal,
47+
showCreateClientModal,
48+
showCreateTaskModal,
49+
showCreateTagModal,
50+
showInviteMemberModal,
51+
showCreateTimeEntryModal,
52+
showProjectSelector,
53+
showTaskSelector,
54+
showTagsSelector,
55+
currentTimeEntry,
56+
updateTimer,
57+
projects,
58+
clients,
59+
tasks,
60+
tags,
61+
} = useCommandPalette();
62+
63+
// Stores for creating entities
64+
const projectsStore = useProjectsStore();
65+
const clientsStore = useClientsStore();
66+
const tagsStore = useTagsStore();
67+
68+
// Time entry mutations
69+
const { createTimeEntry: createTimeEntryMutation } = useTimeEntriesMutations();
70+
71+
// Get available roles from page props (for member invite modal)
72+
const page = usePage<{
73+
availableRoles?: Role[];
74+
auth: {
75+
user: User;
76+
};
77+
}>();
78+
79+
const availableRoles = computed(() => page.props.availableRoles ?? []);
80+
81+
// Active clients for dropdowns
82+
const activeClients = computed(() => clients.value.filter((c) => !c.is_archived));
83+
84+
// Keyboard shortcut handler
85+
function handleKeyDown(e: KeyboardEvent) {
86+
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
87+
e.preventDefault();
88+
togglePalette();
89+
}
90+
}
91+
92+
onMounted(() => {
93+
document.addEventListener('keydown', handleKeyDown);
94+
});
95+
96+
onUnmounted(() => {
97+
document.removeEventListener('keydown', handleKeyDown);
98+
});
99+
100+
// Project creation
101+
async function createProject(project: CreateProjectBody): Promise<Project | undefined> {
102+
const openedFromCommandPalette = showCreateProjectModal.value;
103+
const newProject = await projectsStore.createProject(project);
104+
showCreateProjectModal.value = false;
105+
if (newProject && openedFromCommandPalette) {
106+
router.visit(route('projects.show', { project: newProject.id }));
107+
}
108+
return newProject;
109+
}
110+
111+
async function createClient(client: CreateClientBody): Promise<Client | undefined> {
112+
const openedFromCommandPalette = showCreateClientModal.value;
113+
const newClient = await clientsStore.createClient(client);
114+
if (newClient && openedFromCommandPalette) {
115+
showCreateClientModal.value = false;
116+
router.visit(route('clients'));
117+
}
118+
return newClient;
119+
}
120+
121+
async function createTag(name: string): Promise<Tag | undefined> {
122+
const openedFromCommandPalette = showCreateTagModal.value;
123+
const newTag = await tagsStore.createTag(name);
124+
if (newTag && openedFromCommandPalette) {
125+
showCreateTagModal.value = false;
126+
router.visit(route('tags'));
127+
}
128+
return newTag;
129+
}
130+
131+
async function createTimeEntry(timeEntry: Omit<CreateTimeEntryBody, 'member_id'>) {
132+
await createTimeEntryMutation(timeEntry);
133+
showCreateTimeEntryModal.value = false;
134+
}
135+
136+
async function handleProjectTaskSelect() {
137+
showProjectSelector.value = false;
138+
showTaskSelector.value = false;
139+
await updateTimer();
140+
}
141+
142+
async function handleTagsSelect() {
143+
showTagsSelector.value = false;
144+
await updateTimer();
145+
}
146+
147+
const firstProjectId = computed(() => projects.value[0]?.id ?? '');
148+
</script>
149+
150+
<template>
151+
<!-- Command Palette Dialog -->
152+
<CommandPalette
153+
v-model:open="isOpen"
154+
v-model:search-term="searchTerm"
155+
:groups="groups"
156+
:entity-results="entityResults" />
157+
158+
<!-- Project Create Modal -->
159+
<ProjectCreateModal
160+
v-model:show="showCreateProjectModal"
161+
:create-project="createProject"
162+
:create-client="createClient"
163+
:clients="activeClients"
164+
:currency="getOrganizationCurrencyString()"
165+
:enable-estimated-time="isAllowedToPerformPremiumAction()" />
166+
167+
<!-- Client Create Modal -->
168+
<ClientCreateModal v-model:show="showCreateClientModal" />
169+
170+
<!-- Task Create Modal -->
171+
<TaskCreateModal
172+
v-if="firstProjectId"
173+
v-model:show="showCreateTaskModal"
174+
:project-id="firstProjectId" />
175+
176+
<!-- Tag Create Modal -->
177+
<TagCreateModal v-model:show="showCreateTagModal" :create-tag="createTag" />
178+
179+
<!-- Member Invite Modal -->
180+
<MemberInviteModal v-model:show="showInviteMemberModal" :available-roles="availableRoles" />
181+
182+
<!-- Time Entry Create Modal -->
183+
<TimeEntryCreateModal
184+
v-model:show="showCreateTimeEntryModal"
185+
:create-time-entry="createTimeEntry"
186+
:create-project="createProject"
187+
:create-client="createClient"
188+
:create-tag="createTag"
189+
:projects="projects"
190+
:tasks="tasks"
191+
:tags="tags"
192+
:clients="activeClients"
193+
:currency="getOrganizationCurrencyString()"
194+
:enable-estimated-time="isAllowedToPerformPremiumAction()"
195+
:can-create-project="canCreateProjects()" />
196+
197+
<!-- Project Selector Dialog for Active Timer -->
198+
<DialogModal :show="showProjectSelector" closeable @close="showProjectSelector = false">
199+
<template #title>Set Project</template>
200+
<template #content>
201+
<TimeTrackerProjectTaskDropdown
202+
v-model:project="currentTimeEntry.project_id"
203+
v-model:task="currentTimeEntry.task_id"
204+
:projects="projects"
205+
:tasks="tasks"
206+
:clients="activeClients"
207+
:create-project="createProject"
208+
:create-client="createClient"
209+
:can-create-project="canCreateProjects()"
210+
:currency="getOrganizationCurrencyString()"
211+
:enable-estimated-time="isAllowedToPerformPremiumAction()"
212+
size="xlarge"
213+
class="w-full" />
214+
</template>
215+
<template #footer>
216+
<SecondaryButton @click="showProjectSelector = false"> Cancel </SecondaryButton>
217+
<SecondaryButton class="ms-3" @click="handleProjectTaskSelect"> Save </SecondaryButton>
218+
</template>
219+
</DialogModal>
220+
221+
<!-- Task Selector Dialog for Active Timer -->
222+
<DialogModal :show="showTaskSelector" closeable @close="showTaskSelector = false">
223+
<template #title>Set Task</template>
224+
<template #content>
225+
<TimeTrackerProjectTaskDropdown
226+
v-model:project="currentTimeEntry.project_id"
227+
v-model:task="currentTimeEntry.task_id"
228+
:projects="projects"
229+
:tasks="tasks"
230+
:clients="activeClients"
231+
:create-project="createProject"
232+
:create-client="createClient"
233+
:can-create-project="canCreateProjects()"
234+
:currency="getOrganizationCurrencyString()"
235+
:enable-estimated-time="isAllowedToPerformPremiumAction()"
236+
size="xlarge"
237+
class="w-full" />
238+
</template>
239+
<template #footer>
240+
<SecondaryButton @click="showTaskSelector = false"> Cancel </SecondaryButton>
241+
<SecondaryButton class="ms-3" @click="handleProjectTaskSelect"> Save </SecondaryButton>
242+
</template>
243+
</DialogModal>
244+
245+
<!-- Tags Selector Dialog for Active Timer -->
246+
<DialogModal :show="showTagsSelector" closeable @close="showTagsSelector = false">
247+
<template #title>Set Tags</template>
248+
<template #content>
249+
<TagDropdown v-model="currentTimeEntry.tags" :tags="tags" :create-tag="createTag">
250+
<template #trigger>
251+
<div
252+
class="w-full p-3 border border-card-border rounded-lg cursor-pointer hover:bg-tertiary transition">
253+
<span
254+
v-if="currentTimeEntry.tags.length === 0"
255+
class="text-muted-foreground">
256+
Click to select tags...
257+
</span>
258+
<span v-else> {{ currentTimeEntry.tags.length }} tag(s) selected </span>
259+
</div>
260+
</template>
261+
</TagDropdown>
262+
</template>
263+
<template #footer>
264+
<SecondaryButton @click="showTagsSelector = false"> Cancel </SecondaryButton>
265+
<SecondaryButton class="ms-3" @click="handleTagsSelect"> Save </SecondaryButton>
266+
</template>
267+
</DialogModal>
268+
</template>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default as CommandPaletteProvider } from './CommandPaletteProvider.vue';

resources/js/Components/ui/dialog/DialogContent.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
3838
v-bind="forwarded"
3939
:class="
4040
cn(
41-
'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',
41+
'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',
4242
props.class
4343
)
4444
">

resources/js/Components/ui/dropdown-menu/DropdownMenuContent.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
3232
v-bind="forwarded"
3333
:class="
3434
cn(
35-
'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',
35+
'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',
3636
props.class
3737
)
3838
">

0 commit comments

Comments
 (0)