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 83784d9e7b Expand/collapse all groups (#23487)
83784d9e7b is described below

commit 83784d9e7b79d2400307454ccafdacddaee16769
Author: Brent Bovenzi <[email protected]>
AuthorDate: Thu May 5 14:20:22 2022 -0400

    Expand/collapse all groups (#23487)
    
    * Add expand/collapse all groups button to Grid
    
    * add tests
    
    * add comments
    
    * Switch to 2 icon buttons
    
    Disable buttons if all groups are expanded or collapsed
    
    * Update localStorage key
---
 airflow/www/static/js/grid/Grid.jsx              |  36 +++--
 airflow/www/static/js/grid/TaskName.jsx          |   8 +-
 airflow/www/static/js/grid/ToggleGroups.jsx      |  92 ++++++++++++
 airflow/www/static/js/grid/ToggleGroups.test.jsx | 177 +++++++++++++++++++++++
 airflow/www/static/js/grid/renderTaskRows.jsx    |  56 ++++---
 5 files changed, 331 insertions(+), 38 deletions(-)

diff --git a/airflow/www/static/js/grid/Grid.jsx 
b/airflow/www/static/js/grid/Grid.jsx
index 6a252b30f5..72fc0f3e2b 100644
--- a/airflow/www/static/js/grid/Grid.jsx
+++ b/airflow/www/static/js/grid/Grid.jsx
@@ -41,6 +41,7 @@ import DagRuns from './dagRuns';
 import Details from './details';
 import useSelection from './utils/useSelection';
 import { useAutoRefresh } from './context/autorefresh';
+import ToggleGroups from './ToggleGroups';
 
 const sidePanelKey = 'hideSidePanel';
 
@@ -86,22 +87,25 @@ const Grid = () => {
 
   return (
     <Box>
-      <Flex flexGrow={1} justifyContent="flex-end" alignItems="center">
-        <ResetRoot />
-        <FormControl display="flex" width="auto" mr={2}>
-          {isRefreshOn && <Spinner color="blue.500" speed="1s" mr="4px" />}
-          <FormLabel htmlFor="auto-refresh" mb={0} fontWeight="normal">
-            Auto-refresh
-          </FormLabel>
-          <Switch
-            id="auto-refresh"
-            onChange={() => toggleRefresh(true)}
-            isDisabled={isPaused}
-            isChecked={isRefreshOn}
-            size="lg"
-            title={isPaused ? 'Autorefresh is disabled while the DAG is 
paused' : ''}
-          />
-        </FormControl>
+      <Flex flexGrow={1} justifyContent="space-between" alignItems="center">
+        <Flex alignItems="center">
+          <FormControl display="flex" width="auto" mr={2}>
+            {isRefreshOn && <Spinner color="blue.500" speed="1s" mr="4px" />}
+            <FormLabel htmlFor="auto-refresh" mb={0} fontWeight="normal">
+              Auto-refresh
+            </FormLabel>
+            <Switch
+              id="auto-refresh"
+              onChange={() => toggleRefresh(true)}
+              isDisabled={isPaused}
+              isChecked={isRefreshOn}
+              size="lg"
+              title={isPaused ? 'Autorefresh is disabled while the DAG is 
paused' : ''}
+            />
+          </FormControl>
+          <ToggleGroups groups={groups} />
+          <ResetRoot />
+        </Flex>
         <Button
           onClick={toggleSidePanel}
           aria-label={isOpen ? 'Show Details' : 'Hide Details'}
diff --git a/airflow/www/static/js/grid/TaskName.jsx 
b/airflow/www/static/js/grid/TaskName.jsx
index 34736842d8..24c80b863e 100644
--- a/airflow/www/static/js/grid/TaskName.jsx
+++ b/airflow/www/static/js/grid/TaskName.jsx
@@ -25,13 +25,13 @@ import {
 import { FiChevronUp, FiChevronDown } from 'react-icons/fi';
 
 const TaskName = ({
-  isGroup = false, isMapped = false, onToggle, isOpen, level, taskName,
+  isGroup = false, isMapped = false, onToggle, isOpen, level, label,
 }) => (
   <Flex
     as={isGroup ? 'button' : 'div'}
     onClick={onToggle}
-    aria-label={taskName}
-    title={taskName}
+    aria-label={label}
+    title={label}
     mr={4}
     width="100%"
     alignItems="center"
@@ -42,7 +42,7 @@ const TaskName = ({
       ml={level * 4 + 4}
       isTruncated
     >
-      {taskName}
+      {label}
       {isMapped && (
         ' [ ]'
       )}
diff --git a/airflow/www/static/js/grid/ToggleGroups.jsx 
b/airflow/www/static/js/grid/ToggleGroups.jsx
new file mode 100644
index 0000000000..193c3b81f0
--- /dev/null
+++ b/airflow/www/static/js/grid/ToggleGroups.jsx
@@ -0,0 +1,92 @@
+/*!
+ * 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 localStorage, CustomEvent, document */
+
+import React, { useState } from 'react';
+import { Flex, IconButton } from '@chakra-ui/react';
+import { MdExpand, MdCompress } from 'react-icons/md';
+
+import { getMetaValue } from '../utils';
+
+const dagId = getMetaValue('dag_id');
+
+const getGroupIds = (groups) => {
+  const groupIds = [];
+  const checkTasks = (tasks) => tasks.forEach((task) => {
+    if (task.children) {
+      groupIds.push(task.label);
+      checkTasks(task.children);
+    }
+  });
+  checkTasks(groups);
+  return groupIds;
+};
+
+const ToggleGroups = ({ groups }) => {
+  const openGroupsKey = `${dagId}/open-groups`;
+  const allGroupIds = getGroupIds(groups.children);
+  const storedGroups = JSON.parse(localStorage.getItem(openGroupsKey)) || [];
+  const [openGroupIds, setOpenGroupIds] = useState(storedGroups);
+
+  const isExpandDisabled = allGroupIds.length === openGroupIds.length;
+  const isCollapseDisabled = !openGroupIds.length;
+
+  // Don't show button if the DAG has no task groups
+  const hasGroups = groups.children.find((c) => !!c.children);
+  if (!hasGroups) return null;
+
+  const onExpand = () => {
+    const closeEvent = new CustomEvent('toggleGroups', { detail: { dagId, 
openGroups: true } });
+    document.dispatchEvent(closeEvent);
+    localStorage.setItem(openGroupsKey, JSON.stringify(allGroupIds));
+    setOpenGroupIds(allGroupIds);
+  };
+
+  const onCollapse = () => {
+    const closeEvent = new CustomEvent('toggleGroups', { detail: { dagId, 
closeGroups: true } });
+    document.dispatchEvent(closeEvent);
+    localStorage.removeItem(openGroupsKey);
+    setOpenGroupIds([]);
+  };
+
+  return (
+    <Flex>
+      <IconButton
+        fontSize="2xl"
+        onClick={onExpand}
+        title="Expand all task groups"
+        aria-label="Expand all task groups"
+        icon={<MdExpand />}
+        isDisabled={isExpandDisabled}
+        mr={2}
+      />
+      <IconButton
+        fontSize="2xl"
+        onClick={onCollapse}
+        title="Collapse all task groups"
+        aria-label="Collapse all task groups"
+        isDisabled={isCollapseDisabled}
+        icon={<MdCompress />}
+      />
+    </Flex>
+  );
+};
+
+export default ToggleGroups;
diff --git a/airflow/www/static/js/grid/ToggleGroups.test.jsx 
b/airflow/www/static/js/grid/ToggleGroups.test.jsx
new file mode 100644
index 0000000000..ba91f0df35
--- /dev/null
+++ b/airflow/www/static/js/grid/ToggleGroups.test.jsx
@@ -0,0 +1,177 @@
+/*!
+ * 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 */
+
+import React from 'react';
+import { Flex, Table, Tbody } from '@chakra-ui/react';
+import { render, fireEvent, waitFor } from '@testing-library/react';
+
+import renderTaskRows from './renderTaskRows';
+import ToggleGroups from './ToggleGroups';
+import { Wrapper } from './utils/testUtils';
+
+const mockGridData = {
+  groups: {
+    id: null,
+    label: null,
+    children: [
+      {
+        extraLinks: [],
+        id: 'group_1',
+        label: 'group_1',
+        instances: [
+          {
+            dagId: 'dagId',
+            duration: 0,
+            endDate: '2021-10-26T15:42:03.391939+00:00',
+            executionDate: '2021-10-25T15:41:09.726436+00:00',
+            operator: 'DummyOperator',
+            runId: 'run1',
+            startDate: '2021-10-26T15:42:03.391917+00:00',
+            state: 'success',
+            taskId: 'group_1',
+            tryNumber: 1,
+          },
+        ],
+        children: [
+          {
+            id: 'group_1.task_1',
+            label: 'task_1',
+            extraLinks: [],
+            instances: [
+              {
+                dagId: 'dagId',
+                duration: 0,
+                endDate: '2021-10-26T15:42:03.391939+00:00',
+                executionDate: '2021-10-25T15:41:09.726436+00:00',
+                operator: 'DummyOperator',
+                runId: 'run1',
+                startDate: '2021-10-26T15:42:03.391917+00:00',
+                state: 'success',
+                taskId: 'group_1.task_1',
+                tryNumber: 1,
+              },
+            ],
+            children: [
+              {
+                id: 'group_1.task_1.sub_task_1',
+                label: 'sub_task_1',
+                extraLinks: [],
+                instances: [
+                  {
+                    dagId: 'dagId',
+                    duration: 0,
+                    endDate: '2021-10-26T15:42:03.391939+00:00',
+                    executionDate: '2021-10-25T15:41:09.726436+00:00',
+                    operator: 'DummyOperator',
+                    runId: 'run1',
+                    startDate: '2021-10-26T15:42:03.391917+00:00',
+                    state: 'success',
+                    taskId: 'group_1.task_1.sub_task_1',
+                    tryNumber: 1,
+                  },
+                ],
+              },
+            ],
+          },
+        ],
+      },
+    ],
+    instances: [],
+  },
+  dagRuns: [
+    {
+      dagId: 'dagId',
+      runId: 'run1',
+      dataIntervalStart: new Date(),
+      dataIntervalEnd: new Date(),
+      startDate: '2021-11-08T21:14:19.704433+00:00',
+      endDate: '2021-11-08T21:17:13.206426+00:00',
+      state: 'failed',
+      runType: 'scheduled',
+      executionDate: '2021-11-08T21:14:19.704433+00:00',
+    },
+  ],
+};
+
+const EXPAND = 'Expand all task groups';
+const COLLAPSE = 'Collapse all task groups';
+
+describe('Test ToggleGroups', () => {
+  test('Buttons are disabled if all groups are expanded or collapsed', () => {
+    const { getByTitle } = render(
+      <ToggleGroups groups={mockGridData.groups} />,
+      { wrapper: Wrapper },
+    );
+
+    const expandButton = getByTitle(EXPAND);
+    const collapseButton = getByTitle(COLLAPSE);
+
+    expect(expandButton).toBeEnabled();
+    expect(collapseButton).toBeDisabled();
+
+    fireEvent.click(expandButton);
+
+    expect(collapseButton).toBeEnabled();
+    expect(expandButton).toBeDisabled();
+  });
+
+  test('Expand/collapse buttons toggle nested groups', async () => {
+    global.gridData = mockGridData;
+    const dagRunIds = mockGridData.dagRuns.map((dr) => dr.runId);
+    const task = mockGridData.groups;
+
+    const { getByText, queryAllByTestId, getByTitle } = render(
+      <Flex>
+        <ToggleGroups groups={task} />
+        <Table>
+          <Tbody>
+            {renderTaskRows({ task, dagRunIds })}
+          </Tbody>
+        </Table>
+      </Flex>,
+      { wrapper: Wrapper },
+    );
+
+    const expandButton = getByTitle(EXPAND);
+    const collapseButton = getByTitle(COLLAPSE);
+
+    const groupName = getByText('group_1');
+
+    expect(queryAllByTestId('task-instance')).toHaveLength(3);
+    expect(groupName).toBeInTheDocument();
+
+    expect(queryAllByTestId('open-group')).toHaveLength(2);
+    expect(queryAllByTestId('closed-group')).toHaveLength(0);
+
+    fireEvent.click(collapseButton);
+
+    await waitFor(() => 
expect(queryAllByTestId('task-instance')).toHaveLength(1));
+    expect(queryAllByTestId('open-group')).toHaveLength(0);
+    // Since the groups are nested, only the parent row is rendered
+    expect(queryAllByTestId('closed-group')).toHaveLength(1);
+
+    fireEvent.click(expandButton);
+
+    await waitFor(() => 
expect(queryAllByTestId('task-instance')).toHaveLength(3));
+    expect(queryAllByTestId('open-group')).toHaveLength(2);
+    expect(queryAllByTestId('closed-group')).toHaveLength(0);
+  });
+});
diff --git a/airflow/www/static/js/grid/renderTaskRows.jsx 
b/airflow/www/static/js/grid/renderTaskRows.jsx
index 9c4f6d7690..e16f05dd4a 100644
--- a/airflow/www/static/js/grid/renderTaskRows.jsx
+++ b/airflow/www/static/js/grid/renderTaskRows.jsx
@@ -17,9 +17,9 @@
  * under the License.
  */
 
-/* global localStorage */
+/* global localStorage, document */
 
-import React, { useCallback } from 'react';
+import React, { useCallback, useEffect } from 'react';
 import {
   Tr,
   Td,
@@ -51,7 +51,6 @@ const renderTaskRows = ({
     key={t.id}
     task={t}
     level={level}
-    prevTaskId={task.id}
   />
 ));
 
@@ -88,11 +87,12 @@ const TaskInstances = ({
   </Flex>
 );
 
+const openGroupsKey = `${dagId}/open-groups`;
+
 const Row = (props) => {
   const {
     task,
     level,
-    prevTaskId,
     isParentOpen = true,
     dagRunIds,
   } = props;
@@ -103,23 +103,43 @@ const Row = (props) => {
   const isGroup = !!task.children;
   const isSelected = selected.taskId === task.id;
 
-  const taskName = prevTaskId ? task.id.replace(`${prevTaskId}.`, '') : 
task.id;
+  const openGroups = JSON.parse(localStorage.getItem(openGroupsKey)) || [];
+  const defaultIsOpen = openGroups.some((g) => g === task.label);
 
-  const storageKey = `${dagId}-open-groups`;
-  const openGroups = JSON.parse(localStorage.getItem(storageKey)) || [];
-  const isGroupId = openGroups.some((g) => g === taskName);
-  const onOpen = () => {
-    localStorage.setItem(storageKey, JSON.stringify([...openGroups, 
taskName]));
-  };
-  const onClose = () => {
-    localStorage.setItem(storageKey, JSON.stringify(openGroups.filter((g) => g 
!== taskName)));
-  };
-  const { isOpen, onToggle } = useDisclosure({ defaultIsOpen: isGroupId, 
onClose, onOpen });
+  const {
+    isOpen, onToggle, onOpen, onClose,
+  } = useDisclosure({ defaultIsOpen });
+
+  // Listen to changes from ToggleGroups
+  useEffect(() => {
+    const handleChange = ({ detail }) => {
+      if (detail.dagId === dagId) {
+        if (detail.closeGroups) onClose();
+        else if (detail.openGroups) onOpen();
+      }
+    };
+    if (isGroup) document.addEventListener('toggleGroups', handleChange);
+    return () => {
+      if (isGroup) document.removeEventListener('toggleGroups', handleChange);
+    };
+  });
 
   // assure the function is the same across renders
   const memoizedToggle = useCallback(
-    () => isGroup && onToggle(),
-    [onToggle, isGroup],
+    () => {
+      if (isGroup) {
+        if (!isOpen) {
+          localStorage.setItem(openGroupsKey, JSON.stringify([...openGroups, 
task.label]));
+        } else {
+          localStorage.setItem(
+            openGroupsKey,
+            JSON.stringify(openGroups.filter((g) => g !== task.label)),
+          );
+        }
+        onToggle();
+      }
+    },
+    [onToggle, isGroup, isOpen, openGroups, task.label],
   );
 
   const parentTasks = task.id.split('.');
@@ -154,7 +174,7 @@ const Row = (props) => {
               onToggle={memoizedToggle}
               isGroup={isGroup}
               isMapped={task.isMapped}
-              taskName={taskName}
+              label={task.label}
               isOpen={isOpen}
               level={level}
             />

Reply via email to