Compare commits

...

3 Commits

Author SHA1 Message Date
Gregor Vostrak
d9ffa9899f add focus trap to tag dropdown to fix focus management 2025-03-05 11:57:26 +01:00
Gregor Vostrak
aa588196ee fix datepicker dropdown and taborder in create time entry 2025-03-05 11:13:07 +01:00
Constantin Graf
b6bbcd7097 Fixed bug in toggl data importer if import contains invalid timezone 2025-03-04 17:08:28 -05:00
15 changed files with 269 additions and 40 deletions

View File

@@ -5,8 +5,11 @@ declare(strict_types=1);
namespace App\Service\Import\Importers;
use App\Enums\Role;
use App\Service\TimezoneService;
use Exception;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Override;
use Spatie\TemporaryDirectory\TemporaryDirectory;
use ValueError;
@@ -93,11 +96,22 @@ class TogglDataImporter extends DefaultImporter
}
foreach ($workspaceUsers as $workspaceUser) {
$timezone = Str::trim($workspaceUser->timezone);
if ($timezone === '') {
$timezone = 'UTC';
}
if (! app(TimezoneService::class)->isValid($timezone)) {
Log::warning('TogglDateImporter: Invalid timezone', [
'timezone' => $timezone,
]);
$timezone = 'UTC';
}
$userId = $this->userImportHelper->getKey([
'email' => $workspaceUser->email,
], [
'name' => $workspaceUser->name,
'timezone' => $workspaceUser->timezone ?? 'UTC',
'timezone' => $timezone,
'is_placeholder' => true,
], (string) $workspaceUser->uid);
$this->memberImportHelper->getKey([

View File

@@ -8,6 +8,7 @@ import { twMerge } from 'tailwind-merge';
const props = defineProps<{
class?: string;
tabindex?: string;
}>();
// This has to be a localized timestamp, not UTC
@@ -50,6 +51,7 @@ const emit = defineEmits(['changed']);
<input
id="start"
ref="datePicker"
:tabindex="tabindex"
:class="
twMerge(
'bg-input-background border text-white border-input-border focus-visible:outline-0 focus-visible:border-input-border-active focus-visible:ring-0 rounded-md',

View File

@@ -6,6 +6,7 @@ import TagCreateModal from '@/packages/ui/src/Tag/TagCreateModal.vue';
import MultiselectDropdownItem from '@/packages/ui/src/Input/MultiselectDropdownItem.vue';
import type { Tag } from '@/packages/api/src';
import type { Placement } from '@floating-ui/vue';
import {UseFocusTrap} from "@vueuse/integrations/useFocusTrap/component";
const props = withDefaults(
defineProps<{
@@ -177,46 +178,50 @@ const showCreateTagModal = ref(false);
<slot name="trigger"></slot>
</template>
<template #content>
<input
ref="searchInput"
:value="searchValue"
data-testid="tag_dropdown_search"
class="bg-card-background border-0 placeholder-muted text-sm text-white py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
placeholder="Search for a Tag..."
@input="updateSearchValue"
@keydown.enter="addTagIfNoneExists"
@keydown.up.prevent="moveHighlightUp"
@keydown.down.prevent="moveHighlightDown" />
<div ref="dropdownViewport" class="w-60 max-h-60 overflow-y-scroll">
<div
v-for="tag in filteredTags"
:key="tag.id"
role="option"
:value="tag.id"
:class="{
<UseFocusTrap
v-if="open"
:options="{ immediate: true, allowOutsideClick: true }">
<input
ref="searchInput"
:value="searchValue"
data-testid="tag_dropdown_search"
class="bg-card-background border-0 placeholder-muted text-sm text-white py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
placeholder="Search for a Tag..."
@input="updateSearchValue"
@keydown.enter="addTagIfNoneExists"
@keydown.up.prevent="moveHighlightUp"
@keydown.down.prevent="moveHighlightDown" />
<div ref="dropdownViewport" class="w-60 max-h-60 overflow-y-scroll">
<div
v-for="tag in filteredTags"
:key="tag.id"
role="option"
:value="tag.id"
:class="{
'bg-card-background-active':
tag.id === highlightedItemId,
}"
data-testid="tag_dropdown_entries"
:data-tag-id="tag.id">
<MultiselectDropdownItem
:selected="isTagSelected(tag.id)"
:name="tag.name"
@click="toggleTag(tag.id)"></MultiselectDropdownItem>
data-testid="tag_dropdown_entries"
:data-tag-id="tag.id">
<MultiselectDropdownItem
:selected="isTagSelected(tag.id)"
:name="tag.name"
@click="toggleTag(tag.id)"></MultiselectDropdownItem>
</div>
</div>
</div>
<div class="hover:bg-card-background-active rounded-b-lg">
<button
class="text-white w-full flex space-x-3 items-center px-4 py-3 text-xs font-semibold border-t border-card-background-separator"
@click="
<div class="hover:bg-card-background-active rounded-b-lg">
<button
class="text-white w-full flex space-x-3 items-center px-4 py-3 text-xs font-semibold border-t border-card-background-separator"
@click="
open = false;
showCreateTagModal = true;
">
<PlusCircleIcon
class="w-5 flex-shrink-0 text-icon-default"></PlusCircleIcon>
<span>Create new Tag</span>
</button>
</div>
<PlusCircleIcon
class="w-5 flex-shrink-0 text-icon-default"></PlusCircleIcon>
<span>Create new Tag</span>
</button>
</div>
</UseFocusTrap>
</template>
</Dropdown>
</template>

View File

@@ -18,7 +18,6 @@ import type {
Client,
CreateTimeEntryBody,
} from '@/packages/api/src';
import TimePicker from '@/packages/ui/src/Input/TimePicker.vue';
import { getOrganizationCurrencyString } from '@/utils/money';
import { canCreateProjects } from '@/utils/permissions';
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
@@ -30,6 +29,7 @@ import DurationHumanInput from '@/packages/ui/src/Input/DurationHumanInput.vue';
import { InformationCircleIcon } from '@heroicons/vue/20/solid';
import type { Tag, Task } from '@/packages/api/src';
import TimePickerSimple from "@/packages/ui/src/Input/TimePickerSimple.vue";
const show = defineModel('show', { default: false });
const saving = ref(false);
@@ -244,30 +244,34 @@ type BillableOption = {
<div class="">
<InputLabel>Start</InputLabel>
<div class="flex flex-col items-center space-y-2 mt-1">
<TimePicker
<TimePickerSimple
v-model="localStart"
size="large"></TimePicker>
size="large"></TimePickerSimple>
<DatePicker
v-model="localStart"
tabindex="1"
class="text-xs text-text-tertiary max-w-28 px-1.5 py-1.5"></DatePicker>
</div>
</div>
<div class="">
<InputLabel>End</InputLabel>
<div class="flex flex-col items-center space-y-2 mt-1">
<TimePicker
<TimePickerSimple
v-model="localEnd"
size="large"></TimePicker>
size="large"></TimePickerSimple>
<DatePicker
v-model="localEnd"
tabindex="1"
class="text-xs text-text-tertiary max-w-28 px-1.5 py-1.5"></DatePicker>
</div>
</div>
</div>
</template>
<template #footer>
<SecondaryButton @click="show = false"> Cancel</SecondaryButton>
<SecondaryButton tabindex="2" @click="show = false"> Cancel</SecondaryButton>
<PrimaryButton
tabindex="2"
class="ms-3"
:class="{ 'opacity-25': saving }"
:disabled="saving"

View File

@@ -0,0 +1,16 @@
[
{
"archived": false,
"creator_id": 201,
"id": 301,
"name": "Big Company",
"wid": 0
},
{
"archived": true,
"creator_id": 201,
"id": 302,
"name": "Other Company (Archived)",
"wid": 0
}
]

View File

@@ -0,0 +1,86 @@
[
{
"active": true,
"actual_hours": null,
"actual_seconds": null,
"auto_estimates": false,
"billable": false,
"cid": null,
"client_id": null,
"color": "#ef5350",
"currency": "EUR",
"estimated_hours": null,
"estimated_seconds": null,
"fixed_fee": null,
"guid": "",
"id": 401,
"is_private": true,
"name": "Project without Client",
"rate": null,
"rate_last_updated": null,
"recurring": false,
"recurring_parameters": null,
"start_date": "2020-01-01",
"status": "active",
"template": false,
"template_id": null,
"wid": 0,
"workspace_id": 0
},
{
"active": true,
"actual_hours": null,
"actual_seconds": null,
"auto_estimates": false,
"billable": true,
"cid": 301,
"client_id": 301,
"color": "#ec407a",
"currency": null,
"estimated_hours": null,
"estimated_seconds": null,
"fixed_fee": null,
"guid": "",
"id": 402,
"is_private": true,
"name": "Project for Big Company",
"rate": 100.01,
"rate_last_updated": null,
"recurring": false,
"recurring_parameters": null,
"start_date": "2020-01-01",
"status": "active",
"template": false,
"template_id": null,
"wid": 0,
"workspace_id": 0
},
{
"active": false,
"actual_hours": null,
"actual_seconds": null,
"auto_estimates": false,
"billable": true,
"cid": 302,
"client_id": 302,
"color": "#6a407f",
"currency": null,
"estimated_hours": null,
"estimated_seconds": null,
"fixed_fee": null,
"guid": "",
"id": 403,
"is_private": false,
"name": "Project (Archived)",
"rate": null,
"rate_last_updated": null,
"recurring": false,
"recurring_parameters": null,
"start_date": "2020-01-01",
"status": "active",
"template": false,
"template_id": null,
"wid": 0,
"workspace_id": 0
}
]

View File

@@ -0,0 +1,14 @@
[
{
"gid": null,
"group_id": null,
"id": 801,
"labour_cost": null,
"manager": true,
"project_id": 402,
"rate": 100.02,
"rate_last_updated": null,
"user_id": 2001,
"workspace_id": 0
}
]

View File

@@ -0,0 +1,14 @@
[
{
"creator_id": 0,
"id": 501,
"name": "Development",
"workspace_id": 0
},
{
"creator_id": 0,
"id": 502,
"name": "Backend",
"workspace_id": 0
}
]

View File

@@ -0,0 +1 @@
[]

View File

@@ -0,0 +1,24 @@
[
{
"active": true,
"estimated_seconds": 0,
"id": 601,
"name": "Task 1",
"project_id": 402,
"recurring": false,
"tracked_seconds": 0,
"user_id": null,
"workspace_id": 0
},
{
"active": false,
"estimated_seconds": 0,
"id": 602,
"name": "Task 2",
"project_id": 403,
"recurring": false,
"tracked_seconds": 0,
"user_id": null,
"workspace_id": 0
}
]

View File

@@ -0,0 +1 @@
[]

View File

@@ -0,0 +1,19 @@
[
{
"active": true,
"admin": true,
"email": "peter.test@email.test",
"group_ids": [],
"id": 201,
"inactive": false,
"labour_cost": null,
"name": "Peter Tester",
"rate": null,
"rate_last_updated": null,
"role": "admin",
"timezone": "Etc/UTC",
"uid": 2001,
"wid": 0,
"working_hours_in_minutes": null
}
]

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Tests\Unit\Service\Import\Importers;
use App\Models\Organization;
use App\Models\User;
use App\Service\Import\Importers\DefaultImporter;
use App\Service\Import\Importers\ImportException;
use App\Service\Import\Importers\TogglDataImporter;
@@ -88,4 +89,30 @@ class TogglDataImporterTest extends ImporterTestAbstract
$this->assertSame(0, $report->projectsCreated);
$this->assertSame(0, $report->clientsCreated);
}
public function test_import_of_user_with_unknown_timezone_will_be_mapped_to_utc(): void
{
// Arrange
$zipPath = $this->createTestZip('toggl_data_import_test_2');
$timezone = 'Europe/Vienna';
$organization = Organization::factory()->create();
$importer = new TogglDataImporter;
$importer->init($organization);
$data = file_get_contents($zipPath);
// Act
$importer->importData($data, $timezone);
$report = $importer->getReport();
// Assert
$this->assertSame(0, $report->timeEntriesCreated);
$this->assertSame(2, $report->tagsCreated);
$this->assertSame(2, $report->tasksCreated);
$this->assertSame(1, $report->usersCreated);
$this->assertSame(3, $report->projectsCreated);
$this->assertSame(2, $report->clientsCreated);
$user = User::query()->where('email', '=', 'peter.test@email.test')->first();
$this->assertSame('UTC', $user->timezone);
$this->assertTrue($user->is_placeholder);
}
}