From bf352fda650253f32de68e2aa949c26ab7c6bb4a Mon Sep 17 00:00:00 2001 From: Morris Jencen Chavez Date: Sun, 13 Sep 2026 19:19:38 +0000 Subject: [PATCH] fix: surface invalid JSON errors in the create/duplicate event modal instead of silently discarding them Events/Index.vue parsed the Payload/Schema textareas in a watcher and swallowed JSON.parse errors, leaving form.payload/form.schema on their last-valid value. Create Event then submitted successfully with the stale data (or null, on first entry), so a user typing a broken payload/schema in the create or duplicate modal saw a success message while their actual input was silently dropped. Track parse failures per field, surface them via InputError next to the textarea, and block saveEvent() (both the button and Enter-triggered submit) while either field is invalid, so the request is never sent with data the user never intended to submit. This mirrors the fix already applied to the event edit page in #223. Fixes #98 --- resources/js/Pages/Events/Index.vue | 38 +++++++++++++---- tests/Feature/DashboardEventCreateTest.php | 48 ++++++++++++++++++++++ tests/e2e/specs/events.spec.ts | 20 +++++++++ 3 files changed, 99 insertions(+), 7 deletions(-) create mode 100644 tests/Feature/DashboardEventCreateTest.php diff --git a/resources/js/Pages/Events/Index.vue b/resources/js/Pages/Events/Index.vue index dd716d2..141af76 100644 --- a/resources/js/Pages/Events/Index.vue +++ b/resources/js/Pages/Events/Index.vue @@ -230,7 +230,7 @@

This JSON template will be used when triggering the event. Use variables like {{user_id}} for dynamic values.

- +
@@ -245,7 +245,7 @@

Optional. Define expected payload fields using Laravel validation rules. Trigger requests that don't match will be rejected with a 422.

- +
@@ -257,7 +257,7 @@ {{ form.processing ? 'Saving...' : 'Create' }} @@ -388,6 +388,8 @@ const managingEvent = ref(null) const triggeringEvent = ref(null) const payloadText = ref('') const schemaText = ref('') +const payloadJsonError = ref('') +const schemaJsonError = ref('') const triggerPayload = ref('') const triggerProcessing = ref(false) const selectedEndpoints = ref([]) @@ -433,18 +435,34 @@ const availableEndpoints = computed(() => props.endpoints || []) // Watch for payload changes watch(payloadText, (newValue) => { + if (!newValue) { + form.payload = null + payloadJsonError.value = '' + return + } + try { - form.payload = newValue ? JSON.parse(newValue) : null + form.payload = JSON.parse(newValue) + payloadJsonError.value = '' } catch (e) { - // Invalid JSON - will be handled by backend validation + // Invalid JSON - surface the error and stop the stale value from being submitted + payloadJsonError.value = 'Invalid JSON: ' + e.message } }) watch(schemaText, (newValue) => { + if (!newValue) { + form.schema = null + schemaJsonError.value = '' + return + } + try { - form.schema = newValue ? JSON.parse(newValue) : null + form.schema = JSON.parse(newValue) + schemaJsonError.value = '' } catch (e) { - // Invalid JSON - will be handled by backend validation + // Invalid JSON - surface the error and stop the stale value from being submitted + schemaJsonError.value = 'Invalid JSON: ' + e.message } }) @@ -455,9 +473,15 @@ function closeModal() { form.clearErrors() payloadText.value = '' schemaText.value = '' + payloadJsonError.value = '' + schemaJsonError.value = '' } function saveEvent() { + if (payloadJsonError.value || schemaJsonError.value) { + return + } + form.post(route('events.store'), { onSuccess: () => closeModal() }) diff --git a/tests/Feature/DashboardEventCreateTest.php b/tests/Feature/DashboardEventCreateTest.php new file mode 100644 index 0000000..e4516ca --- /dev/null +++ b/tests/Feature/DashboardEventCreateTest.php @@ -0,0 +1,48 @@ +withPersonalTeam()->create(); + + $response = $this->actingAs($user)->post(route('events.store'), [ + 'name' => 'order.created', + 'event_type' => 'order.created', + 'description' => 'Fired when an order is created', + 'payload' => ['user_id' => 1, 'nested' => ['count' => 2]], + 'schema' => ['user_id' => 'required|integer'], + ]); + + $response->assertRedirect(route('events')); + $response->assertSessionHas('success'); + + $event = Event::where('user_id', $user->id)->where('name', 'order.created')->firstOrFail(); + + $this->assertSame(['user_id' => 1, 'nested' => ['count' => 2]], $event->payload); + $this->assertSame(['user_id' => 'required|integer'], $event->schema); + } + + public function test_dashboard_create_rejects_duplicate_name_for_same_user(): void + { + $user = User::factory()->withPersonalTeam()->create(); + Event::factory()->for($user)->create(['name' => 'order.created']); + + $response = $this->actingAs($user)->post(route('events.store'), [ + 'name' => 'order.created', + 'payload' => ['user_id' => 1], + ]); + + $response->assertSessionHasErrors('name'); + $this->assertSame(1, Event::where('user_id', $user->id)->where('name', 'order.created')->count()); + } +} diff --git a/tests/e2e/specs/events.spec.ts b/tests/e2e/specs/events.spec.ts index 3cc0c4a..d6e24f1 100644 --- a/tests/e2e/specs/events.spec.ts +++ b/tests/e2e/specs/events.spec.ts @@ -44,6 +44,26 @@ test.describe('Events', () => { await expect(page.getByText(EVENT_NAME)).toBeVisible({ timeout: 10_000 }) }) + test('rejects invalid JSON payload in the create modal instead of silently discarding it', async ({ page }) => { + await page.getByRole('button', { name: /create event/i }).click() + + const createButton = page.getByRole('button', { name: /^create$/i }) + await expect(createButton).toBeEnabled() + + // Break the JSON - the Create button must disable and an error must appear, + // instead of silently keeping the last valid value and reporting success. + await page.fill('#payload', '{ "user_id": 1, ') + await expect(page.getByText(/invalid json/i)).toBeVisible() + await expect(createButton).toBeDisabled() + + // Fixing the JSON clears the error and re-enables saving. + await page.fill('#payload', JSON.stringify({ user_id: 1 })) + await expect(page.getByText(/invalid json/i)).not.toBeVisible() + await expect(createButton).toBeEnabled() + + await page.getByRole('button', { name: /cancel/i }).click() + }) + test('searches events by name', async ({ page }) => { await page.fill('input[placeholder*="Search"]', EVENT_NAME) await expect(page.getByText(EVENT_NAME)).toBeVisible({ timeout: 8_000 })