From 2c6b003fbe619d5d736cf97f20a94a3451e1a14a Mon Sep 17 00:00:00 2001 From: Brent Bovenzi Date: Thu, 27 May 2021 16:23:02 -0400 Subject: [PATCH] Updated table component (#15805) * use react-table for table data * add skeleton loader to table * refactor loading data * UI pagination * server-side pagination * componentize custom react table * fix placeholder switch * add test that errors are rendered * Update airflow/ui/src/interfaces/react-table-config.d.ts remove extraneous comment Co-authored-by: Ash Berlin-Taylor * Update airflow/ui/src/components/Table.tsx Co-authored-by: Ryan Hamilton * update sort icons and pagination display Co-authored-by: Ash Berlin-Taylor Co-authored-by: Ryan Hamilton --- airflow/ui/package.json | 2 + airflow/ui/src/api/index.ts | 23 ++- airflow/ui/src/components/Table.tsx | 187 ++++++++++++++++++ .../ui/src/interfaces/react-table-config.d.ts | 145 ++++++++++++++ .../ui/src/views/Pipelines/PipelinesTable.tsx | 120 +++++++---- airflow/ui/src/views/Pipelines/Row.tsx | 141 +++++++------ airflow/ui/test/Pipelines.test.tsx | 26 ++- airflow/ui/yarn.lock | 12 ++ 8 files changed, 531 insertions(+), 125 deletions(-) create mode 100644 airflow/ui/src/components/Table.tsx create mode 100644 airflow/ui/src/interfaces/react-table-config.d.ts diff --git a/airflow/ui/package.json b/airflow/ui/package.json index d7e7857b53892..de2cb90f939a6 100644 --- a/airflow/ui/package.json +++ b/airflow/ui/package.json @@ -26,6 +26,7 @@ "react-query": "^3.12.3", "react-router-dom": "^5.2.0", "react-select": "^4.3.0", + "react-table": "^7.7.0", "use-react-router": "^1.0.7" }, "devDependencies": { @@ -40,6 +41,7 @@ "@types/react-dom": "^17.0.2", "@types/react-router-dom": "^5.1.7", "@types/react-select": "^4.0.15", + "@types/react-table": "^7.7.0", "eslint": "^7", "eslint-config-airbnb-typescript": "^12.3.1", "eslint-plugin-import": "^2.22.1", diff --git a/airflow/ui/src/api/index.ts b/airflow/ui/src/api/index.ts index d711ef66784ae..ee3ab5b55eed3 100644 --- a/airflow/ui/src/api/index.ts +++ b/airflow/ui/src/api/index.ts @@ -52,10 +52,17 @@ setLogger({ const toastDuration = 3000; const refetchInterval = isTest ? false : 1000; -export function useDags() { +interface PageProps { + offset?: number; + limit?: number +} + +export function useDags({ offset = 0, limit }: PageProps) { return useQuery( - 'dags', - (): Promise => axios.get('/dags'), + ['dags', offset], + (): Promise => axios.get('/dags', { + params: { offset, limit }, + }), { refetchInterval, retry: !isTest, @@ -132,7 +139,7 @@ export function useTriggerRun(dagId: Dag['dagId']) { ); } -export function useSaveDag(dagId: Dag['dagId']) { +export function useSaveDag(dagId: Dag['dagId'], offset: number) { const queryClient = useQueryClient(); const toast = useToast(); return useMutation( @@ -141,7 +148,7 @@ export function useSaveDag(dagId: Dag['dagId']) { onMutate: async (updatedValues: Record) => { await queryClient.cancelQueries(['dag', dagId]); const previousDag = queryClient.getQueryData(['dag', dagId]) as Dag; - const previousDags = queryClient.getQueryData('dags') as DagsResponse; + const previousDags = queryClient.getQueryData(['dags', offset]) as DagsResponse; const newDags = previousDags.dags.map((dag) => ( dag.dagId === dagId ? { ...dag, ...updatedValues } : dag @@ -153,7 +160,7 @@ export function useSaveDag(dagId: Dag['dagId']) { // optimistically set the dag before the async request queryClient.setQueryData(['dag', dagId], () => newDag); - queryClient.setQueryData('dags', (old) => ({ + queryClient.setQueryData(['dags', offset], (old) => ({ ...(old as Dag[]), ...{ dags: newDags, @@ -168,7 +175,7 @@ export function useSaveDag(dagId: Dag['dagId']) { // rollback to previous cache on error if (error) { queryClient.setQueryData(['dag', dagId], previousDag); - queryClient.setQueryData('dags', previousDags); + queryClient.setQueryData(['dags', offset], previousDags); toast({ title: 'Error updating pipeline', description: (error as Error).message, @@ -180,7 +187,7 @@ export function useSaveDag(dagId: Dag['dagId']) { // check if server response is different from our optimistic update if (JSON.stringify(res) !== JSON.stringify(previousDag)) { queryClient.setQueryData(['dag', dagId], res); - queryClient.setQueryData('dags', { + queryClient.setQueryData(['dags', offset], { dags: previousDags.dags.map((dag) => ( dag.dagId === dagId ? res : dag )), diff --git a/airflow/ui/src/components/Table.tsx b/airflow/ui/src/components/Table.tsx new file mode 100644 index 0000000000000..bcee2fa74405a --- /dev/null +++ b/airflow/ui/src/components/Table.tsx @@ -0,0 +1,187 @@ +/*! + * 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. + */ + +/* + * Custom wrapper of react-table using Chakra UI components +*/ + +import React, { useEffect } from 'react'; +import { + Flex, + Table as ChakraTable, + Thead, + Tbody, + Tr, + Th, + Td, + IconButton, + Text, + useColorModeValue, +} from '@chakra-ui/react'; +import { + useTable, useSortBy, Column, usePagination, SortingRule, +} from 'react-table'; +import { + MdKeyboardArrowLeft, MdKeyboardArrowRight, +} from 'react-icons/md'; +import { + TiArrowUnsorted, TiArrowSortedDown, TiArrowSortedUp, +} from 'react-icons/ti'; + +interface Props { + data: any[]; + columns: Column[]; + /* + * manualPagination is when you need to do server-side pagination. + * Leave blank for client-side only + */ + manualPagination?: { + offset: number; + setOffset: (off: number) => void; + totalEntries: number; + }; + /* + * setSortBy is for custom sorting such as server-side sorting + */ + setSortBy?: (sortBy: SortingRule[]) => void; + pageSize?: number; +} + +const Table: React.FC = ({ + data, columns, manualPagination, pageSize = 25, setSortBy, +}) => { + const { totalEntries, offset, setOffset } = manualPagination || {}; + const oddColor = useColorModeValue('gray.50', 'gray.900'); + const hoverColor = useColorModeValue('gray.100', 'gray.700'); + + const pageCount = totalEntries ? (Math.ceil(totalEntries / pageSize) || 1) : data.length; + + const lowerCount = (offset || 0) + 1; + const upperCount = lowerCount + data.length - 1; + + const { + getTableProps, + getTableBodyProps, + allColumns, + prepareRow, + page, + canPreviousPage, + canNextPage, + nextPage, + previousPage, + state: { pageIndex, sortBy }, + } = useTable( + { + columns, + data, + pageCount, + manualPagination: !!manualPagination, + manualSortBy: !!setSortBy, + initialState: { + pageIndex: offset ? offset / pageSize : 0, + pageSize, + }, + }, + useSortBy, + usePagination, + ); + + const handleNext = () => { + nextPage(); + if (setOffset) setOffset((pageIndex + 1) * pageSize); + }; + + const handlePrevious = () => { + previousPage(); + if (setOffset) setOffset((pageIndex - 1 || 0) * pageSize); + }; + + useEffect(() => { + if (setSortBy) setSortBy(sortBy); + }, [sortBy, setSortBy]); + + return ( + <> + + + + {allColumns.map((column) => ( + + {column.render('Header')} + {column.isSorted && ( + column.isSortedDesc ? ( + + ) : ( + + ) + )} + {(!column.isSorted && column.canSort) && ()} + + ))} + + + + {!data.length && ( + + No Data found. + + )} + {page.map((row) => { + prepareRow(row); + return ( + + {row.cells.map((cell) => ( + + {cell.render('Cell')} + + ))} + + ); + })} + + + + + + + + + + + {lowerCount} + - + {upperCount} + {' of '} + {totalEntries} + + + + ); +}; + +export default Table; diff --git a/airflow/ui/src/interfaces/react-table-config.d.ts b/airflow/ui/src/interfaces/react-table-config.d.ts new file mode 100644 index 0000000000000..ea0fa9363e0fc --- /dev/null +++ b/airflow/ui/src/interfaces/react-table-config.d.ts @@ -0,0 +1,145 @@ +/*! + * 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. + */ + +/* + Unfortunately, this is needed for now. + We can remove whenever this issue is resolved: https://github.com/tannerlinsley/react-table/issues/2970 +*/ + +import { + UseColumnOrderInstanceProps, + UseColumnOrderState, + UseExpandedHooks, + UseExpandedInstanceProps, + UseExpandedOptions, + UseExpandedRowProps, + UseExpandedState, + UseFiltersColumnOptions, + UseFiltersColumnProps, + UseFiltersInstanceProps, + UseFiltersOptions, + UseFiltersState, + UseGlobalFiltersColumnOptions, + UseGlobalFiltersInstanceProps, + UseGlobalFiltersOptions, + UseGlobalFiltersState, + UseGroupByCellProps, + UseGroupByColumnOptions, + UseGroupByColumnProps, + UseGroupByHooks, + UseGroupByInstanceProps, + UseGroupByOptions, + UseGroupByRowProps, + UseGroupByState, + UsePaginationInstanceProps, + UsePaginationOptions, + UsePaginationState, + UseResizeColumnsColumnOptions, + UseResizeColumnsColumnProps, + UseResizeColumnsOptions, + UseResizeColumnsState, + UseRowSelectHooks, + UseRowSelectInstanceProps, + UseRowSelectOptions, + UseRowSelectRowProps, + UseRowSelectState, + UseRowStateCellProps, + UseRowStateInstanceProps, + UseRowStateOptions, + UseRowStateRowProps, + UseRowStateState, + UseSortByColumnOptions, + UseSortByColumnProps, + UseSortByHooks, + UseSortByInstanceProps, + UseSortByOptions, + UseSortByState, +} from 'react-table'; + +declare module 'react-table' { + + export interface TableOptions> + extends UseExpandedOptions, + UseFiltersOptions, + UseGlobalFiltersOptions, + UseGroupByOptions, + UsePaginationOptions, + UseResizeColumnsOptions, + UseRowSelectOptions, + UseRowStateOptions, + UseSortByOptions, + // note that having Record here allows you to add anything to the options, + // this matches the spirit of the underlying js library, + // but might be cleaner if it's replaced by a more specific type that matches your + // feature set, this is a safe default. + Record {} + + export interface Hooks = Record> + extends UseExpandedHooks, + UseGroupByHooks, + UseRowSelectHooks, + UseSortByHooks {} + + export interface TableInstance = Record> + extends UseColumnOrderInstanceProps, + UseExpandedInstanceProps, + UseFiltersInstanceProps, + UseGlobalFiltersInstanceProps, + UseGroupByInstanceProps, + UsePaginationInstanceProps, + UseRowSelectInstanceProps, + UseRowStateInstanceProps, + UseSortByInstanceProps {} + + export interface TableState = Record> + extends UseColumnOrderState, + UseExpandedState, + UseFiltersState, + UseGlobalFiltersState, + UseGroupByState, + UsePaginationState, + UseResizeColumnsState, + UseRowSelectState, + UseRowStateState, + UseSortByState {} + + export interface ColumnInterface = Record> + extends UseFiltersColumnOptions, + UseGlobalFiltersColumnOptions, + UseGroupByColumnOptions, + UseResizeColumnsColumnOptions, + UseSortByColumnOptions {} + + export interface ColumnInstance = Record> + extends UseFiltersColumnProps, + UseGroupByColumnProps, + UseResizeColumnsColumnProps, + UseSortByColumnProps {} + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + export interface Cell = Record, V = any> + extends UseGroupByCellProps, + UseRowStateCellProps {} + + export interface Row = Record> + extends UseExpandedRowProps, + UseGroupByRowProps, + UseRowSelectRowProps, + UseRowStateRowProps {} +} diff --git a/airflow/ui/src/views/Pipelines/PipelinesTable.tsx b/airflow/ui/src/views/Pipelines/PipelinesTable.tsx index 8b1a4daa98515..2f5070821ed40 100644 --- a/airflow/ui/src/views/Pipelines/PipelinesTable.tsx +++ b/airflow/ui/src/views/Pipelines/PipelinesTable.tsx @@ -17,59 +17,101 @@ * under the License. */ -import React from 'react'; +import React, { useMemo, useState } from 'react'; import { Alert, AlertIcon, - Table, - Thead, - Tbody, - Tr, - Th, - Td, + Progress, + Switch, + IconButton, } from '@chakra-ui/react'; +import type { Column } from 'react-table'; +import { + MdPlayArrow, +} from 'react-icons/md'; +import Table from 'components/Table'; import { defaultDags } from 'api/defaults'; import { useDags } from 'api'; -import type { Dag } from 'interfaces'; -import Row from './Row'; +import { + DagName, PauseToggle, TriggerDagButton, DagTag, +} from './Row'; + +const getRandomInt = (max: number) => Math.floor(Math.random() * max); + +// Generate 1-10 placeholder rows +const skeletonLoader = [...Array(getRandomInt(10) || 1)].map(() => ({ + active: , + tags: '', + dagId: , + trigger: } aria-label="Trigger Dag" disabled />, +})); + +const LIMIT = 25; const PipelinesTable: React.FC = () => { - const { data: { dags } = defaultDags, isLoading, error } = useDags(); + const [offset, setOffset] = useState(0); + const { + data: { dags, totalEntries } = defaultDags, + isLoading, + error, + } = useDags({ limit: LIMIT, offset }); + + // Show placeholders rows when data is loading for the first time + const data = useMemo( + () => (isLoading && !dags.length + ? skeletonLoader + : dags.map((d) => ({ + ...d, + tags: d.tags.map((tag) => ), + dagId: , + trigger: , + active: , + }))), + [dags, isLoading, offset], + ); + + const columns = useMemo[]>( + () => [ + { + Header: 'Active', + accessor: 'active', + // Implement custom sort function because the data is a react component + sortType: (rowA, rowB) => (rowA.original.isPaused && !rowB.original.isPaused ? 1 : -1), + }, + { + Header: 'Dag Id', + accessor: 'dagId', + }, + { + Header: 'Tags', + accessor: 'tags', + }, + { + disableSortBy: true, + accessor: 'trigger', + }, + ], + [], + ); return ( <> {error && ( - - - {error.message} - + + + {error.message} + )} - - - - - - - - {isLoading && ( - - - - )} - {(!isLoading && !dags.length) && ( - - - - )} - {dags.map((dag: Dag) => )} - -
- DAG ID -
Loading…
No Pipelines found.
+ ); }; diff --git a/airflow/ui/src/views/Pipelines/Row.tsx b/airflow/ui/src/views/Pipelines/Row.tsx index deff408c9290e..f9e97a961cb15 100644 --- a/airflow/ui/src/views/Pipelines/Row.tsx +++ b/airflow/ui/src/views/Pipelines/Row.tsx @@ -17,99 +17,90 @@ * under the License. */ +// Components to customize cell elements in the PipelinesTable + import React from 'react'; import { Link as RouterLink } from 'react-router-dom'; import { - Flex, - Link, - Tr, - Td, - Tag, Tooltip, - useColorModeValue, Switch, useDisclosure, IconButton, + Link, + Tag, } from '@chakra-ui/react'; import TriggerRunModal from 'components/TriggerRunModal'; -import compareObjectProps from 'utils/memo'; -import type { Dag, DagTag } from 'interfaces'; import { useSaveDag } from 'api'; import { MdPlayArrow } from 'react-icons/md'; +import type { DagTag as DagTagType } from 'interfaces'; -interface Props { - dag: Dag; +interface PauseProps { + dagId: string; + isPaused: boolean; + offset?: number; } -const Row: React.FC = ({ dag }) => { - const { isOpen, onToggle, onClose } = useDisclosure(); - const mutation = useSaveDag(dag.dagId); - const togglePaused = () => mutation.mutate({ isPaused: !dag.isPaused }); - - const oddColor = useColorModeValue('gray.50', 'gray.900'); - const hoverColor = useColorModeValue('gray.100', 'gray.700'); +export const PauseToggle: React.FC = ({ dagId, isPaused, offset = 0 }) => { + const mutation = useSaveDag(dagId, offset); + const togglePaused = () => mutation.mutate({ isPaused: !isPaused }); return ( - - - - - + {/* span helps tooltip find its position */} + + + + ); }; -export default React.memo(Row, compareObjectProps); +export const TriggerDagButton: React.FC<{ dagId: string }> = ({ dagId }) => { + const { isOpen, onToggle, onClose } = useDisclosure(); + return ( + <> + + } + onClick={onToggle} + /> + + + + ); +}; + +export const DagName: React.FC<{ dagId: string }> = ({ dagId }) => ( + + {dagId} + +); + +export const DagTag: React.FC<{ tag: DagTagType }> = ({ tag }) => ( + + {tag.name} + +); diff --git a/airflow/ui/test/Pipelines.test.tsx b/airflow/ui/test/Pipelines.test.tsx index 5426772407092..1e1b32d4a30fe 100644 --- a/airflow/ui/test/Pipelines.test.tsx +++ b/airflow/ui/test/Pipelines.test.tsx @@ -74,18 +74,20 @@ describe('Test Pipelines Table', () => { nock(url) .defaultReplyHeaders(defaultHeaders) .get('/dags') + .query(() => true) .reply(200, { dags: [sampleDag], totalEntries: 1, }); - const { getByText } = render( + const { getByText, getAllByTestId } = render( , { wrapper: RouterWrapper, }, ); - expect(getByText('Loading…')).toBeInTheDocument(); + // At least one loading bar is rendered + expect(getAllByTestId('pipelines-loading').length).toBeGreaterThanOrEqual(1); await waitFor(() => expect(getByText(sampleDag.dagId)).toBeInTheDocument()); }); @@ -93,6 +95,7 @@ describe('Test Pipelines Table', () => { nock(url) .defaultReplyHeaders(defaultHeaders) .get('/dags') + .query(() => true) .reply(404, { dags: [], totalEntries: 0, @@ -111,13 +114,14 @@ describe('Test Pipelines Table', () => { }, ); - await waitFor(() => expect(getByText('No Pipelines found.')).toBeInTheDocument()); + await waitFor(() => expect(getByText('No Data found.')).toBeInTheDocument()); }); test('Toggle a pipeline on/off', async () => { nock(url) .defaultReplyHeaders(defaultHeaders) .get('/dags') + .query(() => true) .reply(200, { dags: [sampleDag], totalEntries: 1, @@ -145,4 +149,20 @@ describe('Test Pipelines Table', () => { await waitFor(() => expect(getByText('Pipeline Updated')).toBeInTheDocument()); await waitFor(() => expect(input.checked).toBeFalsy()); }); + + test('Errors when retrieving dags are shown to the user', async () => { + nock(url) + .defaultReplyHeaders(defaultHeaders) + .get('/dags') + .query(() => true) + .replyWithError('something awful happened'); + + const { getByText } = render( + , + { + wrapper: RouterWrapper, + }, + ); + await waitFor(() => expect(getByText('something awful happened')).toBeInTheDocument()); + }); }); diff --git a/airflow/ui/yarn.lock b/airflow/ui/yarn.lock index 3e75fd01f540f..bfb647426222a 100644 --- a/airflow/ui/yarn.lock +++ b/airflow/ui/yarn.lock @@ -2240,6 +2240,13 @@ "@types/react-dom" "*" "@types/react-transition-group" "*" +"@types/react-table@^7.7.0": + version "7.7.0" + resolved "https://registry.yarnpkg.com/@types/react-table/-/react-table-7.7.0.tgz#cb441a03af7303efd570b816b5ed0ea4d35ce705" + integrity sha512-xx2PJO9a0FEY7s96KWmadrhWNGxkNZgMnoKbXKPepqzz4FHKVds1tPDqWOU7NpP+dAsRli/wn0ysZ4MxWZI4Nw== + dependencies: + "@types/react" "*" + "@types/react-transition-group@*": version "4.4.1" resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.1.tgz#e1a3cb278df7f47f17b5082b1b3da17170bd44b1" @@ -8511,6 +8518,11 @@ react-style-singleton@^2.1.0: invariant "^2.2.4" tslib "^1.0.0" +react-table@^7.7.0: + version "7.7.0" + resolved "https://registry.yarnpkg.com/react-table/-/react-table-7.7.0.tgz#e2ce14d7fe3a559f7444e9ecfe8231ea8373f912" + integrity sha512-jBlj70iBwOTvvImsU9t01LjFjy4sXEtclBovl3mTiqjz23Reu0DKnRza4zlLtOPACx6j2/7MrQIthIK1Wi+LIA== + react-test-renderer@^17.0.1: version "17.0.1" resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-17.0.1.tgz#3187e636c3063e6ae498aedf21ecf972721574c7"
e.stopPropagation()} paddingRight="0" width="58px"> - - {/* span helps tooltip find its position */} - - - - - - - - {dag.dagId} - - {dag.tags.map((tag: DagTag) => ( - - {tag.name} - - ))} - - - - } - onClick={onToggle} - /> - - -