Merge branch 'main' of github.com:solidtime-io/solidtime into feature/reporting

# Conflicts:
#	tests/Feature/RegistrationTest.php
This commit is contained in:
Gregor Vostrak
2024-05-16 17:39:18 +02:00
34 changed files with 868 additions and 522 deletions

View File

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

View File

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

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -60,9 +60,8 @@ return [
*/
'features' => [
// Features::termsAndPrivacyPolicy(),
Features::termsAndPrivacyPolicy(),
Features::profilePhotos(),
// Features::api(),
Features::teams(['invitations' => true]),
Features::accountDeletion(),
],

View File

@@ -91,7 +91,7 @@ return [
'sentry' => [
'driver' => 'sentry',
'level' => env('LOG_LEVEL', 'error'),
'level' => env('LOG_LEVEL_SENTRY', 'error'),
'bubble' => true,
],

View File

@@ -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'),
],
];

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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() {

View File

@@ -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(() => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)} - ...`;
}
}

View File

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

View File

@@ -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([]));

View File

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