diff --git a/ProcessMaker/Traits/TaskControllerIndexMethods.php b/ProcessMaker/Traits/TaskControllerIndexMethods.php index dcfa97bea0..84aa6aa9ae 100644 --- a/ProcessMaker/Traits/TaskControllerIndexMethods.php +++ b/ProcessMaker/Traits/TaskControllerIndexMethods.php @@ -349,13 +349,27 @@ private function advancedFilterHasStatus($request): bool private function advancedFilterHasSelfServiceStatus($request): bool { - foreach ($this->getAdvancedFilterArray($request) as $filter) { + return $this->filterDefinitionsContainSelfService($this->getAdvancedFilterArray($request)); + } + + /** + * Detect "Self Service" in Status filters, including nested `or` chains produced by the + * column filter UI (Status = X OR Status = Self Service). + */ + private function filterDefinitionsContainSelfService(array $filters): bool + { + foreach ($filters as $filter) { $values = (array) ($filter['value'] ?? []); - foreach ($values as $v) { - if (mb_strtolower($v) === self::SELF_SERVICE_STATUS) { + foreach ($values as $value) { + if (is_string($value) && mb_strtolower($value) === self::SELF_SERVICE_STATUS) { return true; } } + + $nestedOr = $filter['or'] ?? []; + if (is_array($nestedOr) && $nestedOr !== [] && $this->filterDefinitionsContainSelfService($nestedOr)) { + return true; + } } return false; @@ -378,6 +392,52 @@ private function getAdvancedFilterArray($request): array }); } + /** + * Remove Self Service values from a Status filter (including nested `or`), keeping other statuses. + * Returns null when nothing remains after stripping Self Service. + */ + private function stripSelfServiceFromStatusFilter(array $filter): ?array + { + $values = is_array($filter['value'] ?? null) ? $filter['value'] : [$filter['value'] ?? null]; + $values = array_values(array_filter($values, function ($value) { + return $value !== null && $value !== '' + && (!is_string($value) || mb_strtolower($value) !== self::SELF_SERVICE_STATUS); + })); + + $nestedOr = []; + foreach ($filter['or'] ?? [] as $orFilter) { + if (!is_array($orFilter)) { + continue; + } + $stripped = $this->stripSelfServiceFromStatusFilter($orFilter); + if ($stripped !== null) { + $nestedOr[] = $stripped; + } + } + + if ($values === [] && $nestedOr === []) { + return null; + } + + if ($values === [] && $nestedOr !== []) { + $first = array_shift($nestedOr); + if ($nestedOr !== []) { + $first['or'] = array_values(array_merge($first['or'] ?? [], $nestedOr)); + } + + return $first; + } + + $filter['value'] = count($values) === 1 ? $values[0] : $values; + if ($nestedOr === []) { + unset($filter['or']); + } else { + $filter['or'] = $nestedOr; + } + + return $filter; + } + private function removeStatusFromPmql(string $pmql): string { $pmql = preg_replace('/\s+AND\s+\(status\s*=\s*"[^"]*"\)/i', '', $pmql); @@ -419,24 +479,12 @@ private function applyAdvancedFilter($query, $request) continue; } - $values = is_array($filter['value']) ? $filter['value'] : [$filter['value']]; - $hasSelfServiceValue = in_array( - self::SELF_SERVICE_STATUS, - array_map(fn ($value) => is_string($value) ? mb_strtolower($value) : $value, $values), - true - ); - - if ($hasSelfServiceValue) { + if ($this->filterDefinitionsContainSelfService([$filter])) { $hasSelfServiceFilter = true; - $values = array_values(array_filter($values, function ($value) { - return !is_string($value) || mb_strtolower($value) !== self::SELF_SERVICE_STATUS; - })); - - if (empty($values)) { + $filter = $this->stripSelfServiceFromStatusFilter($filter); + if ($filter === null) { continue; } - - $filter['value'] = is_array($filter['value']) ? $values : $values[0]; } $statusFilters[] = $filter; diff --git a/resources/js/common/PMColumnFilterPopoverCommonMixin.js b/resources/js/common/PMColumnFilterPopoverCommonMixin.js index a1d17aa8dc..a64739b2ac 100644 --- a/resources/js/common/PMColumnFilterPopoverCommonMixin.js +++ b/resources/js/common/PMColumnFilterPopoverCommonMixin.js @@ -160,10 +160,41 @@ const PMColumnFilterCommonMixin = { }, onClear(index) { this.advancedFilter[index] = []; + // Inbox/tasks: clearing Status restores the default tray filter (In Progress, etc.) + // so Clear does not leave "no status" (all tasks) or fail to persist. + if (index === "status" && this.shouldRestoreDefaultTaskStatusFilter()) { + this.advancedFilter.status = [this.buildDefaultTaskStatusFilter()]; + } this.markStyleWhenColumnSetAFilter(); this.storeFilterConfiguration(); this.fetch(true); }, + shouldRestoreDefaultTaskStatusFilter() { + return typeof this.filterConfiguration === "function" + && this.filterConfiguration()?.type === "taskFilter"; + }, + getDefaultTaskInboxStatus() { + const statusParam = new URL(document.location).searchParams.get("status"); + switch (statusParam) { + case "CLOSED": + return "Completed"; + case "SELF_SERVICE": + return "Self Service"; + default: + return "In Progress"; + } + }, + buildDefaultTaskStatusFilter() { + return { + subject: { + type: "Status", + }, + operator: "=", + value: this.getDefaultTaskInboxStatus(), + _column_field: "status", + _column_label: "Status", + }; + }, onChangeSort(value, field) { this.setOrderByProps(field, value); this.markStyleWhenColumnSetAFilter(); @@ -182,6 +213,7 @@ const PMColumnFilterCommonMixin = { Object.keys(filterCopy).forEach((key) => { if (filterCopy[key].length === 0) { delete filterCopy[key]; + return; } const label = this.tableHeaders.find(column => column.field === key)?.label; this.addAliases(filterCopy[key], key, label); diff --git a/resources/js/components/PMColumnFilterPopover/PMColumnFilterForm.vue b/resources/js/components/PMColumnFilterPopover/PMColumnFilterForm.vue index 6afe5f5dd5..cf65a81138 100644 --- a/resources/js/components/PMColumnFilterPopover/PMColumnFilterForm.vue +++ b/resources/js/components/PMColumnFilterPopover/PMColumnFilterForm.vue @@ -41,7 +41,7 @@ assertNotContains($regularTask->id, $returnedIds); } + public function testNestedOrSelfServiceWithInProgressReturnsBothStatuses() + { + $user = User::factory()->create(['is_administrator' => true]); + $group = Group::factory()->create(); + + GroupMember::factory()->create([ + 'group_id' => $group->id, + 'member_id' => $user->id, + 'member_type' => User::class, + ]); + + $selfServiceTask = ProcessRequestToken::factory()->create([ + 'status' => 'ACTIVE', + 'element_type' => 'task', + 'user_id' => null, + 'is_self_service' => 1, + 'self_service_groups' => ['groups' => [strval($group->id)], 'users' => []], + ]); + + $inProgressTask = ProcessRequestToken::factory()->create([ + 'status' => 'ACTIVE', + 'element_type' => 'task', + 'user_id' => $user->id, + 'is_self_service' => 0, + ]); + + $completedTask = ProcessRequestToken::factory()->create([ + 'status' => 'CLOSED', + 'element_type' => 'task', + 'user_id' => $user->id, + 'is_self_service' => 0, + ]); + + // Same nested `or` payload produced by the Status column filter UI + $statusFilter = json_encode([ + [ + 'subject' => ['type' => 'Status'], + 'operator' => '=', + 'value' => 'In Progress', + 'or' => [ + [ + 'subject' => ['type' => 'Status'], + 'operator' => '=', + 'value' => 'Self Service', + ], + ], + ], + ]); + + $response = $this->actingAs($user, 'api')->get(route('api.tasks.index', [ + 'pmql' => '(user_id = ' . $user->id . ') AND (status = "In Progress")', + 'advanced_filter' => $statusFilter, + ])); + + $response->assertStatus(200); + $returnedIds = collect($response->json('data'))->pluck('id')->toArray(); + + $this->assertContains($inProgressTask->id, $returnedIds); + $this->assertContains($selfServiceTask->id, $returnedIds); + $this->assertNotContains($completedTask->id, $returnedIds); + } + public function testGetScreenFields() { $this->be($this->user);