mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-10-21 20:23:19 +01:00
Compare commits
22 Commits
feature/bi
...
chore/fran
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f41bca1a3 | ||
|
|
eed654b9c8 | ||
|
|
68833a5125 | ||
|
|
735f3b8d39 | ||
|
|
9caf911795 | ||
|
|
c55afd234e | ||
|
|
20cc5f9e5b | ||
|
|
2df7a2845e | ||
|
|
510cb10cb9 | ||
|
|
b46f015754 | ||
|
|
fc8985f513 | ||
|
|
812b3f2f54 | ||
|
|
4b25b2adfa | ||
|
|
9557296eb0 | ||
|
|
c69edc81be | ||
|
|
902270a189 | ||
|
|
f60c9ca457 | ||
|
|
1c41432389 | ||
|
|
3197c8c7a0 | ||
|
|
b8761dffce | ||
|
|
ba2b37f264 | ||
|
|
9e6013eaf1 |
14
.github/workflows/generate-api-docs.yml
vendored
14
.github/workflows/generate-api-docs.yml
vendored
@@ -3,6 +3,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -57,10 +58,9 @@ jobs:
|
||||
name: api-docs.json
|
||||
path: build/api-docs.json
|
||||
|
||||
- name: "Download Fastfront CLI"
|
||||
run: curl https://fastfront-cli.s3.fr-par.scw.cloud/fastfront-cli.phar -o fastfront-cli.phar
|
||||
|
||||
- name: "Deploy with Fastfront"
|
||||
run: php fastfront-cli.phar deploy 9beab6cf-f459-446b-85f1-38ec007cf457 ./build
|
||||
env:
|
||||
FASTFRONT_API_KEY: ${{ secrets.FASTFRONT_API_DOCS_API_KEY }}
|
||||
- name: "Deploy with FastFront"
|
||||
uses: fastfront-io/deploy-action@v0.0.1
|
||||
with:
|
||||
folder: './build'
|
||||
environment: ${{ vars.FASTFRONT_API_DOCS_STAGE }}
|
||||
api_key: ${{ secrets.FASTFRONT_API_DOCS_STAGE_KEY }}
|
||||
|
||||
@@ -6,7 +6,19 @@ Contributions are greatly apprecited, please make sure to read the rules and vis
|
||||
|
||||
### Issues for Bugs, Discussions for Feature requests
|
||||
|
||||
In order to keep the issues of the repository clean we decided to only use them for bugs. Feature Requests and enhancement are handled in discussions. This also helps us to see which feature requests are popular as they can be upvoted.
|
||||
In order to keep the issues of the repository clean we decided to only use them for bugs. Feature Requests and enhancements are handled in discussions. This also helps us to see which feature requests are popular as they can be upvoted.
|
||||
|
||||
### Do not use AI-generated text in issues, discussions and PR descriptions
|
||||
|
||||
We are human, so please write in natural language in your issues, discussions, and PR descriptions.
|
||||
Don't be a meatproxy. We will ban people who repeatedly violate this rule from further communication on our repositories.
|
||||
|
||||
### Before you contribute, ask yourself what value you add
|
||||
|
||||
We much prefer reading your thoughts on features or issues you encountered than reading through hundreds of lines of code changes produced by your agent.
|
||||
Consider that the maintainers of this project have the same access to LLMs and more knowledge about the project itself.
|
||||
Prompting a feature into existence without further consideration or discussion is mostly a burden for maintainers.
|
||||
If we want to, we can burn the tokens ourselves.
|
||||
|
||||
### Only work on approved issues
|
||||
|
||||
|
||||
12
README.md
12
README.md
@@ -30,8 +30,16 @@ If you do not want to self-host solidtime or try it out you can sign up for [sol
|
||||
|
||||
## Issues & Feature Requests
|
||||
|
||||
If you find any **bugs in solidtime**, please feel free to [**open an issue**](https://github.com/solidtime-io/solidtime/issues/new) in this repository, with instructions on how to reproduce the bug.
|
||||
If you have a **feature request**, please [**create a discussion**](https://github.com/solidtime-io/solidtime/discussions/new?category=feature-requests) in this repository.
|
||||
If you
|
||||
* find any **bugs in solidtime**, please feel free to [**open an issue**](https://github.com/solidtime-io/solidtime/issues/new) in this repository, with instructions on how to reproduce the bug.
|
||||
* have a **feature request**, please [**create a discussion**](https://github.com/solidtime-io/solidtime/discussions/new?category=feature-requests) in this repository.
|
||||
|
||||
|
||||
> **Do not use AI-generated text in issues, discussions and PR descriptions.**
|
||||
>
|
||||
> We are human, so please write in natural language in your issues, discussions, and PR descriptions.
|
||||
> Don't be a meatproxy. We will ban people who repeatedly violate this rule from further communication on our repositories.
|
||||
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
ARG PHP_VERSION=8.3
|
||||
ARG FRANKENPHP_VERSION=1.11
|
||||
ARG FRANKENPHP_VERSION=1.13
|
||||
ARG COMPOSER_VERSION=2.8
|
||||
ARG APP_ENV
|
||||
ARG DOCKER_FILES_BASE_PATH="docker/prod/"
|
||||
|
||||
@@ -7,6 +7,7 @@ realpath_cache_ttl = 720
|
||||
memory_limit = 256M
|
||||
max_input_time = 5
|
||||
register_argc_argv = 0
|
||||
session.upload_progress.enabled = 0
|
||||
date.timezone = ${TZ:-UTC}
|
||||
|
||||
[Opcache]
|
||||
|
||||
@@ -342,6 +342,89 @@ test('test that a running time entry counts towards the goal progress', async ({
|
||||
);
|
||||
});
|
||||
|
||||
// Close to midnight UTC an entry that started a few minutes ago could fall on the previous day, so
|
||||
// the day of the goal is moved to a timezone (without daylight saving time) where it is late morning
|
||||
function timezoneAwayFromMidnight() {
|
||||
const utcHour = new Date().getUTCHours();
|
||||
return utcHour >= 2 && utcHour < 22 ? 'UTC' : 'Asia/Tokyo';
|
||||
}
|
||||
|
||||
// Navigates via the sidebar, a full page load would start with an empty query cache and hide
|
||||
// a missing goals invalidation
|
||||
async function navigateViaSidebar(page: Page, name: 'Time' | 'Goals') {
|
||||
await page.getByRole('link', { name, exact: true }).first().click();
|
||||
await expect(page).toHaveURL(new RegExp('/' + name.toLowerCase() + '$'));
|
||||
}
|
||||
|
||||
test('test that goal progress updates after creating a time entry without a page reload', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
const goal = await createGoalViaApi(ctx, {
|
||||
name: 'Cache goal',
|
||||
target_seconds: 3600,
|
||||
period: 'day',
|
||||
timezone: timezoneAwayFromMidnight(),
|
||||
});
|
||||
|
||||
await goToGoalsOverview(page);
|
||||
await expect(goalRow(page, goal.id).getByTestId('goal_tracked_time')).toHaveText('0h 00min');
|
||||
|
||||
await navigateViaSidebar(page, 'Time');
|
||||
await page.getByRole('button', { name: 'Time entry actions' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Manual time entry' }).click();
|
||||
const durationInput = page.locator('[role="dialog"] input[name="Duration"]');
|
||||
await durationInput.fill('15min');
|
||||
await durationInput.press('Tab');
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(response) => response.url().includes('/time-entries') && response.status() === 201
|
||||
),
|
||||
page.getByRole('button', { name: 'Create Time Entry' }).click(),
|
||||
]);
|
||||
|
||||
await navigateViaSidebar(page, 'Goals');
|
||||
await expect(goalRow(page, goal.id).getByTestId('goal_tracked_time')).toHaveText('0h 15min');
|
||||
});
|
||||
|
||||
test('test that goal progress updates after changing the project of the running time entry without a page reload', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
const project = await createProjectViaApi(ctx, { name: 'Running Project', is_public: true });
|
||||
const start = new Date(Date.now() - 30 * 60 * 1000).toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||
await createRunningTimeEntryWithStartViaApi(ctx, 'Running work', start);
|
||||
const goal = await createGoalViaApi(ctx, {
|
||||
name: 'Running project goal',
|
||||
target_seconds: 3600,
|
||||
period: 'day',
|
||||
timezone: timezoneAwayFromMidnight(),
|
||||
filters: { project_ids: [project.id], time_entry_type: 'work' },
|
||||
});
|
||||
|
||||
await goToGoalsOverview(page);
|
||||
await expect(goalRow(page, goal.id).getByTestId('goal_tracked_time')).toHaveText('0h 00min');
|
||||
|
||||
await navigateViaSidebar(page, 'Time');
|
||||
await page.getByRole('button', { name: 'No Project' }).click();
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
async (response) =>
|
||||
response.url().includes('/time-entries') &&
|
||||
response.request().method() === 'PUT' &&
|
||||
response.status() === 200 &&
|
||||
(await response.json()).data.project_id === project.id
|
||||
),
|
||||
page.getByRole('option').filter({ hasText: project.name }).click(),
|
||||
]);
|
||||
|
||||
await navigateViaSidebar(page, 'Goals');
|
||||
// The entry keeps running while the test runs
|
||||
await expect(goalRow(page, goal.id).getByTestId('goal_tracked_time')).toHaveText(
|
||||
/^0h 3[0-2]min$/
|
||||
);
|
||||
});
|
||||
|
||||
test('test that editing a goal via the modal works', async ({ page, ctx }) => {
|
||||
const goal = await createGoalViaApi(ctx, {
|
||||
name: 'Old goal name',
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
},
|
||||
"Services": {
|
||||
"repository": "solidtime-io/extension-services",
|
||||
"ref": "v0.0.4"
|
||||
"ref": "v0.0.6"
|
||||
},
|
||||
"Invoicing": {
|
||||
"repository": "solidtime-io/extension-invoicing",
|
||||
|
||||
@@ -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 ?? '');
|
||||
|
||||
<!-- Goal Create Modal -->
|
||||
<GoalCreateModal v-model:show="showCreateGoalModal" />
|
||||
<UpgradeModal v-model:show="showGoalUpgradeModal" feature="goals" />
|
||||
|
||||
<!-- Member Invite Modal -->
|
||||
<MemberInviteModal v-model:show="showInviteMemberModal" :available-roles="availableRoles" />
|
||||
|
||||
43
resources/js/Components/Common/Goal/GoalCreateButton.test.ts
Normal file
43
resources/js/Components/Common/Goal/GoalCreateButton.test.ts
Normal file
@@ -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: '<div />' },
|
||||
UpgradeModal: {
|
||||
name: 'UpgradeModal',
|
||||
props: ['show', 'feature'],
|
||||
template: '<div />',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
31
resources/js/Components/Common/Goal/GoalCreateButton.vue
Normal file
31
resources/js/Components/Common/Goal/GoalCreateButton.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||
import UpgradeModal from '@/packages/ui/src/UpgradeModal.vue';
|
||||
import GoalCreateModal from '@/Components/Common/Goal/GoalCreateModal.vue';
|
||||
import { PlusIcon } from '@heroicons/vue/16/solid';
|
||||
import { ref } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
canCreateMoreGoals: boolean;
|
||||
}>();
|
||||
|
||||
const showCreateGoalModal = ref(false);
|
||||
const showUpgradeModal = ref(false);
|
||||
|
||||
// The button stays visible when the goal limit is reached and explains the limit instead
|
||||
function onClick() {
|
||||
if (props.canCreateMoreGoals) {
|
||||
showCreateGoalModal.value = true;
|
||||
} else {
|
||||
showUpgradeModal.value = true;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<GoalCreateModal v-model:show="showCreateGoalModal"></GoalCreateModal>
|
||||
<UpgradeModal v-model:show="showUpgradeModal" feature="goals" />
|
||||
<SecondaryButton :icon="PlusIcon" @click="onClick">
|
||||
<slot>Create Goal</slot>
|
||||
</SecondaryButton>
|
||||
</template>
|
||||
@@ -1,12 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||
import { Target } from '@lucide/vue';
|
||||
import { PlusIcon } from '@heroicons/vue/16/solid';
|
||||
import { ref } from 'vue';
|
||||
import type { Goal } from '@/packages/api/src';
|
||||
import GoalTableRow from '@/Components/Common/Goal/GoalTableRow.vue';
|
||||
import GoalTableHeading from '@/Components/Common/Goal/GoalTableHeading.vue';
|
||||
import GoalCreateModal from '@/Components/Common/Goal/GoalCreateModal.vue';
|
||||
import GoalCreateButton from '@/Components/Common/Goal/GoalCreateButton.vue';
|
||||
import { canCreateGoals } from '@/utils/permissions';
|
||||
import { LoadingSpinner } from '@/packages/ui/src';
|
||||
import { isGoalsExtensionActivated } from '@/utils/billing';
|
||||
@@ -18,7 +15,6 @@ const props = defineProps<{
|
||||
showingArchived: boolean;
|
||||
}>();
|
||||
|
||||
const showCreateGoalModal = ref(false);
|
||||
const showTargetColumn = isGoalsExtensionActivated();
|
||||
// Every column gets a share of the free space so the columns are spread evenly over the table
|
||||
const gridTemplateColumns = [
|
||||
@@ -33,7 +29,6 @@ const gridTemplateColumns = [
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<GoalCreateModal v-model:show="showCreateGoalModal"></GoalCreateModal>
|
||||
<div class="flow-root max-w-[100vw] overflow-x-auto">
|
||||
<div class="inline-block min-w-full align-middle">
|
||||
<div data-testid="goal_table" class="grid min-w-full" :style="{ gridTemplateColumns }">
|
||||
@@ -51,14 +46,11 @@ const gridTemplateColumns = [
|
||||
<p v-if="canCreateGoals() && !props.showingArchived" class="pb-5">
|
||||
Set yourself a target and track your progress every day, week or month.
|
||||
</p>
|
||||
<SecondaryButton
|
||||
v-if="
|
||||
canCreateGoals() && !props.showingArchived && props.canCreateMoreGoals
|
||||
"
|
||||
:icon="PlusIcon"
|
||||
@click="showCreateGoalModal = true"
|
||||
<GoalCreateButton
|
||||
v-if="canCreateGoals() && !props.showingArchived"
|
||||
:can-create-more-goals="props.canCreateMoreGoals"
|
||||
>Create your first goal
|
||||
</SecondaryButton>
|
||||
</GoalCreateButton>
|
||||
</div>
|
||||
<template v-for="goal in props.goals" :key="goal.id">
|
||||
<GoalTableRow
|
||||
|
||||
@@ -43,6 +43,7 @@ const deleteMutation = useMutation({
|
||||
onSuccess: () => {
|
||||
close();
|
||||
queryClient.invalidateQueries({ queryKey: ['members'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['goals'] });
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ async function submit() {
|
||||
'There was an error merging the members.',
|
||||
() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['members'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['goals'] });
|
||||
show.value = false;
|
||||
}
|
||||
);
|
||||
|
||||
@@ -2,19 +2,15 @@
|
||||
import MainContainer from '@/packages/ui/src/MainContainer.vue';
|
||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||
import { Target } from '@lucide/vue';
|
||||
import { PlusIcon } from '@heroicons/vue/16/solid';
|
||||
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import GoalTable from '@/Components/Common/Goal/GoalTable.vue';
|
||||
import GoalCreateModal from '@/Components/Common/Goal/GoalCreateModal.vue';
|
||||
import GoalCreateButton from '@/Components/Common/Goal/GoalCreateButton.vue';
|
||||
import PageTitle from '@/Components/Common/PageTitle.vue';
|
||||
import UpgradeBadge from '@/packages/ui/src/UpgradeBadge.vue';
|
||||
import { canCreateGoals } from '@/utils/permissions';
|
||||
import { canCreateMoreGoals } from '@/utils/billing';
|
||||
import { useGoalsQuery } from '@/utils/useGoalsQuery';
|
||||
import { TabBar, TabBarItem } from '@/packages/ui/src';
|
||||
|
||||
const showCreateGoalModal = ref(false);
|
||||
const activeTab = ref('active');
|
||||
const showArchived = computed(() => activeTab.value === 'archived');
|
||||
|
||||
@@ -41,16 +37,9 @@ const canCreateMore = computed(() => canCreateMoreGoals(goals.value.length));
|
||||
</TabBarItem>
|
||||
</TabBar>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
<UpgradeBadge v-if="canCreateGoals() && !canCreateMore" feature="goals" />
|
||||
<SecondaryButton
|
||||
v-if="canCreateGoals() && canCreateMore"
|
||||
:icon="PlusIcon"
|
||||
@click="showCreateGoalModal = true"
|
||||
>Create Goal
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
<GoalCreateModal v-model:show="showCreateGoalModal"></GoalCreateModal>
|
||||
<GoalCreateButton
|
||||
v-if="canCreateGoals()"
|
||||
:can-create-more-goals="canCreateMore"></GoalCreateButton>
|
||||
</MainContainer>
|
||||
<GoalTable
|
||||
:goals="visibleGoals"
|
||||
|
||||
@@ -422,6 +422,7 @@ export function useBreakPlacement(deps: BreakPlacementDeps) {
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['goals'] });
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -217,6 +217,7 @@ export function useCopyLastWeek(
|
||||
}
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['goals'] });
|
||||
|
||||
if (attempted === 0) return;
|
||||
|
||||
|
||||
@@ -174,6 +174,7 @@ export function useTimesheetCellMutations(
|
||||
);
|
||||
} finally {
|
||||
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['goals'] });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
canCreateInvitations,
|
||||
} from '@/utils/permissions';
|
||||
import { isBillingActivated, isInvoicingActivated } from '@/utils/billing';
|
||||
import { useQueryClient } from '@tanstack/vue-query';
|
||||
import { fetchCanCreateMoreGoals } from '@/utils/useGoalsQuery';
|
||||
import { useTimeEntriesInfiniteQuery } from '@/utils/useTimeEntriesInfiniteQuery';
|
||||
import { useProjectsQuery } from '@/utils/useProjectsQuery';
|
||||
import { useClientsQuery } from '@/utils/useClientsQuery';
|
||||
@@ -66,6 +68,7 @@ const showCreateClientModal = ref(false);
|
||||
const showCreateTaskModal = ref(false);
|
||||
const showCreateTagModal = ref(false);
|
||||
const showCreateGoalModal = ref(false);
|
||||
const showGoalUpgradeModal = ref(false);
|
||||
const showInviteMemberModal = ref(false);
|
||||
const showCreateTimeEntryModal = ref(false);
|
||||
|
||||
@@ -103,6 +106,7 @@ const ENTITY_ICONS: Record<string, typeof FolderIcon> = {
|
||||
};
|
||||
|
||||
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();
|
||||
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,
|
||||
|
||||
@@ -261,6 +261,7 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
|
||||
stopLiveTimer();
|
||||
}
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['goals'] });
|
||||
} else {
|
||||
throw new Error(
|
||||
'Failed to fetch current time entry because organization ID is missing.'
|
||||
|
||||
@@ -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<boolean> {
|
||||
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,
|
||||
});
|
||||
|
||||
@@ -36,6 +36,7 @@ export function useTimeEntriesMutations() {
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['goals'] });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -58,6 +59,7 @@ export function useTimeEntriesMutations() {
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['goals'] });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -105,6 +107,7 @@ export function useTimeEntriesMutations() {
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['goals'] });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -127,6 +130,7 @@ export function useTimeEntriesMutations() {
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['goals'] });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -152,6 +156,7 @@ export function useTimeEntriesMutations() {
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['goals'] });
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user