diff --git a/ProcessMaker/Http/Controllers/Api/UserController.php b/ProcessMaker/Http/Controllers/Api/UserController.php index c1454f5589..3ccc621032 100644 --- a/ProcessMaker/Http/Controllers/Api/UserController.php +++ b/ProcessMaker/Http/Controllers/Api/UserController.php @@ -260,6 +260,9 @@ public function getUsersTaskCount(Request $request) if ($assignmentRule === 'rule_expression' && $request->has('form_data')) { $include_ids = $processRequestToken->getAssigneesFromExpression($request->input('form_data')); } + if ($assignmentRule === 'process_variable' && $request->has('form_data')) { + $include_ids = $processRequestToken->getUsersFromProcessVariable($request->input('form_data')); + } } if (!empty($include_ids)) { diff --git a/ProcessMaker/Models/ProcessRequestToken.php b/ProcessMaker/Models/ProcessRequestToken.php index 06b9251607..a32cf13dab 100644 --- a/ProcessMaker/Models/ProcessRequestToken.php +++ b/ProcessMaker/Models/ProcessRequestToken.php @@ -991,6 +991,60 @@ public function getAssignmentRule() return $assignment; } + /** + * Get user IDs from process variables for task assignment. + * + * Extracts user IDs and group IDs from form data based on the activity's + * assignedUsers and assignedGroups properties. Retrieves all users from + * specified groups (including subgroups recursively) and combines them + * with directly assigned users. + * + * Used when assignment rule is 'process_variable'. + * + * @param array $form_data Form data containing process variable values. + * Keys must match activity's assignedUsers and + * assignedGroups properties. Values must be arrays. + * + * @return array Unique numeric user IDs (direct users + users from groups). + */ + public function getUsersFromProcessVariable(array $form_data) + { + $activity = $this->getBpmnDefinition()->getBpmnElementInstance(); + $assignedUsers = $activity->getProperty('assignedUsers', null); + $assignedGroups = $activity->getProperty('assignedGroups', null); + + $usersIds = []; + $groupsIds = []; + + // Validate and get user IDs from form_data + if ($assignedUsers && isset($form_data[$assignedUsers]) && is_array($form_data[$assignedUsers])) { + $usersIds = $form_data[$assignedUsers]; + } + + // Validate and get group IDs from form_data + if ($assignedGroups && isset($form_data[$assignedGroups]) && is_array($form_data[$assignedGroups])) { + $groupsIds = $form_data[$assignedGroups]; + } + + // Get users from groups using the Process model method + $usersFromGroups = []; + if (!empty($groupsIds) && $this->process) { + // Use the getConsolidatedUsers method from the Process model + // This method gets users from groups including subgroups recursively + $this->process->getConsolidatedUsers($groupsIds, $usersFromGroups); + } + + // Combine direct users with users from groups + $allUserIds = array_unique(array_merge($usersIds, $usersFromGroups)); + + // Convert to numeric array and filter valid values + $allUserIds = array_values(array_filter($allUserIds, function ($id) { + return !empty($id) && is_numeric($id) && $id > 0; + })); + + return $allUserIds; + } + /** * Get the assignees for the token. * diff --git a/resources/js/common/reassignMixin.js b/resources/js/common/reassignMixin.js index fa1a5c81b5..0e6379dcb9 100644 --- a/resources/js/common/reassignMixin.js +++ b/resources/js/common/reassignMixin.js @@ -1,3 +1,5 @@ +import { getReassignUsers as getReassignUsersApi } from "../tasks/api"; + export default { data() { return { @@ -21,32 +23,28 @@ export default { this.allowReassignment = response.data[this.task.id]; }); }, - getReassignUsers(filter = null) { - const params = { }; - if (filter) { - params.filter = filter; - } - if (this.task?.id) { - params.assignable_for_task_id = this.task.id; - // The variables are needed to calculate the rule expression. - if (this?.formData) { - params.form_data = this.formData; - delete params.form_data._user; - delete params.form_data._request; - delete params.form_data._process; - } - } + async getReassignUsers(filter = null) { + try { + const response = await getReassignUsersApi( + filter, + this.task?.id, + this.task?.request_data, + this.currentTaskUserId + ); - ProcessMaker.apiClient.post('users_task_count', params ).then(response => { this.reassignUsers = []; - response.data.data.forEach((user) => { - this.reassignUsers.push({ - text: user.fullname, - value: user.id, - active_tasks_count: user.active_tasks_count + if (response?.data) { + response.data.forEach((user) => { + this.reassignUsers.push({ + text: user.fullname, + value: user.id, + active_tasks_count: user.active_tasks_count + }); }); - }); - }); + } + } catch (error) { + console.error('Error loading reassign users:', error); + } }, onReassignInput: _.debounce(function (filter) { this.getReassignUsers(filter); diff --git a/resources/js/tasks/api/index.js b/resources/js/tasks/api/index.js index 0b55e7e35f..c89c360bca 100644 --- a/resources/js/tasks/api/index.js +++ b/resources/js/tasks/api/index.js @@ -1,12 +1,49 @@ import { getApi } from "../variables/index"; -export const getReassignUsers = async (filter = null, taskId = null, currentTaskUserId = null) => { +/** + * Get reassign users using POST with form_data (for rule expression evaluation) + * This replaces the obsolete GET method with the advanced POST logic from reassignMixin + * + * @param {string|null} filter - Filter string to search users + * @param {number|null} taskId - Task ID to get assignable users for + * @param {Object|null} formData - Form data needed to calculate rule expressions + * @param {number|null} currentTaskUserId - User ID to exclude from results (matches: task?.user_id ?? task?.user?.id) + * @returns {Promise} Response data with users array + */ +export const getReassignUsers = async ( + filter = null, + taskId = null, + formData = null, + currentTaskUserId = null +) => { const api = getApi(); - const response = await api.get("users_task_count", { params: { filter, assignable_for_task_id: taskId, include_current_user: true } }); + const params = {}; + + if (filter) { + params.filter = filter; + } + + if (taskId) { + params.assignable_for_task_id = taskId; + + // The variables are needed to calculate the rule expression. + if (formData) { + params.form_data = { ...formData }; + // Remove internal variables + delete params.form_data._user; + delete params.form_data._request; + delete params.form_data._process; + } + } + + const response = await api.post("users_task_count", params); const data = response.data; + + // Filter out current user to prevent self-reassignment (matches mixin logic) if (currentTaskUserId && Array.isArray(data?.data)) { data.data = data.data.filter((user) => user.id !== currentTaskUserId); } + return data; }; diff --git a/resources/js/tasks/components/TasksPreview.vue b/resources/js/tasks/components/TasksPreview.vue index e9b27459df..8f3ec7c85f 100644 --- a/resources/js/tasks/components/TasksPreview.vue +++ b/resources/js/tasks/components/TasksPreview.vue @@ -201,6 +201,8 @@ @@ -413,7 +415,6 @@ export default { }, openReassignment() { this.showReassignment = !this.showReassignment; - this.getReassignUsers(); }, getTaskDefinitionForReassignmentPermission() { ProcessMaker.apiClient diff --git a/resources/js/tasks/components/taskPreview/TaskPreviewAssignment.vue b/resources/js/tasks/components/taskPreview/TaskPreviewAssignment.vue index 52f066c087..dab085918e 100644 --- a/resources/js/tasks/components/taskPreview/TaskPreviewAssignment.vue +++ b/resources/js/tasks/components/taskPreview/TaskPreviewAssignment.vue @@ -67,6 +67,14 @@ const props = defineProps({ type: Object, required: true, }, + formData: { + type: Object, + default: null, + }, + currentTaskUserId: { + type: Number, + default: null, + }, }); const emit = defineEmits(["on-reassign-user"]); @@ -80,18 +88,29 @@ const disabledAssign = ref(false); // Computed properties const disabled = computed(() => !selectedUser.value || !comments.value?.trim()); -// Load the reassign users +// Load the reassign users using the centralized function with form_data const loadReassignUsers = async (filter) => { - const response = await getReassignUsers(filter, props.task.id, props.task.user_id); - - reassignUsers.value = []; - response.data.forEach((user) => { - reassignUsers.value.push({ - text: user.fullname, - value: user.id, - active_tasks_count: user.active_tasks_count, - }); - }); + try { + const response = await getReassignUsers( + filter, + props.task?.id, + props.formData, + props.currentTaskUserId + ); + + reassignUsers.value = []; + if (response?.data) { + response.data.forEach((user) => { + reassignUsers.value.push({ + text: user.fullname, + value: user.id, + active_tasks_count: user.active_tasks_count, + }); + }); + } + } catch (error) { + console.error('Error loading reassign users:', error); + } }; /** diff --git a/tests/Model/ProcessRequestTokenTest.php b/tests/Model/ProcessRequestTokenTest.php index ff8388a5fc..210dd4aaa5 100644 --- a/tests/Model/ProcessRequestTokenTest.php +++ b/tests/Model/ProcessRequestTokenTest.php @@ -2,9 +2,12 @@ namespace Tests\Model; -use DOMXPath; +use ProcessMaker\Models\Group; +use ProcessMaker\Models\GroupMember; +use ProcessMaker\Models\Process; +use ProcessMaker\Models\ProcessRequest; use ProcessMaker\Models\ProcessRequestToken; -use ProcessMaker\Nayra\Storage\BpmnDocument; +use ProcessMaker\Models\User; use stdClass; use Tests\TestCase; @@ -39,4 +42,384 @@ public function testSetStagePropertiesInRecord() $this->assertEquals(7, $token->stage_id); $this->assertEquals('Review', $token->stage_name); } + + /** + * Test getUsersFromProcessVariable with direct users only + */ + public function testGetUsersFromProcessVariableWithDirectUsers() + { + // Create process and token + $process = Process::factory()->create(); + $request = ProcessRequest::factory()->create(['process_id' => $process->id]); + + // Create users + $user1 = User::factory()->create(['status' => 'ACTIVE']); + $user2 = User::factory()->create(['status' => 'ACTIVE']); + + // Mock the BPMN definition and activity + $activity = $this->createMock(\ProcessMaker\Nayra\Contracts\Bpmn\ActivityInterface::class); + $activity->method('getProperty') + ->willReturnCallback(function ($key, $default) { + if ($key === 'assignedUsers') { + return 'assigned_users_var'; + } + if ($key === 'assignedGroups') { + return null; + } + + return $default; + }); + + $bpmnDefinition = $this->createMock(\ProcessMaker\Nayra\Storage\BpmnElement::class); + $bpmnDefinition->method('getBpmnElementInstance') + ->willReturn($activity); + + $token = $this->getMockBuilder(ProcessRequestToken::class) + ->onlyMethods(['getBpmnDefinition']) + ->getMock(); + + $token->process_id = $process->id; + $token->process_request_id = $request->id; + $token->element_id = 'task_1'; + $token->process = $process; + + $token->expects($this->atLeastOnce()) + ->method('getBpmnDefinition') + ->willReturn($bpmnDefinition); + + // Form data with direct users + $formData = [ + 'assigned_users_var' => [$user1->id, $user2->id], + ]; + + // Act + $result = $token->getUsersFromProcessVariable($formData); + + // Assert + $this->assertIsArray($result); + $this->assertCount(2, $result); + $this->assertContains($user1->id, $result); + $this->assertContains($user2->id, $result); + } + + /** + * Test getUsersFromProcessVariable with groups only + */ + public function testGetUsersFromProcessVariableWithGroups() + { + // Create process and token + $process = Process::factory()->create(); + $request = ProcessRequest::factory()->create(['process_id' => $process->id]); + + // Create groups and users + $group1 = Group::factory()->create(['status' => 'ACTIVE']); + $group2 = Group::factory()->create(['status' => 'ACTIVE']); + $user1 = User::factory()->create(['status' => 'ACTIVE']); + $user2 = User::factory()->create(['status' => 'ACTIVE']); + $user3 = User::factory()->create(['status' => 'ACTIVE']); + + // Add users to groups + GroupMember::factory()->create([ + 'group_id' => $group1->id, + 'member_id' => $user1->id, + 'member_type' => User::class, + ]); + GroupMember::factory()->create([ + 'group_id' => $group1->id, + 'member_id' => $user2->id, + 'member_type' => User::class, + ]); + GroupMember::factory()->create([ + 'group_id' => $group2->id, + 'member_id' => $user3->id, + 'member_type' => User::class, + ]); + + // Mock the BPMN definition and activity + $activity = $this->createMock(\ProcessMaker\Nayra\Contracts\Bpmn\ActivityInterface::class); + $activity->method('getProperty') + ->willReturnCallback(function ($key, $default) { + if ($key === 'assignedUsers') { + return null; + } + if ($key === 'assignedGroups') { + return 'assigned_groups_var'; + } + + return $default; + }); + + $bpmnDefinition = $this->createMock(\ProcessMaker\Nayra\Storage\BpmnElement::class); + $bpmnDefinition->method('getBpmnElementInstance') + ->willReturn($activity); + + $token = $this->getMockBuilder(ProcessRequestToken::class) + ->onlyMethods(['getBpmnDefinition']) + ->getMock(); + + $token->process_id = $process->id; + $token->process_request_id = $request->id; + $token->element_id = 'task_1'; + $token->process = $process; + + $token->expects($this->atLeastOnce()) + ->method('getBpmnDefinition') + ->willReturn($bpmnDefinition); + + // Form data with groups + $formData = [ + 'assigned_groups_var' => [$group1->id, $group2->id], + ]; + + // Act + $result = $token->getUsersFromProcessVariable($formData); + + // Assert + $this->assertIsArray($result); + $this->assertCount(3, $result); + $this->assertContains($user1->id, $result); + $this->assertContains($user2->id, $result); + $this->assertContains($user3->id, $result); + } + + /** + * Test getUsersFromProcessVariable with users and groups combined + */ + public function testGetUsersFromProcessVariableWithUsersAndGroups() + { + // Create process and token + $process = Process::factory()->create(); + $request = ProcessRequest::factory()->create(['process_id' => $process->id]); + + // Create users + $directUser = User::factory()->create(['status' => 'ACTIVE']); + + // Create group with users + $group = Group::factory()->create(['status' => 'ACTIVE']); + $groupUser = User::factory()->create(['status' => 'ACTIVE']); + + GroupMember::factory()->create([ + 'group_id' => $group->id, + 'member_id' => $groupUser->id, + 'member_type' => User::class, + ]); + + // Mock the BPMN definition and activity + $activity = $this->createMock(\ProcessMaker\Nayra\Contracts\Bpmn\ActivityInterface::class); + $activity->method('getProperty') + ->willReturnCallback(function ($key, $default) { + if ($key === 'assignedUsers') { + return 'assigned_users_var'; + } + if ($key === 'assignedGroups') { + return 'assigned_groups_var'; + } + + return $default; + }); + + $bpmnDefinition = $this->createMock(\ProcessMaker\Nayra\Storage\BpmnElement::class); + $bpmnDefinition->method('getBpmnElementInstance') + ->willReturn($activity); + + $token = $this->getMockBuilder(ProcessRequestToken::class) + ->onlyMethods(['getBpmnDefinition']) + ->getMock(); + + $token->process_id = $process->id; + $token->process_request_id = $request->id; + $token->element_id = 'task_1'; + $token->process = $process; + + $token->expects($this->atLeastOnce()) + ->method('getBpmnDefinition') + ->willReturn($bpmnDefinition); + + // Form data with both users and groups + $formData = [ + 'assigned_users_var' => [$directUser->id], + 'assigned_groups_var' => [$group->id], + ]; + + // Act + $result = $token->getUsersFromProcessVariable($formData); + + // Assert + $this->assertIsArray($result); + $this->assertCount(2, $result); + $this->assertContains($directUser->id, $result); + $this->assertContains($groupUser->id, $result); + } + + /** + * Test getUsersFromProcessVariable with empty form data + */ + public function testGetUsersFromProcessVariableWithEmptyFormData() + { + // Create process and token + $process = Process::factory()->create(); + $request = ProcessRequest::factory()->create(['process_id' => $process->id]); + + // Mock the BPMN definition and activity + $activity = $this->createMock(\ProcessMaker\Nayra\Contracts\Bpmn\ActivityInterface::class); + $activity->method('getProperty') + ->willReturnCallback(function ($key, $default) { + if ($key === 'assignedUsers') { + return 'assigned_users_var'; + } + if ($key === 'assignedGroups') { + return 'assigned_groups_var'; + } + + return $default; + }); + + $bpmnDefinition = $this->createMock(\ProcessMaker\Nayra\Storage\BpmnElement::class); + $bpmnDefinition->method('getBpmnElementInstance') + ->willReturn($activity); + + $token = $this->getMockBuilder(ProcessRequestToken::class) + ->onlyMethods(['getBpmnDefinition']) + ->getMock(); + + $token->process_id = $process->id; + $token->process_request_id = $request->id; + $token->element_id = 'task_1'; + $token->process = $process; + + $token->expects($this->atLeastOnce()) + ->method('getBpmnDefinition') + ->willReturn($bpmnDefinition); + + // Empty form data + $formData = []; + + // Act + $result = $token->getUsersFromProcessVariable($formData); + + // Assert + $this->assertIsArray($result); + $this->assertEmpty($result); + } + + /** + * Test getUsersFromProcessVariable with non-array values (should be ignored) + */ + public function testGetUsersFromProcessVariableWithNonArrayValues() + { + // Create process and token + $process = Process::factory()->create(); + $request = ProcessRequest::factory()->create(['process_id' => $process->id]); + + // Mock the BPMN definition and activity + $activity = $this->createMock(\ProcessMaker\Nayra\Contracts\Bpmn\ActivityInterface::class); + $activity->method('getProperty') + ->willReturnCallback(function ($key, $default) { + if ($key === 'assignedUsers') { + return 'assigned_users_var'; + } + if ($key === 'assignedGroups') { + return 'assigned_groups_var'; + } + + return $default; + }); + + $bpmnDefinition = $this->createMock(\ProcessMaker\Nayra\Storage\BpmnElement::class); + $bpmnDefinition->method('getBpmnElementInstance') + ->willReturn($activity); + + $token = $this->getMockBuilder(ProcessRequestToken::class) + ->onlyMethods(['getBpmnDefinition']) + ->getMock(); + + $token->process_id = $process->id; + $token->process_request_id = $request->id; + $token->element_id = 'task_1'; + $token->process = $process; + + $token->expects($this->atLeastOnce()) + ->method('getBpmnDefinition') + ->willReturn($bpmnDefinition); + + // Form data with non-array values + $formData = [ + 'assigned_users_var' => 'not_an_array', + 'assigned_groups_var' => 123, + ]; + + // Act + $result = $token->getUsersFromProcessVariable($formData); + + // Assert - should return empty array since values are not arrays + $this->assertIsArray($result); + $this->assertEmpty($result); + } + + /** + * Test getUsersFromProcessVariable filters invalid user IDs + */ + public function testGetUsersFromProcessVariableFiltersInvalidIds() + { + // Create process and token + $process = Process::factory()->create(); + $request = ProcessRequest::factory()->create(['process_id' => $process->id]); + + // Create valid user + $validUser = User::factory()->create(['status' => 'ACTIVE']); + + // Mock the BPMN definition and activity + $activity = $this->createMock(\ProcessMaker\Nayra\Contracts\Bpmn\ActivityInterface::class); + $activity->method('getProperty') + ->willReturnCallback(function ($key, $default) { + if ($key === 'assignedUsers') { + return 'assigned_users_var'; + } + if ($key === 'assignedGroups') { + return null; + } + + return $default; + }); + + $bpmnDefinition = $this->createMock(\ProcessMaker\Nayra\Storage\BpmnElement::class); + $bpmnDefinition->method('getBpmnElementInstance') + ->willReturn($activity); + + $token = $this->getMockBuilder(ProcessRequestToken::class) + ->onlyMethods(['getBpmnDefinition']) + ->getMock(); + + $token->process_id = $process->id; + $token->process_request_id = $request->id; + $token->element_id = 'task_1'; + $token->process = $process; + + $token->expects($this->atLeastOnce()) + ->method('getBpmnDefinition') + ->willReturn($bpmnDefinition); + + // Form data with valid and invalid IDs + $formData = [ + 'assigned_users_var' => [ + $validUser->id, + null, + '', + 0, + -1, + 'invalid_string', + ], + ]; + + // Act + $result = $token->getUsersFromProcessVariable($formData); + + // Assert - should only contain valid user ID + $this->assertIsArray($result); + $this->assertCount(1, $result); + $this->assertContains($validUser->id, $result); + $this->assertNotContains(null, $result); + $this->assertNotContains(0, $result); + $this->assertNotContains(-1, $result); + } }