diff --git a/app/Http/Resources/FormSessionResource.php b/app/Http/Resources/FormSessionResource.php index f3ea2208..9818f0e0 100644 --- a/app/Http/Resources/FormSessionResource.php +++ b/app/Http/Resources/FormSessionResource.php @@ -23,6 +23,7 @@ public function toArray($request) 'completed_at' => (string) $this->getRawOriginal('is_completed'), 'params' => $this->params ? json_encode($this->params) : null, 'responses' => $this->getResponses(), + 'webhooks' => $this->webhooks ]; } diff --git a/app/Jobs/CallWebhookJob.php b/app/Jobs/CallWebhookJob.php index 2341b173..ada0ff3b 100644 --- a/app/Jobs/CallWebhookJob.php +++ b/app/Jobs/CallWebhookJob.php @@ -10,6 +10,7 @@ use Illuminate\Queue\SerializesModels; use Illuminate\Queue\InteractsWithQueue; use App\Http\Resources\FormSessionResource; +use App\Models\FormSessionWebhook; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -43,7 +44,7 @@ public function handle() { $payload = FormSessionResource::make($this->session)->resolve(); - Http::withHeaders( + $response = Http::withHeaders( $this->webhook->headers ?? [] )->send( $this->webhook->webhook_method, @@ -54,5 +55,13 @@ public function handle() ); // TODO: we need to somehow track status of the webhook submit in a new table with relation to the session and the webhook + + $this->session->webhooks()->updateOrCreate([ + 'form_webhook_id' => $this->webhook->id + ], [ + 'status' => $response->status(), + 'response' => $response->body(), + 'tries' => $this->session->webhooks()->where('form_webhook_id', $this->webhook->id)->count() + 1, + ]); } } diff --git a/app/Models/FormSession.php b/app/Models/FormSession.php index 87d619c0..c99c4616 100644 --- a/app/Models/FormSession.php +++ b/app/Models/FormSession.php @@ -33,6 +33,11 @@ public function form() return $this->belongsTo(Form::class, 'form_id', 'id'); } + public function webhooks() + { + return $this->hasMany(FormSessionWebhook::class); + } + public static function getByTokenAndForm(String $token, Form $form) { return self::where('token', $token)->where('form_id', $form->id)->first(); diff --git a/app/Models/FormSessionWebhook.php b/app/Models/FormSessionWebhook.php new file mode 100644 index 00000000..9f0b7529 --- /dev/null +++ b/app/Models/FormSessionWebhook.php @@ -0,0 +1,13 @@ +id(); + $table->integer('status')->nullable(); + $table->json('response')->nullable(); + $table->integer('tries')->default(0); + $table->foreignIdFor(FormSession::class); + $table->foreignIdFor(FormWebhook::class); + $table->timestamps(); + + // make form_session_id and form_webhook_id unique, so we can't have duplicate entries + $table->unique(['form_session_id', 'form_webhook_id']); + }); + } +}; diff --git a/package-lock.json b/package-lock.json index 3e708e05..1b2632b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@deck9/input", - "version": "1.0.0", + "version": "1.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@deck9/input", - "version": "1.0.0", + "version": "1.5.0", "license": "GNU Affero General Public License v3.0", "dependencies": { "@deck9/ui": "^0.12.9", @@ -34,6 +34,7 @@ "copy-text-to-clipboard": "^3.0.1", "eslint": "^8.28.0", "eslint-plugin-vue": "^8.7.1", + "floating-vue": "^2.0.0-beta.20", "highlight.js": "^11.6.0", "laravel-vite-plugin": "^0.7.4", "lodash": "^4.17.19", @@ -340,6 +341,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@floating-ui/core": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-0.3.1.tgz", + "integrity": "sha512-ensKY7Ub59u16qsVIFEo2hwTCqZ/r9oZZFh51ivcLGHfUwTn8l1Xzng8RJUe91H/UP8PeqeBronAGx0qmzwk2g==" + }, + "node_modules/@floating-ui/dom": { + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-0.1.10.tgz", + "integrity": "sha512-4kAVoogvQm2N0XE0G6APQJuCNuErjOfPW8Ux7DFxh8+AfugWflwVJ5LDlHOwrwut7z/30NUvdtHzQ3zSip4EzQ==", + "dependencies": { + "@floating-ui/core": "^0.3.0" + } + }, "node_modules/@fortawesome/fontawesome-common-types": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.3.0.tgz", @@ -4777,6 +4791,18 @@ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.2.tgz", "integrity": "sha512-JaTY/wtrcSyvXJl4IMFHPKyFur1sE9AUqc0QnhOaJ0CxHtAoIV8pYDzeEfAaNEtGkOfq4gr3LBFmdXW5mOQFnA==" }, + "node_modules/floating-vue": { + "version": "2.0.0-beta.20", + "resolved": "https://registry.npmjs.org/floating-vue/-/floating-vue-2.0.0-beta.20.tgz", + "integrity": "sha512-N68otcpp6WwcYC7zP8GeJqNZVdfvS7tEY88lwmuAHeqRgnfWx1Un8enzLxROyVnBDZ3TwUoUdj5IFg+bUT7JeA==", + "dependencies": { + "@floating-ui/dom": "^0.1.10", + "vue-resize": "^2.0.0-alpha.1" + }, + "peerDependencies": { + "vue": "^3.2.0" + } + }, "node_modules/follow-redirects": { "version": "1.14.9", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.9.tgz", @@ -9376,6 +9402,14 @@ "node": ">=8.9.0" } }, + "node_modules/vue-resize": { + "version": "2.0.0-alpha.1", + "resolved": "https://registry.npmjs.org/vue-resize/-/vue-resize-2.0.0-alpha.1.tgz", + "integrity": "sha512-7+iqOueLU7uc9NrMfrzbG8hwMqchfVfSzpVlCMeJQe4pyibqyoifDNbKTZvwxZKDvGkB+PdFeKvnGZMoEb8esg==", + "peerDependencies": { + "vue": "^3.0.0" + } + }, "node_modules/vue-template-compiler": { "version": "2.7.14", "resolved": "https://registry.npmjs.org/vue-template-compiler/-/vue-template-compiler-2.7.14.tgz", diff --git a/package.json b/package.json index 908f874f..cf3ac0e9 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "copy-text-to-clipboard": "^3.0.1", "eslint": "^8.28.0", "eslint-plugin-vue": "^8.7.1", + "floating-vue": "^2.0.0-beta.20", "highlight.js": "^11.6.0", "laravel-vite-plugin": "^0.7.4", "lodash": "^4.17.19", diff --git a/resources/css/_tooltip.css b/resources/css/_tooltip.css new file mode 100644 index 00000000..e4c011cb --- /dev/null +++ b/resources/css/_tooltip.css @@ -0,0 +1,11 @@ +/* Style */ +.v-popper--theme-tooltip .v-popper__inner { + background: #1e293b; + color: #ffffff; + padding: 3px 12px; + border-radius: 6px; +} + +.v-popper--theme-tooltip .v-popper__arrow-outer { + border-color: #1e293b; +} diff --git a/resources/css/app.css b/resources/css/app.css index b882a7ee..f322a06f 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -9,6 +9,7 @@ /* CUSTOM */ @import "./_scrollbar.css"; @import "./_smooth-dnd.css"; +@import "./_tooltip.css"; /* TAILWIND */ @import "tailwindcss/utilities"; diff --git a/resources/js/app.ts b/resources/js/app.ts index 907c7042..8fe89542 100644 --- a/resources/js/app.ts +++ b/resources/js/app.ts @@ -1,3 +1,4 @@ +import "floating-vue/dist/style.css"; import "@css/app.css"; import { createApp, h } from "vue"; import { resolvePageComponent } from "laravel-vite-plugin/inertia-helpers"; @@ -6,6 +7,7 @@ import { InertiaProgress } from "@inertiajs/progress"; import { createPinia } from "pinia"; import { PiniaDebounce } from "@pinia/plugin-debounce"; import { createI18n } from "vue-i18n"; +import FloatingVue from "floating-vue"; import debounce from "lodash/debounce"; import localeEN from "@i18n/en.json"; @@ -41,6 +43,15 @@ createInertiaApp({ .use(plugin) .use(pinia) .use(i18n) + .use(FloatingVue, { + themes: { + tooltip: { + autoHide: true, + placement: "auto", + html: true, + }, + }, + }) .mixin({ methods: { route: window.route } }) .mount(el); }, diff --git a/resources/js/components/Factory/Submissions/SubmissionTableItem.vue b/resources/js/components/Factory/Submissions/SubmissionTableItem.vue index 548186a3..63bbaaf5 100644 --- a/resources/js/components/Factory/Submissions/SubmissionTableItem.vue +++ b/resources/js/components/Factory/Submissions/SubmissionTableItem.vue @@ -20,15 +20,19 @@ class="mt-1" v-bind="{ params: submission.params }" /> + +
+ +
- + @@ -43,6 +47,7 @@ diff --git a/tests/Feature/FormSessionWebhookLogTest.php b/tests/Feature/FormSessionWebhookLogTest.php new file mode 100644 index 00000000..9aef5da7 --- /dev/null +++ b/tests/Feature/FormSessionWebhookLogTest.php @@ -0,0 +1,82 @@ +has( + FormWebhook::factory() + )->create(); + $session = FormSession::factory()->for($form)->create(); + + CallWebhookJob::dispatch($session, $form->formWebhooks->first()); + + $this->assertDatabaseHas('form_session_webhooks', [ + 'form_session_id' => $session->id, + 'form_webhook_id' => $form->formWebhooks->first()->id, + 'status' => 200, + 'tries' => 1, + ]); + + Http::assertSentCount(1); +}); + +it('will use the same database entry if a webhook gets called twice for the same session', function () { + Http::fake(); + + $form = Form::factory()->has( + FormWebhook::factory() + )->create(); + $session = FormSession::factory()->for($form)->create(); + + CallWebhookJob::dispatch($session, $form->formWebhooks->first()); + CallWebhookJob::dispatch($session, $form->formWebhooks->first()); + + $this->assertDatabaseHas('form_session_webhooks', [ + 'form_session_id' => $session->id, + 'form_webhook_id' => $form->formWebhooks->first()->id, + 'status' => 200, + 'tries' => 2, + ]); + + Http::assertSentCount(2); +}); + +it('submissions api endpoint will include the session webhook data', function () { + Http::fake(function () { + return Http::response('OK!', 200); + }); + + $form = Form::factory()->has( + FormWebhook::factory() + )->create(); + $session = FormSession::factory()->for($form)->completed()->create(); + + CallWebhookJob::dispatch($session, $form->formWebhooks->first()); + + $response = $this->actingAs($form->user) + ->json('get', route('api.forms.submissions', ['form' => $form->uuid])) + ->assertStatus(200); + + $response->assertJson([ + 'data' => [ + [ + 'webhooks' => [ + [ + 'response' => 'OK!', + 'status' => 200, + 'tries' => 1, + ], + ], + ], + ], + ]); +}); diff --git a/tests/Feature/Submissions/ManageFormSubmissionsTest.php b/tests/Feature/Submissions/ManageFormSubmissionsTest.php index 10002a9b..eed12621 100644 --- a/tests/Feature/Submissions/ManageFormSubmissionsTest.php +++ b/tests/Feature/Submissions/ManageFormSubmissionsTest.php @@ -10,7 +10,7 @@ uses(RefreshDatabase::class); -test('can_purge_all_results_for_a_form', function () { +test('can purge all results for a form', function () { $form = Form::factory()->create(); FormSession::factory() @@ -25,7 +25,7 @@ $this->assertCount(0, $form->fresh()->formSessions); }); -test('can_delete_a_single_form_submission', function () { +test('can delete a single form submission', function () { $form = Form::factory()->create(); FormSession::factory() @@ -48,7 +48,7 @@ $this->assertCount(2, $form->fresh()->formSessions); }); -test('can_get_all_submissions_for_a_form_via_api', function () { +test('can get all submissions for a form via api', function () { $form = Form::factory()->create(); FormBlock::factory() @@ -91,7 +91,7 @@ $this->assertCount(0, $response->json('data.2.responses')); }); -test('returned_submission_has_responses_keyed_by_form_block_uuid', function () { +test('returned submission has responses keyed by form block uuid', function () { $form = Form::factory()->create(); $block = FormBlock::factory() @@ -112,7 +112,7 @@ $this->assertArrayHasKey($block->uuid, $response->json('data.0.responses')); }); -test('submissions_only_viewable_to_authenticated_owner_of_the_form', function () { +test('submissions only viewable to authenticated owner of the form', function () { $form = Form::factory()->create(); FormBlock::factory() @@ -135,7 +135,7 @@ ->assertStatus(401); }); -test('submissions_response_is_a_pagination_response', function () { +test('submissions response is a pagination response', function () { $form = Form::factory()->create(); FormBlock::factory()