diff --git a/resources/js/Components/CommandPalette/CommandPaletteProvider.vue b/resources/js/Components/CommandPalette/CommandPaletteProvider.vue
index 43aa0e3c..910c766b 100644
--- a/resources/js/Components/CommandPalette/CommandPaletteProvider.vue
+++ b/resources/js/Components/CommandPalette/CommandPaletteProvider.vue
@@ -29,6 +29,7 @@ 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 GoalCreateModal from '@/Components/Common/Goal/GoalCreateModal.vue';
+import UpgradeModal from '@/packages/ui/src/UpgradeModal.vue';
import MemberInviteModal from '@/Components/Common/Member/MemberInviteModal.vue';
import TimeEntryCreateModal from '@/packages/ui/src/TimeEntry/TimeEntryCreateModal.vue';
@@ -53,6 +54,7 @@ const {
showCreateTaskModal,
showCreateTagModal,
showCreateGoalModal,
+ showGoalUpgradeModal,
showInviteMemberModal,
showCreateTimeEntryModal,
showProjectSelector,
@@ -185,6 +187,7 @@ const firstProjectId = computed(() => projects.value[0]?.id ?? '');
+
diff --git a/resources/js/Components/Common/Goal/GoalCreateButton.test.ts b/resources/js/Components/Common/Goal/GoalCreateButton.test.ts
new file mode 100644
index 00000000..52ef7cac
--- /dev/null
+++ b/resources/js/Components/Common/Goal/GoalCreateButton.test.ts
@@ -0,0 +1,43 @@
+import { describe, it, expect } from 'vitest';
+import { mount } from '@vue/test-utils';
+import GoalCreateButton from './GoalCreateButton.vue';
+
+function mountGoalCreateButton(canCreateMoreGoals: boolean) {
+ return mount(GoalCreateButton, {
+ props: { canCreateMoreGoals },
+ slots: { default: 'Create your first goal' },
+ global: {
+ stubs: {
+ GoalCreateModal: { name: 'GoalCreateModal', props: ['show'], template: '
' },
+ UpgradeModal: {
+ name: 'UpgradeModal',
+ props: ['show', 'feature'],
+ template: '',
+ },
+ },
+ },
+ });
+}
+
+describe('GoalCreateButton', () => {
+ it('opens the create modal while more goals can be created', async () => {
+ const wrapper = mountGoalCreateButton(true);
+
+ await wrapper.get('button').trigger('click');
+
+ expect(wrapper.getComponent({ name: 'GoalCreateModal' }).props('show')).toBe(true);
+ expect(wrapper.getComponent({ name: 'UpgradeModal' }).props('show')).toBe(false);
+ });
+
+ it('opens the upgrade modal once the goal limit is reached', async () => {
+ const wrapper = mountGoalCreateButton(false);
+
+ expect(wrapper.get('button').text()).toBe('Create your first goal');
+ await wrapper.get('button').trigger('click');
+
+ const upgradeModal = wrapper.getComponent({ name: 'UpgradeModal' });
+ expect(upgradeModal.props('show')).toBe(true);
+ expect(upgradeModal.props('feature')).toBe('goals');
+ expect(wrapper.getComponent({ name: 'GoalCreateModal' }).props('show')).toBe(false);
+ });
+});
diff --git a/resources/js/Components/Common/Goal/GoalCreateButton.vue b/resources/js/Components/Common/Goal/GoalCreateButton.vue
new file mode 100644
index 00000000..17928e43
--- /dev/null
+++ b/resources/js/Components/Common/Goal/GoalCreateButton.vue
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+ Create Goal
+
+
diff --git a/resources/js/Components/Common/Goal/GoalTable.vue b/resources/js/Components/Common/Goal/GoalTable.vue
index a5360828..53606f65 100644
--- a/resources/js/Components/Common/Goal/GoalTable.vue
+++ b/resources/js/Components/Common/Goal/GoalTable.vue
@@ -1,12 +1,9 @@
-
@@ -51,14 +46,11 @@ const gridTemplateColumns = [
Set yourself a target and track your progress every day, week or month.
-
Create your first goal
-
+
activeTab.value === 'archived');
@@ -41,16 +37,9 @@ const canCreateMore = computed(() => canCreateMoreGoals(goals.value.length));
-
-
- Create Goal
-
-
-
+
= {
};
export function useCommandPalette() {
+ const queryClient = useQueryClient();
const currentTimeEntryStore = useCurrentTimeEntryStore();
const { currentTimeEntry, isActive } = storeToRefs(currentTimeEntryStore);
const { setActiveState, updateTimer } = currentTimeEntryStore;
@@ -240,9 +244,13 @@ export function useCommandPalette() {
showCreateTagModal.value = true;
}
- function openCreateGoalModal() {
+ async function openCreateGoalModal() {
closePaletteAfterAction();
- showCreateGoalModal.value = true;
+ if (await fetchCanCreateMoreGoals(queryClient)) {
+ showCreateGoalModal.value = true;
+ } else {
+ showGoalUpgradeModal.value = true;
+ }
}
function openInviteMemberModal() {
@@ -525,6 +533,7 @@ export function useCommandPalette() {
showCreateTaskModal,
showCreateTagModal,
showCreateGoalModal,
+ showGoalUpgradeModal,
showInviteMemberModal,
showCreateTimeEntryModal,
showProjectSelector,
diff --git a/resources/js/utils/useGoalsQuery.ts b/resources/js/utils/useGoalsQuery.ts
index 5146bba6..e2890f3c 100644
--- a/resources/js/utils/useGoalsQuery.ts
+++ b/resources/js/utils/useGoalsQuery.ts
@@ -1,9 +1,10 @@
-import { useQuery, useQueryClient } from '@tanstack/vue-query';
+import { useQuery, useQueryClient, type QueryClient } from '@tanstack/vue-query';
import { api } from '@/packages/api/src';
import { getCurrentOrganizationId } from '@/utils/useUser';
import type { Goal } from '@/packages/api/src';
import { computed } from 'vue';
import { fetchAllPages } from '@/utils/fetchAllPages';
+import { canCreateMoreGoals, goalLimit } from '@/utils/billing';
export type GoalArchivedFilter = 'true' | 'false' | 'all';
@@ -19,19 +20,38 @@ export async function fetchAllGoals(
);
}
-export function useGoalsQuery(archived: GoalArchivedFilter = 'false') {
- const queryClient = useQueryClient();
-
- const query = useQuery({
- queryKey: computed(() => ['goals', getCurrentOrganizationId(), archived]),
+export function goalsQueryOptions(archived: GoalArchivedFilter = 'false') {
+ return {
+ queryKey: ['goals', getCurrentOrganizationId(), archived],
queryFn: async () => {
const organizationId = getCurrentOrganizationId();
if (!organizationId) throw new Error('No organization');
const data = await fetchAllGoals(organizationId, archived);
return { data };
},
- enabled: () => !!getCurrentOrganizationId(),
staleTime: 1000 * 30, // 30 seconds
+ };
+}
+
+/**
+ * Checks the goal limit outside of the goals page, where the goals are not loaded yet.
+ * Archived goals count towards the limit, so all goals are counted.
+ */
+export async function fetchCanCreateMoreGoals(queryClient: QueryClient): Promise {
+ if (goalLimit() === null) {
+ return true;
+ }
+ const { data } = await queryClient.fetchQuery(goalsQueryOptions('all'));
+ return canCreateMoreGoals(data.length);
+}
+
+export function useGoalsQuery(archived: GoalArchivedFilter = 'false') {
+ const queryClient = useQueryClient();
+
+ const query = useQuery({
+ ...goalsQueryOptions(archived),
+ queryKey: computed(() => goalsQueryOptions(archived).queryKey),
+ enabled: () => !!getCurrentOrganizationId(),
// Progress is computed server side, keep it fresh while the page is open
refetchInterval: 1000 * 60,
});