diff --git a/ProcessMaker/Traits/TaskControllerIndexMethods.php b/ProcessMaker/Traits/TaskControllerIndexMethods.php index fd76a3065d..eec587e9fc 100644 --- a/ProcessMaker/Traits/TaskControllerIndexMethods.php +++ b/ProcessMaker/Traits/TaskControllerIndexMethods.php @@ -324,6 +324,9 @@ private function applyPmql($query, $request, $user) if ($this->advancedFilterHasStatus($request)) { $pmql = $this->removeStatusFromPmql($pmql); + // Exclusive Self Service: unassigned tasks cannot match a global user_id AND. + // Mixed Self Service + other statuses: also drop the global user_id AND here; + // applyMixedSelfServiceStatusFilter() re-applies it only on the regular-status branch. if ($this->advancedFilterHasSelfServiceStatus($request)) { $pmql = $this->removeUserIdFromPmql($pmql); } @@ -350,13 +353,46 @@ private function advancedFilterHasStatus($request): bool private function advancedFilterHasSelfServiceStatus($request): bool { - foreach ($this->getAdvancedFilterArray($request) as $filter) { + return $this->filterDefinitionsContainSelfService($this->getAdvancedFilterArray($request)); + } + + /** + * True when every Status criterion is Self Service (no In Progress / Completed / etc.). + */ + private function advancedFilterIsExclusiveSelfService($request): bool + { + $statusFilters = array_values($this->getAdvancedFilterArray($request)); + if ($statusFilters === [] || !$this->filterDefinitionsContainSelfService($statusFilters)) { + return false; + } + + foreach ($statusFilters as $filter) { + if ($this->stripSelfServiceFromStatusFilter($filter) !== null) { + return false; + } + } + + return true; + } + + /** + * 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; @@ -379,6 +415,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); @@ -420,24 +502,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; @@ -468,6 +538,12 @@ private function applyAdvancedFilter($query, $request) } else { $query->whereIn('process_request_tokens.id', $selfServiceTaskIds); } + } elseif ( + is_array($filterArray) + && $this->filterDefinitionsContainSelfService(array_values($this->getAdvancedFilterArray($request))) + && !$this->advancedFilterIsExclusiveSelfService($request) + ) { + $this->applyMixedSelfServiceStatusFilter($query, $filterArray, $request); } else { // Normal behavior - apply the filter as-is Filter::filter($query, $advancedFilter); @@ -475,6 +551,64 @@ private function applyAdvancedFilter($query, $request) } } + /** + * Mixed Status filter (e.g. In Progress OR Self Service): + * (assignee AND regular statuses) OR eligible Self Service tasks. + */ + private function applyMixedSelfServiceStatusFilter($query, array $filterArray, $request): void + { + $nonStatusFilters = []; + $regularStatusFilters = []; + + foreach ($filterArray as $filter) { + $isStatusFilter = isset($filter['subject']['type']) && $filter['subject']['type'] === 'Status'; + if (!$isStatusFilter) { + $nonStatusFilters[] = $filter; + continue; + } + + $stripped = $this->stripSelfServiceFromStatusFilter($filter); + if ($stripped !== null) { + $regularStatusFilters[] = $stripped; + } + } + + if ($nonStatusFilters !== []) { + Filter::filter($query, $nonStatusFilters); + } + + $requestedUserId = $this->parseUserIdFromPmql((string) $request->input('pmql', '')); + $user = $request->user() ?? Auth::user(); + + $query->where(function ($query) use ($regularStatusFilters, $requestedUserId, $user) { + $query->where(function ($regularQuery) use ($regularStatusFilters, $requestedUserId) { + if ($regularStatusFilters !== []) { + Filter::filter($regularQuery, $regularStatusFilters); + } + if ($requestedUserId) { + $regularQuery->where('process_request_tokens.user_id', $requestedUserId); + } + }); + + if ($user) { + $query->orWhereIn('process_request_tokens.id', $user->availableSelfServiceTasksQuery()); + } + }); + } + + private function parseUserIdFromPmql(string $pmql): ?int + { + if ($pmql === '') { + return null; + } + + if (preg_match('/user_id\s*=\s*(\d+)/i', $pmql, $matches)) { + return (int) $matches[1]; + } + + return null; + } + private function applyUserFilter($response, $request, $user) { // Only filter results if the user id was specified 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, + ]); + + $otherUser = User::factory()->create(); + $foreignInProgressTask = ProcessRequestToken::factory()->create([ + 'status' => 'ACTIVE', + 'element_type' => 'task', + 'element_name' => 'FOREIGN TASK - MUST NOT APPEAR', + 'user_id' => $otherUser->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, + 'per_page' => 100, + ])); + + $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); + $this->assertNotContains( + $foreignInProgressTask->id, + $returnedIds, + 'ACTIVE task assigned to another user must not appear for mixed In Progress OR Self Service' + ); + } + + public function testInOperatorSelfServiceWithInProgressScopesRegularTasksToPmqlUser() + { + $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, + ]); + + $otherUser = User::factory()->create(); + $foreignInProgressTask = ProcessRequestToken::factory()->create([ + 'status' => 'ACTIVE', + 'element_type' => 'task', + 'element_name' => 'FOREIGN TASK - MUST NOT APPEAR', + 'user_id' => $otherUser->id, + 'is_self_service' => 0, + ]); + + $statusFilter = json_encode([ + [ + 'subject' => ['type' => 'Status'], + 'operator' => 'in', + 'value' => ['In Progress', 'Self Service'], + ], + ]); + + $response = $this->actingAs($user, 'api')->get(route('api.tasks.index', [ + 'pmql' => '(user_id = ' . $user->id . ') AND (status = "In Progress")', + 'advanced_filter' => $statusFilter, + 'per_page' => 100, + ])); + + $response->assertStatus(200); + $returnedIds = collect($response->json('data'))->pluck('id')->toArray(); + + $this->assertContains($inProgressTask->id, $returnedIds); + $this->assertContains($selfServiceTask->id, $returnedIds); + $this->assertNotContains( + $foreignInProgressTask->id, + $returnedIds, + 'ACTIVE task assigned to another user must not appear for mixed In Progress OR Self Service' + ); + } + public function testGetScreenFields() { $this->be($this->user);