diff --git a/modules/issue_tracker/jsx/IssueCard.js b/modules/issue_tracker/jsx/IssueCard.js index 8804f5ba482..892a38b95b7 100644 --- a/modules/issue_tracker/jsx/IssueCard.js +++ b/modules/issue_tracker/jsx/IssueCard.js @@ -11,6 +11,8 @@ const IssueCard = React.memo(function IssueCard({ priorities, categories, sites, + assignees, + otherWatchers, }) { const [isEditing, setIsEditing] = useState(false); const [editedIssue, setEditedIssue] = useState({...issue}); @@ -21,17 +23,20 @@ const IssueCard = React.memo(function IssueCard({ const [newComment, setNewComment] = useState(''); const [isSubmittingComment, setIsSubmittingComment] = useState(false); + const [newAssignee, setNewAssignee] = useState(issue.assignee || ''); + const [newWatchers, setNewWatchers] = useState(issue.othersWatching || []); + const handleInputChange = (field, value) => { setTempEditedIssue((prev) => ({ ...prev, - [field]: value, + [field]: value === '' ? null : value, })); }; const handleSubmit = (e) => { e.preventDefault(); - if (!tempEditedIssue.title.trim()) { + if (!tempEditedIssue.title || !tempEditedIssue.title.trim()) { showAlertMessage('error', 'Title cannot be empty'); return; } @@ -100,23 +105,46 @@ const IssueCard = React.memo(function IssueCard({ }; const handleOpenAddCommentModal = () => { + setNewAssignee(issue.assignee || ''); + setNewWatchers(issue.othersWatching || []); setShowAddCommentModal(true); }; const handleCloseAddCommentModal = () => { setShowAddCommentModal(false); setNewComment(''); + setNewAssignee(issue.assignee || ''); + setNewWatchers(issue.othersWatching || []); }; const handleAddCommentChange = (e) => { setNewComment(e.target.value); }; + const handleNewAssigneeChange = (e) => { + setNewAssignee(e.target.value); + }; + + const handleNewWatchersChange = (e) => { + const options = e.target.options; + const selectedWatchers = []; + for (let i = 0; i < options.length; i++) { + if (options[i].selected) { + selectedWatchers.push(options[i].value); + } + } + setNewWatchers(selectedWatchers); + }; + const handleAddCommentSubmit = (e) => { e.preventDefault(); - if (!newComment.trim()) { - showAlertMessage('error', 'Comment cannot be empty'); + const trimmedComment = newComment.trim(); + const hasAssigneeChanged = newAssignee !== issue.assignee; + const hasWatchersChanged = JSON.stringify(newWatchers) !== + JSON.stringify(issue.othersWatching); + if (!trimmedComment && !hasAssigneeChanged && !hasWatchersChanged) { + showAlertMessage('info', 'Please add a comment or make changes'); return; } @@ -129,7 +157,16 @@ const IssueCard = React.memo(function IssueCard({ formData.append(key, value === null ? 'null' : value); }); - formData.append('comment', newComment.trim()); + // Only append comment if it's not empty + if (trimmedComment) { + formData.append('comment', newComment.trim()); + } + + formData.append('assignee', newAssignee || 'null'); + formData.append( + 'othersWatching', + newWatchers.length > 0 ? newWatchers.join(',') : '' + ); fetch(`${loris.BaseURL}/issue_tracker/Edit/`, { method: 'POST', @@ -145,7 +182,7 @@ const IssueCard = React.memo(function IssueCard({ return response.json(); }) .then((data) => { - showAlertMessage('success', 'Comment added successfully'); + showAlertMessage('success', 'Issue updated successfully'); handleCloseAddCommentModal(); onUpdate(); }) @@ -174,10 +211,48 @@ const IssueCard = React.memo(function IssueCard({ value={newComment} onChange={handleAddCommentChange} className="textarea" - required disabled={isSubmittingComment} /> +
+ + +
+
+ + +
+
@@ -296,6 +366,28 @@ const IssueCard = React.memo(function IssueCard({ ))} +
+ + +
) : ( <> @@ -320,6 +412,13 @@ const IssueCard = React.memo(function IssueCard({ 'Uncategorized'} +
+ + + {sites[String(tempEditedIssue.centerID)] || + 'All Sites'} + +
)} @@ -407,6 +506,7 @@ IssueCard.propTypes = { title: PropTypes.string.isRequired, reporter: PropTypes.string.isRequired, assignee: PropTypes.string, + othersWatching: PropTypes.arrayOf(PropTypes.string), status: PropTypes.string.isRequired, priority: PropTypes.string.isRequired, module: PropTypes.number, @@ -434,6 +534,8 @@ IssueCard.propTypes = { priorities: PropTypes.object.isRequired, categories: PropTypes.object.isRequired, sites: PropTypes.object.isRequired, + assignees: PropTypes.object.isRequired, + otherWatchers: PropTypes.object.isRequired, }; export default IssueCard; diff --git a/modules/issue_tracker/jsx/IssueTrackerBatchMode.js b/modules/issue_tracker/jsx/IssueTrackerBatchMode.js index 986b1d2d39e..9e582f277b8 100644 --- a/modules/issue_tracker/jsx/IssueTrackerBatchMode.js +++ b/modules/issue_tracker/jsx/IssueTrackerBatchMode.js @@ -22,6 +22,8 @@ function IssueTrackerBatchMode({options}) { const [filteredIssues, setFilteredIssues] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); + const [assignees, setAssignees] = useState({}); + const [otherWatchers, setOtherWatchers] = useState({}); // Pagination state const [page, setPage] = useState({ @@ -63,7 +65,9 @@ function IssueTrackerBatchMode({options}) { throw new Error('Network response was not ok'); } const data = await response.json(); - setIssues(data); + setIssues(data.issues || []); + setAssignees(data.assignees || {}); + setOtherWatchers(data.otherWatchers || {}); setIsLoading(false); } catch (error) { console.error('Error fetching issues:', error); @@ -73,7 +77,7 @@ function IssueTrackerBatchMode({options}) { } /** - * Filters issues based on selected categories, priorities, and statuses + * Filters issues based on selected categories, priorities, statuses, and sites */ function filterIssues() { setFilteredIssues(issues.filter((issue) => @@ -323,6 +327,8 @@ function IssueTrackerBatchMode({options}) { loris->getDatabaseConnection(); + $db = $this->loris->getDatabaseConnection(); + $assignees = []; + $otherWatchers = []; + $centerIDsArray = []; + $dccID = null; + $centerPlaceholders = ''; + + // Initialize variables based on permissions + if (!$user->hasPermission('access_all_profiles')) { + $centerIDsArray = array_map( + fn($centerID) => (int)$centerID->__toString(), + $user->getCenterIDs() + ); + + if (empty($centerIDsArray)) { + return [ + 'issues' => [], + 'assignees' => [], + 'otherWatchers' => [], + ]; + } + + // Fetch DCC ID + $dccRow = $db->pselectOne( + "SELECT CenterID FROM psc WHERE Name = :name", + ['name' => 'DCC'] + ); + $dccID = $dccRow ? (int)$dccRow: null; + + // Prepare placeholders + $centerPlaceholders = implode( + ',', + array_fill(0, count($centerIDsArray), '?') + ); + } + + // Prepare assignee query + if ($user->hasPermission('access_all_profiles')) { + $assigneeQuery = " + SELECT Real_name, UserID, Active + FROM users + WHERE Pending_approval = 'N' + "; + $assigneeParams = []; + } else { + $assigneeQuery = " + SELECT DISTINCT u.Real_name, u.UserID, u.Active + FROM users u + INNER JOIN user_psc_rel upr ON upr.UserID = u.ID + WHERE (upr.CenterID IN ($centerPlaceholders) OR upr.CenterID = ?) + AND u.Pending_approval = 'N' + "; + $assigneeParams = array_merge($centerIDsArray, [$dccID]); + } + + // Fetch assignees + $assigneeRows = iterator_to_array( + $db->pselect($assigneeQuery, $assigneeParams) + ); + + foreach ($assigneeRows as $a_row) { + if (!empty($a_row['UserID'])) { + $assignees[$a_row['UserID']] = $this->formatUserInformation( + $a_row['UserID'] + ); + } + } + + // Fetch potential watchers + $currentUsername = $user->getUsername(); + + $potentialWatchers = iterator_to_array( + $db->pselect( + "SELECT Real_name, UserID + FROM users + WHERE Active = 'Y' AND Pending_approval = 'N'", + [] + ) + ); - $query = "SELECT i.*, c.PSCID, s.Visit_label AS visitLabel - FROM issues AS i - LEFT JOIN candidate c ON (i.candID = c.CandID) - LEFT JOIN session s ON (i.sessionID = s.ID)"; + foreach ($potentialWatchers as $w_row) { + if (!empty($w_row['UserID']) && $w_row['UserID'] != $currentUsername) { + $otherWatchers[$w_row['UserID']] = $this->formatUserInformation( + $w_row['UserID'] + ); + } + } + + // Fetch issues + $baseQuery = " + SELECT i.*, c.PSCID, s.Visit_label AS visitLabel + FROM issues AS i + LEFT JOIN candidate c ON i.candID = c.CandID + LEFT JOIN session s ON i.sessionID = s.ID + "; - // Add permission check if needed + $issueParams = []; if (!$user->hasPermission('access_all_profiles')) { - $query .= " WHERE i.centerID IN (" . - implode(',', $user->getCenterIDs()) . ")"; + $baseQuery .= " WHERE i.centerID IN ($centerPlaceholders)"; + $issueParams = $centerIDsArray; } - $query .= " ORDER BY i.issueID DESC"; + $baseQuery .= " ORDER BY i.issueID DESC"; - $issues = $db->pselect($query, []); + $issues = iterator_to_array($db->pselect($baseQuery, $issueParams)); - // Ensure $issues is an array - if (!is_array($issues)) { - $issues = iterator_to_array($issues); + if (empty($issues)) { + return [ + 'issues' => [], + 'assignees' => $assignees, + 'otherWatchers' => $otherWatchers, + ]; + } + + // Get all issue IDs + $issueIDs = array_column($issues, 'issueID'); + + // Fetch watchers for these issues + $watchingPlaceholders = implode(',', array_fill(0, count($issueIDs), '?')); + $watchersRows = iterator_to_array( + $db->pselect( + "SELECT issueID, userID + FROM issues_watching + WHERE issueID IN ($watchingPlaceholders)", + $issueIDs + ) + ); + + // Organize watchers by issue + $othersWatchingByIssue = []; + foreach ($watchersRows as $row) { + if (!empty($row['userID'])) { + $issueID = (int)$row['issueID']; + $othersWatchingByIssue[$issueID][] = $row['userID']; + } } // Format the issues data foreach ($issues as &$issue) { - $issue['reporter'] = $this->formatUserInformation( - $issue['reporter'] - ); - $issue['lastUpdatedBy'] = $this->formatUserInformation( - $issue['lastUpdatedBy'] - ); - $issue['topComments'] = $this->getTopComments( - (int)$issue['issueID'] + $issueID = (int)$issue['issueID']; + + // Set watching status + $isWatching = in_array( + $currentUsername, + $othersWatchingByIssue[$issueID] ?? [] ); + $issue['watching'] = $isWatching ? 'Yes' : 'No'; + + $assigneeUserID = $issue['assignee'] ?? null; + + // Exclude assignee from othersWatching + if (!empty($assigneeUserID) && isset($othersWatchingByIssue[$issueID])) { + $filteredWatchers = array_filter( + $othersWatchingByIssue[$issueID], + fn($watcherUserID) => $watcherUserID !== $assigneeUserID + ); + $othersWatchingByIssue[$issueID] = array_values($filteredWatchers); + } + + $issue['othersWatching'] = $othersWatchingByIssue[$issueID] ?? []; + + $issue['reporter'] = !empty($issue['reporter']) + ? $this->formatUserInformation($issue['reporter']) + : null; + $issue['lastUpdatedBy'] = !empty($issue['lastUpdatedBy']) + ? $this->formatUserInformation($issue['lastUpdatedBy']) + : null; + + $issue['topComments'] = $this->getTopComments($issueID); } - return $issues; + return [ + 'issues' => $issues, + 'assignees' => $assignees, + 'otherWatchers' => $otherWatchers, + ]; } /**