From a7da60f289a992c7a9428483abc22ee7ec2fddb4 Mon Sep 17 00:00:00 2001 From: Cameron Dawson Date: Sat, 20 Jun 2026 09:19:06 -0700 Subject: [PATCH] Convert SummaryPanel and ActionBar to functional components Convert the SummaryPanel and ActionBar class components to functional components with hooks: - SummaryPanel becomes a memoized function component. - ActionBar moves its lifecycle window-event listeners into a useEffect and reads decisionTaskMap directly from the pushes store, so the class wrapper is no longer needed. Its export becomes the function itself (the default export remains a memoized wrapper for compatibility). No behavior change. --- ui/job-view/details/summary/ActionBar.jsx | 707 +++++++++---------- ui/job-view/details/summary/SummaryPanel.jsx | 185 +++-- 2 files changed, 419 insertions(+), 473 deletions(-) diff --git a/ui/job-view/details/summary/ActionBar.jsx b/ui/job-view/details/summary/ActionBar.jsx index 04f7f916472..932ec83a0e2 100644 --- a/ui/job-view/details/summary/ActionBar.jsx +++ b/ui/job-view/details/summary/ActionBar.jsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import PropTypes from 'prop-types'; import { Button, Dropdown } from 'react-bootstrap'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; @@ -43,102 +43,28 @@ import { usePushesStore } from '../../../shared/stores/pushesStore'; import LogUrls from './LogUrls'; -class ActionBar extends React.PureComponent { - constructor(props) { - super(props); - - this.state = { - customJobActionsShowing: false, - }; - } - - componentDidMount() { - window.addEventListener(thEvents.openLogviewer, this.onOpenLogviewer); - window.addEventListener(thEvents.openRawLog, this.onOpenRawLog); - window.addEventListener(thEvents.openGeckoProfile, this.onOpenGeckoProfile); - window.addEventListener(thEvents.jobRetrigger, this.onRetriggerJob); - } - - componentWillUnmount() { - window.removeEventListener(thEvents.openLogviewer, this.onOpenLogviewer); - window.removeEventListener(thEvents.openRawLog, this.onOpenRawLog); - window.removeEventListener( - thEvents.openGeckoProfile, - this.onOpenGeckoProfile, - ); - window.removeEventListener(thEvents.jobRetrigger, this.onRetriggerJob); - } - - onRetriggerJob = (event) => { - this.retriggerJob([event.detail.job]); - }; - - // Open the logviewer and provide notifications if it isn't available - onOpenLogviewer = () => { - const { logParseStatus } = this.props; - - switch (logParseStatus) { - case 'pending': - notify('Log parsing in progress, log viewer not yet available'); - break; - case 'failed': - notify('Log parsing has failed, log viewer is unavailable', 'warning'); - break; - case 'skipped-size': - notify('Log parsing was skipped, log viewer is unavailable', 'warning'); - break; - case 'unavailable': - notify('No logs available for this job'); - break; - case 'parsed': - document.querySelector('.logviewer-btn').click(); - } - }; - - // Open the raw log and provide notifications if it isn't available - onOpenRawLog = () => { - const rawLogButton = document.querySelector('.rawlog-btn'); - - if (rawLogButton) { - rawLogButton.click(); - } else { - notify('No logs available for this job'); - } - }; - - // Open the gecko profile and provide notifications if it isn't available - onOpenGeckoProfile = () => { - const { selectedJobFull } = this.props; - const resourceUsageProfile = this.getResourceUsageProfile(); - - if (resourceUsageProfile) { - window.open( - getPerfAnalysisUrl(resourceUsageProfile.url, selectedJobFull), - '_blank', - ); - } else { - notify('No resource usage profile available for this job'); - } - }; - - getResourceUsageProfile = () => { - const { jobDetails } = this.props; - return jobDetails.find((artifact) => - isResourceUsageProfile(artifact.value), - ); - }; +export function ActionBar({ + user, + selectedJobFull, + logParseStatus, + jobLogUrls = [], + jobDetails = [], + currentRepo, + isTryRepo, + logViewerUrl = null, + logViewerFullUrl = null, + taskExpired = false, +}) { + const [customJobActionsShowing, setCustomJobActionsShowing] = useState(false); + const decisionTaskMap = usePushesStore((state) => state.decisionTaskMap); - canCancel = () => { - const { selectedJobFull } = this.props; - return ( - selectedJobFull.state === 'pending' || selectedJobFull.state === 'running' - ); - }; + const getResourceUsageProfile = () => + jobDetails.find((artifact) => isResourceUsageProfile(artifact.value)); - createGeckoProfile = async () => { - const { selectedJobFull, decisionTaskMap, currentRepo, taskExpired } = - this.props; + const canCancel = () => + selectedJobFull.state === 'pending' || selectedJobFull.state === 'running'; + const createGeckoProfile = async () => { if (taskExpired) { return undefined; } @@ -152,10 +78,7 @@ class ActionBar extends React.PureComponent { ); }; - createSideBySide = async () => { - const { selectedJobFull, decisionTaskMap, currentRepo, taskExpired } = - this.props; - + const createSideBySide = async () => { if (taskExpired) { return; } @@ -169,9 +92,7 @@ class ActionBar extends React.PureComponent { ); }; - retriggerJob = async (jobs) => { - const { decisionTaskMap, currentRepo, taskExpired } = this.props; - + const retriggerJob = async (jobs) => { // The retrigger keyboard shortcut and jobRetrigger event can reach this // even though the button is disabled, so guard here too. if (taskExpired) { @@ -193,16 +114,13 @@ class ActionBar extends React.PureComponent { JobModel.retrigger(jobs, currentRepo, notify, 1, decisionTaskMap); }; - backfillJob = async () => { - const { selectedJobFull, decisionTaskMap, currentRepo } = this.props; - - if (!this.canBackfill()) { + const backfillJob = async () => { + if (!canBackfill()) { return; } if (!selectedJobFull.id) { notify('Job not yet loaded for backfill', 'warning'); - return; } @@ -240,15 +158,7 @@ class ActionBar extends React.PureComponent { ); }; - handleConfirmFailure = async () => { - const { - selectedJobFull, - notify, - decisionTaskMap, - currentRepo, - taskExpired, - } = this.props; - + const handleConfirmFailure = async () => { if (taskExpired) { return; } @@ -258,14 +168,9 @@ class ActionBar extends React.PureComponent { // Can we backfill? Excludes 'try' repos and tasks whose Taskcluster // definition has expired (backfill needs a live task definition). - canBackfill = () => { - const { isTryRepo, taskExpired } = this.props; - - return !isTryRepo && !taskExpired; - }; + const canBackfill = () => !isTryRepo && !taskExpired; - backfillButtonTitle = () => { - const { isTryRepo, taskExpired } = this.props; + const backfillButtonTitle = () => { let title = ''; if (isTryRepo) { @@ -291,10 +196,7 @@ class ActionBar extends React.PureComponent { return title; }; - createInteractiveTask = async () => { - const { user, selectedJobFull, decisionTaskMap, currentRepo, taskExpired } = - this.props; - + const createInteractiveTask = async () => { if (taskExpired) { return; } @@ -337,9 +239,7 @@ class ActionBar extends React.PureComponent { } }; - cancelJobs = (jobs) => { - const { decisionTaskMap, currentRepo } = this.props; - + const cancelJobs = (jobs) => { JobModel.cancel( jobs.filter(({ state }) => state === 'pending' || state === 'running'), currentRepo, @@ -348,212 +248,283 @@ class ActionBar extends React.PureComponent { ); }; - cancelJob = () => { - this.cancelJobs([this.props.selectedJobFull]); + const cancelJob = () => { + cancelJobs([selectedJobFull]); }; - toggleCustomJobActions = () => { - if (this.props.taskExpired) { + const toggleCustomJobActions = () => { + if (taskExpired) { return; } - const { customJobActionsShowing } = this.state; - - this.setState({ customJobActionsShowing: !customJobActionsShowing }); + setCustomJobActionsShowing((showing) => !showing); }; - render() { - const { - selectedJobFull, - logViewerUrl = null, - logViewerFullUrl = null, - jobLogUrls = [], - currentRepo, - jobDetails, - taskExpired = false, - } = this.props; - const { customJobActionsShowing } = this.state; - const resourceUsageProfile = this.getResourceUsageProfile(); - const expiredTitleSuffix = taskExpired - ? ' (unavailable — Taskcluster task expired)' - : ''; - - // For running tasks, add the live.log from artifacts for raw log only - let rawLogUrls = jobLogUrls; - if ( - selectedJobFull.state === 'running' && - jobDetails && - !jobLogUrls.length - ) { - const liveLog = jobDetails.find((detail) => - detail.value.includes('live.log'), - ); - if (liveLog) { - rawLogUrls = [{ url: liveLog.url, name: 'live.log', id: 'live' }]; + // Re-register window listeners whenever values they read change so the + // captured closures stay current. + useEffect(() => { + // Open the logviewer and provide notifications if it isn't available + const onOpenLogviewer = () => { + switch (logParseStatus) { + case 'pending': + notify('Log parsing in progress, log viewer not yet available'); + break; + case 'failed': + notify( + 'Log parsing has failed, log viewer is unavailable', + 'warning', + ); + break; + case 'skipped-size': + notify( + 'Log parsing was skipped, log viewer is unavailable', + 'warning', + ); + break; + case 'unavailable': + notify('No logs available for this job'); + break; + case 'parsed': + document.querySelector('.logviewer-btn').click(); } + }; + + // Open the raw log and provide notifications if it isn't available + const onOpenRawLog = () => { + const rawLogButton = document.querySelector('.rawlog-btn'); + + if (rawLogButton) { + rawLogButton.click(); + } else { + notify('No logs available for this job'); + } + }; + + // Open the gecko profile and provide notifications if it isn't available + const onOpenGeckoProfile = () => { + const resourceUsageProfile = getResourceUsageProfile(); + if (resourceUsageProfile) { + window.open( + getPerfAnalysisUrl(resourceUsageProfile.url, selectedJobFull), + '_blank', + ); + } else { + notify('No resource usage profile available for this job'); + } + }; + + const onRetriggerJob = (event) => { + retriggerJob([event.detail.job]); + }; + + window.addEventListener(thEvents.openLogviewer, onOpenLogviewer); + window.addEventListener(thEvents.openRawLog, onOpenRawLog); + window.addEventListener(thEvents.openGeckoProfile, onOpenGeckoProfile); + window.addEventListener(thEvents.jobRetrigger, onRetriggerJob); + + return () => { + window.removeEventListener(thEvents.openLogviewer, onOpenLogviewer); + window.removeEventListener(thEvents.openRawLog, onOpenRawLog); + window.removeEventListener(thEvents.openGeckoProfile, onOpenGeckoProfile); + window.removeEventListener(thEvents.jobRetrigger, onRetriggerJob); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + logParseStatus, + // The handlers below read these transitively, and they can load async + // after a job is selected, so they must stay in the deps to avoid stale + // closures: onOpenGeckoProfile -> getResourceUsageProfile() reads + // jobDetails; onRetriggerJob -> retriggerJob() reads decisionTaskMap and + // currentRepo. Both fire from keyboard shortcuts (KeyboardShortcuts.jsx). + selectedJobFull, + jobDetails, + decisionTaskMap, + currentRepo, + ]); + + const resourceUsageProfile = getResourceUsageProfile(); + const expiredTitleSuffix = taskExpired + ? ' (unavailable — Taskcluster task expired)' + : ''; + + // For running tasks, fall back to the live.log artifact for raw log only. + let rawLogUrls = jobLogUrls; + if (selectedJobFull.state === 'running' && jobDetails && !jobLogUrls.length) { + const liveLog = jobDetails.find((detail) => + detail.value.includes('live.log'), + ); + if (liveLog) { + rawLogUrls = [{ url: liveLog.url, name: 'live.log', id: 'live' }]; } + } - return ( -
- + {customJobActionsShowing && ( + + )} +
+ ); } ActionBar.propTypes = { - decisionTaskMap: PropTypes.shape({}).isRequired, user: PropTypes.shape({}).isRequired, selectedJobFull: PropTypes.shape({}).isRequired, logParseStatus: PropTypes.string.isRequired, @@ -660,10 +615,4 @@ ActionBar.propTypes = { taskExpired: PropTypes.bool, }; -// Wrapper to inject Zustand state into class component -function ActionBarWrapper(props) { - const decisionTaskMap = usePushesStore((state) => state.decisionTaskMap); - return ; -} - -export default ActionBarWrapper; +export default React.memo(ActionBar); diff --git a/ui/job-view/details/summary/SummaryPanel.jsx b/ui/job-view/details/summary/SummaryPanel.jsx index 86a4bcf331d..935f389792b 100644 --- a/ui/job-view/details/summary/SummaryPanel.jsx +++ b/ui/job-view/details/summary/SummaryPanel.jsx @@ -7,105 +7,102 @@ import ActionBar from './ActionBar'; import ClassificationsPanel from './ClassificationsPanel'; import StatusPanel from './StatusPanel'; -class SummaryPanel extends React.PureComponent { - render() { - const { - selectedJobFull, - latestClassification = null, - bugs, - jobLogUrls = [], - jobDetails = [], - logViewerUrl = null, - logViewerFullUrl = null, - logParseStatus = 'pending', - user, - currentRepo, - classificationMap, - taskExpired = false, - } = this.props; +function SummaryPanel({ + selectedJobFull, + latestClassification = null, + bugs, + jobLogUrls = [], + jobDetails = [], + logViewerUrl = null, + logViewerFullUrl = null, + logParseStatus = 'pending', + user, + currentRepo, + classificationMap, + taskExpired = false, +}) { + const logs = jobLogUrls.filter( + (log) => !log.name.includes('perfherder-data'), + ); + const artifacts = jobLogUrls.filter((artifact) => + artifact.name.includes('perfherder-data'), + ); - const logs = jobLogUrls.filter( - (log) => !log.name.includes('perfherder-data'), - ); - const artifacts = jobLogUrls.filter((artifact) => - artifact.name.includes('perfherder-data'), - ); + let logParsingValue; + if (taskExpired) { + logParsingValue = 'Expired, not available'; + } else if (!logs.length) { + logParsingValue = 'No logs'; + } else { + logParsingValue = logs.map((log) => log.parse_status).join(', '); + } + const logStatus = [ + { + title: 'Log parsing status', + value: logParsingValue, + }, + ]; - let logParsingValue; - if (taskExpired) { - logParsingValue = 'Expired, not available'; - } else if (!logs.length) { - logParsingValue = 'No logs'; - } else { - logParsingValue = logs.map((log) => log.parse_status).join(', '); - } - const logStatus = [ - { - title: 'Log parsing status', - value: logParsingValue, - }, - ]; - const artifactStatus = [ - { - title: 'Artifact parsing status', - value: !artifacts.length ? 'No artifacts' : null, - subfields: artifacts.length - ? artifacts.map((artifact) => ({ - name: artifact.name, - value: artifact.parse_status, - })) - : null, - }, - ]; + const artifactStatus = [ + { + title: 'Artifact parsing status', + value: !artifacts.length ? 'No artifacts' : null, + subfields: artifacts.length + ? artifacts.map((artifact) => ({ + name: artifact.name, + value: artifact.parse_status, + })) + : null, + }, + ]; - return ( -
- {!!selectedJobFull && ( - <> - -
-
    - {latestClassification && ( - - )} - - + {!!selectedJobFull && ( + <> + +
    +
      + {latestClassification && ( + -
    -
    - - )} -
- ); - } + )} + + + +
+ + )} + + ); } SummaryPanel.propTypes = { @@ -129,4 +126,4 @@ SummaryPanel.propTypes = { taskExpired: PropTypes.bool, }; -export default SummaryPanel; +export default React.memo(SummaryPanel);