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 46c1c006f7 19943 Grid view status filters (#23392)
46c1c006f7 is described below

commit 46c1c006f747798f7c80f615d791553f25dd1770
Author: pierrejeambrun <[email protected]>
AuthorDate: Mon May 9 22:32:02 2022 +0200

    19943 Grid view status filters (#23392)
    
    * Move tree filtering inside react and add some filters
    
    * Move filters from context to utils
    
    * Fix tests for useTreeData
    
    * Fix last tests.
    
    * Add tests for useFilters
    
    * Refact to use existing SimpleStatus component
    
    * Additional fix after rebase.
    
    * Update following bbovenzi code review
    
    * Update following code review
    
    * Fix tests.
    
    * Fix page flickering issues from react-query
    
    * Fix side panel and small changes.
    
    * Use default_dag_run_display_number in the filter options
    
    * Handle timezone
    
    * Fix flaky test
    
    Co-authored-by: Brent Bovenzi <[email protected]>
---
 airflow/settings.py                                |   2 +-
 airflow/utils/state.py                             |   2 +-
 airflow/www/jest-setup.js                          |   6 +-
 airflow/www/static/js/datetime_utils.js            |   1 +
 airflow/www/static/js/grid/FilterBar.jsx           | 127 +++++++++++++++++++++
 airflow/www/static/js/grid/Grid.jsx                |  14 ++-
 .../js/grid/LegendRow.jsx}                         |  38 +++---
 airflow/www/static/js/grid/StatusBox.jsx           |   3 +
 airflow/www/static/js/grid/api/useGridData.js      |  41 ++++---
 .../www/static/js/grid/api/useGridData.test.jsx    |   4 +-
 airflow/www/static/js/grid/dagRuns/index.test.jsx  |  31 ++++-
 airflow/www/static/js/grid/details/Header.jsx      |  10 +-
 .../js/grid/details/content/taskInstance/index.jsx |   7 +-
 airflow/www/static/js/grid/index.jsx               |   1 -
 airflow/www/static/js/grid/renderTaskRows.jsx      |   2 +-
 airflow/www/static/js/grid/utils/testUtils.jsx     |   6 +
 airflow/www/static/js/grid/utils/useFilters.js     |  93 +++++++++++++++
 .../www/static/js/grid/utils/useFilters.test.jsx   |  80 +++++++++++++
 .../utils/{useSelection.jsx => useSelection.js}    |   0
 airflow/www/templates/airflow/grid.html            |  43 +------
 airflow/www/views.py                               |  36 +++++-
 tests/www/views/test_views_home.py                 |   2 +-
 22 files changed, 451 insertions(+), 98 deletions(-)

diff --git a/airflow/settings.py b/airflow/settings.py
index 86b22fab93..5693575ed9 100644
--- a/airflow/settings.py
+++ b/airflow/settings.py
@@ -95,7 +95,7 @@ STATE_COLORS = {
     "up_for_retry": "gold",
     "up_for_reschedule": "turquoise",
     "upstream_failed": "orange",
-    "skipped": "pink",
+    "skipped": "hotpink",
     "scheduled": "tan",
     "deferred": "mediumpurple",
 }
diff --git a/airflow/utils/state.py b/airflow/utils/state.py
index e9b3610c9d..8415dd1666 100644
--- a/airflow/utils/state.py
+++ b/airflow/utils/state.py
@@ -120,7 +120,7 @@ class State:
         TaskInstanceState.UP_FOR_RETRY: 'gold',
         TaskInstanceState.UP_FOR_RESCHEDULE: 'turquoise',
         TaskInstanceState.UPSTREAM_FAILED: 'orange',
-        TaskInstanceState.SKIPPED: 'pink',
+        TaskInstanceState.SKIPPED: 'hotpink',
         TaskInstanceState.REMOVED: 'lightgrey',
         TaskInstanceState.SCHEDULED: 'tan',
         TaskInstanceState.DEFERRED: 'mediumpurple',
diff --git a/airflow/www/jest-setup.js b/airflow/www/jest-setup.js
index b4605d3191..5ffa8890d5 100644
--- a/airflow/www/jest-setup.js
+++ b/airflow/www/jest-setup.js
@@ -21,16 +21,18 @@
 
 import '@testing-library/jest-dom';
 
-// Mock a global object we use across the app
+// Mock global objects we use across the app
 global.stateColors = {
   deferred: 'mediumpurple',
   failed: 'red',
   queued: 'gray',
   running: 'lime',
   scheduled: 'tan',
-  skipped: 'pink',
+  skipped: 'hotpink',
   success: 'green',
   up_for_reschedule: 'turquoise',
   up_for_retry: 'gold',
   upstream_failed: 'orange',
 };
+
+global.defaultDagRunDisplayNumber = 245;
diff --git a/airflow/www/static/js/datetime_utils.js 
b/airflow/www/static/js/datetime_utils.js
index 0601971114..3e1befa203 100644
--- a/airflow/www/static/js/datetime_utils.js
+++ b/airflow/www/static/js/datetime_utils.js
@@ -19,6 +19,7 @@
 
 /* global moment, $, document */
 export const defaultFormat = 'YYYY-MM-DD, HH:mm:ss';
+export const isoFormatWithoutTZ = 'YYYY-MM-DDTHH:mm:ss.SSS';
 export const defaultFormatWithTZ = 'YYYY-MM-DD, HH:mm:ss z';
 export const defaultTZFormat = 'z (Z)';
 export const dateTimeAttrFormat = 'YYYY-MM-DDThh:mm:ssTZD';
diff --git a/airflow/www/static/js/grid/FilterBar.jsx 
b/airflow/www/static/js/grid/FilterBar.jsx
new file mode 100644
index 0000000000..26d99f1d2e
--- /dev/null
+++ b/airflow/www/static/js/grid/FilterBar.jsx
@@ -0,0 +1,127 @@
+/*!
+ * 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 filtersOptions, moment */
+
+import {
+  Box,
+  Button,
+  Flex,
+  Input,
+  Select,
+} from '@chakra-ui/react';
+import React from 'react';
+import { useTimezone } from './context/timezone';
+import { isoFormatWithoutTZ } from '../datetime_utils';
+
+import useFilters from './utils/useFilters';
+
+const FilterBar = () => {
+  const {
+    filters,
+    onBaseDateChange,
+    onNumRunsChange,
+    onRunTypeChange,
+    onRunStateChange,
+    onTaskStateChange,
+    clearFilters,
+  } = useFilters();
+
+  const { timezone } = useTimezone();
+  const time = moment(filters.baseDate);
+  const formattedTime = time.tz(timezone).format(isoFormatWithoutTZ);
+
+  const inputStyles = { backgroundColor: 'white', size: 'lg' };
+
+  return (
+    <Flex backgroundColor="#f0f0f0" mt={0} mb={2} p={4}>
+      <Box px={2}>
+        <Input
+          {...inputStyles}
+          type="datetime-local"
+          value={formattedTime || ''}
+          onChange={onBaseDateChange}
+        />
+      </Box>
+      <Box px={2}>
+        <Select
+          {...inputStyles}
+          placeholder="Runs"
+          value={filters.numRuns || ''}
+          onChange={onNumRunsChange}
+        >
+          {filtersOptions.numRuns.map((value) => (
+            <option value={value} key={value}>{value}</option>
+          ))}
+        </Select>
+      </Box>
+      <Box px={2}>
+        <Select
+          {...inputStyles}
+          value={filters.runType || ''}
+          onChange={onRunTypeChange}
+        >
+          <option value="" key="all">All Run Types</option>
+          {filtersOptions.runTypes.map((value) => (
+            <option value={value} key={value}>{value}</option>
+          ))}
+        </Select>
+      </Box>
+      <Box />
+      <Box px={2}>
+        <Select
+          {...inputStyles}
+          value={filters.runState || ''}
+          onChange={onRunStateChange}
+        >
+          <option value="" key="all">All Run States</option>
+          {filtersOptions.dagStates.map((value) => (
+            <option value={value} key={value}>{value}</option>
+          ))}
+        </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"
+          aria-label="Reset filters"
+          background="white"
+          variant="outline"
+          onClick={clearFilters}
+          size="lg"
+        >
+          Clear Filters
+        </Button>
+      </Box>
+    </Flex>
+  );
+};
+
+export default FilterBar;
diff --git a/airflow/www/static/js/grid/Grid.jsx 
b/airflow/www/static/js/grid/Grid.jsx
index 72fc0f3e2b..79447ac683 100644
--- a/airflow/www/static/js/grid/Grid.jsx
+++ b/airflow/www/static/js/grid/Grid.jsx
@@ -32,6 +32,7 @@ import {
   Flex,
   useDisclosure,
   Button,
+  Divider,
 } from '@chakra-ui/react';
 
 import { useGridData } from './api';
@@ -42,17 +43,21 @@ import Details from './details';
 import useSelection from './utils/useSelection';
 import { useAutoRefresh } from './context/autorefresh';
 import ToggleGroups from './ToggleGroups';
+import FilterBar from './FilterBar';
+import LegendRow from './LegendRow';
 
 const sidePanelKey = 'hideSidePanel';
 
 const Grid = () => {
   const scrollRef = useRef();
   const tableRef = useRef();
+
   const { data: { groups, dagRuns } } = useGridData();
+  const dagRunIds = dagRuns.map((dr) => dr.runId);
+
   const { isRefreshOn, toggleRefresh, isPaused } = useAutoRefresh();
   const isPanelOpen = localStorage.getItem(sidePanelKey) !== 'true';
   const { isOpen, onToggle } = useDisclosure({ defaultIsOpen: isPanelOpen });
-  const dagRunIds = dagRuns.map((dr) => dr.runId);
 
   const { clearSelection } = useSelection();
   const toggleSidePanel = () => {
@@ -86,7 +91,10 @@ const Grid = () => {
   }, [tableRef, scrollOnResize]);
 
   return (
-    <Box>
+    <Box mt={3}>
+      <FilterBar />
+      <LegendRow />
+      <Divider mb={5} borderBottomWidth={2} />
       <Flex flexGrow={1} justifyContent="space-between" alignItems="center">
         <Flex alignItems="center">
           <FormControl display="flex" width="auto" mr={2}>
@@ -129,7 +137,7 @@ const Grid = () => {
             <Thead display="block" pr="10px" position="sticky" top={0} 
zIndex={2} bg="white">
               <DagRuns />
             </Thead>
-            {/* TODO: remove hardcoded values. 665px is roughly the total 
heade+footer height */}
+            {/* TODO: remove hardcoded values. 665px is roughly the total 
header+footer height */}
             <Tbody display="block" width="100%" maxHeight="calc(100vh - 
665px)" minHeight="500px" ref={tableRef} pr="10px">
               {renderTaskRows({
                 task: groups, dagRunIds,
diff --git a/airflow/www/jest-setup.js 
b/airflow/www/static/js/grid/LegendRow.jsx
similarity index 61%
copy from airflow/www/jest-setup.js
copy to airflow/www/static/js/grid/LegendRow.jsx
index b4605d3191..75fba14358 100644
--- a/airflow/www/jest-setup.js
+++ b/airflow/www/static/js/grid/LegendRow.jsx
@@ -1,5 +1,3 @@
-// We need this lint rule for now because these are only dev-dependencies
-/* eslint-disable import/no-extraneous-dependencies */
 /*!
  * Licensed to the Apache Software Foundation (ASF) under one
  * or more contributor license agreements.  See the NOTICE file
@@ -19,18 +17,26 @@
  * under the License.
  */
 
-import '@testing-library/jest-dom';
+/* global stateColors */
 
-// Mock a global object we use across the app
-global.stateColors = {
-  deferred: 'mediumpurple',
-  failed: 'red',
-  queued: 'gray',
-  running: 'lime',
-  scheduled: 'tan',
-  skipped: 'pink',
-  success: 'green',
-  up_for_reschedule: 'turquoise',
-  up_for_retry: 'gold',
-  upstream_failed: 'orange',
-};
+import {
+  Flex,
+  Text,
+} from '@chakra-ui/react';
+import React from 'react';
+import { SimpleStatus } from './StatusBox';
+
+const LegendRow = () => (
+  <Flex mt={0} mb={2} p={4} flexWrap="wrap">
+    {
+      Object.entries(stateColors).map(([state, stateColor]) => (
+        <Flex alignItems="center" mr={3} key={stateColor}>
+          <SimpleStatus mr={1} state={state} />
+          <Text fontSize="md">{state}</Text>
+        </Flex>
+      ))
+    }
+  </Flex>
+);
+
+export default LegendRow;
diff --git a/airflow/www/static/js/grid/StatusBox.jsx 
b/airflow/www/static/js/grid/StatusBox.jsx
index d3b5e9f1ab..14831562c0 100644
--- a/airflow/www/static/js/grid/StatusBox.jsx
+++ b/airflow/www/static/js/grid/StatusBox.jsx
@@ -29,6 +29,7 @@ import {
 
 import InstanceTooltip from './InstanceTooltip';
 import { useContainerRef } from './context/containerRef';
+import useFilters from './utils/useFilters';
 
 export const boxSize = 10;
 export const boxSizePx = `${boxSize}px`;
@@ -51,6 +52,7 @@ const StatusBox = ({
   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 = () => {
@@ -87,6 +89,7 @@ const StatusBox = ({
           zIndex={1}
           onMouseEnter={onMouseEnter}
           onMouseLeave={onMouseLeave}
+          opacity={(filters.taskState && filters.taskState !== instance.state) 
? 0.30 : 1}
         />
       </Box>
     </Tooltip>
diff --git a/airflow/www/static/js/grid/api/useGridData.js 
b/airflow/www/static/js/grid/api/useGridData.js
index 8c16947c8c..d31712989b 100644
--- a/airflow/www/static/js/grid/api/useGridData.js
+++ b/airflow/www/static/js/grid/api/useGridData.js
@@ -17,22 +17,25 @@
  * under the License.
  */
 
-/* global gridData, autoRefreshInterval */
+/* global autoRefreshInterval, gridData */
 
 import { useQuery } from 'react-query';
 import axios from 'axios';
 
 import { getMetaValue } from '../../utils';
 import { useAutoRefresh } from '../context/autorefresh';
-import { formatData, areActiveRuns } from '../utils/gridData';
+import { areActiveRuns, formatData } from '../utils/gridData';
 import useErrorToast from '../utils/useErrorToast';
+import useFilters, {
+  BASE_DATE_PARAM, NUM_RUNS_PARAM, RUN_STATE_PARAM, RUN_TYPE_PARAM, now,
+} from '../utils/useFilters';
+
+const DAG_ID_PARAM = 'dag_id';
 
 // dagId comes from dag.html
-const dagId = getMetaValue('dag_id');
+const dagId = getMetaValue(DAG_ID_PARAM);
 const gridDataUrl = getMetaValue('grid_data_url') || '';
-const numRuns = getMetaValue('num_runs');
 const urlRoot = getMetaValue('root');
-const baseDate = getMetaValue('base_date');
 
 const emptyData = {
   dagRuns: [],
@@ -43,15 +46,22 @@ const useGridData = () => {
   const initialData = formatData(gridData, emptyData);
   const { isRefreshOn, stopRefresh } = useAutoRefresh();
   const errorToast = useErrorToast();
-  return useQuery('gridData', async () => {
-    try {
-      const params = new URLSearchParams({
-        dag_id: dagId,
-      });
-      if (numRuns && numRuns !== 25) params.append('num_runs', numRuns);
-      if (urlRoot) params.append('root', urlRoot);
-      if (baseDate) params.append('base_date', baseDate);
+  const {
+    filters: {
+      baseDate, numRuns, runType, runState,
+    },
+  } = useFilters();
 
+  return useQuery(['gridData', baseDate, numRuns, runType, runState], async () 
=> {
+    try {
+      const params = {
+        root: urlRoot || undefined,
+        [DAG_ID_PARAM]: dagId,
+        [BASE_DATE_PARAM]: baseDate === now ? undefined : baseDate,
+        [NUM_RUNS_PARAM]: numRuns,
+        [RUN_TYPE_PARAM]: runType,
+        [RUN_STATE_PARAM]: runState,
+      };
       const newData = await axios.get(gridDataUrl, { params });
       // turn off auto refresh if there are no active runs
       if (!areActiveRuns(newData.dagRuns)) stopRefresh();
@@ -65,10 +75,11 @@ const useGridData = () => {
       throw (error);
     }
   }, {
-    // only refetch if the refresh switch is on
-    refetchInterval: isRefreshOn && autoRefreshInterval * 1000,
     initialData,
     placeholderData: emptyData,
+    // only refetch if the refresh switch is on
+    refetchInterval: isRefreshOn && autoRefreshInterval * 1000,
+    keepPreviousData: true,
   });
 };
 
diff --git a/airflow/www/static/js/grid/api/useGridData.test.jsx 
b/airflow/www/static/js/grid/api/useGridData.test.jsx
index 06a1cb9a08..19c2e9276e 100644
--- a/airflow/www/static/js/grid/api/useGridData.test.jsx
+++ b/airflow/www/static/js/grid/api/useGridData.test.jsx
@@ -17,12 +17,12 @@
  * under the License.
  */
 
+/* global describe, test, expect, beforeAll */
+
 import { renderHook } from '@testing-library/react-hooks';
 import useGridData from './useGridData';
 import { Wrapper } from '../utils/testUtils';
 
-/* global describe, test, expect, beforeAll */
-
 const pendingGridData = {
   groups: {},
   dag_runs: [
diff --git a/airflow/www/static/js/grid/dagRuns/index.test.jsx 
b/airflow/www/static/js/grid/dagRuns/index.test.jsx
index 7cc52355eb..a507df522c 100644
--- a/airflow/www/static/js/grid/dagRuns/index.test.jsx
+++ b/airflow/www/static/js/grid/dagRuns/index.test.jsx
@@ -17,7 +17,7 @@
  * under the License.
  */
 
-/* global describe, test, expect */
+/* global describe, test, expect, jest */
 
 import React from 'react';
 import { render } from '@testing-library/react';
@@ -25,6 +25,7 @@ import moment from 'moment-timezone';
 
 import DagRuns from './index';
 import { TableWrapper } from '../utils/testUtils';
+import * as useGridDataModule from '../api/useGridData';
 
 const dagRuns = [
   {
@@ -52,23 +53,29 @@ const dagRuns = [
 
 describe('Test DagRuns', () => {
   test('Durations and manual run arrow render correctly, but without any date 
ticks', () => {
-    global.gridData = JSON.stringify({
+    const data = {
       groups: {},
       dagRuns,
-    });
+    };
+
+    const spy = jest.spyOn(useGridDataModule, 'default').mockImplementation(() 
=> ({
+      data,
+    }));
     const { queryAllByTestId, getByText, queryByText } = render(
       <DagRuns />, { wrapper: TableWrapper },
     );
+
     expect(queryAllByTestId('run')).toHaveLength(2);
     expect(queryAllByTestId('manual-run')).toHaveLength(1);
-
     expect(getByText('00:02:53')).toBeInTheDocument();
     expect(getByText('00:01:26')).toBeInTheDocument();
     expect(queryByText(moment.utc(dagRuns[0].executionDate).format('MMM DD, 
HH:mm'))).toBeNull();
+
+    spy.mockRestore();
   });
 
   test('Top date ticks appear when there are 4 or more runs', () => {
-    global.gridData = JSON.stringify({
+    const data = {
       groups: {},
       dagRuns: [
         ...dagRuns,
@@ -93,11 +100,15 @@ describe('Test DagRuns', () => {
           endDate: '2021-11-09T00:22:18.607167+00:00',
         },
       ],
-    });
+    };
+    const spy = jest.spyOn(useGridDataModule, 'default').mockImplementation(() 
=> ({
+      data,
+    }));
     const { getByText } = render(
       <DagRuns />, { wrapper: TableWrapper },
     );
     expect(getByText(moment.utc(dagRuns[0].executionDate).format('MMM DD, 
HH:mm'))).toBeInTheDocument();
+    spy.mockRestore();
   });
 
   test('Handles empty data correctly', () => {
@@ -107,4 +118,12 @@ describe('Test DagRuns', () => {
     );
     expect(queryByTestId('run')).toBeNull();
   });
+
+  test('Handles no data correctly', () => {
+    global.gridData = {};
+    const { queryByTestId } = render(
+      <DagRuns />, { wrapper: TableWrapper },
+    );
+    expect(queryByTestId('run')).toBeNull();
+  });
 });
diff --git a/airflow/www/static/js/grid/details/Header.jsx 
b/airflow/www/static/js/grid/details/Header.jsx
index e2041b3665..d656ec26b8 100644
--- a/airflow/www/static/js/grid/details/Header.jsx
+++ b/airflow/www/static/js/grid/details/Header.jsx
@@ -17,7 +17,7 @@
  * under the License.
  */
 
-import React from 'react';
+import React, { useEffect } from 'react';
 import {
   Breadcrumb,
   BreadcrumbItem,
@@ -49,6 +49,14 @@ const Header = () => {
   const dagRun = dagRuns.find((r) => r.runId === runId);
   const task = tasks.find((t) => t.taskId === taskId);
 
+  // clearSelection if the current selected dagRun is
+  // filtered out.
+  useEffect(() => {
+    if (runId && !dagRun) {
+      clearSelection();
+    }
+  }, [clearSelection, dagRun, runId]);
+
   let runLabel;
   if (dagRun) {
     if (runId.includes('manual__') || runId.includes('scheduled__') || 
runId.includes('backfill__')) {
diff --git a/airflow/www/static/js/grid/details/content/taskInstance/index.jsx 
b/airflow/www/static/js/grid/details/content/taskInstance/index.jsx
index d30f65acc2..b6aad7d4d3 100644
--- a/airflow/www/static/js/grid/details/content/taskInstance/index.jsx
+++ b/airflow/www/static/js/grid/details/content/taskInstance/index.jsx
@@ -57,11 +57,14 @@ const getTask = ({ taskId, runId, task }) => {
 const TaskInstance = ({ taskId, runId }) => {
   const [selectedRows, setSelectedRows] = useState([]);
   const { data: { groups, dagRuns } } = useGridData();
+  const { data: { tasks } } = useTasks(dagId);
+
   const group = getTask({ taskId, runId, task: groups });
   const run = dagRuns.find((r) => r.runId === runId);
+
+  if (!group || !run) return null;
+
   const { executionDate } = run;
-  const { data: { tasks } } = useTasks(dagId);
-  if (!group) return null;
   const task = tasks.find((t) => t.taskId === taskId);
   const operator = task && task.classRef && task.classRef.className ? 
task.classRef.className : '';
 
diff --git a/airflow/www/static/js/grid/index.jsx 
b/airflow/www/static/js/grid/index.jsx
index 15eaa4a669..8c67ec00f8 100644
--- a/airflow/www/static/js/grid/index.jsx
+++ b/airflow/www/static/js/grid/index.jsx
@@ -48,7 +48,6 @@ const queryClient = new QueryClient({
       refetchOnWindowFocus: false,
       retry: 1,
       retryDelay: 500,
-      staleTime: 60 * 1000, // one minute
       refetchOnMount: true, // Refetches stale queries, not "always"
     },
     mutations: {
diff --git a/airflow/www/static/js/grid/renderTaskRows.jsx 
b/airflow/www/static/js/grid/renderTaskRows.jsx
index e16f05dd4a..85c0e55f87 100644
--- a/airflow/www/static/js/grid/renderTaskRows.jsx
+++ b/airflow/www/static/js/grid/renderTaskRows.jsx
@@ -45,7 +45,7 @@ const dagId = getMetaValue('dag_id');
 
 const renderTaskRows = ({
   task, level = 0, ...rest
-}) => task.children.map((t) => (
+}) => task.children && task.children.map((t) => (
   <Row
     {...rest}
     key={t.id}
diff --git a/airflow/www/static/js/grid/utils/testUtils.jsx 
b/airflow/www/static/js/grid/utils/testUtils.jsx
index 946fe0dd88..436288c8d5 100644
--- a/airflow/www/static/js/grid/utils/testUtils.jsx
+++ b/airflow/www/static/js/grid/utils/testUtils.jsx
@@ -65,3 +65,9 @@ export const TableWrapper = ({ children }) => (
     </Table>
   </Wrapper>
 );
+
+export const RouterWrapper = ({ children }) => (
+  <MemoryRouter>
+    {children}
+  </MemoryRouter>
+);
diff --git a/airflow/www/static/js/grid/utils/useFilters.js 
b/airflow/www/static/js/grid/utils/useFilters.js
new file mode 100644
index 0000000000..f253d32ba5
--- /dev/null
+++ b/airflow/www/static/js/grid/utils/useFilters.js
@@ -0,0 +1,93 @@
+/*!
+ * 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 defaultDagRunDisplayNumber, moment */
+
+import { useSearchParams } from 'react-router-dom';
+
+// Params names
+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);
+
+export const now = date.toISOString();
+
+const useFilters = () => {
+  const [searchParams, setSearchParams] = useSearchParams();
+
+  const baseDate = searchParams.get(BASE_DATE_PARAM) || now;
+  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 params = new URLSearchParams(searchParams);
+
+    if (value) params.set(paramName, value);
+    else params.delete(paramName);
+
+    setSearchParams(params);
+  };
+
+  const onBaseDateChange = makeOnChangeFn(BASE_DATE_PARAM,
+    (localDate) => moment(localDate).utc().format());
+  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);
+  };
+
+  return {
+    filters: {
+      baseDate,
+      numRuns,
+      runType,
+      runState,
+      taskState,
+    },
+    onBaseDateChange,
+    onNumRunsChange,
+    onRunTypeChange,
+    onRunStateChange,
+    onTaskStateChange,
+    clearFilters,
+  };
+};
+
+export default useFilters;
diff --git a/airflow/www/static/js/grid/utils/useFilters.test.jsx 
b/airflow/www/static/js/grid/utils/useFilters.test.jsx
new file mode 100644
index 0000000000..cbf5824519
--- /dev/null
+++ b/airflow/www/static/js/grid/utils/useFilters.test.jsx
@@ -0,0 +1,80 @@
+/*!
+ * 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, expect, jest, test, moment */
+import { act, renderHook } from '@testing-library/react-hooks';
+
+import { RouterWrapper } from './testUtils';
+
+const date = new Date();
+date.setMilliseconds(0);
+jest.useFakeTimers().setSystemTime(date);
+
+// eslint-disable-next-line import/first
+import useFilters from './useFilters';
+
+describe('Test useFilters hook', () => {
+  test('Initial values when url does not have query params', async () => {
+    const { result } = renderHook(() => useFilters(), { wrapper: RouterWrapper 
});
+    const {
+      filters: {
+        baseDate,
+        numRuns,
+        runType,
+        runState,
+        taskState,
+      },
+    } = result.current;
+
+    expect(baseDate).toBe(date.toISOString());
+    expect(numRuns).toBe(global.defaultDagRunDisplayNumber);
+    expect(runType).toBeNull();
+    expect(runState).toBeNull();
+    expect(taskState).toBeNull();
+  });
+
+  test.each([
+    { fnName: 'onBaseDateChange', paramName: 'baseDate', paramValue: 
moment.utc().format() },
+    { 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 } });
+    });
+
+    expect(result.current.filters[paramName]).toBe(paramValue);
+
+    // clearFilters
+    await act(async () => {
+      result.current.clearFilters();
+    });
+
+    if (paramName === 'baseDate') {
+      expect(result.current.filters[paramName]).toBe(date.toISOString());
+    } else if (paramName === 'numRuns') {
+      
expect(result.current.filters[paramName]).toBe(global.defaultDagRunDisplayNumber);
+    } else {
+      expect(result.current.filters[paramName]).toBeNull();
+    }
+  });
+});
diff --git a/airflow/www/static/js/grid/utils/useSelection.jsx 
b/airflow/www/static/js/grid/utils/useSelection.js
similarity index 100%
rename from airflow/www/static/js/grid/utils/useSelection.jsx
rename to airflow/www/static/js/grid/utils/useSelection.js
diff --git a/airflow/www/templates/airflow/grid.html 
b/airflow/www/templates/airflow/grid.html
index 06461d42b4..46d52f7d27 100644
--- a/airflow/www/templates/airflow/grid.html
+++ b/airflow/www/templates/airflow/grid.html
@@ -30,45 +30,6 @@
 
 {% block content %}
   {{ super() }}
-  <div class="row dag-view-tools">
-    <div class="col-md-12">
-      <form method="get" class="form-inline">
-        <input type="hidden" name="root" value="{{ root if root else '' }}">
-        <input type="hidden" value="{{ dag.dag_id }}" name="dag_id">
-        <input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
-        <div class="form-group">
-          <label class="sr-only" for="base_date">Base date</label>
-          <div class="input-group">
-            {{ form.base_date(class_="form-control", 
disabled=not(dag.has_dag_runs())) }}
-          </div>
-        </div>
-        <div class="form-group">
-          <label class="sr-only" for="num_runs">Number of runs</label>
-          <div class="input-group">
-            <div class="input-group-addon">Runs</div>
-            {{ form.num_runs(class_="form-control", 
disabled=not(dag.has_dag_runs())) }}
-          </div>
-        </div>
-        <button type="submit" class="btn"{{' disabled' if not 
dag.has_dag_runs() else ''}}>Update</button>
-        {% if not dag.has_dag_runs() %}<span class="text-warning" 
style="margin-left:16px;">No DAG runs yet.</span>{% endif %}
-      </form>
-    </div>
-  </div>
-  <div class="legend-row">
-    <div>
-      {% for state, state_color in state_color_mapping.items() %}
-        <span class="legend-item legend-item--no-border">
-          <span class="legend-item__swatch legend-item__swatch--no-border" 
style="background: {{ state_color }};"></span>
-          {{state}}
-        </span>
-      {% endfor %}
-      <span class="legend-item legend-item--no-border">
-        <span class="legend-item__swatch"></span>
-        no_status
-      </span>
-    </div>
-  </div>
-  <hr>
   <div id="root">
     <div id="react-container"></div>
     {{ loading_dots(id='react-loading') }}
@@ -76,11 +37,13 @@
 {% endblock %}
 
 {% block tail_js %}
-  {{ super() }}
+  {{ super()}}
   <script>
     const gridData = {{ data|tojson }};
     const stateColors = {{ state_color_mapping|tojson }};
     const autoRefreshInterval = {{ auto_refresh_interval }};
+    const defaultDagRunDisplayNumber = {{ default_dag_run_display_number }};
+    const filtersOptions = {{ filters_drop_down_values }};
   </script>
   <script src="{{ url_for_asset('grid.js') }}"></script>
 {% endblock %}
diff --git a/airflow/www/views.py b/airflow/www/views.py
index 991b47297c..5964015457 100644
--- a/airflow/www/views.py
+++ b/airflow/www/views.py
@@ -26,6 +26,7 @@ import socket
 import sys
 import traceback
 import warnings
+from bisect import insort_left
 from collections import defaultdict
 from datetime import timedelta
 from functools import wraps
@@ -2638,6 +2639,13 @@ class Airflow(AirflowBaseView):
         # avoid spaces to reduce payload size
         data = htmlsafe_json_dumps(data, separators=(',', ':'))
 
+        default_dag_run_display_number = conf.getint('webserver', 
'default_dag_run_display_number')
+
+        num_runs_options = [5, 25, 50, 100, 365]
+
+        if default_dag_run_display_number not in num_runs_options:
+            insort_left(num_runs_options, default_dag_run_display_number)
+
         return self.render_template(
             'airflow/grid.html',
             operators=sorted({op.task_type: op for op in dag.tasks}.values(), 
key=lambda x: x.task_type),
@@ -2651,6 +2659,16 @@ class Airflow(AirflowBaseView):
             external_log_name=external_log_name,
             dag_model=dag_model,
             auto_refresh_interval=conf.getint('webserver', 
'auto_refresh_interval'),
+            default_dag_run_display_number=default_dag_run_display_number,
+            task_instances=tis,
+            filters_drop_down_values=htmlsafe_json_dumps(
+                {
+                    "taskStates": [state.value for state in TaskInstanceState],
+                    "dagStates": [state.value for state in State.dag_states],
+                    "runTypes": [run_type.value for run_type in DagRunType],
+                    "numRuns": num_runs_options,
+                }
+            ),
         )
 
     @expose('/calendar')
@@ -3493,13 +3511,19 @@ class Airflow(AirflowBaseView):
             base_date = dag.get_latest_execution_date() or timezone.utcnow()
 
         with create_session() as session:
-            dag_runs = (
-                session.query(DagRun)
-                .filter(DagRun.dag_id == dag.dag_id, DagRun.execution_date <= 
base_date)
-                .order_by(DagRun.execution_date.desc())
-                .limit(num_runs)
-                .all()
+            query = session.query(DagRun).filter(
+                DagRun.dag_id == dag.dag_id, DagRun.execution_date <= base_date
             )
+
+            run_type = request.args.get("run_type")
+            if run_type:
+                query = query.filter(DagRun.run_type == run_type)
+
+            run_state = request.args.get("run_state")
+            if run_state:
+                query = query.filter(DagRun.state == run_state)
+
+            dag_runs = 
query.order_by(DagRun.execution_date.desc()).limit(num_runs).all()
             dag_runs.reverse()
             encoded_runs = [wwwutils.encode_dag_run(dr) for dr in dag_runs]
             dag_run_dates = {dr.execution_date: alchemy_to_dict(dr) for dr in 
dag_runs}
diff --git a/tests/www/views/test_views_home.py 
b/tests/www/views/test_views_home.py
index 1d4c126e5d..a6d8dcac0c 100644
--- a/tests/www/views/test_views_home.py
+++ b/tests/www/views/test_views_home.py
@@ -56,7 +56,7 @@ def test_home(capture_templates, admin_client):
             '"null": "lightblue", "queued": "gray", '
             '"removed": "lightgrey", "restarting": "violet", "running": 
"lime", '
             '"scheduled": "tan", "sensing": "mediumpurple", '
-            '"shutdown": "blue", "skipped": "pink", '
+            '"shutdown": "blue", "skipped": "hotpink", '
             '"success": "green", "up_for_reschedule": "turquoise", '
             '"up_for_retry": "gold", "upstream_failed": "orange"};'
         )

Reply via email to