mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-08 00:02:15 +01:00
Merge branch 'main' of github.com:solidtime-io/solidtime into feature/reporting
# Conflicts: # tests/Feature/RegistrationTest.php
This commit is contained in:
2
.github/workflows/phpunit.yml
vendored
2
.github/workflows/phpunit.yml
vendored
@@ -54,7 +54,7 @@ jobs:
|
||||
run: php artisan test --stop-on-failure --coverage-text --coverage-clover=coverage.xml
|
||||
|
||||
- name: "Upload coverage reports to Codecov"
|
||||
uses: codecov/codecov-action@v4.3.0
|
||||
uses: codecov/codecov-action@v4.3.1
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
slug: solidtime-io/solidtime
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Actions\Fortify;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Enums\Weekday;
|
||||
use App\Events\NewsletterRegistered;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use App\Service\TimezoneService;
|
||||
@@ -49,6 +50,9 @@ class CreateNewUser implements CreatesNewUsers
|
||||
],
|
||||
'password' => $this->passwordRules(),
|
||||
'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature() ? ['accepted', 'required'] : '',
|
||||
'newsletter_consent' => [
|
||||
'boolean',
|
||||
],
|
||||
])->validate();
|
||||
|
||||
$timezone = 'UTC';
|
||||
@@ -56,7 +60,7 @@ class CreateNewUser implements CreatesNewUsers
|
||||
$timezone = $input['timezone'];
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($input, $timezone) {
|
||||
$user = DB::transaction(function () use ($input, $timezone) {
|
||||
return tap(User::create([
|
||||
'name' => $input['name'],
|
||||
'email' => $input['email'],
|
||||
@@ -67,6 +71,13 @@ class CreateNewUser implements CreatesNewUsers
|
||||
$this->createTeam($user);
|
||||
});
|
||||
});
|
||||
|
||||
$newsletterConsent = isset($input['newsletter_consent']) && (bool) $input['newsletter_consent'];
|
||||
if ($newsletterConsent) {
|
||||
NewsletterRegistered::dispatch($input['name'], $input['email'], $user->getKey());
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
28
app/Events/NewsletterRegistered.php
Normal file
28
app/Events/NewsletterRegistered.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
|
||||
class NewsletterRegistered
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
public string $name;
|
||||
|
||||
public string $email;
|
||||
|
||||
public string $id;
|
||||
|
||||
/**
|
||||
* Create a new event instance.
|
||||
*/
|
||||
public function __construct(string $name, string $email, string $id)
|
||||
{
|
||||
$this->name = $name;
|
||||
$this->email = $email;
|
||||
$this->id = $id;
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ class TimeEntryResource extends BaseResource
|
||||
*/
|
||||
'end' => $this->formatDateTime($this->resource->end),
|
||||
/** @var int|null $duration Duration of time entry in seconds */
|
||||
'duration' => $this->resource->getDuration()?->seconds,
|
||||
'duration' => (int) $this->resource->getDuration()?->totalSeconds,
|
||||
/** @var string|null $description Description of time entry */
|
||||
'description' => $this->resource->description,
|
||||
/** @var string|null $task_id ID of task */
|
||||
|
||||
@@ -23,6 +23,8 @@ use Brick\Money\Currency;
|
||||
use Brick\Money\ISOCurrencyProvider;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Inertia\Inertia;
|
||||
use Laravel\Fortify\Fortify;
|
||||
use Laravel\Jetstream\Actions\UpdateTeamMemberRole;
|
||||
use Laravel\Jetstream\Jetstream;
|
||||
|
||||
@@ -54,6 +56,13 @@ class JetstreamServiceProvider extends ServiceProvider
|
||||
Jetstream::useMembershipModel(Member::class);
|
||||
Jetstream::useTeamInvitationModel(OrganizationInvitation::class);
|
||||
app()->singleton(UpdateTeamMemberRole::class, UpdateMemberRole::class);
|
||||
Fortify::registerView(function () {
|
||||
return Inertia::render('Auth/Register', [
|
||||
'terms_url' => config('auth.terms_url'),
|
||||
'privacy_policy_url' => config('auth.privacy_policy_url'),
|
||||
'newsletter_consent' => config('auth.newsletter_consent'),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
696
composer.lock
generated
696
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -117,4 +117,10 @@ return [
|
||||
|
||||
'super_admins' => ! is_string(env('SUPER_ADMINS', null)) ? [] : explode(',', env('SUPER_ADMINS')),
|
||||
|
||||
'terms_url' => env('TERMS_URL'),
|
||||
|
||||
'privacy_policy_url' => env('PRIVACY_POLICY_URL'),
|
||||
|
||||
'newsletter_consent' => env('NEWSLETTER_CONSENT', false),
|
||||
|
||||
];
|
||||
|
||||
@@ -60,9 +60,8 @@ return [
|
||||
*/
|
||||
|
||||
'features' => [
|
||||
// Features::termsAndPrivacyPolicy(),
|
||||
Features::termsAndPrivacyPolicy(),
|
||||
Features::profilePhotos(),
|
||||
// Features::api(),
|
||||
Features::teams(['invitations' => true]),
|
||||
Features::accountDeletion(),
|
||||
],
|
||||
|
||||
@@ -91,7 +91,7 @@ return [
|
||||
|
||||
'sentry' => [
|
||||
'driver' => 'sentry',
|
||||
'level' => env('LOG_LEVEL', 'error'),
|
||||
'level' => env('LOG_LEVEL_SENTRY', 'error'),
|
||||
'bubble' => true,
|
||||
],
|
||||
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Third Party Services
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This file is for storing the credentials for third party services such
|
||||
| as Mailgun, Postmark, AWS and more. This file provides the de facto
|
||||
| location for this type of information, allowing packages to have
|
||||
| a conventional file to locate the various service credentials.
|
||||
|
|
||||
*/
|
||||
|
||||
'mailgun' => [
|
||||
'domain' => env('MAILGUN_DOMAIN'),
|
||||
'secret' => env('MAILGUN_SECRET'),
|
||||
'endpoint' => env('MAILGUN_ENDPOINT', 'api.mailgun.net'),
|
||||
'scheme' => 'https',
|
||||
],
|
||||
|
||||
'postmark' => [
|
||||
'token' => env('POSTMARK_TOKEN'),
|
||||
],
|
||||
|
||||
'ses' => [
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
||||
],
|
||||
|
||||
];
|
||||
@@ -7,6 +7,7 @@ async function registerNewUser(page, email, password) {
|
||||
await page.getByLabel('Email').fill(email);
|
||||
await page.getByLabel('Password', { exact: true }).fill(password);
|
||||
await page.getByLabel('Confirm Password').fill(password);
|
||||
await page.getByLabel('I agree to the Terms of').click();
|
||||
await page.getByRole('button', { name: 'Register' }).click();
|
||||
await expect(page.getByTestId('dashboard_view')).toBeVisible();
|
||||
}
|
||||
|
||||
@@ -12,7 +12,8 @@ test('test that user name can be updated', async ({ page }) => {
|
||||
await expect(page.getByLabel('Name')).toHaveValue('NEW NAME');
|
||||
});
|
||||
|
||||
test('test that user email can be updated', async ({ page }) => {
|
||||
test.skip('test that user email can be updated', async ({ page }) => {
|
||||
// this does not work because of email verification currently
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
|
||||
const emailId = Math.round(Math.random() * 10000);
|
||||
await page.getByLabel('Email').fill(`newemail+${emailId}@test.com`);
|
||||
|
||||
@@ -421,9 +421,10 @@ test('test that deleting a time entry from the overview works', async ({
|
||||
await expect(timeEntryRows).toHaveCount(timeEntryCount - 1);
|
||||
});
|
||||
|
||||
test('test that load more works when the end of page is reached', async ({
|
||||
test.skip('test that load more works when the end of page is reached', async ({
|
||||
page,
|
||||
}) => {
|
||||
// this test is flaky when you do not need to scroll
|
||||
await Promise.all([
|
||||
goToTimeOverview(page),
|
||||
page.waitForResponse(
|
||||
@@ -463,3 +464,5 @@ test('test that load more works when the end of page is reached', async ({
|
||||
// TODO: Test that time entries are loaded at the end of the page
|
||||
|
||||
// TODO: Test manual time entries
|
||||
|
||||
// TODO: Test Grouped time entries by description/project
|
||||
|
||||
@@ -36,6 +36,7 @@ test('test that starting and stopping a timer with a description works', async (
|
||||
}) => {
|
||||
await goToDashboard(page);
|
||||
// TODO: Fix flakyness by disabling description input field until timer is loaded
|
||||
await page.waitForTimeout(500);
|
||||
await page
|
||||
.getByTestId('time_entry_description')
|
||||
.fill('New Time Entry Description');
|
||||
|
||||
8
package-lock.json
generated
8
package-lock.json
generated
@@ -13,7 +13,7 @@
|
||||
"@vue/eslint-config-prettier": "^9.0.0",
|
||||
"@vue/eslint-config-typescript": "^13.0.0",
|
||||
"@vueuse/core": "^10.9.0",
|
||||
"dayjs": "^1.11.10",
|
||||
"dayjs": "^1.11.11",
|
||||
"echarts": "^5.5.0",
|
||||
"parse-duration": "^1.1.0",
|
||||
"pinia": "^2.1.7",
|
||||
@@ -2549,9 +2549,9 @@
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
|
||||
},
|
||||
"node_modules/dayjs": {
|
||||
"version": "1.11.10",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.10.tgz",
|
||||
"integrity": "sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ=="
|
||||
"version": "1.11.11",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.11.tgz",
|
||||
"integrity": "sha512-okzr3f11N6WuqYtZSvm+F776mB41wRZMhKP+hc34YdW+KmtYYK9iqvHSwo2k9FEH3fhGXvOPV6yz2IcSrfRUDg=="
|
||||
},
|
||||
"node_modules/de-indent": {
|
||||
"version": "1.0.2",
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
"@vue/eslint-config-prettier": "^9.0.0",
|
||||
"@vue/eslint-config-typescript": "^13.0.0",
|
||||
"@vueuse/core": "^10.9.0",
|
||||
"dayjs": "^1.11.10",
|
||||
"dayjs": "^1.11.11",
|
||||
"echarts": "^5.5.0",
|
||||
"parse-duration": "^1.1.0",
|
||||
"pinia": "^2.1.7",
|
||||
|
||||
@@ -1,66 +1,31 @@
|
||||
import { test as baseTest } from '@playwright/test';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { PLAYWRIGHT_BASE_URL } from './config';
|
||||
|
||||
export * from '@playwright/test';
|
||||
export const test = baseTest.extend<object, { workerStorageState: string }>({
|
||||
// Use the same storage state for all tests in this worker.
|
||||
storageState: ({ workerStorageState }, use) => use(workerStorageState),
|
||||
page: async ({ page }, use) => {
|
||||
// Perform authentication steps. Replace these actions with your own.
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/register');
|
||||
await page.getByLabel('Name').fill('John Doe');
|
||||
await page
|
||||
.getByLabel('Email')
|
||||
.fill(`john+${Math.round(Math.random() * 10000)}@doe.com`);
|
||||
await page
|
||||
.getByLabel('Password', { exact: true })
|
||||
.fill('amazingpassword123');
|
||||
await page.getByLabel('Confirm Password').fill('amazingpassword123');
|
||||
await page.getByLabel('I agree to the Terms of').click();
|
||||
await page.getByRole('button', { name: 'Register' }).click();
|
||||
|
||||
// Authenticate once per worker with a worker-scoped fixture.
|
||||
workerStorageState: [
|
||||
async ({ browser }, use) => {
|
||||
// Use parallelIndex as a unique identifier for each worker.
|
||||
const id = test.info().parallelIndex;
|
||||
const fileName = path.resolve(
|
||||
test.info().project.outputDir,
|
||||
`.auth/${id}.json`
|
||||
);
|
||||
// Wait until the page receives the cookies.
|
||||
//
|
||||
// Sometimes login flow sets cookies in the process of several redirects.
|
||||
// Wait for the final URL to ensure that the cookies are actually set.
|
||||
await page.waitForURL(PLAYWRIGHT_BASE_URL + '/dashboard');
|
||||
|
||||
if (fs.existsSync(fileName)) {
|
||||
// Reuse existing authentication state if any.
|
||||
await use(fileName);
|
||||
return;
|
||||
}
|
||||
// End of authentication steps.
|
||||
|
||||
// Important: make sure we authenticate in a clean environment by unsetting storage state.
|
||||
const page = await browser.newPage({ storageState: undefined });
|
||||
|
||||
// Acquire a unique account, for example create a new one.
|
||||
// Alternatively, you can have a list of precreated accounts for testing.
|
||||
// Make sure that accounts are unique, so that multiple team members
|
||||
// can run tests at the same time without interference.
|
||||
// const account = await acquireAccount(id);
|
||||
|
||||
// TODO: Use Seeder Accounts instead of creating new ones
|
||||
|
||||
// Perform authentication steps. Replace these actions with your own.
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/register');
|
||||
await page.getByLabel('Name').fill('John Doe');
|
||||
await page
|
||||
.getByLabel('Email')
|
||||
.fill(`john+${Math.round(Math.random() * 10000)}@doe.com`);
|
||||
await page
|
||||
.getByLabel('Password', { exact: true })
|
||||
.fill('amazingpassword123');
|
||||
await page
|
||||
.getByLabel('Confirm Password')
|
||||
.fill('amazingpassword123');
|
||||
await page.getByRole('button', { name: 'Register' }).click();
|
||||
|
||||
// Wait until the page receives the cookies.
|
||||
//
|
||||
// Sometimes login flow sets cookies in the process of several redirects.
|
||||
// Wait for the final URL to ensure that the cookies are actually set.
|
||||
await page.waitForURL(PLAYWRIGHT_BASE_URL + '/dashboard');
|
||||
|
||||
// End of authentication steps.
|
||||
|
||||
await page.context().storageState({ path: fileName });
|
||||
await page.close();
|
||||
await use(fileName);
|
||||
},
|
||||
{ scope: 'worker' },
|
||||
],
|
||||
await use(page);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ function cleanUpDecimalValue(value: string) {
|
||||
}
|
||||
|
||||
function updateRate(value: string) {
|
||||
value = value.trim();
|
||||
if (value.includes(',')) {
|
||||
const parts = value.split(',');
|
||||
const lastPart = (parts[parts.length - 1] = parts[parts.length - 1]);
|
||||
@@ -43,7 +44,7 @@ function formatCents(modelValue: number) {
|
||||
modelValue / 100,
|
||||
getOrganizationCurrencyString()
|
||||
);
|
||||
return formattedValue.replace(getOrganizationCurrencySymbol(), '');
|
||||
return formattedValue.replace(getOrganizationCurrencySymbol(), '').trim();
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { Member } from '@/utils/api';
|
||||
import { CheckCircleIcon, UserCircleIcon } from '@heroicons/vue/20/solid';
|
||||
import { useClientsStore } from '@/utils/useClients';
|
||||
import MemberMoreOptionsDropdown from '@/Components/Common/Member/MemberMoreOptionsDropdown.vue';
|
||||
import TableRow from '@/Components/TableRow.vue';
|
||||
import { capitalizeFirstLetter } from '../../../utils/format';
|
||||
@@ -10,13 +9,14 @@ import { api } from '../../../../../openapi.json.client';
|
||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||
import { useNotificationsStore } from '@/utils/notification';
|
||||
import { canInvitePlaceholderMembers } from '@/utils/permissions';
|
||||
import { useMembersStore } from '@/utils/useMembers';
|
||||
|
||||
const props = defineProps<{
|
||||
member: Member;
|
||||
}>();
|
||||
|
||||
function removeMember() {
|
||||
useClientsStore().deleteClient(props.member.id);
|
||||
useMembersStore().removeMember(props.member.id);
|
||||
}
|
||||
|
||||
async function invitePlaceholder(id: string) {
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
<script setup lang="ts">
|
||||
import MainContainer from '@/Pages/MainContainer.vue';
|
||||
import TimeTrackerStartStop from '@/Components/Common/TimeTrackerStartStop.vue';
|
||||
import type { TimeEntry } from '@/utils/api';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import TimeEntryDescriptionInput from '@/Components/Common/TimeEntry/TimeEntryDescriptionInput.vue';
|
||||
import {
|
||||
type TimeEntriesGroupedByType,
|
||||
useTimeEntriesStore,
|
||||
} from '@/utils/useTimeEntries';
|
||||
import TimeEntryRowTagDropdown from '@/Components/Common/TimeEntry/TimeEntryRowTagDropdown.vue';
|
||||
import dayjs from 'dayjs';
|
||||
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
|
||||
import TimeEntryMoreOptionsDropdown from '@/Components/Common/TimeEntry/TimeEntryMoreOptionsDropdown.vue';
|
||||
import TimeTrackerProjectTaskDropdown from '@/Components/Common/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
|
||||
import BillableToggleButton from '@/Components/Common/BillableToggleButton.vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import {
|
||||
formatHumanReadableDuration,
|
||||
formatStartEnd,
|
||||
} from '../../../utils/time';
|
||||
import TimeEntryRow from '@/Components/Common/TimeEntry/TimeEntryRow.vue';
|
||||
|
||||
const currentTimeEntryStore = useCurrentTimeEntryStore();
|
||||
const { stopTimer } = currentTimeEntryStore;
|
||||
const { currentTimeEntry } = storeToRefs(currentTimeEntryStore);
|
||||
|
||||
const props = defineProps<{
|
||||
timeEntry: TimeEntriesGroupedByType;
|
||||
}>();
|
||||
|
||||
const { updateTimeEntry, createTimeEntry, fetchTimeEntries } =
|
||||
useTimeEntriesStore();
|
||||
|
||||
async function onStartStopClick() {
|
||||
if (props.timeEntry.start && !props.timeEntry.end) {
|
||||
await updateTimeEntry({
|
||||
...props.timeEntry,
|
||||
end: dayjs().utc().format(),
|
||||
});
|
||||
} else {
|
||||
if (currentTimeEntry.value.id) {
|
||||
await stopTimer();
|
||||
}
|
||||
await createTimeEntry({
|
||||
...props.timeEntry,
|
||||
start: dayjs().utc().format(),
|
||||
end: null,
|
||||
});
|
||||
}
|
||||
useCurrentTimeEntryStore().fetchCurrentTimeEntry();
|
||||
fetchTimeEntries();
|
||||
}
|
||||
|
||||
function deleteTimeEntry() {
|
||||
const timeEntries = props.timeEntry.timeEntries;
|
||||
timeEntries.forEach((entry) => {
|
||||
useTimeEntriesStore().deleteTimeEntry(entry.id);
|
||||
});
|
||||
fetchTimeEntries();
|
||||
}
|
||||
|
||||
function updateTimeEntryDescription(description: string) {
|
||||
const timeEntries = props.timeEntry.timeEntries;
|
||||
timeEntries.forEach((entry) => {
|
||||
updateTimeEntry({ ...entry, description });
|
||||
entry.description = description;
|
||||
});
|
||||
}
|
||||
|
||||
function updateTimeEntryTags(tags: string[]) {
|
||||
const timeEntries = props.timeEntry.timeEntries as TimeEntry[];
|
||||
timeEntries.forEach((entry) => {
|
||||
updateTimeEntry({ ...entry, tags });
|
||||
entry.tags = tags;
|
||||
});
|
||||
}
|
||||
|
||||
function updateTimeEntryBillable(billable: boolean) {
|
||||
const timeEntries = props.timeEntry.timeEntries as TimeEntry[];
|
||||
timeEntries.forEach((entry) => {
|
||||
updateTimeEntry({ ...entry, billable });
|
||||
entry.billable = billable;
|
||||
});
|
||||
}
|
||||
|
||||
function updateProjectAndTask(projectId: string, taskId: string) {
|
||||
const timeEntries = props.timeEntry.timeEntries as TimeEntry[];
|
||||
timeEntries.forEach((entry) => {
|
||||
updateTimeEntry({
|
||||
...entry,
|
||||
project_id: projectId,
|
||||
task_id: taskId,
|
||||
});
|
||||
entry.project_id = projectId;
|
||||
entry.task_id = taskId;
|
||||
});
|
||||
}
|
||||
|
||||
const expanded = ref(false);
|
||||
|
||||
const expandedStatusClasses = computed(() => {
|
||||
if (expanded.value) {
|
||||
return 'border-card-border border bg-card-background-active text-white';
|
||||
}
|
||||
return 'border-card-border border bg-card-background text-muted';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="border-b border-default-background-separator transition"
|
||||
data-testid="time_entry_row">
|
||||
<MainContainer>
|
||||
<div class="sm:flex py-1.5 items-center justify-between group">
|
||||
<div class="flex space-x-3 items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded bg-card-background border-input-border text-accent-500/80 focus:ring-accent-500/80" />
|
||||
<button
|
||||
@click="expanded = !expanded"
|
||||
:class="
|
||||
twMerge(
|
||||
expandedStatusClasses,
|
||||
'font-medium w-7 h-7 rounded flex items-center transition justify-center'
|
||||
)
|
||||
">
|
||||
<span>
|
||||
{{ timeEntry?.timeEntries?.length }}
|
||||
</span>
|
||||
</button>
|
||||
<TimeEntryDescriptionInput
|
||||
@changed="updateTimeEntryDescription"
|
||||
class="flex-1"
|
||||
:modelValue="
|
||||
timeEntry.description
|
||||
"></TimeEntryDescriptionInput>
|
||||
<TimeTrackerProjectTaskDropdown
|
||||
:showBadgeBorder="false"
|
||||
@changed="updateProjectAndTask"
|
||||
:project="timeEntry.project_id"
|
||||
:task="
|
||||
timeEntry.task_id
|
||||
"></TimeTrackerProjectTaskDropdown>
|
||||
</div>
|
||||
<div class="flex items-center font-medium space-x-2">
|
||||
<TimeEntryRowTagDropdown
|
||||
@changed="updateTimeEntryTags"
|
||||
:modelValue="timeEntry.tags"></TimeEntryRowTagDropdown>
|
||||
<BillableToggleButton
|
||||
:modelValue="timeEntry.billable"
|
||||
size="small"
|
||||
@changed="
|
||||
updateTimeEntryBillable
|
||||
"></BillableToggleButton>
|
||||
<div class="flex-1">
|
||||
<button
|
||||
@click="expanded = !expanded"
|
||||
class="text-muted w-[110px] px-2 py-2 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-medium">
|
||||
{{ formatStartEnd(timeEntry.start, timeEntry.end) }}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
@click="expanded = !expanded"
|
||||
class="text-white w-[100px] px-3 py-2 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-semibold">
|
||||
{{
|
||||
formatHumanReadableDuration(timeEntry.duration ?? 0)
|
||||
}}
|
||||
</button>
|
||||
|
||||
<TimeTrackerStartStop
|
||||
@changed="onStartStopClick"
|
||||
:active="!!(timeEntry.start && !timeEntry.end)"
|
||||
class="opacity-20 hidden sm:flex group-hover:opacity-100"></TimeTrackerStartStop>
|
||||
<TimeEntryMoreOptionsDropdown
|
||||
@delete="
|
||||
deleteTimeEntry
|
||||
"></TimeEntryMoreOptionsDropdown>
|
||||
</div>
|
||||
</div>
|
||||
</MainContainer>
|
||||
<div
|
||||
v-if="expanded"
|
||||
class="w-full border-t border-default-background-separator bg-black/15">
|
||||
<TimeEntryRow
|
||||
indent
|
||||
:key="subEntry.id"
|
||||
v-for="subEntry in timeEntry.timeEntries"
|
||||
:time-entry="subEntry"></TimeEntryRow>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -4,7 +4,9 @@ const emit = defineEmits(['changed']);
|
||||
|
||||
function onChange(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
emit('changed', target.value);
|
||||
if (target.value !== value.value) {
|
||||
emit('changed', target.value);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -13,7 +15,7 @@ function onChange(event: Event) {
|
||||
<label class="input-sizer text-sm font-medium" :data-value="value">
|
||||
<input
|
||||
data-testid="time_entry_description"
|
||||
v-model="value"
|
||||
:value="value"
|
||||
@blur="onChange"
|
||||
@keydown.enter="onChange"
|
||||
placeholder="Add a description"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import Dropdown from '@/Components/Dropdown.vue';
|
||||
import { defineProps, ref, watch } from 'vue';
|
||||
import { formatTime } from '@/utils/time';
|
||||
import { formatStartEnd } from '@/utils/time';
|
||||
import TimePicker from '@/Components/Common/TimePicker.vue';
|
||||
import { useFocusWithin } from '@vueuse/core';
|
||||
|
||||
@@ -10,13 +10,6 @@ const props = defineProps<{
|
||||
end: string | null;
|
||||
}>();
|
||||
|
||||
function formatStartEnd(start: string, end: string | null) {
|
||||
if (end) {
|
||||
return `${formatTime(start)} - ${formatTime(end)}`;
|
||||
} else {
|
||||
return `${formatTime(start)} - ...`;
|
||||
}
|
||||
}
|
||||
const emit = defineEmits(['changed']);
|
||||
const tempStart = ref(props.start);
|
||||
const tempEnd = ref(props.end || null);
|
||||
|
||||
@@ -20,6 +20,7 @@ const { currentTimeEntry } = storeToRefs(currentTimeEntryStore);
|
||||
|
||||
const props = defineProps<{
|
||||
timeEntry: TimeEntry;
|
||||
indent?: boolean;
|
||||
}>();
|
||||
|
||||
const { updateTimeEntry, createTimeEntry, fetchTimeEntries } =
|
||||
@@ -69,6 +70,10 @@ function updateTimeEntryTags(tags: string[]) {
|
||||
updateTimeEntry({ ...props.timeEntry, tags });
|
||||
}
|
||||
|
||||
function updateTimeEntryBillable(billable: boolean) {
|
||||
updateTimeEntry({ ...props.timeEntry, billable });
|
||||
}
|
||||
|
||||
function updateProjectAndTask(projectId: string, taskId: string) {
|
||||
updateTimeEntry({
|
||||
...props.timeEntry,
|
||||
@@ -88,13 +93,13 @@ function updateProjectAndTask(projectId: string, taskId: string) {
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded bg-card-background border-input-border text-accent-500/80 focus:ring-accent-500/80" />
|
||||
<div class="w-7 h-7" v-if="indent === true"></div>
|
||||
<TimeEntryDescriptionInput
|
||||
@changed="updateTimeEntryDescription"
|
||||
class="flex-1"
|
||||
:modelValue="
|
||||
timeEntry.description
|
||||
"></TimeEntryDescriptionInput>
|
||||
|
||||
<TimeTrackerProjectTaskDropdown
|
||||
:showBadgeBorder="false"
|
||||
@changed="updateProjectAndTask"
|
||||
@@ -111,10 +116,7 @@ function updateProjectAndTask(projectId: string, taskId: string) {
|
||||
:modelValue="timeEntry.billable"
|
||||
size="small"
|
||||
@changed="
|
||||
updateTimeEntry({
|
||||
...timeEntry,
|
||||
billable: $event,
|
||||
})
|
||||
updateTimeEntryBillable
|
||||
"></BillableToggleButton>
|
||||
<div class="flex-1">
|
||||
<TimeEntryRangeSelector
|
||||
|
||||
@@ -54,28 +54,43 @@ withDefaults(
|
||||
);
|
||||
|
||||
const filteredProjects = computed(() => {
|
||||
return projects.value.reduce((filtered: ProjectWithTasks[], project) => {
|
||||
const projectNameIncludesSearchTerm = project.name
|
||||
.toLowerCase()
|
||||
.includes(searchValue.value?.toLowerCase()?.trim() || '');
|
||||
|
||||
// check if one of the project tasks
|
||||
const projectTasks = tasks.value.filter((task) => {
|
||||
return task.project_id === project.id;
|
||||
});
|
||||
|
||||
const filteredTasks = projectTasks.filter((task) => {
|
||||
return task.name
|
||||
return projects.value.reduce(
|
||||
(filtered: ProjectWithTasks[], project) => {
|
||||
const projectNameIncludesSearchTerm = project.name
|
||||
.toLowerCase()
|
||||
.includes(searchValue.value?.toLowerCase()?.trim() || '');
|
||||
});
|
||||
|
||||
if (projectNameIncludesSearchTerm || filteredTasks.length > 0) {
|
||||
filtered.push({ project: project, tasks: filteredTasks });
|
||||
}
|
||||
// check if one of the project tasks
|
||||
const projectTasks = tasks.value.filter((task) => {
|
||||
return task.project_id === project.id;
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}, []);
|
||||
const filteredTasks = projectTasks.filter((task) => {
|
||||
return task.name
|
||||
.toLowerCase()
|
||||
.includes(searchValue.value?.toLowerCase()?.trim() || '');
|
||||
});
|
||||
|
||||
if (projectNameIncludesSearchTerm || filteredTasks.length > 0) {
|
||||
filtered.push({ project: project, tasks: filteredTasks });
|
||||
}
|
||||
|
||||
return filtered;
|
||||
},
|
||||
[
|
||||
{
|
||||
project: {
|
||||
id: '',
|
||||
name: 'No Project',
|
||||
color: 'var(--theme-color-icon-default)',
|
||||
value: '',
|
||||
client_id: null,
|
||||
billable_rate: null,
|
||||
},
|
||||
tasks: [],
|
||||
},
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
async function addClientIfNoneExists() {
|
||||
|
||||
@@ -16,11 +16,11 @@ const props = withDefaults(
|
||||
);
|
||||
const buttonSizeClasses = {
|
||||
base: 'w-8 h-8 bg-accent-200/40 hover:scale-110 hover:bg-accent-300/70 ring-accent-200/10 focus:ring-accent-200/10 hover:ring-4',
|
||||
large: 'w-8 sm:w-11 h-8 sm:h-11 ring-accent-200/10 focus:ring-accent-200/20 ring-4 sm:ring-8 hover:scale-110',
|
||||
large: 'w-11 h-11 ring-accent-200/10 focus:ring-accent-200/20 ring-4 sm:ring-8 hover:scale-110',
|
||||
};
|
||||
const iconClass = {
|
||||
base: 'w-3.5 h-3.5',
|
||||
large: 'w-3.5 h-3.5 sm:w-4 sm:h-4',
|
||||
large: 'w-4 h-4',
|
||||
};
|
||||
|
||||
const buttonColorClasses = computed(() => {
|
||||
|
||||
@@ -8,18 +8,11 @@ const props = withDefaults(
|
||||
defineProps<{
|
||||
align: Placement;
|
||||
width: string;
|
||||
contentClasses?: string[];
|
||||
closeOnContentClick: boolean;
|
||||
}>(),
|
||||
{
|
||||
align: 'bottom-start',
|
||||
width: '48',
|
||||
contentClasses: () => [
|
||||
'overflow-none',
|
||||
'bg-card-background',
|
||||
'border',
|
||||
'border-card-border',
|
||||
],
|
||||
closeOnContentClick: true,
|
||||
}
|
||||
);
|
||||
@@ -101,8 +94,7 @@ const { floatingStyles } = useFloating(reference, floating, {
|
||||
leave-to-class="transform opacity-0 scale-95">
|
||||
<div
|
||||
v-if="open"
|
||||
class="rounded-lg ring-1 relative ring-black ring-opacity-5"
|
||||
:class="contentClasses">
|
||||
class="rounded-lg ring-1 relative ring-black ring-opacity-5 border border-card-border overflow-none bg-card-background shadow-lg">
|
||||
<slot name="content" />
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
@@ -194,7 +194,7 @@ function switchToTimeEntryOrganization() {
|
||||
v-model="currentTimeEntry.description"
|
||||
@keydown.enter="startTimerIfNotActive"
|
||||
@blur="updateTimeEntry"
|
||||
class="w-full rounded-l-lg py-2.5 px-3 border-b border-b-card-background-separator sm:px-4 text-sm sm:text-lg text-white focus:bg-card-background-active font-medium bg-transparent border-none placeholder-muted focus:ring-0 transition"
|
||||
class="w-full rounded-l-lg py-4 sm:py-2.5 px-3 border-b border-b-card-background-separator sm:px-4 text-base sm:text-lg text-white focus:bg-card-background-active font-medium bg-transparent border-none placeholder-muted focus:ring-0 transition"
|
||||
type="text" />
|
||||
</div>
|
||||
<div class="flex items-center justify-between pl-2">
|
||||
|
||||
@@ -15,15 +15,21 @@ const form = useForm({
|
||||
password_confirmation: '',
|
||||
terms: false,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone ?? null,
|
||||
newsletter_consent: false,
|
||||
});
|
||||
|
||||
const submit = () => {
|
||||
form.post(route('register'), {
|
||||
onFinish: () => form.reset('password', 'password_confirmation'),
|
||||
onSuccess: () => {
|
||||
form.reset('password', 'password_confirmation');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const page = usePage<{
|
||||
terms_url: string | null;
|
||||
privacy_policy_url: string | null;
|
||||
newsletter_consent: boolean;
|
||||
jetstream: {
|
||||
hasTermsAndPrivacyPolicyFeature: boolean;
|
||||
};
|
||||
@@ -111,29 +117,32 @@ const page = usePage<{
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="page.props.jetstream.hasTermsAndPrivacyPolicyFeature"
|
||||
v-if="
|
||||
page.props.jetstream.hasTermsAndPrivacyPolicyFeature &&
|
||||
page.props.terms_url !== null &&
|
||||
page.props.privacy_policy_url !== null
|
||||
"
|
||||
class="mt-4">
|
||||
<InputLabel for="terms">
|
||||
<div class="flex items-center">
|
||||
<Checkbox
|
||||
id="terms"
|
||||
v-model:checked="form.terms"
|
||||
name="terms"
|
||||
required />
|
||||
name="terms" />
|
||||
|
||||
<div class="ms-2">
|
||||
I agree to the
|
||||
<a
|
||||
target="_blank"
|
||||
:href="route('terms.show')"
|
||||
class="underline text-sm text-muted hover:text-gray-900 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
:href="page.props.terms_url"
|
||||
class="underline text-sm text-muted hover:text-white rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>Terms of Service</a
|
||||
>
|
||||
and
|
||||
<a
|
||||
target="_blank"
|
||||
:href="route('policy.show')"
|
||||
class="underline text-sm text-muted hover:text-gray-900 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
:href="page.props.privacy_policy_url"
|
||||
class="underline text-sm text-muted hover:text-white rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>Privacy Policy</a
|
||||
>
|
||||
</div>
|
||||
@@ -142,6 +151,25 @@ const page = usePage<{
|
||||
</InputLabel>
|
||||
</div>
|
||||
|
||||
<div class="mt-4" v-if="page.props.newsletter_consent">
|
||||
<InputLabel for="newsletter_consent">
|
||||
<div class="flex items-center">
|
||||
<Checkbox
|
||||
id="newsletter_consent"
|
||||
v-model:checked="form.newsletter_consent"
|
||||
name="newsletter_consent" />
|
||||
|
||||
<div class="ms-2">
|
||||
I agree to receive emails about product related
|
||||
updates
|
||||
</div>
|
||||
</div>
|
||||
<InputError
|
||||
class="mt-2"
|
||||
:message="form.errors.newsletter_consent" />
|
||||
</InputLabel>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end mt-4">
|
||||
<Link
|
||||
:href="route('login')"
|
||||
|
||||
@@ -51,7 +51,7 @@ const verificationLinkSent = computed(
|
||||
<div>
|
||||
<Link
|
||||
:href="route('profile.show')"
|
||||
class="underline text-sm text-muted hover:text-gray-900 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||
class="underline text-sm text-muted hover:text-white rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||
Edit Profile</Link
|
||||
>
|
||||
|
||||
|
||||
@@ -3,17 +3,21 @@ import AppLayout from '@/Layouts/AppLayout.vue';
|
||||
import TimeTracker from '@/Components/TimeTracker.vue';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import MainContainer from '@/Pages/MainContainer.vue';
|
||||
import { useTimeEntriesStore } from '@/utils/useTimeEntries';
|
||||
import {
|
||||
type TimeEntriesGroupedByType,
|
||||
useTimeEntriesStore,
|
||||
} from '@/utils/useTimeEntries';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import type { TimeEntry } from '@/utils/api';
|
||||
import TimeEntryRowHeading from '@/Components/Common/TimeEntry/TimeEntryRowHeading.vue';
|
||||
import TimeEntryRow from '@/Components/Common/TimeEntry/TimeEntryRow.vue';
|
||||
import { useElementVisibility } from '@vueuse/core';
|
||||
import { ClockIcon } from '@heroicons/vue/20/solid';
|
||||
import { getLocalizedDateFromTimestamp } from '@/utils/time';
|
||||
import { getDayJsInstance, getLocalizedDateFromTimestamp } from '@/utils/time';
|
||||
import SecondaryButton from '@/Components/SecondaryButton.vue';
|
||||
import { PlusIcon } from '@heroicons/vue/16/solid';
|
||||
import TimeEntryCreateModal from '@/Components/Common/TimeEntry/TimeEntryCreateModal.vue';
|
||||
import TimeEntryAggregateRow from '@/Components/Common/TimeEntry/TimeEntryAggregateRow.vue';
|
||||
|
||||
const timeEntriesStore = useTimeEntriesStore();
|
||||
const { timeEntries, allTimeEntriesLoaded } = storeToRefs(timeEntriesStore);
|
||||
@@ -38,14 +42,67 @@ onMounted(async () => {
|
||||
});
|
||||
|
||||
const groupedTimeEntries = computed(() => {
|
||||
const groupedEntries: Record<string, TimeEntry[]> = {};
|
||||
const groupedEntriesByDay: Record<string, TimeEntry[]> = {};
|
||||
for (const entry of timeEntries.value) {
|
||||
const oldEntries =
|
||||
groupedEntries[getLocalizedDateFromTimestamp(entry.start)];
|
||||
const newEntries = [...(oldEntries ?? []), entry];
|
||||
groupedEntries[getLocalizedDateFromTimestamp(entry.start)] = newEntries;
|
||||
groupedEntriesByDay[getLocalizedDateFromTimestamp(entry.start)];
|
||||
groupedEntriesByDay[getLocalizedDateFromTimestamp(entry.start)] = [
|
||||
...(oldEntries ?? []),
|
||||
entry,
|
||||
];
|
||||
}
|
||||
return groupedEntries;
|
||||
const groupedEntriesByDayAndType: Record<
|
||||
string,
|
||||
TimeEntriesGroupedByType[]
|
||||
> = {};
|
||||
for (const dailyEntriesKey in groupedEntriesByDay) {
|
||||
const dailyEntries = groupedEntriesByDay[dailyEntriesKey];
|
||||
const newDailyEntries: TimeEntriesGroupedByType[] = [];
|
||||
|
||||
for (const entry of dailyEntries) {
|
||||
// check if same entry already exists
|
||||
const oldEntriesIndex = newDailyEntries.findIndex(
|
||||
(e) =>
|
||||
e.project_id === entry.project_id &&
|
||||
e.task_id === entry.task_id &&
|
||||
e.billable === entry.billable &&
|
||||
e.description === entry.description
|
||||
);
|
||||
console.log(oldEntriesIndex);
|
||||
if (oldEntriesIndex !== -1 && newDailyEntries[oldEntriesIndex]) {
|
||||
newDailyEntries[oldEntriesIndex].timeEntries.push(entry);
|
||||
|
||||
// Add up durations for time entries of the same type
|
||||
console.log(newDailyEntries[oldEntriesIndex], entry?.duration);
|
||||
newDailyEntries[oldEntriesIndex].duration =
|
||||
(newDailyEntries[oldEntriesIndex].duration ?? 0) +
|
||||
(entry?.duration ?? 0);
|
||||
|
||||
// adapt start end times so they show the earliest start and latest end time
|
||||
if (
|
||||
getDayJsInstance()(entry.start).isBefore(
|
||||
getDayJsInstance()(
|
||||
newDailyEntries[oldEntriesIndex].start
|
||||
)
|
||||
)
|
||||
) {
|
||||
newDailyEntries[oldEntriesIndex].start = entry.start;
|
||||
}
|
||||
if (
|
||||
getDayJsInstance()(entry.end).isAfter(
|
||||
getDayJsInstance()(newDailyEntries[oldEntriesIndex].end)
|
||||
)
|
||||
) {
|
||||
newDailyEntries[oldEntriesIndex].end = entry.end;
|
||||
}
|
||||
} else {
|
||||
newDailyEntries.push({ ...entry, timeEntries: [entry] });
|
||||
}
|
||||
}
|
||||
|
||||
groupedEntriesByDayAndType[dailyEntriesKey] = newDailyEntries;
|
||||
}
|
||||
return groupedEntriesByDayAndType;
|
||||
});
|
||||
const showManualTimeEntryModal = ref(false);
|
||||
</script>
|
||||
@@ -66,17 +123,21 @@ const showManualTimeEntryModal = ref(false);
|
||||
class="w-full text-center flex justify-center"
|
||||
@click="showManualTimeEntryModal = true"
|
||||
:icon="PlusIcon"
|
||||
>Manual time entry</SecondaryButton
|
||||
>
|
||||
>Manual time entry
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
</div>
|
||||
</MainContainer>
|
||||
<div v-for="(value, key) in groupedTimeEntries" :key="key">
|
||||
<TimeEntryRowHeading :date="key"></TimeEntryRowHeading>
|
||||
<TimeEntryRow
|
||||
:key="entry.id"
|
||||
v-for="entry in value"
|
||||
:time-entry="entry"></TimeEntryRow>
|
||||
<template v-for="entry in value" :key="entry.id">
|
||||
<TimeEntryAggregateRow
|
||||
v-if="
|
||||
'timeEntries' in entry && entry.timeEntries.length > 1
|
||||
"
|
||||
:time-entry="entry"></TimeEntryAggregateRow>
|
||||
<TimeEntryRow v-else :time-entry="entry"></TimeEntryRow>
|
||||
</template>
|
||||
</div>
|
||||
<div
|
||||
v-if="Object.keys(groupedTimeEntries).length === 0"
|
||||
|
||||
@@ -87,3 +87,11 @@ export function formatHumanReadableDate(date: string) {
|
||||
}
|
||||
return dayjs(date).fromNow();
|
||||
}
|
||||
|
||||
export function formatStartEnd(start: string, end: string | null) {
|
||||
if (end) {
|
||||
return `${formatTime(start)} - ${formatTime(end)}`;
|
||||
} else {
|
||||
return `${formatTime(start)} - ...`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,9 +24,29 @@ export const useMembersStore = defineStore('members', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function removeMember(membershipId: string) {
|
||||
const organization = getCurrentOrganizationId();
|
||||
if (organization) {
|
||||
await handleApiRequestNotifications(
|
||||
api.removeMember(
|
||||
{},
|
||||
{
|
||||
params: {
|
||||
organization: organization,
|
||||
membership: membershipId,
|
||||
},
|
||||
}
|
||||
),
|
||||
'Member deleted successfully',
|
||||
'Failed to delete member'
|
||||
);
|
||||
await fetchMembers();
|
||||
}
|
||||
}
|
||||
|
||||
const members = computed<Member[]>(() => {
|
||||
return membersResponse.value?.data || [];
|
||||
});
|
||||
|
||||
return { members, fetchMembers };
|
||||
return { members, fetchMembers, removeMember };
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { reactive, ref } from 'vue';
|
||||
import type { CreateTimeEntryBody, TimeEntry } from '@/utils/api';
|
||||
import dayjs from 'dayjs';
|
||||
import { useNotificationsStore } from '@/utils/notification';
|
||||
export type TimeEntriesGroupedByType = TimeEntry & { timeEntries: TimeEntry[] };
|
||||
|
||||
export const useTimeEntriesStore = defineStore('timeEntries', () => {
|
||||
const timeEntries = ref<TimeEntry[]>(reactive([]));
|
||||
|
||||
@@ -5,10 +5,12 @@ declare(strict_types=1);
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Events\NewsletterRegistered;
|
||||
use App\Models\Member;
|
||||
use App\Models\User;
|
||||
use App\Providers\RouteServiceProvider;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Laravel\Fortify\Features;
|
||||
use Laravel\Jetstream\Jetstream;
|
||||
use Tests\TestCase;
|
||||
@@ -41,6 +43,11 @@ class RegistrationTest extends TestCase
|
||||
|
||||
public function test_new_users_can_register(): void
|
||||
{
|
||||
// Arrange
|
||||
Event::fake([
|
||||
NewsletterRegistered::class,
|
||||
]);
|
||||
|
||||
// Act
|
||||
$response = $this->post('/register', [
|
||||
'name' => 'Test User',
|
||||
@@ -51,6 +58,7 @@ class RegistrationTest extends TestCase
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertValid();
|
||||
$this->assertAuthenticated();
|
||||
$response->assertRedirect(RouteServiceProvider::HOME);
|
||||
$user = User::where('email', 'test@example.com')->firstOrFail();
|
||||
@@ -60,6 +68,34 @@ class RegistrationTest extends TestCase
|
||||
$this->assertSame(true, $organization->personal_team);
|
||||
$member = Member::query()->whereBelongsTo($user, 'user')->whereBelongsTo($organization, 'organization')->firstOrFail();
|
||||
$this->assertSame(Role::Owner->value, $member->role);
|
||||
Event::assertNotDispatched(NewsletterRegistered::class);
|
||||
}
|
||||
|
||||
public function test_new_users_can_consent_to_newsletter_during_registration(): void
|
||||
{
|
||||
// Arrange
|
||||
Event::fake([
|
||||
NewsletterRegistered::class,
|
||||
]);
|
||||
|
||||
// Act
|
||||
$response = $this->post('/register', [
|
||||
'name' => 'Test User',
|
||||
'email' => 'test@example.com',
|
||||
'password' => 'password',
|
||||
'password_confirmation' => 'password',
|
||||
'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature(),
|
||||
'newsletter_consent' => true,
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertValid();
|
||||
$this->assertAuthenticated();
|
||||
$response->assertRedirect(RouteServiceProvider::HOME);
|
||||
$user = User::where('email', 'test@example.com')->firstOrFail();
|
||||
$this->assertSame('Test User', $user->name);
|
||||
$this->assertSame('UTC', $user->timezone);
|
||||
Event::assertDispatched(NewsletterRegistered::class);
|
||||
}
|
||||
|
||||
public function test_new_users_can_register_and_frontend_can_send_timezone_for_user(): void
|
||||
|
||||
Reference in New Issue
Block a user