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();
+ });
+});