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 694e380e52 Add UI tests for /utils and /components (#23456)
694e380e52 is described below

commit 694e380e52a1b2c9ad74148e1dda2914a0496b70
Author: Brent Bovenzi <[email protected]>
AuthorDate: Fri May 13 10:58:28 2022 -0400

    Add UI tests for /utils and /components (#23456)
    
    * Add UI tests for /utils and /components
    
    * add test for Table
    
    * Address PR feedback
    
    * Fix window prompt var
    
    * Fix TaskName test from rebase
    
    * fix lint errors
---
 airflow/www/static/js/grid/LegendRow.jsx           |   2 +-
 .../static/js/grid/{ => components}/Clipboard.jsx  |   2 +-
 .../Clipboard.test.jsx}                            |  35 ++--
 .../js/grid/{ => components}/InstanceTooltip.jsx   |   4 +-
 .../js/grid/components/InstanceTooltip.test.jsx    |  86 +++++++++
 .../static/js/grid/{ => components}/StatusBox.jsx  |   4 +-
 .../www/static/js/grid/{ => components}/Table.jsx  |   6 +-
 .../www/static/js/grid/components/Table.test.jsx   | 211 +++++++++++++++++++++
 .../static/js/grid/{ => components}/TaskName.jsx   |   2 +-
 .../static/js/grid/components/TaskName.test.jsx    |  53 ++++++
 .../www/static/js/grid/{ => components}/Time.jsx   |   5 +-
 .../www/static/js/grid/components/Time.test.jsx    |  83 ++++++++
 airflow/www/static/js/grid/context/timezone.jsx    |   5 +-
 airflow/www/static/js/grid/dagRuns/Bar.jsx         |   2 +-
 airflow/www/static/js/grid/dagRuns/Tooltip.jsx     |   2 +-
 airflow/www/static/js/grid/details/Header.jsx      |   2 +-
 airflow/www/static/js/grid/details/content/Dag.jsx |   4 +-
 .../js/grid/details/content/dagRun/index.jsx       |   6 +-
 .../grid/details/content/taskInstance/Details.jsx  |   6 +-
 .../content/taskInstance/MappedInstances.jsx       |   6 +-
 airflow/www/static/js/grid/renderTaskRows.jsx      |   4 +-
 .../grid/{LegendRow.jsx => utils/gridData.test.js} |  45 +++--
 airflow/www/static/js/grid/utils/testUtils.jsx     |   6 +
 airflow/www/static/js/grid/utils/useErrorToast.js  |   4 +-
 .../static/js/grid/utils/useErrorToast.test.jsx    |  52 +++++
 airflow/www/static/js/grid/utils/useSelection.js   |  16 +-
 .../www/static/js/grid/utils/useSelection.test.jsx |  70 +++++++
 27 files changed, 649 insertions(+), 74 deletions(-)

diff --git a/airflow/www/static/js/grid/LegendRow.jsx 
b/airflow/www/static/js/grid/LegendRow.jsx
index 75fba14358..b155c1ba22 100644
--- a/airflow/www/static/js/grid/LegendRow.jsx
+++ b/airflow/www/static/js/grid/LegendRow.jsx
@@ -24,7 +24,7 @@ import {
   Text,
 } from '@chakra-ui/react';
 import React from 'react';
-import { SimpleStatus } from './StatusBox';
+import { SimpleStatus } from './components/StatusBox';
 
 const LegendRow = () => (
   <Flex mt={0} mb={2} p={4} flexWrap="wrap">
diff --git a/airflow/www/static/js/grid/Clipboard.jsx 
b/airflow/www/static/js/grid/components/Clipboard.jsx
similarity index 97%
rename from airflow/www/static/js/grid/Clipboard.jsx
rename to airflow/www/static/js/grid/components/Clipboard.jsx
index 5fa645e61a..794e363fa0 100644
--- a/airflow/www/static/js/grid/Clipboard.jsx
+++ b/airflow/www/static/js/grid/components/Clipboard.jsx
@@ -27,7 +27,7 @@ import {
 } from '@chakra-ui/react';
 import { FiCopy } from 'react-icons/fi';
 
-import { useContainerRef } from './context/containerRef';
+import { useContainerRef } from '../context/containerRef';
 
 export const ClipboardButton = forwardRef(
   (
diff --git a/airflow/www/static/js/grid/LegendRow.jsx 
b/airflow/www/static/js/grid/components/Clipboard.test.jsx
similarity index 57%
copy from airflow/www/static/js/grid/LegendRow.jsx
copy to airflow/www/static/js/grid/components/Clipboard.test.jsx
index 75fba14358..a27cdf16ce 100644
--- a/airflow/www/static/js/grid/LegendRow.jsx
+++ b/airflow/www/static/js/grid/components/Clipboard.test.jsx
@@ -17,26 +17,23 @@
  * under the License.
  */
 
-/* global stateColors */
+/* global describe, test, expect, jest, window */
 
-import {
-  Flex,
-  Text,
-} from '@chakra-ui/react';
 import React from 'react';
-import { SimpleStatus } from './StatusBox';
+import '@testing-library/jest-dom';
+import { render, fireEvent } from '@testing-library/react';
 
-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>
-);
+import { ClipboardButton } from './Clipboard';
 
-export default LegendRow;
+describe('ClipboardButton', () => {
+  test('Loads button', async () => {
+    const windowPrompt = window.prompt;
+    window.prompt = jest.fn();
+    const { getByText } = render(<ClipboardButton value="lorem ipsum" />);
+
+    const button = getByText(/copy/i);
+    fireEvent.click(button);
+    expect(window.prompt).toHaveBeenCalledWith('Copy to clipboard: Ctrl+C, 
Enter', 'lorem ipsum');
+    window.prompt = windowPrompt;
+  });
+});
diff --git a/airflow/www/static/js/grid/InstanceTooltip.jsx 
b/airflow/www/static/js/grid/components/InstanceTooltip.jsx
similarity index 95%
rename from airflow/www/static/js/grid/InstanceTooltip.jsx
rename to airflow/www/static/js/grid/components/InstanceTooltip.jsx
index 49d24124db..ebcecc5341 100644
--- a/airflow/www/static/js/grid/InstanceTooltip.jsx
+++ b/airflow/www/static/js/grid/components/InstanceTooltip.jsx
@@ -20,8 +20,8 @@
 import React from 'react';
 import { Box, Text } from '@chakra-ui/react';
 
-import { finalStatesMap } from '../utils';
-import { formatDuration, getDuration } from '../datetime_utils';
+import { finalStatesMap } from '../../utils';
+import { formatDuration, getDuration } from '../../datetime_utils';
 import Time from './Time';
 
 const InstanceTooltip = ({
diff --git a/airflow/www/static/js/grid/components/InstanceTooltip.test.jsx 
b/airflow/www/static/js/grid/components/InstanceTooltip.test.jsx
new file mode 100644
index 0000000000..fc6ab848c9
--- /dev/null
+++ b/airflow/www/static/js/grid/components/InstanceTooltip.test.jsx
@@ -0,0 +1,86 @@
+/*!
+ * 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 { render } from '@testing-library/react';
+
+import InstanceTooltip from './InstanceTooltip';
+import { Wrapper } from '../utils/testUtils';
+
+const instance = {
+  startDate: new Date(),
+  endDate: new Date(),
+  state: 'success',
+  runId: 'run',
+};
+
+describe('Test Task InstanceTooltip', () => {
+  test('Displays a normal task', () => {
+    const { getByText } = render(
+      <InstanceTooltip
+        group={{}}
+        instance={instance}
+      />,
+      { wrapper: Wrapper },
+    );
+
+    expect(getByText('Status: success')).toBeDefined();
+  });
+
+  test('Displays a mapped task with overall status', () => {
+    const { getByText } = render(
+      <InstanceTooltip
+        group={{ isMapped: true }}
+        instance={{ ...instance, mappedStates: ['success', 'success'] }}
+      />,
+      { wrapper: Wrapper },
+    );
+
+    expect(getByText('Overall Status: success')).toBeDefined();
+    expect(getByText('2 mapped tasks')).toBeDefined();
+    expect(getByText('success: 2')).toBeDefined();
+  });
+
+  test('Displays a task group with overall status', () => {
+    const { getByText, queryByText } = render(
+      <InstanceTooltip
+        group={{
+          children: [
+            {
+              instances: [
+                {
+                  runId: 'run',
+                  state: 'success',
+                },
+              ],
+            },
+          ],
+        }}
+        instance={instance}
+      />,
+      { wrapper: Wrapper },
+    );
+
+    expect(getByText('Overall Status: success')).toBeDefined();
+    expect(queryByText('mapped task')).toBeNull();
+    expect(getByText('success: 1')).toBeDefined();
+  });
+});
diff --git a/airflow/www/static/js/grid/StatusBox.jsx 
b/airflow/www/static/js/grid/components/StatusBox.jsx
similarity index 96%
rename from airflow/www/static/js/grid/StatusBox.jsx
rename to airflow/www/static/js/grid/components/StatusBox.jsx
index 14831562c0..2868e8727c 100644
--- a/airflow/www/static/js/grid/StatusBox.jsx
+++ b/airflow/www/static/js/grid/components/StatusBox.jsx
@@ -28,8 +28,8 @@ import {
 } from '@chakra-ui/react';
 
 import InstanceTooltip from './InstanceTooltip';
-import { useContainerRef } from './context/containerRef';
-import useFilters from './utils/useFilters';
+import { useContainerRef } from '../context/containerRef';
+import useFilters from '../utils/useFilters';
 
 export const boxSize = 10;
 export const boxSizePx = `${boxSize}px`;
diff --git a/airflow/www/static/js/grid/Table.jsx 
b/airflow/www/static/js/grid/components/Table.jsx
similarity index 97%
rename from airflow/www/static/js/grid/Table.jsx
rename to airflow/www/static/js/grid/components/Table.jsx
index 6f8e351d86..570becf12a 100644
--- a/airflow/www/static/js/grid/Table.jsx
+++ b/airflow/www/static/js/grid/components/Table.jsx
@@ -187,13 +187,14 @@ const Table = ({
           })}
         </Tbody>
       </ChakraTable>
-      {totalEntries > data.length && (
+      {(canPreviousPage || canNextPage) && (
       <Flex alignItems="center" justifyContent="flex-start" my={4}>
         <IconButton
           variant="ghost"
           onClick={handlePrevious}
           disabled={!canPreviousPage}
           aria-label="Previous Page"
+          title="Previous Page"
           icon={<MdKeyboardArrowLeft />}
         />
         <IconButton
@@ -201,6 +202,7 @@ const Table = ({
           onClick={handleNext}
           disabled={!canNextPage}
           aria-label="Next Page"
+          title="Next Page"
           icon={<MdKeyboardArrowRight />}
         />
         <Text>
@@ -208,7 +210,7 @@ const Table = ({
           -
           {upperCount}
           {' of '}
-          {totalEntries}
+          {totalEntries || data.length}
         </Text>
       </Flex>
       )}
diff --git a/airflow/www/static/js/grid/components/Table.test.jsx 
b/airflow/www/static/js/grid/components/Table.test.jsx
new file mode 100644
index 0000000000..dd6cea13ef
--- /dev/null
+++ b/airflow/www/static/js/grid/components/Table.test.jsx
@@ -0,0 +1,211 @@
+/*!
+ * 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 '@testing-library/jest-dom';
+import { render, fireEvent, within } from '@testing-library/react';
+import { sortBy } from 'lodash';
+
+import Table from './Table';
+import { ChakraWrapper } from '../utils/testUtils';
+
+const data = [
+  { firstName: 'Lamont', lastName: 'Grimes', country: 'United States' },
+  { firstName: 'Alysa', lastName: 'Armstrong', country: 'Spain' },
+  { firstName: 'Petra', lastName: 'Blick', country: 'France' },
+  { firstName: 'Jeromy', lastName: 'Herman', country: 'Mexico' },
+  { firstName: 'Eleonore', lastName: 'Rohan', country: 'Nigeria' },
+];
+
+const columns = [
+  {
+    Header: 'First Name',
+    accessor: 'firstName',
+  },
+  {
+    Header: 'Last Name',
+    accessor: 'lastName',
+  },
+  {
+    Header: 'Country',
+    accessor: 'country',
+  },
+];
+
+describe('Test Table', () => {
+  test('Displays correct data', async () => {
+    const { getAllByRole, getByText, queryByTitle } = render(
+      <Table data={data} columns={columns} />,
+      { wrapper: ChakraWrapper },
+    );
+
+    const rows = getAllByRole('row');
+    const name1 = getByText(data[0].firstName);
+    const name2 = getByText(data[1].firstName);
+    const name3 = getByText(data[2].firstName);
+    const name4 = getByText(data[3].firstName);
+    const name5 = getByText(data[4].firstName);
+    const previous = queryByTitle('Previous Page');
+    const next = queryByTitle('Next Page');
+
+    // table header is a row so add 1 to expected amount
+    expect(rows).toHaveLength(6);
+
+    expect(name1).toBeInTheDocument();
+    expect(name2).toBeInTheDocument();
+    expect(name3).toBeInTheDocument();
+    expect(name4).toBeInTheDocument();
+    expect(name5).toBeInTheDocument();
+
+    // expect pagination to de hidden when fewer results than default pageSize 
(24)
+    expect(previous).toBeNull();
+    expect(next).toBeNull();
+  });
+
+  test('Shows empty state', async () => {
+    const { getAllByRole, getByText } = render(
+      <Table data={[]} columns={columns} />,
+      { wrapper: ChakraWrapper },
+    );
+
+    const rows = getAllByRole('row');
+
+    // table header is a row so add 1 to expected amount
+    expect(rows).toHaveLength(2);
+    expect(getByText('No Data found.')).toBeInTheDocument();
+  });
+
+  test('With pagination', async () => {
+    const { getAllByRole, queryByText, getByTitle } = render(
+      <Table data={data} columns={columns} pageSize={2} />,
+      { wrapper: ChakraWrapper },
+    );
+
+    const name1 = data[0].firstName;
+    const name2 = data[1].firstName;
+    const name3 = data[2].firstName;
+    const name4 = data[3].firstName;
+    const name5 = data[4].firstName;
+    const previous = getByTitle('Previous Page');
+    const next = getByTitle('Next Page');
+
+    /// // PAGE ONE // ///
+    // table header is a row so add 1 to expected amount
+    expect(getAllByRole('row')).toHaveLength(3);
+
+    expect(queryByText(name1)).toBeInTheDocument();
+    expect(queryByText(name2)).toBeInTheDocument();
+    expect(queryByText(name3)).toBeNull();
+    expect(queryByText(name4)).toBeNull();
+    expect(queryByText(name5)).toBeNull();
+
+    // expect only pagination next button to be functional on 1st page
+    expect(previous).toBeDisabled();
+    expect(next).toBeEnabled();
+
+    fireEvent.click(next);
+
+    /// // PAGE TWO // ///
+    expect(getAllByRole('row')).toHaveLength(3);
+
+    expect(queryByText(name1)).toBeNull();
+    expect(queryByText(name2)).toBeNull();
+    expect(queryByText(name3)).toBeInTheDocument();
+    expect(queryByText(name4)).toBeInTheDocument();
+    expect(queryByText(name5)).toBeNull();
+
+    // expect both pagination buttons to be functional on 2nd page
+    expect(previous).toBeEnabled();
+    expect(next).toBeEnabled();
+
+    fireEvent.click(next);
+
+    /// // PAGE THREE // ///
+    expect(getAllByRole('row')).toHaveLength(2);
+
+    expect(queryByText(name1)).toBeNull();
+    expect(queryByText(name2)).toBeNull();
+    expect(queryByText(name3)).toBeNull();
+    expect(queryByText(name4)).toBeNull();
+    expect(queryByText(name5)).toBeInTheDocument();
+
+    // expect only pagination previous button to be functional on last page
+    expect(previous).toBeEnabled();
+    expect(next).toBeDisabled();
+  });
+
+  test('With sorting', async () => {
+    const { getAllByRole } = render(<Table data={data} columns={columns} />, {
+      wrapper: ChakraWrapper,
+    });
+
+    // Default order matches original data order //
+    const firstNameHeader = getAllByRole('columnheader')[0];
+    const rows = getAllByRole('row');
+
+    const firstRowName = within(rows[1]).queryByText(data[0].firstName);
+    const lastRowName = within(rows[5]).queryByText(data[4].firstName);
+    expect(firstRowName).toBeInTheDocument();
+    expect(lastRowName).toBeInTheDocument();
+
+    fireEvent.click(firstNameHeader);
+
+    /// // ASCENDING SORT // ///
+    const ascendingRows = getAllByRole('row');
+    const ascendingData = sortBy(data, [(o) => o.firstName]);
+
+    const ascendingFirstRowName = 
within(ascendingRows[1]).queryByText(ascendingData[0].firstName);
+    const ascendingLastRowName = 
within(ascendingRows[5]).queryByText(ascendingData[4].firstName);
+    expect(ascendingFirstRowName).toBeInTheDocument();
+    expect(ascendingLastRowName).toBeInTheDocument();
+
+    fireEvent.click(firstNameHeader);
+
+    /// // DESCENDING SORT // ///
+    const descendingRows = getAllByRole('row');
+    const descendingData = sortBy(data, [(o) => o.firstName]).reverse();
+
+    const descendingFirstRowName = within(descendingRows[1]).queryByText(
+      descendingData[0].firstName,
+    );
+    const descendingLastRowName = within(descendingRows[5]).queryByText(
+      descendingData[4].firstName,
+    );
+    expect(descendingFirstRowName).toBeInTheDocument();
+    expect(descendingLastRowName).toBeInTheDocument();
+  });
+
+  test('Shows checkboxes', async () => {
+    const { getAllByTitle } = render(
+      <Table data={data} columns={columns} selectRows={() => {}} />,
+      { wrapper: ChakraWrapper },
+    );
+
+    const checkboxes = getAllByTitle('Toggle Row Selected');
+    expect(checkboxes).toHaveLength(data.length);
+
+    const checkbox1 = checkboxes[1];
+
+    fireEvent.click(checkbox1);
+
+    expect(checkbox1).toHaveAttribute('data-checked');
+  });
+});
diff --git a/airflow/www/static/js/grid/TaskName.jsx 
b/airflow/www/static/js/grid/components/TaskName.jsx
similarity index 95%
rename from airflow/www/static/js/grid/TaskName.jsx
rename to airflow/www/static/js/grid/components/TaskName.jsx
index 24c80b863e..316dd89e95 100644
--- a/airflow/www/static/js/grid/TaskName.jsx
+++ b/airflow/www/static/js/grid/components/TaskName.jsx
@@ -25,7 +25,7 @@ import {
 import { FiChevronUp, FiChevronDown } from 'react-icons/fi';
 
 const TaskName = ({
-  isGroup = false, isMapped = false, onToggle, isOpen, level, label,
+  isGroup = false, isMapped = false, onToggle, isOpen = false, level = 0, 
label,
 }) => (
   <Flex
     as={isGroup ? 'button' : 'div'}
diff --git a/airflow/www/static/js/grid/components/TaskName.test.jsx 
b/airflow/www/static/js/grid/components/TaskName.test.jsx
new file mode 100644
index 0000000000..9a403735ba
--- /dev/null
+++ b/airflow/www/static/js/grid/components/TaskName.test.jsx
@@ -0,0 +1,53 @@
+/*!
+ * 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 { render } from '@testing-library/react';
+
+import TaskName from './TaskName';
+import { ChakraWrapper } from '../utils/testUtils';
+
+describe('Test TaskName', () => {
+  test('Displays a normal task name', () => {
+    const { getByText } = render(
+      <TaskName label="test" />, { wrapper: ChakraWrapper },
+    );
+
+    expect(getByText('test')).toBeDefined();
+  });
+
+  test('Displays a mapped task name', () => {
+    const { getByText } = render(
+      <TaskName level={0} label="test" isMapped />, { wrapper: ChakraWrapper },
+    );
+
+    expect(getByText('test [ ]')).toBeDefined();
+  });
+
+  test('Displays a group task name', () => {
+    const { getByText, getByTestId } = render(
+      <TaskName level={0} label="test" isGroup />, { wrapper: ChakraWrapper },
+    );
+
+    expect(getByText('test')).toBeDefined();
+    expect(getByTestId('closed-group')).toBeDefined();
+  });
+});
diff --git a/airflow/www/static/js/grid/Time.jsx 
b/airflow/www/static/js/grid/components/Time.jsx
similarity index 92%
rename from airflow/www/static/js/grid/Time.jsx
rename to airflow/www/static/js/grid/components/Time.jsx
index 2cd940b10e..571374be48 100644
--- a/airflow/www/static/js/grid/Time.jsx
+++ b/airflow/www/static/js/grid/components/Time.jsx
@@ -20,8 +20,8 @@
 /* global moment */
 
 import React from 'react';
-import { useTimezone } from './context/timezone';
-import { defaultFormatWithTZ } from '../datetime_utils';
+import { useTimezone } from '../context/timezone';
+import { defaultFormatWithTZ } from '../../datetime_utils';
 
 const Time = ({ dateTime, format = defaultFormatWithTZ }) => {
   const { timezone } = useTimezone();
@@ -32,6 +32,7 @@ const Time = ({ dateTime, format = defaultFormatWithTZ }) => {
 
   const formattedTime = time.tz(timezone).format(format);
   const utcTime = time.tz('UTC').format(defaultFormatWithTZ);
+
   return (
     <time
       dateTime={dateTime}
diff --git a/airflow/www/static/js/grid/components/Time.test.jsx 
b/airflow/www/static/js/grid/components/Time.test.jsx
new file mode 100644
index 0000000000..c3044c59fc
--- /dev/null
+++ b/airflow/www/static/js/grid/components/Time.test.jsx
@@ -0,0 +1,83 @@
+/*!
+ * 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, document, Event */
+
+import React from 'react';
+import {
+  render, fireEvent, act,
+} from '@testing-library/react';
+import moment from 'moment-timezone';
+
+import { defaultFormatWithTZ, TimezoneEvent } from '../../datetime_utils';
+import Time from './Time';
+import { Wrapper } from '../utils/testUtils';
+
+describe('Test Time and TimezoneProvider', () => {
+  test('Displays a UTC time correctly', () => {
+    const now = new Date();
+    const { getByText } = render(
+      <Time dateTime={now} />,
+      { wrapper: Wrapper },
+    );
+
+    const utcTime = getByText(moment.utc(now).format(defaultFormatWithTZ));
+    expect(utcTime).toBeDefined();
+    expect(utcTime.title).toBeFalsy();
+  });
+
+  test('Displays moment default tz, includes UTC date in title', () => {
+    const now = new Date();
+    const tz = 'US/Samoa';
+    moment.tz.setDefault(tz);
+
+    const { getByText } = render(
+      <Time dateTime={now} />,
+      { wrapper: Wrapper },
+    );
+
+    const samoaTime = 
getByText(moment(now).tz(tz).format(defaultFormatWithTZ));
+    expect(samoaTime).toBeDefined();
+    
expect(samoaTime.title).toEqual(moment.utc(now).format(defaultFormatWithTZ));
+  });
+
+  test('Updates based on timezone change', async () => {
+    const now = new Date();
+    const { getByText, queryByText } = render(
+      <Time dateTime={now} />,
+      { wrapper: Wrapper },
+    );
+
+    const utcTime = queryByText(moment.utc(now).format(defaultFormatWithTZ));
+    expect(utcTime).toBeDefined();
+
+    // Fire a custom timezone change event
+    const event = new Event(TimezoneEvent);
+    event.value = 'EST';
+    event.key = 'selected-timezone';
+    await act(async () => {
+      fireEvent(document, event);
+    });
+
+    expect(utcTime).toBeNull();
+    const estTime = 
getByText(moment(now).tz('EST').format(defaultFormatWithTZ));
+    expect(estTime).toBeDefined();
+    expect(estTime.title).toEqual(moment.utc(now).format(defaultFormatWithTZ));
+  });
+});
diff --git a/airflow/www/static/js/grid/context/timezone.jsx 
b/airflow/www/static/js/grid/context/timezone.jsx
index 98f1cfac70..cbc4396c26 100644
--- a/airflow/www/static/js/grid/context/timezone.jsx
+++ b/airflow/www/static/js/grid/context/timezone.jsx
@@ -27,8 +27,11 @@ const TimezoneContext = React.createContext(null);
 export const TimezoneProvider = ({ children }) => {
   const [timezone, setTimezone] = useState((moment.defaultZone && 
moment.defaultZone.name) || 'UTC');
 
+  const handleChange = (e) => {
+    if (e.value && e.value !== timezone) setTimezone(e.value);
+  };
+
   useEffect(() => {
-    const handleChange = (e) => setTimezone(e.value);
     document.addEventListener(TimezoneEvent, handleChange);
     return () => {
       document.removeEventListener(TimezoneEvent, handleChange);
diff --git a/airflow/www/static/js/grid/dagRuns/Bar.jsx 
b/airflow/www/static/js/grid/dagRuns/Bar.jsx
index d972582ced..7523b8dd2a 100644
--- a/airflow/www/static/js/grid/dagRuns/Bar.jsx
+++ b/airflow/www/static/js/grid/dagRuns/Bar.jsx
@@ -33,7 +33,7 @@ import { MdPlayArrow } from 'react-icons/md';
 
 import DagRunTooltip from './Tooltip';
 import { useContainerRef } from '../context/containerRef';
-import Time from '../Time';
+import Time from '../components/Time';
 
 const BAR_HEIGHT = 100;
 
diff --git a/airflow/www/static/js/grid/dagRuns/Tooltip.jsx 
b/airflow/www/static/js/grid/dagRuns/Tooltip.jsx
index 6c00b66bf5..82cec0d8c7 100644
--- a/airflow/www/static/js/grid/dagRuns/Tooltip.jsx
+++ b/airflow/www/static/js/grid/dagRuns/Tooltip.jsx
@@ -21,7 +21,7 @@ import React from 'react';
 import { Box, Text } from '@chakra-ui/react';
 
 import { formatDuration } from '../../datetime_utils';
-import Time from '../Time';
+import Time from '../components/Time';
 
 const DagRunTooltip = ({
   dagRun: {
diff --git a/airflow/www/static/js/grid/details/Header.jsx 
b/airflow/www/static/js/grid/details/Header.jsx
index d656ec26b8..ea71060a95 100644
--- a/airflow/www/static/js/grid/details/Header.jsx
+++ b/airflow/www/static/js/grid/details/Header.jsx
@@ -30,7 +30,7 @@ import { MdPlayArrow } from 'react-icons/md';
 
 import { getMetaValue } from '../../utils';
 import useSelection from '../utils/useSelection';
-import Time from '../Time';
+import Time from '../components/Time';
 import { useTasks, useGridData } from '../api';
 
 const dagId = getMetaValue('dag_id');
diff --git a/airflow/www/static/js/grid/details/content/Dag.jsx 
b/airflow/www/static/js/grid/details/content/Dag.jsx
index a6a48e7887..0f434d88fd 100644
--- a/airflow/www/static/js/grid/details/content/Dag.jsx
+++ b/airflow/www/static/js/grid/details/content/Dag.jsx
@@ -34,8 +34,8 @@ import { mean } from 'lodash';
 import { getDuration, formatDuration } from '../../../datetime_utils';
 import { finalStatesMap, getMetaValue } from '../../../utils';
 import { useTasks, useGridData } from '../../api';
-import Time from '../../Time';
-import { SimpleStatus } from '../../StatusBox';
+import Time from '../../components/Time';
+import { SimpleStatus } from '../../components/StatusBox';
 
 const dagId = getMetaValue('dag_id');
 const dagDetailsUrl = getMetaValue('dag_details_url');
diff --git a/airflow/www/static/js/grid/details/content/dagRun/index.jsx 
b/airflow/www/static/js/grid/details/content/dagRun/index.jsx
index 41bf4e5146..61f66a7367 100644
--- a/airflow/www/static/js/grid/details/content/dagRun/index.jsx
+++ b/airflow/www/static/js/grid/details/content/dagRun/index.jsx
@@ -27,10 +27,10 @@ import {
 } from '@chakra-ui/react';
 import { MdPlayArrow, MdOutlineAccountTree } from 'react-icons/md';
 
-import { SimpleStatus } from '../../../StatusBox';
-import { ClipboardText } from '../../../Clipboard';
+import { SimpleStatus } from '../../../components/StatusBox';
+import { ClipboardText } from '../../../components/Clipboard';
 import { formatDuration, getDuration } from '../../../../datetime_utils';
-import Time from '../../../Time';
+import Time from '../../../components/Time';
 import MarkFailedRun from './MarkFailedRun';
 import MarkSuccessRun from './MarkSuccessRun';
 import QueueRun from './QueueRun';
diff --git 
a/airflow/www/static/js/grid/details/content/taskInstance/Details.jsx 
b/airflow/www/static/js/grid/details/content/taskInstance/Details.jsx
index 317e22c250..55ea09951f 100644
--- a/airflow/www/static/js/grid/details/content/taskInstance/Details.jsx
+++ b/airflow/www/static/js/grid/details/content/taskInstance/Details.jsx
@@ -26,9 +26,9 @@ import {
 
 import { finalStatesMap } from '../../../../utils';
 import { getDuration, formatDuration } from '../../../../datetime_utils';
-import { SimpleStatus } from '../../../StatusBox';
-import Time from '../../../Time';
-import { ClipboardText } from '../../../Clipboard';
+import { SimpleStatus } from '../../../components/StatusBox';
+import Time from '../../../components/Time';
+import { ClipboardText } from '../../../components/Clipboard';
 
 const Details = ({ instance, group, operator }) => {
   const isGroup = !!group.children;
diff --git 
a/airflow/www/static/js/grid/details/content/taskInstance/MappedInstances.jsx 
b/airflow/www/static/js/grid/details/content/taskInstance/MappedInstances.jsx
index d2f211cd62..13c03b0435 100644
--- 
a/airflow/www/static/js/grid/details/content/taskInstance/MappedInstances.jsx
+++ 
b/airflow/www/static/js/grid/details/content/taskInstance/MappedInstances.jsx
@@ -33,9 +33,9 @@ import {
 import { getMetaValue } from '../../../../utils';
 import { formatDuration, getDuration } from '../../../../datetime_utils';
 import { useMappedInstances } from '../../../api';
-import { SimpleStatus } from '../../../StatusBox';
-import Table from '../../../Table';
-import Time from '../../../Time';
+import { SimpleStatus } from '../../../components/StatusBox';
+import Table from '../../../components/Table';
+import Time from '../../../components/Time';
 
 const canEdit = getMetaValue('can_edit') === 'True';
 const renderedTemplatesUrl = getMetaValue('rendered_templates_url');
diff --git a/airflow/www/static/js/grid/renderTaskRows.jsx 
b/airflow/www/static/js/grid/renderTaskRows.jsx
index 72df309aed..4f3abb8594 100644
--- a/airflow/www/static/js/grid/renderTaskRows.jsx
+++ b/airflow/www/static/js/grid/renderTaskRows.jsx
@@ -27,8 +27,8 @@ import {
   useTheme,
 } from '@chakra-ui/react';
 
-import StatusBox, { boxSize, boxSizePx } from './StatusBox';
-import TaskName from './TaskName';
+import StatusBox, { boxSize, boxSizePx } from './components/StatusBox';
+import TaskName from './components/TaskName';
 
 import useSelection from './utils/useSelection';
 
diff --git a/airflow/www/static/js/grid/LegendRow.jsx 
b/airflow/www/static/js/grid/utils/gridData.test.js
similarity index 54%
copy from airflow/www/static/js/grid/LegendRow.jsx
copy to airflow/www/static/js/grid/utils/gridData.test.js
index 75fba14358..6bbd8bf8b6 100644
--- a/airflow/www/static/js/grid/LegendRow.jsx
+++ b/airflow/www/static/js/grid/utils/gridData.test.js
@@ -17,26 +17,31 @@
  * under the License.
  */
 
-/* global stateColors */
+/* global describe, test, expect */
 
-import {
-  Flex,
-  Text,
-} from '@chakra-ui/react';
-import React from 'react';
-import { SimpleStatus } from './StatusBox';
+import { areActiveRuns } from './gridData';
 
-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>
-);
+describe('Test areActiveRuns()', () => {
+  test('Correctly detects active runs', () => {
+    const runs = [
+      { state: 'success' },
+      { state: 'queued' },
+    ];
+    expect(areActiveRuns(runs)).toBe(true);
+  });
 
-export default LegendRow;
+  test('Returns false when all runs are resolved', () => {
+    const runs = [
+      { state: 'success' },
+      { state: 'failed' },
+      { state: 'not_queued' },
+    ];
+    const result = areActiveRuns(runs);
+    expect(result).toBe(false);
+  });
+
+  test('Returns false when there are no runs', () => {
+    const result = areActiveRuns();
+    expect(result).toBe(false);
+  });
+});
diff --git a/airflow/www/static/js/grid/utils/testUtils.jsx 
b/airflow/www/static/js/grid/utils/testUtils.jsx
index 436288c8d5..50ef2305fe 100644
--- a/airflow/www/static/js/grid/utils/testUtils.jsx
+++ b/airflow/www/static/js/grid/utils/testUtils.jsx
@@ -56,6 +56,12 @@ export const Wrapper = ({ children }) => {
   );
 };
 
+export const ChakraWrapper = ({ children }) => (
+  <ChakraProvider>
+    {children}
+  </ChakraProvider>
+);
+
 export const TableWrapper = ({ children }) => (
   <Wrapper>
     <Table>
diff --git a/airflow/www/static/js/grid/utils/useErrorToast.js 
b/airflow/www/static/js/grid/utils/useErrorToast.js
index 02d3195f6c..842eb53810 100644
--- a/airflow/www/static/js/grid/utils/useErrorToast.js
+++ b/airflow/www/static/js/grid/utils/useErrorToast.js
@@ -19,8 +19,8 @@
 
 import { useToast } from '@chakra-ui/react';
 
-const getErrorDescription = (error, fallbackMessage) => {
-  if (error.response && error.response.data) {
+export const getErrorDescription = (error, fallbackMessage) => {
+  if (error && error.response && error.response.data) {
     return error.response.data;
   }
   if (error instanceof Error) return error.message;
diff --git a/airflow/www/static/js/grid/utils/useErrorToast.test.jsx 
b/airflow/www/static/js/grid/utils/useErrorToast.test.jsx
new file mode 100644
index 0000000000..39256b3d30
--- /dev/null
+++ b/airflow/www/static/js/grid/utils/useErrorToast.test.jsx
@@ -0,0 +1,52 @@
+/*!
+ * 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 { getErrorDescription } from './useErrorToast';
+
+describe('Test getErrorDescription()', () => {
+  test('Returns expected results', () => {
+    let description;
+
+    // is response.data is defined
+    description = getErrorDescription({ response: { data: 'uh oh' } });
+    expect(description).toBe('uh oh');
+
+    // if it is not, use default message
+    description = getErrorDescription({ response: { data: '' } });
+    expect(description).toBe('Something went wrong.');
+
+    // if error object, return the message
+    description = getErrorDescription(new Error('no no'));
+    expect(description).toBe('no no');
+
+    // if string, return the string
+    description = getErrorDescription('error!');
+    expect(description).toBe('error!');
+
+    // if it's undefined, use a fallback
+    description = getErrorDescription(null, 'fallback');
+    expect(description).toBe('fallback');
+
+    // use default if nothing is defined
+    description = getErrorDescription();
+    expect(description).toBe('Something went wrong.');
+  });
+});
diff --git a/airflow/www/static/js/grid/utils/useSelection.js 
b/airflow/www/static/js/grid/utils/useSelection.js
index c7220b4f21..c90578837a 100644
--- a/airflow/www/static/js/grid/utils/useSelection.js
+++ b/airflow/www/static/js/grid/utils/useSelection.js
@@ -32,13 +32,13 @@ const useSelection = () => {
     setSearchParams(searchParams);
   };
 
-  const onSelect = (payload) => {
+  const onSelect = ({ runId, taskId }) => {
     const params = new URLSearchParams(searchParams);
 
-    if (payload.runId) params.set(RUN_ID, payload.runId);
+    if (runId) params.set(RUN_ID, runId);
     else params.delete(RUN_ID);
 
-    if (payload.taskId) params.set(TASK_ID, payload.taskId);
+    if (taskId) params.set(TASK_ID, taskId);
     else params.delete(TASK_ID);
 
     setSearchParams(params);
@@ -46,9 +46,15 @@ const useSelection = () => {
 
   const runId = searchParams.get(RUN_ID);
   const taskId = searchParams.get(TASK_ID);
-  const selected = { runId, taskId };
 
-  return { selected, clearSelection, onSelect };
+  return {
+    selected: {
+      runId,
+      taskId,
+    },
+    clearSelection,
+    onSelect,
+  };
 };
 
 export default useSelection;
diff --git a/airflow/www/static/js/grid/utils/useSelection.test.jsx 
b/airflow/www/static/js/grid/utils/useSelection.test.jsx
new file mode 100644
index 0000000000..2d2eeeb4db
--- /dev/null
+++ b/airflow/www/static/js/grid/utils/useSelection.test.jsx
@@ -0,0 +1,70 @@
+/*!
+ * 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 { act, renderHook } from '@testing-library/react-hooks';
+import { MemoryRouter } from 'react-router-dom';
+
+import useSelection from './useSelection';
+
+const Wrapper = ({ children }) => (
+  <MemoryRouter>
+    {children}
+  </MemoryRouter>
+);
+
+describe('Test useSelection hook', () => {
+  test('Initial values', async () => {
+    const { result } = renderHook(() => useSelection(), { wrapper: Wrapper });
+    const {
+      selected: {
+        runId,
+        taskId,
+      },
+    } = result.current;
+
+    expect(runId).toBeNull();
+    expect(taskId).toBeNull();
+  });
+
+  test.each([
+    { taskId: 'task_1', runId: 'run_1' },
+    { taskId: null, runId: 'run_1' },
+    { taskId: 'task_1', runId: null },
+  ])('Test onSelect() and clearSelection()', async (selected) => {
+    const { result } = renderHook(() => useSelection(), { wrapper: Wrapper });
+
+    await act(async () => {
+      result.current.onSelect(selected);
+    });
+
+    expect(result.current.selected.taskId).toBe(selected.taskId);
+    expect(result.current.selected.runId).toBe(selected.runId);
+
+    // clearSelection
+    await act(async () => {
+      result.current.clearSelection();
+    });
+
+    expect(result.current.selected.taskId).toBeNull();
+    expect(result.current.selected.runId).toBeNull();
+  });
+});

Reply via email to