diff --git a/airflow/www/static/js/api/index.ts b/airflow/www/static/js/api/index.ts index 054e831008d4b..82758738f0b3a 100644 --- a/airflow/www/static/js/api/index.ts +++ b/airflow/www/static/js/api/index.ts @@ -34,6 +34,8 @@ import useConfirmMarkTask from './useConfirmMarkTask'; import useGridData from './useGridData'; import useMappedInstances from './useMappedInstances'; import useDatasets from './useDatasets'; +import useDataset from './useDataset'; +import useDatasetEvents from './useDatasetEvents'; axios.interceptors.response.use( (res: AxiosResponse) => (res.data ? camelcaseKeys(res.data, { deep: true }) : res), @@ -56,4 +58,6 @@ export { useGridData, useMappedInstances, useDatasets, + useDataset, + useDatasetEvents, }; diff --git a/airflow/www/static/js/api/useDataset.ts b/airflow/www/static/js/api/useDataset.ts new file mode 100644 index 0000000000000..9149f1c1592af --- /dev/null +++ b/airflow/www/static/js/api/useDataset.ts @@ -0,0 +1,38 @@ +/*! + * 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. + */ + +import axios, { AxiosResponse } from 'axios'; +import { useQuery } from 'react-query'; + +import { getMetaValue } from 'src/utils'; +import type { API } from 'src/types'; + +interface Props { + datasetId: string; +} + +export default function useDataset({ datasetId }: Props) { + return useQuery( + ['dataset', datasetId], + () => { + const datasetUrl = `${getMetaValue('datasets_api') || '/api/v1/datasets'}/${datasetId}`; + return axios.get(datasetUrl); + }, + ); +} diff --git a/airflow/www/static/js/api/useDatasetEvents.ts b/airflow/www/static/js/api/useDatasetEvents.ts new file mode 100644 index 0000000000000..7df7c91a1cd51 --- /dev/null +++ b/airflow/www/static/js/api/useDatasetEvents.ts @@ -0,0 +1,73 @@ +/*! + * 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. + */ + +import axios, { AxiosResponse } from 'axios'; +import { useQuery } from 'react-query'; + +import { getMetaValue } from 'src/utils'; +import type { API } from 'src/types'; + +interface DatasetEventsData { + datasetEvents: API.DatasetEvent[]; + totalEntries: number; +} + +interface Props { + datasetId?: string; + dagId?: string; + taskId?: string; + runId?: string; + mapIndex?: number; + limit?: number; + offset?: number; + order?: string; +} + +export default function useDatasetEvents({ + datasetId, dagId, runId, taskId, mapIndex, limit, offset, order, +}: Props) { + const query = useQuery( + ['datasets-events', datasetId, dagId, runId, taskId, mapIndex, limit, offset, order], + () => { + const datasetsUrl = getMetaValue('dataset_events_api') || '/api/v1/datasets/events'; + + const params = new URLSearchParams(); + + if (limit) params.set('limit', limit.toString()); + if (offset) params.set('offset', offset.toString()); + if (order) params.set('order_by', order); + if (datasetId) params.set('dataset_id', datasetId); + if (dagId) params.set('source_dag_id', dagId); + if (runId) params.set('source_run_id', runId); + if (taskId) params.set('source_task_id', taskId); + if (mapIndex) params.set('source_map_index', mapIndex.toString()); + + return axios.get(datasetsUrl, { + params, + }); + }, + { + keepPreviousData: true, + }, + ); + return { + ...query, + data: query.data ?? { datasetEvents: [], totalEntries: 0 }, + }; +} diff --git a/airflow/www/static/js/api/useDatasets.ts b/airflow/www/static/js/api/useDatasets.ts index 7800a7c6cff0a..e2cf856c3f573 100644 --- a/airflow/www/static/js/api/useDatasets.ts +++ b/airflow/www/static/js/api/useDatasets.ts @@ -35,7 +35,7 @@ interface Props { } export default function useDatasets({ limit, offset, order }: Props) { - const query = useQuery( + const query = useQuery( ['datasets', limit, offset, order], () => { const datasetsUrl = getMetaValue('datasets_api') || '/api/v1/datasets'; diff --git a/airflow/www/static/js/components/Table.test.tsx b/airflow/www/static/js/components/Table.test.tsx index dd6cea13efed6..e7a2f14a9be13 100644 --- a/airflow/www/static/js/components/Table.test.tsx +++ b/airflow/www/static/js/components/Table.test.tsx @@ -19,15 +19,16 @@ /* global describe, test, expect */ -import React from 'react'; +import React, { useState } from 'react'; import '@testing-library/jest-dom'; import { render, fireEvent, within } from '@testing-library/react'; import { sortBy } from 'lodash'; +import type { SortingRule } from 'react-table'; import Table from './Table'; import { ChakraWrapper } from '../utils/testUtils'; -const data = [ +const data: Record[] = [ { firstName: 'Lamont', lastName: 'Grimes', country: 'United States' }, { firstName: 'Alysa', lastName: 'Armstrong', country: 'Spain' }, { firstName: 'Petra', lastName: 'Blick', country: 'France' }, @@ -93,9 +94,28 @@ describe('Test Table', () => { expect(getByText('No Data found.')).toBeInTheDocument(); }); - test('With pagination', async () => { + // Simulated pagination that would be done serverside + const PaginatedTable = () => { + const pageSize = 2; + const [offset, setOffset] = useState(0); + const filteredData = data.slice(offset, offset + pageSize); + return ( + + ); + }; + + test('With manual pagination', async () => { const { getAllByRole, queryByText, getByTitle } = render( -
, + , { wrapper: ChakraWrapper }, ); @@ -152,10 +172,33 @@ describe('Test Table', () => { expect(next).toBeDisabled(); }); - test('With sorting', async () => { - const { getAllByRole } = render(
, { - wrapper: ChakraWrapper, - }); + // Simulate manual sorting that would be done serverside + const SortedTable = () => { + const [sortByVar, setSortBy] = useState[]>([]); + const sort = sortByVar[0]; + let sortedData = data; + if (sort) { + sortedData = sort.desc + ? sortBy(data, [(o) => o[sort.id]]).reverse() + : sortBy(data, [(o) => o[sort.id]]); + } + return ( +
+ ); + }; + + test('With manual sorting', async () => { + const { getAllByRole } = render( + , + { wrapper: ChakraWrapper }, + ); // Default order matches original data order // const firstNameHeader = getAllByRole('columnheader')[0]; diff --git a/airflow/www/static/js/components/Table.tsx b/airflow/www/static/js/components/Table.tsx index 47c82c2145269..b539ed165c3db 100644 --- a/airflow/www/static/js/components/Table.tsx +++ b/airflow/www/static/js/components/Table.tsx @@ -46,6 +46,7 @@ import { Column, Hooks, SortingRule, + Row, } from 'react-table'; import { MdKeyboardArrowLeft, MdKeyboardArrowRight, @@ -83,19 +84,26 @@ interface TableProps { offset: number; setOffset: (offset: number) => void; }, + manualSort?: { + sortBy: SortingRule[]; + setSortBy: (sortBy: SortingRule[]) => void; + initialSortBy?: SortingRule[]; + }, pageSize?: number; - setSortBy?: (sortBy: SortingRule[]) => void; isLoading?: boolean; selectRows?: (selectedRows: number[]) => void; + onRowClicked?: (row: Row, e: any) => void; } + const Table = ({ data, columns, manualPagination, + manualSort, pageSize = 25, - setSortBy, isLoading = false, selectRows, + onRowClicked, }: TableProps) => { const { totalEntries, offset, setOffset } = manualPagination || {}; const oddColor = useColorModeValue('gray.50', 'gray.900'); @@ -143,10 +151,12 @@ const Table = ({ data, pageCount, manualPagination: !!manualPagination, - manualSortBy: !!setSortBy, + manualSortBy: !!manualSort, + disableMultiSort: !!manualSort, // API only supporting ordering by a single column initialState: { pageIndex: offset ? offset / pageSize : 0, pageSize, + sortBy: manualSort?.initialSortBy || [], }, }, useSortBy, @@ -164,9 +174,12 @@ const Table = ({ if (setOffset) setOffset((pageIndex - 1 || 0) * pageSize); }; + // When the sortBy state changes we need to manually call setSortBy useEffect(() => { - if (setSortBy) setSortBy(sortBy); - }, [sortBy, setSortBy]); + if (manualSort) { + manualSort.setSortBy(sortBy); + } + }, [sortBy, manualSort]); useEffect(() => { if (selectRows) { @@ -212,7 +225,8 @@ const Table = ({ onRowClicked(row, e) : undefined} > {row.cells.map((cell) => (
diff --git a/airflow/www/static/js/datasets/Details.tsx b/airflow/www/static/js/datasets/Details.tsx new file mode 100644 index 0000000000000..110406f291bef --- /dev/null +++ b/airflow/www/static/js/datasets/Details.tsx @@ -0,0 +1,163 @@ +/*! + * 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. + */ + +import React, { useMemo, useState } from 'react'; +import { + Box, Heading, Text, Code, Flex, Spinner, Button, Link, +} from '@chakra-ui/react'; +import { snakeCase } from 'lodash'; +import type { SortingRule } from 'react-table'; + +import Time from 'src/components/Time'; +import { useDatasetEvents, useDataset } from 'src/api'; +import Table from 'src/components/Table'; +import { ClipboardButton } from 'src/components/Clipboard'; + +interface Props { + datasetId: string; + onBack: () => void; +} + +const TimeCell = ({ cell: { value } }: any) =>