From e4d25e457256edc7babef409108e8b39e5021772 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joachim=20R=C3=BCtter?= Date: Thu, 16 Jul 2026 21:02:23 +0200 Subject: [PATCH] Commit typed validation rules when the input loses focus The rule builder's parameter input committed its value on blur, but listeners passed to the Input component land on its outer wrapper via attribute fallthrough, and blur doesn't bubble, so it never fired. Clicking Save without pressing enter silently dropped the typed rule. Use focusout, which bubbles and does reach the wrapper. The ref focus calls are optional-chained since focusout can also fire while the field settings stack is closing, after the refs are gone. Co-Authored-By: Claude Fable 5 --- .../components/field-validation/Builder.vue | 6 +-- .../field-validation/Builder.test.js | 50 +++++++++++++++++++ 2 files changed, 53 insertions(+), 3 deletions(-) create mode 100644 resources/js/tests/components/field-validation/Builder.test.js diff --git a/resources/js/components/field-validation/Builder.vue b/resources/js/components/field-validation/Builder.vue index 2128eacc9cf..384f30c8f72 100644 --- a/resources/js/components/field-validation/Builder.vue +++ b/resources/js/components/field-validation/Builder.vue @@ -55,7 +55,7 @@ v-model="customRule" ref="customRuleInput" @keydown.enter.prevent="add(customRule)" - @blur="add(customRule)" + @focusout="add(customRule)" /> this.$refs.rulesSelect.focus()); + this.$nextTick(() => this.$refs.rulesSelect?.focus()); }, add(rule) { @@ -248,7 +248,7 @@ export default { this.resetState(); this.selectedLaravelRule = rule; this.customRule = rule; - this.$nextTick(() => this.$refs.customRuleInput.focus()); + this.$nextTick(() => this.$refs.customRuleInput?.focus()); } else { this.ensure(rule); } diff --git a/resources/js/tests/components/field-validation/Builder.test.js b/resources/js/tests/components/field-validation/Builder.test.js new file mode 100644 index 00000000000..560532b9ad5 --- /dev/null +++ b/resources/js/tests/components/field-validation/Builder.test.js @@ -0,0 +1,50 @@ +import { mount } from '@vue/test-utils'; +import { expect, test, vi } from 'vitest'; +import Builder from '@/components/field-validation/Builder.vue'; + +vi.mock('@inertiajs/vue3', () => ({ + usePage: () => ({ props: { extensionRules: [] } }), +})); + +globalThis.__ = (key) => key; +globalThis.__n = (key) => key; +globalThis.clone = (value) => structuredClone(value); + +const mountBuilder = () => + mount(Builder, { + props: { config: {} }, + global: { + mocks: { + $config: { get: () => '12.0.0' }, + }, + stubs: { + SortableList: true, + }, + }, + }); + +test('a typed rule parameter is committed when the input loses focus', async () => { + const wrapper = mountBuilder(); + + // Picking a parameterized rule swaps the combobox for a plain input holding "after:" + wrapper.vm.add('after:'); + await wrapper.vm.$nextTick(); + + const input = wrapper.find('input'); + await input.setValue('after:{this}.start_time'); + await input.trigger('focusout'); + + expect(wrapper.emitted('updated').at(-1)[0]).toEqual(['after:{this}.start_time']); +}); + +test('an unfinished rule is not committed when the input loses focus', async () => { + const wrapper = mountBuilder(); + + wrapper.vm.add('after:'); + await wrapper.vm.$nextTick(); + + const emitsBefore = wrapper.emitted('updated')?.length ?? 0; + await wrapper.find('input').trigger('focusout'); + + expect(wrapper.emitted('updated')?.length ?? 0).toBe(emitsBefore); +});