Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 66 additions & 18 deletions ProcessMaker/Traits/TaskControllerIndexMethods.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
32 changes: 32 additions & 0 deletions resources/js/common/PMColumnFilterPopoverCommonMixin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
<b-form-group :key="'logical' + index"
v-if="switchLogical(index)">
<b-form-select v-model="item.logical"
:options="getLogicals()"
:options="getLogicals(index)"
:data-cy="'logical' + index"
class="pm-filter-form-logical-operators"
@change="onChangeLogicalOp(item,index)"
Expand Down Expand Up @@ -121,6 +121,7 @@
this.$emit("onChangeSort", value);
},
onApply() {
this.normalizeStatusLogicals();
let json = this.getValues();
this.$emit("onApply", json);
},
Expand All @@ -134,22 +135,47 @@
},
onClickButtonAdd() {
this.addItem(this.items.length);
this.normalizeStatusLogicals();
},
onClickButtonRemove(item, index) {
if (this.items.length === 1) {
return;
}
this.removeItem(index);
this.normalizeStatusLogicals();
},
onChangeOperator(item) {
this.switchViewControl(item);
this.normalizeStatusLogicals();
},
onChangeLogicalOp() {
onChangeLogicalOp(item, index) {
if (this.requiresStatusOrLogical(index)) {
item.logical = "or";
}
},
setValues(json) {
let items = this.transformToFilterSyntax(json);
this.normalizeStatusLogicals(items);
this.items = items;
},
/**
* Status is a single-value enum column: AND between different statuses can never match.
* Force OR so multi-status filters mean "any of these statuses".
*/
isStatusColumn() {
return this.value === "status";
},
requiresStatusOrLogical(index) {
return this.isStatusColumn() && this.switchLogical(index);
},
normalizeStatusLogicals(items = this.items) {
if (!this.isStatusColumn()) {
return;
}
for (let i = 0; i < items.length - 1; i++) {
items[i].logical = "or";
}
},
getValues() {
let json = JSON.parse(JSON.stringify(this.items));
return this.transformToPmSyntax(json);
Expand Down Expand Up @@ -236,7 +262,10 @@
}
return operators;
},
getLogicals() {
getLogicals(index) {
if (this.requiresStatusOrLogical(index)) {
return [{ value: "or", text: "or" }];
}
return [
{value: "and", text: "and"},
{value: "or", text: "or"}
Expand Down
62 changes: 62 additions & 0 deletions tests/Feature/Api/TasksTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -1050,6 +1050,68 @@ public function testSelfServiceFilterOverridesPmqlStatusAndUserId()
$this->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);
Expand Down
Loading