This is an automated email from the ASF dual-hosted git repository.

bbovenzi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 637a8b8af1 Highlight task states by hovering on legend row (#23678)
637a8b8af1 is described below

commit 637a8b8af132e6231756160a3f6ce7ed789abaf6
Author: pierrejeambrun <[email protected]>
AuthorDate: Mon May 23 15:41:46 2022 +0800

    Highlight task states by hovering on legend row (#23678)
    
    * Rework the legend row and add the hover effect.
    
    * Move horevedTaskState to state and fix merge conflicts.
    
    * Add tests.
    
    * Order of item in the LegendRow, add no_status support
---
 airflow/settings.py                                | 10 ++--
 airflow/www/static/js/grid/FilterBar.jsx           | 23 ++-------
 airflow/www/static/js/grid/Grid.jsx                |  4 +-
 airflow/www/static/js/grid/Grid.test.jsx           | 32 +++++++++++++
 airflow/www/static/js/grid/LegendRow.jsx           | 45 +++++++++++++----
 airflow/www/static/js/grid/LegendRow.test.jsx      | 56 ++++++++++++++++++++++
 airflow/www/static/js/grid/Main.jsx                |  7 +--
 .../www/static/js/grid/components/StatusBox.jsx    |  7 ++-
 airflow/www/static/js/grid/renderTaskRows.jsx      |  5 +-
 airflow/www/static/js/grid/utils/useFilters.js     | 17 ++-----
 .../www/static/js/grid/utils/useFilters.test.jsx   |  5 +-
 11 files changed, 151 insertions(+), 60 deletions(-)

diff --git a/airflow/settings.py b/airflow/settings.py
index 8b50bde0a1..374960ab3e 100644
--- a/airflow/settings.py
+++ b/airflow/settings.py
@@ -88,16 +88,16 @@ json = json
 # Dictionary containing State and colors associated to each state to
 # display on the Webserver
 STATE_COLORS = {
+    "deferred": "mediumpurple",
+    "failed": "red",
     "queued": "gray",
     "running": "lime",
+    "scheduled": "tan",
+    "skipped": "hotpink",
     "success": "green",
-    "failed": "red",
-    "up_for_retry": "gold",
     "up_for_reschedule": "turquoise",
+    "up_for_retry": "gold",
     "upstream_failed": "orange",
-    "skipped": "hotpink",
-    "scheduled": "tan",
-    "deferred": "mediumpurple",
 }
 
 
diff --git a/airflow/www/static/js/grid/FilterBar.jsx 
b/airflow/www/static/js/grid/FilterBar.jsx
index 26d99f1d2e..8776be4c7f 100644
--- a/airflow/www/static/js/grid/FilterBar.jsx
+++ b/airflow/www/static/js/grid/FilterBar.jsx
@@ -39,7 +39,6 @@ const FilterBar = () => {
     onNumRunsChange,
     onRunTypeChange,
     onRunStateChange,
-    onTaskStateChange,
     clearFilters,
   } = useFilters();
 
@@ -50,13 +49,13 @@ const FilterBar = () => {
   const inputStyles = { backgroundColor: 'white', size: 'lg' };
 
   return (
-    <Flex backgroundColor="#f0f0f0" mt={0} mb={2} p={4}>
+    <Flex backgroundColor="#f0f0f0" mt={4} p={4}>
       <Box px={2}>
         <Input
           {...inputStyles}
           type="datetime-local"
           value={formattedTime || ''}
-          onChange={onBaseDateChange}
+          onChange={(e) => onBaseDateChange(e.target.value)}
         />
       </Box>
       <Box px={2}>
@@ -64,7 +63,7 @@ const FilterBar = () => {
           {...inputStyles}
           placeholder="Runs"
           value={filters.numRuns || ''}
-          onChange={onNumRunsChange}
+          onChange={(e) => onNumRunsChange(e.target.value)}
         >
           {filtersOptions.numRuns.map((value) => (
             <option value={value} key={value}>{value}</option>
@@ -75,7 +74,7 @@ const FilterBar = () => {
         <Select
           {...inputStyles}
           value={filters.runType || ''}
-          onChange={onRunTypeChange}
+          onChange={(e) => onRunTypeChange(e.target.value)}
         >
           <option value="" key="all">All Run Types</option>
           {filtersOptions.runTypes.map((value) => (
@@ -88,7 +87,7 @@ const FilterBar = () => {
         <Select
           {...inputStyles}
           value={filters.runState || ''}
-          onChange={onRunStateChange}
+          onChange={(e) => onRunStateChange(e.target.value)}
         >
           <option value="" key="all">All Run States</option>
           {filtersOptions.dagStates.map((value) => (
@@ -96,18 +95,6 @@ const FilterBar = () => {
           ))}
         </Select>
       </Box>
-      <Box px={2}>
-        <Select
-          {...inputStyles}
-          value={filters.taskState || ''}
-          onChange={onTaskStateChange}
-        >
-          <option value="" key="all">All Task States</option>
-          {filtersOptions.taskStates.map((value) => (
-            <option value={value} key={value}>{value}</option>
-          ))}
-        </Select>
-      </Box>
       <Box px={2}>
         <Button
           colorScheme="cyan"
diff --git a/airflow/www/static/js/grid/Grid.jsx 
b/airflow/www/static/js/grid/Grid.jsx
index 19ba50bc60..2df059dd35 100644
--- a/airflow/www/static/js/grid/Grid.jsx
+++ b/airflow/www/static/js/grid/Grid.jsx
@@ -38,7 +38,7 @@ import AutoRefresh from './AutoRefresh';
 
 const dagId = getMetaValue('dag_id');
 
-const Grid = ({ isPanelOpen = false }) => {
+const Grid = ({ isPanelOpen = false, hoveredTaskState }) => {
   const scrollRef = useRef();
   const tableRef = useRef();
 
@@ -107,7 +107,7 @@ const Grid = ({ isPanelOpen = false }) => {
           pr="10px"
         >
           {renderTaskRows({
-            task: groups, dagRunIds, openGroupIds, onToggleGroups,
+            task: groups, dagRunIds, openGroupIds, onToggleGroups, 
hoveredTaskState,
           })}
         </Tbody>
       </Table>
diff --git a/airflow/www/static/js/grid/Grid.test.jsx 
b/airflow/www/static/js/grid/Grid.test.jsx
index 12a38d1223..9f16fe53d6 100644
--- a/airflow/www/static/js/grid/Grid.test.jsx
+++ b/airflow/www/static/js/grid/Grid.test.jsx
@@ -194,4 +194,36 @@ describe('Test ToggleGroups', () => {
     expect(queryAllByTestId('open-group')).toHaveLength(2);
     expect(queryAllByTestId('closed-group')).toHaveLength(0);
   });
+
+  test('Hovered effect on task state', async () => {
+    const { rerender, queryAllByTestId } = render(
+      <Grid />,
+      { wrapper: Wrapper },
+    );
+
+    const taskElements = queryAllByTestId('task-instance');
+    expect(taskElements).toHaveLength(3);
+
+    taskElements.forEach((taskElement) => {
+      expect(taskElement).toHaveStyle('opacity: 1');
+    });
+
+    rerender(
+      <Grid hoveredTaskState="success" />,
+      { wrapper: Wrapper },
+    );
+
+    taskElements.forEach((taskElement) => {
+      expect(taskElement).toHaveStyle('opacity: 1');
+    });
+
+    rerender(
+      <Grid hoveredTaskState="failed" />,
+      { wrapper: Wrapper },
+    );
+
+    taskElements.forEach((taskElement) => {
+      expect(taskElement).toHaveStyle('opacity: 0.3');
+    });
+  });
 });
diff --git a/airflow/www/static/js/grid/LegendRow.jsx 
b/airflow/www/static/js/grid/LegendRow.jsx
index b155c1ba22..3193435701 100644
--- a/airflow/www/static/js/grid/LegendRow.jsx
+++ b/airflow/www/static/js/grid/LegendRow.jsx
@@ -22,20 +22,47 @@
 import {
   Flex,
   Text,
+  HStack,
 } from '@chakra-ui/react';
 import React from 'react';
-import { SimpleStatus } from './components/StatusBox';
 
-const LegendRow = () => (
-  <Flex mt={0} mb={2} p={4} flexWrap="wrap">
-    {
+const StatusBadge = ({
+  state, stateColor, setHoveredTaskState, displayValue,
+}) => (
+  <Text
+    borderRadius={4}
+    border={`solid 2px ${stateColor}`}
+    px={1}
+    cursor="pointer"
+    fontSize="11px"
+    onMouseEnter={() => setHoveredTaskState(state)}
+    onMouseLeave={() => setHoveredTaskState()}
+  >
+    {displayValue || state }
+  </Text>
+);
+
+const LegendRow = ({ setHoveredTaskState }) => (
+  <Flex p={4} flexWrap="wrap" justifyContent="end">
+    <HStack spacing={2}>
+      {
       Object.entries(stateColors).map(([state, stateColor]) => (
-        <Flex alignItems="center" mr={3} key={stateColor}>
-          <SimpleStatus mr={1} state={state} />
-          <Text fontSize="md">{state}</Text>
-        </Flex>
+        <StatusBadge
+          key={state}
+          state={state}
+          stateColor={stateColor}
+          setHoveredTaskState={setHoveredTaskState}
+        />
       ))
-    }
+      }
+      <StatusBadge
+        key="no_status"
+        displayValue="no_status"
+        state={null}
+        stateColor="white"
+        setHoveredTaskState={setHoveredTaskState}
+      />
+    </HStack>
   </Flex>
 );
 
diff --git a/airflow/www/static/js/grid/LegendRow.test.jsx 
b/airflow/www/static/js/grid/LegendRow.test.jsx
new file mode 100644
index 0000000000..63ea2dfcb4
--- /dev/null
+++ b/airflow/www/static/js/grid/LegendRow.test.jsx
@@ -0,0 +1,56 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/* global describe, test, expect, stateColors, jest */
+
+import React from 'react';
+import { render, fireEvent } from '@testing-library/react';
+
+import LegendRow from './LegendRow';
+
+describe('Test LegendRow', () => {
+  test('Render displays correctly the different task states', () => {
+    const { getByText } = render(
+      <LegendRow />,
+    );
+
+    Object.keys(stateColors).forEach((taskState) => {
+      expect(getByText(taskState)).toBeInTheDocument();
+    });
+
+    expect(getByText('no_status')).toBeInTheDocument();
+  });
+
+  test.each([
+    { state: 'success', expectedSetValue: 'success' },
+    { state: 'failed', expectedSetValue: 'failed' },
+    { state: 'no_status', expectedSetValue: null },
+  ])('Hovering $state badge should trigger setHoverdTaskState function with 
$expectedSetValue',
+    async ({ state, expectedSetValue }) => {
+      const setHoveredTaskState = jest.fn();
+      const { getByText } = render(
+        <LegendRow setHoveredTaskState={setHoveredTaskState} />,
+      );
+      const successElement = getByText(state);
+      fireEvent.mouseEnter(successElement);
+      expect(setHoveredTaskState).toHaveBeenCalledWith(expectedSetValue);
+      fireEvent.mouseLeave(successElement);
+      expect(setHoveredTaskState).toHaveBeenLastCalledWith();
+    });
+});
diff --git a/airflow/www/static/js/grid/Main.jsx 
b/airflow/www/static/js/grid/Main.jsx
index 7f128195b3..8668c1379d 100644
--- a/airflow/www/static/js/grid/Main.jsx
+++ b/airflow/www/static/js/grid/Main.jsx
@@ -19,7 +19,7 @@
 
 /* global localStorage */
 
-import React from 'react';
+import React, { useState } from 'react';
 import {
   Box,
   Flex,
@@ -40,6 +40,7 @@ const Main = () => {
   const isPanelOpen = localStorage.getItem(detailsPanelKey) !== 'true';
   const { isOpen, onToggle } = useDisclosure({ defaultIsOpen: isPanelOpen });
   const { clearSelection } = useSelection();
+  const [hoveredTaskState, setHoveredTaskState] = useState();
 
   const toggleDetailsPanel = () => {
     if (!isOpen) {
@@ -54,10 +55,10 @@ const Main = () => {
   return (
     <Box>
       <FilterBar />
-      <LegendRow />
+      <LegendRow setHoveredTaskState={setHoveredTaskState} />
       <Divider mb={5} borderBottomWidth={2} />
       <Flex flexDirection="row" justifyContent="space-between">
-        <Grid isPanelOpen={isOpen} />
+        <Grid isPanelOpen={isOpen} hoveredTaskState={hoveredTaskState} />
         <Box borderLeftWidth={isOpen ? 1 : 0} position="relative">
           <Button
             position="absolute"
diff --git a/airflow/www/static/js/grid/components/StatusBox.jsx 
b/airflow/www/static/js/grid/components/StatusBox.jsx
index 2868e8727c..316529930f 100644
--- a/airflow/www/static/js/grid/components/StatusBox.jsx
+++ b/airflow/www/static/js/grid/components/StatusBox.jsx
@@ -29,7 +29,6 @@ import {
 
 import InstanceTooltip from './InstanceTooltip';
 import { useContainerRef } from '../context/containerRef';
-import useFilters from '../utils/useFilters';
 
 export const boxSize = 10;
 export const boxSizePx = `${boxSize}px`;
@@ -46,13 +45,12 @@ export const SimpleStatus = ({ state, ...rest }) => (
 );
 
 const StatusBox = ({
-  group, instance, onSelect,
+  group, instance, onSelect, isActive,
 }) => {
   const containerRef = useContainerRef();
   const { runId, taskId } = instance;
   const { colors } = useTheme();
   const hoverBlue = `${colors.blue[100]}50`;
-  const { filters } = useFilters();
 
   // Fetch the corresponding column element and set its background color when 
hovering
   const onMouseEnter = () => {
@@ -89,7 +87,7 @@ const StatusBox = ({
           zIndex={1}
           onMouseEnter={onMouseEnter}
           onMouseLeave={onMouseLeave}
-          opacity={(filters.taskState && filters.taskState !== instance.state) 
? 0.30 : 1}
+          opacity={isActive ? 1 : 0.3}
         />
       </Box>
     </Tooltip>
@@ -104,6 +102,7 @@ const compareProps = (
 ) => (
   isEqual(prevProps.group, nextProps.group)
   && isEqual(prevProps.instance, nextProps.instance)
+  && isEqual(prevProps.isActive, nextProps.isActive)
 );
 
 export default React.memo(StatusBox, compareProps);
diff --git a/airflow/www/static/js/grid/renderTaskRows.jsx 
b/airflow/www/static/js/grid/renderTaskRows.jsx
index 4f3abb8594..94369b4181 100644
--- a/airflow/www/static/js/grid/renderTaskRows.jsx
+++ b/airflow/www/static/js/grid/renderTaskRows.jsx
@@ -48,7 +48,7 @@ const renderTaskRows = ({
 ));
 
 const TaskInstances = ({
-  task, dagRunIds, selectedRunId, onSelect,
+  task, dagRunIds, selectedRunId, onSelect, activeTaskState,
 }) => (
   <Flex justifyContent="flex-end">
     {dagRunIds.map((runId) => {
@@ -71,6 +71,7 @@ const TaskInstances = ({
                 instance={instance}
                 group={task}
                 onSelect={onSelect}
+                isActive={activeTaskState === undefined || activeTaskState === 
instance.state}
               />
             )
             : <Box width={boxSizePx} data-testid="blank-task" />}
@@ -88,6 +89,7 @@ const Row = (props) => {
     openParentCount = 0,
     openGroupIds = [],
     onToggleGroups = () => {},
+    hoveredTaskState,
   } = props;
   const { colors } = useTheme();
   const { selected, onSelect } = useSelection();
@@ -162,6 +164,7 @@ const Row = (props) => {
               task={task}
               selectedRunId={selected.runId}
               onSelect={onSelect}
+              activeTaskState={hoveredTaskState}
             />
           </Collapse>
         </Td>
diff --git a/airflow/www/static/js/grid/utils/useFilters.js 
b/airflow/www/static/js/grid/utils/useFilters.js
index f253d32ba5..aa207e2353 100644
--- a/airflow/www/static/js/grid/utils/useFilters.js
+++ b/airflow/www/static/js/grid/utils/useFilters.js
@@ -26,7 +26,6 @@ export const BASE_DATE_PARAM = 'base_date';
 export const NUM_RUNS_PARAM = 'num_runs';
 export const RUN_TYPE_PARAM = 'run_type';
 export const RUN_STATE_PARAM = 'run_state';
-export const TASK_STATE_PARAM = 'task_state';
 
 const date = new Date();
 date.setMilliseconds(0);
@@ -40,18 +39,12 @@ const useFilters = () => {
   const numRuns = searchParams.get(NUM_RUNS_PARAM) || 
defaultDagRunDisplayNumber;
   const runType = searchParams.get(RUN_TYPE_PARAM);
   const runState = searchParams.get(RUN_STATE_PARAM);
-  // taskState is only used to change the opacity of the tasks,
-  // it is not send to the api for filtering.
-  const taskState = searchParams.get(TASK_STATE_PARAM);
 
-  const makeOnChangeFn = (paramName, formatFn) => (e) => {
-    let { value } = e.target;
-    if (formatFn) {
-      value = formatFn(value);
-    }
+  const makeOnChangeFn = (paramName, formatFn) => (value) => {
+    const formattedValue = formatFn ? formatFn(value) : value;
     const params = new URLSearchParams(searchParams);
 
-    if (value) params.set(paramName, value);
+    if (formattedValue) params.set(paramName, formattedValue);
     else params.delete(paramName);
 
     setSearchParams(params);
@@ -62,14 +55,12 @@ const useFilters = () => {
   const onNumRunsChange = makeOnChangeFn(NUM_RUNS_PARAM);
   const onRunTypeChange = makeOnChangeFn(RUN_TYPE_PARAM);
   const onRunStateChange = makeOnChangeFn(RUN_STATE_PARAM);
-  const onTaskStateChange = makeOnChangeFn(TASK_STATE_PARAM);
 
   const clearFilters = () => {
     searchParams.delete(BASE_DATE_PARAM);
     searchParams.delete(NUM_RUNS_PARAM);
     searchParams.delete(RUN_TYPE_PARAM);
     searchParams.delete(RUN_STATE_PARAM);
-    searchParams.delete(TASK_STATE_PARAM);
     setSearchParams(searchParams);
   };
 
@@ -79,13 +70,11 @@ const useFilters = () => {
       numRuns,
       runType,
       runState,
-      taskState,
     },
     onBaseDateChange,
     onNumRunsChange,
     onRunTypeChange,
     onRunStateChange,
-    onTaskStateChange,
     clearFilters,
   };
 };
diff --git a/airflow/www/static/js/grid/utils/useFilters.test.jsx 
b/airflow/www/static/js/grid/utils/useFilters.test.jsx
index cbf5824519..f23846efd5 100644
--- a/airflow/www/static/js/grid/utils/useFilters.test.jsx
+++ b/airflow/www/static/js/grid/utils/useFilters.test.jsx
@@ -38,7 +38,6 @@ describe('Test useFilters hook', () => {
         numRuns,
         runType,
         runState,
-        taskState,
       },
     } = result.current;
 
@@ -46,7 +45,6 @@ describe('Test useFilters hook', () => {
     expect(numRuns).toBe(global.defaultDagRunDisplayNumber);
     expect(runType).toBeNull();
     expect(runState).toBeNull();
-    expect(taskState).toBeNull();
   });
 
   test.each([
@@ -54,12 +52,11 @@ describe('Test useFilters hook', () => {
     { fnName: 'onNumRunsChange', paramName: 'numRuns', paramValue: '10' },
     { fnName: 'onRunTypeChange', paramName: 'runType', paramValue: 'manual' },
     { fnName: 'onRunStateChange', paramName: 'runState', paramValue: 'success' 
},
-    { fnName: 'onTaskStateChange', paramName: 'taskState', paramValue: 
'deferred' },
   ])('Test $fnName functions', async ({ fnName, paramName, paramValue }) => {
     const { result } = renderHook(() => useFilters(), { wrapper: RouterWrapper 
});
 
     await act(async () => {
-      result.current[fnName]({ target: { value: paramValue } });
+      result.current[fnName](paramValue);
     });
 
     expect(result.current.filters[paramName]).toBe(paramValue);

Reply via email to