From f98013ea37b996afe1f341e1872204f66fbff7e4 Mon Sep 17 00:00:00 2001 From: Mitul Patel <16228595+devmitulpatel@users.noreply.github.com> Date: Wed, 7 Oct 2026 02:41:54 +0530 Subject: [PATCH 1/2] Carry component statuses on incident updates Incident updates can now carry an optional list of impacted components and the status to move them to. The CreateUpdate action syncs those statuses without detaching components the update does not mention, and attaches components that are not impacted yet. --- src/Actions/Update/CreateUpdate.php | 27 +++++++- .../CreateIncidentUpdateRequestData.php | 16 +++++ tests/Feature/Api/IncidentUpdateTest.php | 41 ++++++++++++ .../Unit/Actions/Update/CreateUpdateTest.php | 63 +++++++++++++++++++ 4 files changed, 145 insertions(+), 2 deletions(-) diff --git a/src/Actions/Update/CreateUpdate.php b/src/Actions/Update/CreateUpdate.php index 181d9fb2..19a4e44d 100644 --- a/src/Actions/Update/CreateUpdate.php +++ b/src/Actions/Update/CreateUpdate.php @@ -4,6 +4,7 @@ use Cachet\Actions\Incident\SyncIncidentStatus; use Cachet\Actions\Schedule\NotifyScheduleCompletedSubscribers; +use Cachet\Data\Requests\Incident\IncidentComponentRequestData; use Cachet\Data\Requests\IncidentUpdate\CreateIncidentUpdateRequestData; use Cachet\Data\Requests\ScheduleUpdate\CreateScheduleUpdateRequestData; use Cachet\Enums\ScheduleStatusEnum; @@ -31,14 +32,15 @@ public function handle(Incident|Schedule $resource, CreateIncidentUpdateRequestD { $update = new Update(array_merge( ['user_id' => $user?->getAuthIdentifier()], - $data->except('completedAt')->toArray() + $data->except('completedAt', 'components')->toArray() )); - DB::transaction(function () use ($resource, $update): void { + DB::transaction(function () use ($resource, $update, $data): void { $resource->updates()->save($update); if ($resource instanceof Incident) { $this->syncIncidentStatus->handle($resource); + $this->syncIncidentComponents($resource, $data); } }); @@ -55,6 +57,27 @@ public function handle(Incident|Schedule $resource, CreateIncidentUpdateRequestD return $update; } + /** + * Apply the component statuses carried by an incident update. + * + * Recording an update is often how operators move the affected components + * on, so the update can carry new statuses for them. Components not already + * impacted by the incident are attached to it. + */ + private function syncIncidentComponents(Incident $incident, CreateIncidentUpdateRequestData|CreateScheduleUpdateRequestData $data): void + { + if (! $data instanceof CreateIncidentUpdateRequestData || $data->components === []) { + return; + } + + $components = collect($data->components) + ->mapWithKeys(fn (IncidentComponentRequestData $component): array => [ + $component->id => ['component_status' => $component->status->value], + ]); + + $incident->components()->syncWithoutDetaching($components->all()); + } + /** * Complete the schedule when the update provides a completion time. * diff --git a/src/Data/Requests/IncidentUpdate/CreateIncidentUpdateRequestData.php b/src/Data/Requests/IncidentUpdate/CreateIncidentUpdateRequestData.php index ef14c9a5..8238b3dc 100644 --- a/src/Data/Requests/IncidentUpdate/CreateIncidentUpdateRequestData.php +++ b/src/Data/Requests/IncidentUpdate/CreateIncidentUpdateRequestData.php @@ -3,8 +3,11 @@ namespace Cachet\Data\Requests\IncidentUpdate; use Cachet\Data\BaseData; +use Cachet\Data\Requests\Incident\IncidentComponentRequestData; +use Cachet\Enums\ComponentStatusEnum; use Cachet\Enums\IncidentStatusEnum; use Illuminate\Validation\Rule; +use Spatie\LaravelData\Attributes\DataCollectionOf; use Spatie\LaravelData\Support\Validation\ValidationContext; final class CreateIncidentUpdateRequestData extends BaseData @@ -12,6 +15,9 @@ final class CreateIncidentUpdateRequestData extends BaseData public function __construct( public readonly IncidentStatusEnum $status, public readonly string $message, + /** @var array */ + #[DataCollectionOf(IncidentComponentRequestData::class)] + public readonly array $components = [], ) {} public static function rules(ValidationContext $context): array @@ -19,6 +25,16 @@ public static function rules(ValidationContext $context): array return [ 'status' => ['required', Rule::enum(IncidentStatusEnum::class)], 'message' => ['required', 'string'], + /** + * The components impacted by the incident, with the status to move them to. + * + * @var array + * + * @example [{"id": 1, "status": 3}] + */ + 'components' => ['array'], + 'components.*.id' => ['required', 'int', 'distinct', 'exists:components,id'], + 'components.*.status' => ['required', Rule::enum(ComponentStatusEnum::class)], ]; } } diff --git a/tests/Feature/Api/IncidentUpdateTest.php b/tests/Feature/Api/IncidentUpdateTest.php index 25f63ad4..b3637e92 100644 --- a/tests/Feature/Api/IncidentUpdateTest.php +++ b/tests/Feature/Api/IncidentUpdateTest.php @@ -1,7 +1,9 @@ create(), ['incident-updates.manage']); + + $incident = Incident::factory()->create(); + $component = Component::factory()->create(); + $incident->components()->attach($component->id, [ + 'component_status' => ComponentStatusEnum::major_outage, + ]); + + $response = postJson("/status/api/incidents/{$incident->id}/updates", [ + 'status' => IncidentStatusEnum::identified->value, + 'message' => 'The API is recovering.', + 'components' => [ + ['id' => $component->id, 'status' => ComponentStatusEnum::performance_issues->value], + ], + ]); + + $response->assertCreated(); + + expect($incident->components()->first()->pivot->component_status) + ->toEqual(ComponentStatusEnum::performance_issues); +}); + +it('rejects an incident update with an unknown component', function () { + Sanctum::actingAs(User::factory()->create(), ['incident-updates.manage']); + + $incident = Incident::factory()->create(); + + $response = postJson("/status/api/incidents/{$incident->id}/updates", [ + 'status' => IncidentStatusEnum::identified->value, + 'message' => 'The API is recovering.', + 'components' => [ + ['id' => 999999, 'status' => ComponentStatusEnum::operational->value], + ], + ]); + + $response->assertUnprocessable(); +}); + it('cannot update an incident update if not authenticated', function () { $incidentUpdate = Update::factory()->forIncident()->create(); diff --git a/tests/Unit/Actions/Update/CreateUpdateTest.php b/tests/Unit/Actions/Update/CreateUpdateTest.php index 33bbc80c..f8e99bf1 100644 --- a/tests/Unit/Actions/Update/CreateUpdateTest.php +++ b/tests/Unit/Actions/Update/CreateUpdateTest.php @@ -101,6 +101,69 @@ ->toEqual(ComponentStatusEnum::operational); }); +it('updates the impacted components when the update carries component statuses', function () { + $incident = Incident::factory()->create([ + 'status' => IncidentStatusEnum::investigating, + ]); + + $component = Component::factory()->create(); + + $incident->components()->attach($component->id, [ + 'component_status' => ComponentStatusEnum::major_outage, + ]); + + $data = CreateIncidentUpdateRequestData::from([ + 'message' => 'The API is recovering.', + 'status' => IncidentStatusEnum::identified, + 'components' => [ + ['id' => $component->id, 'status' => ComponentStatusEnum::performance_issues], + ], + ]); + + app(CreateUpdate::class)->handle($incident, $data); + + expect($incident->components()->first()->pivot->component_status) + ->toEqual(ComponentStatusEnum::performance_issues); +}); + +it('attaches components that are not yet impacted by the incident', function () { + $incident = Incident::factory()->create(); + $component = Component::factory()->create(); + + $data = CreateIncidentUpdateRequestData::from([ + 'message' => 'A new component is impacted.', + 'status' => IncidentStatusEnum::investigating, + 'components' => [ + ['id' => $component->id, 'status' => ComponentStatusEnum::partial_outage], + ], + ]); + + app(CreateUpdate::class)->handle($incident, $data); + + expect($incident->components()->first()) + ->id->toEqual($component->id) + ->pivot->component_status->toEqual(ComponentStatusEnum::partial_outage); +}); + +it('leaves the incident components untouched when the update carries none', function () { + $incident = Incident::factory()->create(); + $component = Component::factory()->create(); + + $incident->components()->attach($component->id, [ + 'component_status' => ComponentStatusEnum::major_outage, + ]); + + $data = CreateIncidentUpdateRequestData::from([ + 'message' => 'Still investigating.', + 'status' => IncidentStatusEnum::investigating, + ]); + + app(CreateUpdate::class)->handle($incident, $data); + + expect($incident->components()->first()->pivot->component_status) + ->toEqual(ComponentStatusEnum::major_outage); +}); + it('can create a schedule update', function () { $schedule = Schedule::factory()->create(); From 90537973f523fc1d97684d8f8716fba0eadb9b40 Mon Sep 17 00:00:00 2001 From: Mitul Patel <16228595+devmitulpatel@users.noreply.github.com> Date: Wed, 7 Oct 2026 02:41:54 +0530 Subject: [PATCH 2/2] Add components to the record update action The Record update modal now preloads the incident's impacted components with their current status, so operators can move them on as part of the update message. --- resources/lang/en/incident.php | 3 ++ .../Resources/Incidents/IncidentResource.php | 26 +++++++++++++ .../Resources/IncidentResourceTest.php | 37 +++++++++++++++++++ 3 files changed, 66 insertions(+) diff --git a/resources/lang/en/incident.php b/resources/lang/en/incident.php index 66ccd095..3937c4bf 100644 --- a/resources/lang/en/incident.php +++ b/resources/lang/en/incident.php @@ -93,6 +93,9 @@ 'status_label' => 'Status', 'user_label' => 'User', 'user_helper' => 'The user who reported the incident.', + 'components_label' => 'Components', + 'components_helper' => 'Update the status of the components affected by this incident.', + 'components_add_action_label' => 'Add a component status', ], ], 'overview' => [ diff --git a/src/Filament/Resources/Incidents/IncidentResource.php b/src/Filament/Resources/Incidents/IncidentResource.php index 3002956f..a2b3c5e9 100644 --- a/src/Filament/Resources/Incidents/IncidentResource.php +++ b/src/Filament/Resources/Incidents/IncidentResource.php @@ -14,6 +14,7 @@ use Cachet\Filament\Resources\Incidents\Pages\ListIncidents; use Cachet\Filament\Resources\Incidents\RelationManagers\ComponentsRelationManager; use Cachet\Filament\Resources\Updates\RelationManagers\UpdatesRelationManager; +use Cachet\Models\Component; use Cachet\Models\Incident; use Cachet\Settings\MailSettings; use Cachet\Status; @@ -268,6 +269,31 @@ public static function recordUpdateAction(): Action ->default(fn (Incident $record) => $record->user_id ?? auth()->id()) ->searchable() ->preload(), + Repeater::make('components') + ->label(__('cachet::incident.record_update.form.components_label')) + ->helperText(__('cachet::incident.record_update.form.components_helper')) + ->addActionLabel(__('cachet::incident.record_update.form.components_add_action_label')) + ->default(fn (Incident $record): array => $record->components + ->map(fn (Component $component): array => [ + 'id' => $component->id, + 'status' => $component->pivot->component_status ?? $component->status, + ]) + ->values() + ->all()) + ->schema([ + Select::make('id') + ->label(__('cachet::incident.form.add_component.component_label')) + ->preload() + ->required() + ->options(fn (): array => ComponentOptions::forSelect()) + ->disableOptionsWhenSelectedInSiblingRepeaterItems(), + ToggleButtons::make('status') + ->label(__('cachet::incident.form.add_component.status_label')) + ->inline() + ->options(ComponentStatusEnum::class) + ->required(), + ]) + ->columnSpanFull(), ]); } diff --git a/tests/Feature/Filament/Resources/IncidentResourceTest.php b/tests/Feature/Filament/Resources/IncidentResourceTest.php index 0412b5b4..5642b29e 100644 --- a/tests/Feature/Filament/Resources/IncidentResourceTest.php +++ b/tests/Feature/Filament/Resources/IncidentResourceTest.php @@ -151,3 +151,40 @@ expect(collect($options)->flatten()->all())->not->toContain('Already Attached'); }); + +it('preloads the impacted components into the record update action', function () { + $incident = Incident::factory()->create(); + $component = Component::factory()->create(['name' => 'API']); + $incident->components()->attach($component->id, ['component_status' => ComponentStatusEnum::major_outage]); + + livewire(EditIncident::class, ['record' => $incident->getKey()]) + ->mountAction('add-update') + ->assertActionDataSet(fn (array $data): bool => collect($data['components'] ?? []) + ->contains(function (array $row) use ($component): bool { + $status = $row['status'] instanceof ComponentStatusEnum + ? $row['status'] + : ComponentStatusEnum::from((int) $row['status']); + + return $row['id'] === $component->id + && $status === ComponentStatusEnum::major_outage; + })); +}); + +it('updates the incident component statuses when recording an update', function () { + $incident = Incident::factory()->create(['status' => IncidentStatusEnum::investigating]); + $component = Component::factory()->create(); + $incident->components()->attach($component->id, ['component_status' => ComponentStatusEnum::major_outage]); + + livewire(EditIncident::class, ['record' => $incident->getKey()]) + ->callAction('add-update', data: [ + 'message' => 'The API is recovering.', + 'status' => IncidentStatusEnum::identified, + 'components' => [ + ['id' => $component->id, 'status' => ComponentStatusEnum::performance_issues->value], + ], + ]) + ->assertHasNoActionErrors(); + + expect($incident->components()->first()->pivot->component_status) + ->toEqual(ComponentStatusEnum::performance_issues); +});