From ac0485b057dfd8e6a348e9c8c11271b7774bbf50 Mon Sep 17 00:00:00 2001 From: Gregor Vostrak Date: Sun, 14 Apr 2024 01:54:58 +0200 Subject: [PATCH] added importer to organization settings --- .../Controllers/Api/V1/ImportController.php | 11 +- e2e/organization.spec.ts | 6 +- openapi.json.client.ts | 280 +++++++++++++++++- .../js/Components/OrganizationSwitcher.vue | 12 +- resources/js/Layouts/AppLayout.vue | 4 +- .../js/Pages/Teams/Partials/ImportData.vue | 142 +++++++++ resources/js/Pages/Teams/Show.vue | 5 + .../Endpoint/Api/V1/ImportEndpointTest.php | 6 +- 8 files changed, 442 insertions(+), 24 deletions(-) create mode 100644 resources/js/Pages/Teams/Partials/ImportData.vue diff --git a/app/Http/Controllers/Api/V1/ImportController.php b/app/Http/Controllers/Api/V1/ImportController.php index a3bd4040..3b9cbf2d 100644 --- a/app/Http/Controllers/Api/V1/ImportController.php +++ b/app/Http/Controllers/Api/V1/ImportController.php @@ -17,16 +17,25 @@ class ImportController extends Controller * Import data into the organization * * @throws AuthorizationException + * + * @operationId importData */ public function import(Organization $organization, ImportRequest $request, ImportService $importService): JsonResponse { $this->checkPermission($organization, 'import'); try { + $importData = base64_decode($request->input('data'), true); + if ($importData === false) { + return new JsonResponse([ + 'message' => 'Invalid base64 encoded data', + ], 400); + } + $report = $importService->import( $organization, $request->input('type'), - $request->input('data') + $importData ); return new JsonResponse([ diff --git a/e2e/organization.spec.ts b/e2e/organization.spec.ts index 065cd647..751fba80 100644 --- a/e2e/organization.spec.ts +++ b/e2e/organization.spec.ts @@ -12,9 +12,9 @@ test('test that organization name can be updated', async ({ page }) => { await page.getByLabel('Team Name').fill('NEW ORG NAME'); await page.getByLabel('Team Name').press('Enter'); await page.getByLabel('Team Name').press('Meta+r'); - await expect(page.locator('[data-testid="organization_switcher"]:visible')).toContainText( - 'NEW ORG NAME' - ); + await expect( + page.locator('[data-testid="organization_switcher"]:visible') + ).toContainText('NEW ORG NAME'); }); test('test that new manager can be invited', async ({ page }) => { diff --git a/openapi.json.client.ts b/openapi.json.client.ts index 8037703d..ce149d01 100644 --- a/openapi.json.client.ts +++ b/openapi.json.client.ts @@ -10,12 +10,37 @@ const ClientResource = z }) .passthrough(); const ClientCollection = z.array(ClientResource); -const v1_import_import_Body = z +const importData_Body = z .object({ type: z.string(), data: z.string() }) .passthrough(); +const InvitationResource = z + .object({ id: z.string(), user_id: z.string(), name: z.string() }) + .passthrough(); +const Role = z.enum(['owner', 'admin', 'manager', 'employee', 'placeholder']); +const invite_Body = z + .object({ email: z.string().email(), role: Role }) + .passthrough(); +const MemberPivotResource = z + .object({ + id: z.string(), + user_id: z.string(), + name: z.string(), + email: z.string(), + role: z.string(), + is_placeholder: z.boolean(), + billable_rate: z.union([z.number(), z.null()]), + }) + .passthrough(); +const updateMember_Body = z + .object({ + billable_rate: z.union([z.number(), z.null()]).optional(), + role: Role, + }) + .passthrough(); const MemberResource = z .object({ id: z.string(), + user_id: z.string(), name: z.string(), email: z.string(), role: z.string(), @@ -23,7 +48,6 @@ const MemberResource = z billable_rate: z.union([z.number(), z.null()]), }) .passthrough(); -const MemberCollection = z.array(MemberResource); const OrganizationResource = z .object({ id: z.string(), @@ -137,9 +161,13 @@ const updateTimeEntry_Body = z export const schemas = { ClientResource, ClientCollection, - v1_import_import_Body, + importData_Body, + InvitationResource, + Role, + invite_Body, + MemberPivotResource, + updateMember_Body, MemberResource, - MemberCollection, OrganizationResource, v1_organizations_update_Body, ProjectResource, @@ -378,13 +406,13 @@ const endpoints = makeApi([ { method: 'post', path: '/v1/organizations/:organization/import', - alias: 'v1.import.import', + alias: 'importData', requestFormat: 'json', parameters: [ { name: 'body', type: 'Body', - schema: v1_import_import_Body, + schema: importData_Body, }, { name: 'organization', @@ -445,6 +473,115 @@ const endpoints = makeApi([ }, ], }, + { + method: 'get', + path: '/v1/organizations/:organization/invitations', + alias: 'getInvitations', + requestFormat: 'json', + parameters: [ + { + name: 'organization', + type: 'Path', + schema: z.string().uuid(), + }, + ], + response: z + .object({ + data: z.array(InvitationResource), + links: z + .object({ + first: z.union([z.string(), z.null()]), + last: z.union([z.string(), z.null()]), + prev: z.union([z.string(), z.null()]), + next: z.union([z.string(), z.null()]), + }) + .passthrough(), + meta: z + .object({ + current_page: z.number().int(), + from: z.union([z.number(), z.null()]), + last_page: z.number().int(), + links: z.array( + z + .object({ + url: z.union([z.string(), z.null()]), + label: z.string(), + active: z.boolean(), + }) + .passthrough() + ), + path: z.union([z.string(), z.null()]), + per_page: z.number().int(), + to: z.union([z.number(), z.null()]), + total: z.number().int(), + }) + .passthrough(), + }) + .passthrough(), + errors: [ + { + status: 403, + description: `Authorization error`, + schema: z.object({ message: z.string() }).passthrough(), + }, + { + status: 404, + description: `Not found`, + schema: z.object({ message: z.string() }).passthrough(), + }, + { + status: 422, + description: `Validation error`, + schema: z + .object({ + message: z.string(), + errors: z.record(z.array(z.string())), + }) + .passthrough(), + }, + ], + }, + { + method: 'post', + path: '/v1/organizations/:organization/invitations', + alias: 'invite', + requestFormat: 'json', + parameters: [ + { + name: 'body', + type: 'Body', + schema: invite_Body, + }, + { + name: 'organization', + type: 'Path', + schema: z.string().uuid(), + }, + ], + response: z.null(), + errors: [ + { + status: 403, + description: `Authorization error`, + schema: z.object({ message: z.string() }).passthrough(), + }, + { + status: 404, + description: `Not found`, + schema: z.object({ message: z.string() }).passthrough(), + }, + { + status: 422, + description: `Validation error`, + schema: z + .object({ + message: z.string(), + errors: z.record(z.array(z.string())), + }) + .passthrough(), + }, + ], + }, { method: 'get', path: '/v1/organizations/:organization/members', @@ -457,7 +594,85 @@ const endpoints = makeApi([ schema: z.string().uuid(), }, ], - response: z.object({ data: MemberCollection }).passthrough(), + response: z + .object({ + data: z.array(MemberPivotResource), + links: z + .object({ + first: z.union([z.string(), z.null()]), + last: z.union([z.string(), z.null()]), + prev: z.union([z.string(), z.null()]), + next: z.union([z.string(), z.null()]), + }) + .passthrough(), + meta: z + .object({ + current_page: z.number().int(), + from: z.union([z.number(), z.null()]), + last_page: z.number().int(), + links: z.array( + z + .object({ + url: z.union([z.string(), z.null()]), + label: z.string(), + active: z.boolean(), + }) + .passthrough() + ), + path: z.union([z.string(), z.null()]), + per_page: z.number().int(), + to: z.union([z.number(), z.null()]), + total: z.number().int(), + }) + .passthrough(), + }) + .passthrough(), + errors: [ + { + status: 403, + description: `Authorization error`, + schema: z.object({ message: z.string() }).passthrough(), + }, + { + status: 404, + description: `Not found`, + schema: z.object({ message: z.string() }).passthrough(), + }, + { + status: 422, + description: `Validation error`, + schema: z + .object({ + message: z.string(), + errors: z.record(z.array(z.string())), + }) + .passthrough(), + }, + ], + }, + { + method: 'put', + path: '/v1/organizations/:organization/members/:membership', + alias: 'updateMember', + requestFormat: 'json', + parameters: [ + { + name: 'body', + type: 'Body', + schema: updateMember_Body, + }, + { + name: 'organization', + type: 'Path', + schema: z.string().uuid(), + }, + { + name: 'membership', + type: 'Path', + schema: z.string().uuid(), + }, + ], + response: z.object({ data: MemberResource }).passthrough(), errors: [ { status: 403, @@ -481,9 +696,45 @@ const endpoints = makeApi([ }, ], }, + { + method: 'delete', + path: '/v1/organizations/:organization/members/:membership', + alias: 'removeMember', + requestFormat: 'json', + parameters: [ + { + name: 'body', + type: 'Body', + schema: z.object({}).partial().passthrough(), + }, + { + name: 'organization', + type: 'Path', + schema: z.string().uuid(), + }, + { + name: 'membership', + type: 'Path', + schema: z.string().uuid(), + }, + ], + response: z.null(), + errors: [ + { + status: 403, + description: `Authorization error`, + schema: z.object({ message: z.string() }).passthrough(), + }, + { + status: 404, + description: `Not found`, + schema: z.object({ message: z.string() }).passthrough(), + }, + ], + }, { method: 'post', - path: '/v1/organizations/:organization/members/:user/invite-placeholder', + path: '/v1/organizations/:organization/members/:membership/invite-placeholder', alias: 'invitePlaceholder', requestFormat: 'json', parameters: [ @@ -498,7 +749,7 @@ const endpoints = makeApi([ schema: z.string().uuid(), }, { - name: 'user', + name: 'membership', type: 'Path', schema: z.string().uuid(), }, @@ -909,6 +1160,17 @@ const endpoints = makeApi([ ], response: z.object({ data: ProjectMemberResource }).passthrough(), errors: [ + { + status: 400, + description: `API exception`, + schema: z + .object({ + error: z.boolean(), + key: z.string(), + message: z.string(), + }) + .passthrough(), + }, { status: 403, description: `Authorization error`, diff --git a/resources/js/Components/OrganizationSwitcher.vue b/resources/js/Components/OrganizationSwitcher.vue index 684d947b..f501e26e 100644 --- a/resources/js/Components/OrganizationSwitcher.vue +++ b/resources/js/Components/OrganizationSwitcher.vue @@ -41,23 +41,23 @@ const switchToTeam = (team: Organization) => {
-
+
+ class="rounded sm:rounded-lg bg-blue-900 font-semibold text-xs sm:text-sm flex-shrink-0 text-white w-5 sm:w-6 h-5 sm:h-6 flex items-center justify-center"> {{ page.props.auth.user.current_team.name .slice(0, 1) .toUpperCase() }}
- + {{ page.props.auth.user.current_team.name }}
-
+
diff --git a/resources/js/Layouts/AppLayout.vue b/resources/js/Layouts/AppLayout.vue index 44d19055..11a7d37d 100644 --- a/resources/js/Layouts/AppLayout.vue +++ b/resources/js/Layouts/AppLayout.vue @@ -55,10 +55,10 @@ onMounted(async () => {
- + + class="w-8 sm:hidden">
diff --git a/resources/js/Pages/Teams/Partials/ImportData.vue b/resources/js/Pages/Teams/Partials/ImportData.vue new file mode 100644 index 00000000..10ccca8c --- /dev/null +++ b/resources/js/Pages/Teams/Partials/ImportData.vue @@ -0,0 +1,142 @@ + + + diff --git a/resources/js/Pages/Teams/Show.vue b/resources/js/Pages/Teams/Show.vue index ff392b96..f00b7f54 100644 --- a/resources/js/Pages/Teams/Show.vue +++ b/resources/js/Pages/Teams/Show.vue @@ -6,6 +6,7 @@ import TeamMemberManager from '@/Pages/Teams/Partials/TeamMemberManager.vue'; import UpdateTeamNameForm from '@/Pages/Teams/Partials/UpdateTeamNameForm.vue'; import type { Organization } from '@/types/models'; import type { Permissions, Role } from '@/types/jetstream'; +import ImportData from '@/Pages/Teams/Partials/ImportData.vue'; defineProps<{ team: Organization; @@ -38,6 +39,10 @@ defineProps<{ + + + +
diff --git a/tests/Unit/Endpoint/Api/V1/ImportEndpointTest.php b/tests/Unit/Endpoint/Api/V1/ImportEndpointTest.php index 1bd96343..9c826cf5 100644 --- a/tests/Unit/Endpoint/Api/V1/ImportEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/ImportEndpointTest.php @@ -24,7 +24,7 @@ public function test_import_fails_if_user_does_not_have_permission() // Act $response = $this->postJson(route('api.v1.import.import', ['organization' => $data->organization->id]), [ 'type' => 'toggl_time_entries', - 'data' => 'some data', + 'data' => base64_encode('some data'), 'options' => [], ]); @@ -50,7 +50,7 @@ public function test_import_return_error_message_if_import_fails(): void // Act $response = $this->postJson(route('api.v1.import.import', ['organization' => $user->organization->id]), [ 'type' => 'toggl_time_entries', - 'data' => 'some data', + 'data' => base64_encode('some data'), ]); // Assert @@ -86,7 +86,7 @@ public function test_import_calls_import_service_if_user_has_permission(): void // Act $response = $this->postJson(route('api.v1.import.import', ['organization' => $user->organization->id]), [ 'type' => 'toggl_time_entries', - 'data' => 'some data', + 'data' => base64_encode('some data'), ]); // Assert