mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-12 18:22:16 +01:00
Compare commits
3 Commits
v0.6.0
...
feature/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9ffa9899f | ||
|
|
aa588196ee | ||
|
|
b6bbcd7097 |
@@ -5,8 +5,11 @@ declare(strict_types=1);
|
|||||||
namespace App\Service\Import\Importers;
|
namespace App\Service\Import\Importers;
|
||||||
|
|
||||||
use App\Enums\Role;
|
use App\Enums\Role;
|
||||||
|
use App\Service\TimezoneService;
|
||||||
use Exception;
|
use Exception;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use Override;
|
use Override;
|
||||||
use Spatie\TemporaryDirectory\TemporaryDirectory;
|
use Spatie\TemporaryDirectory\TemporaryDirectory;
|
||||||
use ValueError;
|
use ValueError;
|
||||||
@@ -93,11 +96,22 @@ class TogglDataImporter extends DefaultImporter
|
|||||||
}
|
}
|
||||||
|
|
||||||
foreach ($workspaceUsers as $workspaceUser) {
|
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([
|
$userId = $this->userImportHelper->getKey([
|
||||||
'email' => $workspaceUser->email,
|
'email' => $workspaceUser->email,
|
||||||
], [
|
], [
|
||||||
'name' => $workspaceUser->name,
|
'name' => $workspaceUser->name,
|
||||||
'timezone' => $workspaceUser->timezone ?? 'UTC',
|
'timezone' => $timezone,
|
||||||
'is_placeholder' => true,
|
'is_placeholder' => true,
|
||||||
], (string) $workspaceUser->uid);
|
], (string) $workspaceUser->uid);
|
||||||
$this->memberImportHelper->getKey([
|
$this->memberImportHelper->getKey([
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { twMerge } from 'tailwind-merge';
|
|||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
class?: string;
|
class?: string;
|
||||||
|
tabindex?: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
// This has to be a localized timestamp, not UTC
|
// This has to be a localized timestamp, not UTC
|
||||||
@@ -50,6 +51,7 @@ const emit = defineEmits(['changed']);
|
|||||||
<input
|
<input
|
||||||
id="start"
|
id="start"
|
||||||
ref="datePicker"
|
ref="datePicker"
|
||||||
|
:tabindex="tabindex"
|
||||||
:class="
|
:class="
|
||||||
twMerge(
|
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',
|
'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',
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import TagCreateModal from '@/packages/ui/src/Tag/TagCreateModal.vue';
|
|||||||
import MultiselectDropdownItem from '@/packages/ui/src/Input/MultiselectDropdownItem.vue';
|
import MultiselectDropdownItem from '@/packages/ui/src/Input/MultiselectDropdownItem.vue';
|
||||||
import type { Tag } from '@/packages/api/src';
|
import type { Tag } from '@/packages/api/src';
|
||||||
import type { Placement } from '@floating-ui/vue';
|
import type { Placement } from '@floating-ui/vue';
|
||||||
|
import {UseFocusTrap} from "@vueuse/integrations/useFocusTrap/component";
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@@ -177,46 +178,50 @@ const showCreateTagModal = ref(false);
|
|||||||
<slot name="trigger"></slot>
|
<slot name="trigger"></slot>
|
||||||
</template>
|
</template>
|
||||||
<template #content>
|
<template #content>
|
||||||
<input
|
<UseFocusTrap
|
||||||
ref="searchInput"
|
v-if="open"
|
||||||
:value="searchValue"
|
:options="{ immediate: true, allowOutsideClick: true }">
|
||||||
data-testid="tag_dropdown_search"
|
<input
|
||||||
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"
|
ref="searchInput"
|
||||||
placeholder="Search for a Tag..."
|
:value="searchValue"
|
||||||
@input="updateSearchValue"
|
data-testid="tag_dropdown_search"
|
||||||
@keydown.enter="addTagIfNoneExists"
|
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"
|
||||||
@keydown.up.prevent="moveHighlightUp"
|
placeholder="Search for a Tag..."
|
||||||
@keydown.down.prevent="moveHighlightDown" />
|
@input="updateSearchValue"
|
||||||
<div ref="dropdownViewport" class="w-60 max-h-60 overflow-y-scroll">
|
@keydown.enter="addTagIfNoneExists"
|
||||||
<div
|
@keydown.up.prevent="moveHighlightUp"
|
||||||
v-for="tag in filteredTags"
|
@keydown.down.prevent="moveHighlightDown" />
|
||||||
:key="tag.id"
|
<div ref="dropdownViewport" class="w-60 max-h-60 overflow-y-scroll">
|
||||||
role="option"
|
<div
|
||||||
:value="tag.id"
|
v-for="tag in filteredTags"
|
||||||
:class="{
|
:key="tag.id"
|
||||||
|
role="option"
|
||||||
|
:value="tag.id"
|
||||||
|
:class="{
|
||||||
'bg-card-background-active':
|
'bg-card-background-active':
|
||||||
tag.id === highlightedItemId,
|
tag.id === highlightedItemId,
|
||||||
}"
|
}"
|
||||||
data-testid="tag_dropdown_entries"
|
data-testid="tag_dropdown_entries"
|
||||||
:data-tag-id="tag.id">
|
:data-tag-id="tag.id">
|
||||||
<MultiselectDropdownItem
|
<MultiselectDropdownItem
|
||||||
:selected="isTagSelected(tag.id)"
|
:selected="isTagSelected(tag.id)"
|
||||||
:name="tag.name"
|
:name="tag.name"
|
||||||
@click="toggleTag(tag.id)"></MultiselectDropdownItem>
|
@click="toggleTag(tag.id)"></MultiselectDropdownItem>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="hover:bg-card-background-active rounded-b-lg">
|
||||||
<div class="hover:bg-card-background-active rounded-b-lg">
|
<button
|
||||||
<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"
|
||||||
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="
|
||||||
@click="
|
|
||||||
open = false;
|
open = false;
|
||||||
showCreateTagModal = true;
|
showCreateTagModal = true;
|
||||||
">
|
">
|
||||||
<PlusCircleIcon
|
<PlusCircleIcon
|
||||||
class="w-5 flex-shrink-0 text-icon-default"></PlusCircleIcon>
|
class="w-5 flex-shrink-0 text-icon-default"></PlusCircleIcon>
|
||||||
<span>Create new Tag</span>
|
<span>Create new Tag</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</UseFocusTrap>
|
||||||
</template>
|
</template>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import type {
|
|||||||
Client,
|
Client,
|
||||||
CreateTimeEntryBody,
|
CreateTimeEntryBody,
|
||||||
} from '@/packages/api/src';
|
} from '@/packages/api/src';
|
||||||
import TimePicker from '@/packages/ui/src/Input/TimePicker.vue';
|
|
||||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||||
import { canCreateProjects } from '@/utils/permissions';
|
import { canCreateProjects } from '@/utils/permissions';
|
||||||
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
|
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 { InformationCircleIcon } from '@heroicons/vue/20/solid';
|
||||||
import type { Tag, Task } from '@/packages/api/src';
|
import type { Tag, Task } from '@/packages/api/src';
|
||||||
|
import TimePickerSimple from "@/packages/ui/src/Input/TimePickerSimple.vue";
|
||||||
|
|
||||||
const show = defineModel('show', { default: false });
|
const show = defineModel('show', { default: false });
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
@@ -244,30 +244,34 @@ type BillableOption = {
|
|||||||
<div class="">
|
<div class="">
|
||||||
<InputLabel>Start</InputLabel>
|
<InputLabel>Start</InputLabel>
|
||||||
<div class="flex flex-col items-center space-y-2 mt-1">
|
<div class="flex flex-col items-center space-y-2 mt-1">
|
||||||
<TimePicker
|
<TimePickerSimple
|
||||||
|
|
||||||
v-model="localStart"
|
v-model="localStart"
|
||||||
size="large"></TimePicker>
|
size="large"></TimePickerSimple>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
v-model="localStart"
|
v-model="localStart"
|
||||||
|
tabindex="1"
|
||||||
class="text-xs text-text-tertiary max-w-28 px-1.5 py-1.5"></DatePicker>
|
class="text-xs text-text-tertiary max-w-28 px-1.5 py-1.5"></DatePicker>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="">
|
<div class="">
|
||||||
<InputLabel>End</InputLabel>
|
<InputLabel>End</InputLabel>
|
||||||
<div class="flex flex-col items-center space-y-2 mt-1">
|
<div class="flex flex-col items-center space-y-2 mt-1">
|
||||||
<TimePicker
|
<TimePickerSimple
|
||||||
v-model="localEnd"
|
v-model="localEnd"
|
||||||
size="large"></TimePicker>
|
size="large"></TimePickerSimple>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
v-model="localEnd"
|
v-model="localEnd"
|
||||||
|
tabindex="1"
|
||||||
class="text-xs text-text-tertiary max-w-28 px-1.5 py-1.5"></DatePicker>
|
class="text-xs text-text-tertiary max-w-28 px-1.5 py-1.5"></DatePicker>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<SecondaryButton @click="show = false"> Cancel</SecondaryButton>
|
<SecondaryButton tabindex="2" @click="show = false"> Cancel</SecondaryButton>
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
|
tabindex="2"
|
||||||
class="ms-3"
|
class="ms-3"
|
||||||
:class="{ 'opacity-25': saving }"
|
:class="{ 'opacity-25': saving }"
|
||||||
:disabled="saving"
|
:disabled="saving"
|
||||||
|
|||||||
16
resources/testfiles/toggl_data_import_test_2/clients.json
Normal file
16
resources/testfiles/toggl_data_import_test_2/clients.json
Normal 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
|
||||||
|
}
|
||||||
|
]
|
||||||
86
resources/testfiles/toggl_data_import_test_2/projects.json
Normal file
86
resources/testfiles/toggl_data_import_test_2/projects.json
Normal 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
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
[]
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
[]
|
||||||
14
resources/testfiles/toggl_data_import_test_2/tags.json
Normal file
14
resources/testfiles/toggl_data_import_test_2/tags.json
Normal 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
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
[]
|
||||||
24
resources/testfiles/toggl_data_import_test_2/tasks/402.json
Normal file
24
resources/testfiles/toggl_data_import_test_2/tasks/402.json
Normal 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
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
[]
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace Tests\Unit\Service\Import\Importers;
|
namespace Tests\Unit\Service\Import\Importers;
|
||||||
|
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
|
use App\Models\User;
|
||||||
use App\Service\Import\Importers\DefaultImporter;
|
use App\Service\Import\Importers\DefaultImporter;
|
||||||
use App\Service\Import\Importers\ImportException;
|
use App\Service\Import\Importers\ImportException;
|
||||||
use App\Service\Import\Importers\TogglDataImporter;
|
use App\Service\Import\Importers\TogglDataImporter;
|
||||||
@@ -88,4 +89,30 @@ class TogglDataImporterTest extends ImporterTestAbstract
|
|||||||
$this->assertSame(0, $report->projectsCreated);
|
$this->assertSame(0, $report->projectsCreated);
|
||||||
$this->assertSame(0, $report->clientsCreated);
|
$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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user