diff --git a/Modules/Helpdesk/Livewire/SettingsManager.php b/Modules/Helpdesk/Livewire/SettingsManager.php index 05bef509..a2ab264f 100644 --- a/Modules/Helpdesk/Livewire/SettingsManager.php +++ b/Modules/Helpdesk/Livewire/SettingsManager.php @@ -199,6 +199,8 @@ final class SettingsManager extends Component public function saveCategory(): void { + $this->normalizeNullableFormValues('categoryForm', ['company_id']); + $data = $this->validate([ 'categoryForm.company_id' => ['nullable', 'integer'], 'categoryForm.name' => ['required', 'string', 'max:120'], @@ -241,6 +243,8 @@ final class SettingsManager extends Component public function saveTeam(): void { + $this->normalizeNullableFormValues('teamForm', ['company_id']); + $data = $this->validate([ 'teamForm.company_id' => ['nullable', 'integer'], 'teamForm.name' => ['required', 'string', 'max:120'], @@ -283,6 +287,8 @@ final class SettingsManager extends Component public function saveProject(): void { + $this->normalizeNullableFormValues('projectForm', ['company_id', 'team_id']); + $data = $this->validate([ 'projectForm.company_id' => ['nullable', 'integer'], 'projectForm.team_id' => ['nullable', 'integer', 'exists:helpdesk_teams,id'], @@ -326,6 +332,8 @@ final class SettingsManager extends Component public function saveBoard(): void { + $this->normalizeNullableFormValues('boardForm', ['owner_id']); + $data = $this->validate([ 'boardForm.name' => ['required', 'string', 'max:160'], 'boardForm.slug' => ['nullable', 'string', 'max:180'], @@ -391,6 +399,8 @@ final class SettingsManager extends Component public function saveRepository(): void { + $this->normalizeNullableFormValues('repositoryForm', ['company_id', 'project_id', 'webhook_secret']); + $data = $this->validate([ 'repositoryForm.company_id' => ['nullable', 'integer'], 'repositoryForm.project_id' => ['nullable', 'integer', 'exists:helpdesk_projects,id'], @@ -427,6 +437,8 @@ final class SettingsManager extends Component public function saveWorkflowTemplate(): void { + $this->normalizeNullableFormValues('workflowForm', ['text_body', 'cc', 'bcc']); + $data = $this->validate([ 'workflowForm.name' => ['required', 'string', 'max:160'], 'workflowForm.event' => ['required', 'string', 'max:80'], @@ -463,6 +475,8 @@ final class SettingsManager extends Component public function saveCannedResponse(): void { + $this->normalizeNullableFormValues('cannedForm', ['company_id', 'category_id', 'team_id', 'subject']); + $data = $this->validate([ 'cannedForm.company_id' => ['nullable', 'integer'], 'cannedForm.category_id' => ['nullable', 'integer', 'exists:helpdesk_categories,id'], @@ -500,6 +514,8 @@ final class SettingsManager extends Component public function saveSlaPolicy(): void { + $this->normalizeNullableFormValues('slaForm', ['company_id', 'priority_id', 'category_id']); + $data = $this->validate([ 'slaForm.company_id' => ['nullable', 'integer'], 'slaForm.priority_id' => ['nullable', 'integer', 'exists:helpdesk_priorities,id'], @@ -540,6 +556,8 @@ final class SettingsManager extends Component public function saveAutomationRule(): void { + $this->normalizeNullableFormValues('automationForm', ['company_id', 'match_sender', 'match_subject', 'match_mailbox', 'match_category_id', 'set_priority_id', 'set_category_id', 'set_team_id', 'set_project_id', 'set_status_id', 'set_agent_id']); + $data = $this->validate([ 'automationForm.company_id' => ['nullable', 'integer'], 'automationForm.name' => ['required', 'string', 'max:160'], @@ -582,6 +600,8 @@ final class SettingsManager extends Component public function saveProduct(): void { + $this->normalizeNullableFormValues('productForm', ['company_id', 'version', 'release_channel', 'description']); + $data = $this->validate([ 'productForm.company_id' => ['nullable', 'integer'], 'productForm.name' => ['required', 'string', 'max:160'], @@ -617,6 +637,8 @@ final class SettingsManager extends Component public function saveTag(): void { + $this->normalizeNullableFormValues('tagForm', ['company_id']); + $data = $this->validate([ 'tagForm.company_id' => ['nullable', 'integer'], 'tagForm.name' => ['required', 'string', 'max:80'], @@ -648,6 +670,8 @@ final class SettingsManager extends Component public function saveChecklistTemplate(): void { + $this->normalizeNullableFormValues('checklistTemplateForm', ['company_id', 'category_id', 'project_id']); + $data = $this->validate([ 'checklistTemplateForm.company_id' => ['nullable', 'integer'], 'checklistTemplateForm.category_id' => ['nullable', 'integer', 'exists:helpdesk_categories,id'], @@ -694,8 +718,10 @@ final class SettingsManager extends Component return; } + $this->normalizeNullableScalar('default_helpdesk_company_id'); + $this->validate([ - 'default_helpdesk_company_id' => ['nullable'], + 'default_helpdesk_company_id' => ['nullable', 'integer', 'exists:companies,id'], ]); $companyId = filled($this->default_helpdesk_company_id) @@ -796,6 +822,35 @@ final class SettingsManager extends Component ->get(); } + /** + * Convert browser select empty strings into null before Livewire validation. + * + * Flux/select controls submit optional values as empty strings. Laravel's + * nullable integer and exists rules expect a real null, so normalising here + * keeps every settings CRUD form usable from the browser. + * + * @param array $keys + */ + private function normalizeNullableFormValues(string $formProperty, array $keys): void + { + foreach ($keys as $key) { + if (! array_key_exists($key, $this->{$formProperty})) { + continue; + } + + if (blank($this->{$formProperty}[$key])) { + $this->{$formProperty}[$key] = null; + } + } + } + + private function normalizeNullableScalar(string $property): void + { + if (blank($this->{$property})) { + $this->{$property} = null; + } + } + /** * Remove is_default before save when the database has not been migrated yet. * diff --git a/Modules/Helpdesk/Tests/Feature/SettingsCrudTest.php b/Modules/Helpdesk/Tests/Feature/SettingsCrudTest.php index 3311229f..d0df55ab 100644 --- a/Modules/Helpdesk/Tests/Feature/SettingsCrudTest.php +++ b/Modules/Helpdesk/Tests/Feature/SettingsCrudTest.php @@ -9,6 +9,13 @@ use Livewire\Livewire; use Modules\Helpdesk\Livewire\SettingsManager; use Modules\Helpdesk\Models\Category; use Modules\Helpdesk\Models\GitRepository; +use Modules\Helpdesk\Models\AutomationRule; +use Modules\Helpdesk\Models\CannedResponse; +use Modules\Helpdesk\Models\ChecklistTemplate; +use Modules\Helpdesk\Models\KanbanBoard; +use Modules\Helpdesk\Models\Product; +use Modules\Helpdesk\Models\SlaPolicy; +use Modules\Helpdesk\Models\Tag; use Modules\Helpdesk\Models\Priority; use Modules\Helpdesk\Models\Project; use Modules\Helpdesk\Models\Status; @@ -95,6 +102,132 @@ final class SettingsCrudTest extends TestCase $this->assertDatabaseHas('helpdesk_workflow_templates', ['name' => 'Ticket Raised', 'event' => 'ticket.created']); } + + public function test_settings_manager_creates_every_settings_record_with_browser_empty_select_values(): void + { + $admin = $this->admin(); + $status = $this->ticketStatus(['name' => 'Triage', 'slug' => 'triage']); + $priority = $this->priority(['name' => 'Standard', 'slug' => 'standard']); + $category = $this->category(['name' => 'General', 'slug' => 'general']); + $team = $this->team(['name' => 'Support Desk', 'slug' => 'support-desk']); + $project = $this->project(['title' => 'Support Portal', 'slug' => 'support-portal']); + + Livewire::actingAs($admin) + ->test(SettingsManager::class) + ->set('categoryForm.company_id', '') + ->set('categoryForm.name', 'Accounts') + ->set('categoryForm.color', 'blue') + ->call('saveCategory') + ->assertHasNoErrors() + ->set('teamForm.company_id', '') + ->set('teamForm.name', 'Accounts Team') + ->set('teamForm.color', 'indigo') + ->call('saveTeam') + ->assertHasNoErrors() + ->set('projectForm.company_id', '') + ->set('projectForm.team_id', '') + ->set('projectForm.title', 'Accounts Portal') + ->set('projectForm.color', 'blue') + ->call('saveProject') + ->assertHasNoErrors() + ->set('boardForm.name', 'Support Board') + ->set('boardForm.owner_id', '') + ->set('boardForm.status_ids', [$status->id]) + ->call('saveBoard') + ->assertHasNoErrors() + ->set('repositoryForm.company_id', '') + ->set('repositoryForm.project_id', '') + ->set('repositoryForm.name', 'Support Repo') + ->set('repositoryForm.provider', 'github') + ->set('repositoryForm.remote_url', 'git@example.com:support/repo.git') + ->set('repositoryForm.default_branch', 'main') + ->set('repositoryForm.webhook_secret', '') + ->call('saveRepository') + ->assertHasNoErrors() + ->set('workflowForm.name', 'Updated') + ->set('workflowForm.event', 'ticket.updated') + ->set('workflowForm.subject', 'Updated {{ ticket.reference }}') + ->set('workflowForm.html_body', '

Updated

') + ->set('workflowForm.text_body', '') + ->set('workflowForm.cc', '') + ->set('workflowForm.bcc', '') + ->set('workflowForm.to_role', 'requester') + ->call('saveWorkflowTemplate') + ->assertHasNoErrors() + ->set('cannedForm.company_id', '') + ->set('cannedForm.category_id', '') + ->set('cannedForm.team_id', '') + ->set('cannedForm.name', 'Greeting') + ->set('cannedForm.visibility', 'agents') + ->set('cannedForm.subject', '') + ->set('cannedForm.body', 'Hello {{ requester.name }}') + ->call('saveCannedResponse') + ->assertHasNoErrors() + ->set('slaForm.company_id', '') + ->set('slaForm.priority_id', '') + ->set('slaForm.category_id', '') + ->set('slaForm.name', 'Standard SLA') + ->set('slaForm.first_response_minutes', 120) + ->set('slaForm.resolution_minutes', 1440) + ->call('saveSlaPolicy') + ->assertHasNoErrors() + ->set('automationForm.company_id', '') + ->set('automationForm.name', 'Route Accounts') + ->set('automationForm.match_sender', '') + ->set('automationForm.match_subject', '') + ->set('automationForm.match_mailbox', '') + ->set('automationForm.match_category_id', '') + ->set('automationForm.set_priority_id', $priority->id) + ->set('automationForm.set_category_id', $category->id) + ->set('automationForm.set_team_id', $team->id) + ->set('automationForm.set_project_id', $project->id) + ->set('automationForm.set_status_id', $status->id) + ->set('automationForm.set_agent_id', '') + ->call('saveAutomationRule') + ->assertHasNoErrors() + ->set('checklistTemplateForm.company_id', '') + ->set('checklistTemplateForm.category_id', '') + ->set('checklistTemplateForm.project_id', '') + ->set('checklistTemplateForm.name', 'Closure Checks') + ->set('checklistTemplateForm.items_text', "Confirm requester\nClose ticket") + ->call('saveChecklistTemplate') + ->assertHasNoErrors() + ->set('tagForm.company_id', '') + ->set('tagForm.name', 'Escalated') + ->set('tagForm.color', 'red') + ->call('saveTag') + ->assertHasNoErrors() + ->set('productForm.company_id', '') + ->set('productForm.name', 'WMS Support') + ->set('productForm.type', 'service') + ->set('productForm.version', '') + ->set('productForm.release_channel', '') + ->set('productForm.description', '') + ->call('saveProduct') + ->assertHasNoErrors(); + + $this->assertDatabaseHas('helpdesk_categories', ['slug' => 'accounts', 'company_id' => null]); + $this->assertDatabaseHas('helpdesk_teams', ['slug' => 'accounts-team', 'company_id' => null]); + $this->assertDatabaseHas('helpdesk_projects', ['slug' => 'accounts-portal', 'company_id' => null, 'team_id' => null]); + $this->assertDatabaseHas('helpdesk_kanban_boards', ['slug' => 'support-board', 'owner_id' => null]); + $this->assertDatabaseHas('helpdesk_git_repositories', ['name' => 'Support Repo', 'project_id' => null, 'webhook_secret' => null]); + $this->assertDatabaseHas('helpdesk_workflow_templates', ['name' => 'Updated', 'text_body' => null, 'cc' => null, 'bcc' => null]); + $this->assertDatabaseHas('helpdesk_canned_responses', ['slug' => 'greeting', 'category_id' => null, 'team_id' => null]); + $this->assertDatabaseHas('helpdesk_sla_policies', ['slug' => 'standard-sla', 'priority_id' => null, 'category_id' => null]); + $this->assertDatabaseHas('helpdesk_automation_rules', ['slug' => 'route-accounts', 'set_agent_id' => null]); + $this->assertDatabaseHas('helpdesk_checklist_templates', ['slug' => 'closure-checks', 'category_id' => null, 'project_id' => null]); + $this->assertDatabaseHas('helpdesk_tags', ['slug' => 'escalated', 'company_id' => null]); + $this->assertDatabaseHas('helpdesk_products', ['slug' => 'wms-support', 'company_id' => null]); + + self::assertTrue(KanbanBoard::query()->where('slug', 'support-board')->firstOrFail()->statuses()->whereKey($status->id)->exists()); + self::assertNotNull(CannedResponse::query()->where('slug', 'greeting')->first()); + self::assertNotNull(SlaPolicy::query()->where('slug', 'standard-sla')->first()); + self::assertNotNull(AutomationRule::query()->where('slug', 'route-accounts')->first()); + self::assertNotNull(ChecklistTemplate::query()->where('slug', 'closure-checks')->first()); + self::assertNotNull(Tag::query()->where('slug', 'escalated')->first()); + self::assertNotNull(Product::query()->where('slug', 'wms-support')->first()); + } + public function test_settings_manager_can_edit_and_delete_all_new_setup_records(): void { $admin = $this->admin(); diff --git a/Modules/Helpdesk/changelog.yaml b/Modules/Helpdesk/changelog.yaml index e0b96af4..cd94c771 100644 --- a/Modules/Helpdesk/changelog.yaml +++ b/Modules/Helpdesk/changelog.yaml @@ -393,3 +393,12 @@ versions: changed: - Removed the extra visible plus character from Helpdesk New Ticket action labels while keeping the icon-only plus indicator. - Kept module.json version in sync with changelog.yaml. + +- version: 1.9.27 + date: 2026-06-18 + changed: + - Fixed Helpdesk settings CRUD forms by normalising optional browser select values before Livewire validation. + - Hardened optional nullable fields across categories, teams, projects, boards, Git repositories, workflow templates, canned responses, SLA policies, automation rules, checklists, tags, products and default company selection. + - Added feature coverage proving every Helpdesk settings area can create records when optional select controls submit empty browser values. + - Kept module.json version in sync with changelog.yaml. + diff --git a/Modules/Helpdesk/module.json b/Modules/Helpdesk/module.json index 06d58e3e..1eaf7c35 100644 --- a/Modules/Helpdesk/module.json +++ b/Modules/Helpdesk/module.json @@ -22,5 +22,5 @@ "Modules\\Helpdesk\\Providers\\HelpdeskServiceProvider" ], "files": [], - "version": "1.9.26" + "version": "1.9.27" }