From ecba1043a0480b3403f1893f8bdf486ab6f77456 Mon Sep 17 00:00:00 2001 From: Marco polo Date: Fri, 23 Feb 2024 07:25:34 -0500 Subject: [PATCH] Asset checks UI revamp (#19934) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary & Motivation https://www.figma.com/file/1aaOz2e8PiCOGPGF82vsvw/Asset-Checks?node-id=749%3A1840&mode=dev Things omitted from the designs: - Durations because we don't support that on the backend atm - Partitions also because we don't support that yet - Check dependencies, also because we don't support that yet ## How I Tested These Changes Screenshot 2024-02-20 at 11 56 50 PM Screenshot 2024-02-20 at 11 53 26 PM Screenshot 2024-02-20 at 11 52 37 PM --- .../src/components/CollapsibleSection.tsx | 37 ++ .../src/components/VirtualizedTable.tsx | 2 +- .../packages/ui-components/src/index.ts | 1 + .../src/assets/AssetFeatureContext.tsx | 2 +- .../packages/ui-core/src/assets/AssetTabs.tsx | 1 - .../packages/ui-core/src/assets/AssetView.tsx | 1 - .../AssetViewDefinition.fixtures.ts | 275 +++++----- .../src/assets/__tests__/AssetView.test.tsx | 55 +- .../assets/__tests__/buildAssetTabs.test.tsx | 9 +- .../asset-checks/AssetCheckDetailModal.tsx | 188 ------- .../asset-checks/AssetCheckStatusTag.tsx | 2 +- .../src/assets/asset-checks/AssetChecks.tsx | 491 +++++++++++++++--- .../assets/asset-checks/AssetChecksBanner.tsx | 9 +- .../ui-core/src/assets/asset-checks/util.tsx | 61 +++ .../src/assets/types/AssetView.types.ts | 2 - 15 files changed, 686 insertions(+), 450 deletions(-) create mode 100644 js_modules/dagster-ui/packages/ui-components/src/components/CollapsibleSection.tsx create mode 100644 js_modules/dagster-ui/packages/ui-core/src/assets/asset-checks/util.tsx diff --git a/js_modules/dagster-ui/packages/ui-components/src/components/CollapsibleSection.tsx b/js_modules/dagster-ui/packages/ui-components/src/components/CollapsibleSection.tsx new file mode 100644 index 0000000000000..692f0525c2c0e --- /dev/null +++ b/js_modules/dagster-ui/packages/ui-components/src/components/CollapsibleSection.tsx @@ -0,0 +1,37 @@ +import React from 'react'; + +import {Box} from './Box'; +import {Icon} from './Icon'; + +export const CollapsibleSection = ({ + header, + headerWrapperProps, + children, + isInitiallyCollapsed = false, +}: { + header: React.ReactNode; + headerWrapperProps?: React.ComponentProps; + children: React.ReactNode; + isInitiallyCollapsed?: boolean; +}) => { + const [isCollapsed, setIsCollapsed] = React.useState(isInitiallyCollapsed); + return ( + + { + setIsCollapsed(!isCollapsed); + headerWrapperProps?.onClick?.(); + }} + > + +
{header}
+
+ {isCollapsed ? null : children} +
+ ); +}; diff --git a/js_modules/dagster-ui/packages/ui-components/src/components/VirtualizedTable.tsx b/js_modules/dagster-ui/packages/ui-components/src/components/VirtualizedTable.tsx index 327670c149937..0261036dab8ed 100644 --- a/js_modules/dagster-ui/packages/ui-components/src/components/VirtualizedTable.tsx +++ b/js_modules/dagster-ui/packages/ui-components/src/components/VirtualizedTable.tsx @@ -52,7 +52,7 @@ export const Inner = styled.div.attrs(({$totalHeight}) => ({ width: 100%; `; -type RowProps = {$height: number; $start: number}; +export type RowProps = {$height: number; $start: number}; export const Row = styled.div.attrs(({$height, $start}) => ({ style: { diff --git a/js_modules/dagster-ui/packages/ui-components/src/index.ts b/js_modules/dagster-ui/packages/ui-components/src/index.ts index e4db4521e129e..8da80f433a69d 100644 --- a/js_modules/dagster-ui/packages/ui-components/src/index.ts +++ b/js_modules/dagster-ui/packages/ui-components/src/index.ts @@ -6,6 +6,7 @@ export * from './components/Button'; export * from './components/ButtonGroup'; export * from './components/ButtonLink'; export * from './components/Checkbox'; +export * from './components/CollapsibleSection'; export * from './components/ConfigEditorDialog'; export * from './components/ConfigEditorWithSchema'; export * from './components/ConfigTypeSchema'; diff --git a/js_modules/dagster-ui/packages/ui-core/src/assets/AssetFeatureContext.tsx b/js_modules/dagster-ui/packages/ui-core/src/assets/AssetFeatureContext.tsx index fda68e80cbf45..be61f66d26d46 100644 --- a/js_modules/dagster-ui/packages/ui-core/src/assets/AssetFeatureContext.tsx +++ b/js_modules/dagster-ui/packages/ui-core/src/assets/AssetFeatureContext.tsx @@ -14,7 +14,7 @@ export type AssetViewFeatureInput = { type AssetFeatureContextType = { tabBuilder: (input: AssetTabConfigInput) => AssetTabConfig[]; renderFeatureView: (input: AssetViewFeatureInput) => React.ReactNode; - AssetChecksBanner: React.ComponentType>; + AssetChecksBanner: React.ComponentType<{onClose: () => void}>; }; export const AssetFeatureContext = React.createContext({ diff --git a/js_modules/dagster-ui/packages/ui-core/src/assets/AssetTabs.tsx b/js_modules/dagster-ui/packages/ui-core/src/assets/AssetTabs.tsx index 14d04b78e46f7..38e57ddef92a2 100644 --- a/js_modules/dagster-ui/packages/ui-core/src/assets/AssetTabs.tsx +++ b/js_modules/dagster-ui/packages/ui-core/src/assets/AssetTabs.tsx @@ -75,7 +75,6 @@ export const buildAssetTabMap = (input: AssetTabConfigInput): Record = { - request: { - query: ASSET_GRAPH_QUERY, - variables: {}, - }, - result: { - data: { - __typename: 'Query', - assetNodes: [], - }, - }, -}; - -export const AssetViewDefinitionNonSDA: MockedResponse = buildQueryMock({ +export const AssetViewDefinitionNonSDA = buildQueryMock< + AssetViewDefinitionQuery, + AssetViewDefinitionQueryVariables +>({ query: ASSET_VIEW_DEFINITION_QUERY, variables: { assetKey: {path: ['non_sda_asset']}, }, data: { - assetOrError: { + assetOrError: buildAsset({ id: '["non_sda_asset"]', - key: { + key: buildAssetKey({ path: ['non_sda_asset'], - __typename: 'AssetKey', - }, + }), assetMaterializations: [ buildMaterializationEvent({ timestamp: LatestMaterializationTimestamp, }), ], definition: null, - __typename: 'Asset', - }, + }), }, }); -export const AssetViewDefinitionSourceAsset: MockedResponse = { - request: { - query: ASSET_VIEW_DEFINITION_QUERY, - variables: { - assetKey: {path: ['observable_source_asset']}, - }, +export const AssetViewDefinitionSourceAsset = buildQueryMock< + AssetViewDefinitionQuery, + AssetViewDefinitionQueryVariables +>({ + query: ASSET_VIEW_DEFINITION_QUERY, + variables: { + assetKey: {path: ['observable_source_asset']}, }, - result: { - data: { - __typename: 'Query', - assetOrError: { + data: { + assetOrError: buildAsset({ + id: 'test.py.repo.["observable_source_asset"]', + key: buildAssetKey({ + path: ['observable_source_asset'], + }), + assetMaterializations: [], + definition: buildAssetNode({ id: 'test.py.repo.["observable_source_asset"]', - key: { - path: ['observable_source_asset'], - __typename: 'AssetKey', - }, - assetMaterializations: [], - definition: { - hasAssetChecks: false, - id: 'test.py.repo.["observable_source_asset"]', - groupName: 'GROUP3', - backfillPolicy: null, - partitionDefinition: null, - partitionKeysByDimension: [], - repository: { - id: '4d0b1967471d9a4682ccc97d12c1c508d0d9c2e1', - name: 'repo', - location: { - id: 'test.py', - name: 'test.py', - __typename: 'RepositoryLocation', - }, - __typename: 'Repository', - }, - __typename: 'AssetNode', - description: null, - graphName: null, - opNames: [], - opVersion: null, - jobNames: ['__ASSET_JOB'], - configField: null, - autoMaterializePolicy: null, - freshnessPolicy: null, - hasMaterializePermission: true, - computeKind: null, - isPartitioned: false, - isObservable: true, - isExecutable: true, - isSource: true, - assetKey: { - path: ['observable_source_asset'], - __typename: 'AssetKey', - }, - metadataEntries: [], - type: null, - requiredResources: [ - { - __typename: 'ResourceRequirement', - resourceKey: 'foo', - }, - ], - targetingInstigators: [], - }, - __typename: 'Asset', - }, - }, + groupName: 'GROUP3', + backfillPolicy: null, + partitionDefinition: null, + + partitionKeysByDimension: [], + repository: buildRepository({ + id: '4d0b1967471d9a4682ccc97d12c1c508d0d9c2e1', + name: 'repo', + location: buildRepositoryLocation({ + id: 'test.py', + name: 'test.py', + }), + }), + description: null, + graphName: null, + opNames: [], + opVersion: null, + jobNames: ['__ASSET_JOB'], + configField: null, + autoMaterializePolicy: null, + freshnessPolicy: null, + hasMaterializePermission: true, + computeKind: null, + isPartitioned: false, + isObservable: true, + isExecutable: true, + isSource: true, + metadataEntries: [], + type: null, + requiredResources: [buildResourceRequirement({resourceKey: 'foo'})], + targetingInstigators: [], + }), + }), }, -}; +}); -export const AssetViewDefinitionSDA: MockedResponse = { - request: { - query: ASSET_VIEW_DEFINITION_QUERY, - variables: { - assetKey: {path: ['sda_asset']}, - }, +export const AssetViewDefinitionSDA = buildQueryMock< + AssetViewDefinitionQuery, + AssetViewDefinitionQueryVariables +>({ + query: ASSET_VIEW_DEFINITION_QUERY, + variables: { + assetKey: {path: ['sda_asset']}, }, - result: { - data: { - __typename: 'Query', - assetOrError: { + data: { + assetOrError: buildAsset({ + id: 'test.py.repo.["sda_asset"]', + key: buildAssetKey({ + path: ['sda_asset'], + }), + assetMaterializations: [], + definition: buildAssetNode({ id: 'test.py.repo.["sda_asset"]', - key: { - path: ['sda_asset'], - __typename: 'AssetKey', - }, - assetMaterializations: [], - definition: { - hasAssetChecks: false, - id: 'test.py.repo.["sda_asset"]', - groupName: 'GROUP3', - backfillPolicy: null, - partitionDefinition: null, - partitionKeysByDimension: [], - repository: { - id: '4d0b1967471d9a4682ccc97d12c1c508d0d9c2e1', - name: 'repo', - location: { - id: 'test.py', - name: 'test.py', - __typename: 'RepositoryLocation', - }, - __typename: 'Repository', - }, - __typename: 'AssetNode', - description: null, - graphName: null, - opNames: [], - opVersion: null, - jobNames: ['__ASSET_JOB'], - configField: null, - autoMaterializePolicy: null, - freshnessPolicy: null, - hasMaterializePermission: true, - computeKind: null, - isPartitioned: false, - isObservable: false, - isExecutable: true, - isSource: false, - assetKey: { - path: ['sda_asset'], - __typename: 'AssetKey', - }, - metadataEntries: [], - type: null, - requiredResources: [ - { - __typename: 'ResourceRequirement', - resourceKey: 'foo', - }, - ], - targetingInstigators: [], - }, - __typename: 'Asset', - }, - }, + groupName: 'GROUP3', + backfillPolicy: null, + partitionDefinition: null, + partitionKeysByDimension: [], + repository: buildRepository({ + id: '4d0b1967471d9a4682ccc97d12c1c508d0d9c2e1', + name: 'repo', + location: buildRepositoryLocation({ + id: 'test.py', + name: 'test.py', + }), + }), + description: null, + graphName: null, + opNames: [], + opVersion: null, + jobNames: ['__ASSET_JOB'], + configField: null, + autoMaterializePolicy: null, + freshnessPolicy: null, + hasMaterializePermission: true, + computeKind: null, + isPartitioned: false, + isObservable: false, + isExecutable: true, + isSource: false, + metadataEntries: [], + type: null, + requiredResources: [buildResourceRequirement({resourceKey: 'foo'})], + targetingInstigators: [], + }), + }), }, -}; +}); diff --git a/js_modules/dagster-ui/packages/ui-core/src/assets/__tests__/AssetView.test.tsx b/js_modules/dagster-ui/packages/ui-core/src/assets/__tests__/AssetView.test.tsx index 00f3abd1f2ded..f9c4fa04b1e5e 100644 --- a/js_modules/dagster-ui/packages/ui-core/src/assets/__tests__/AssetView.test.tsx +++ b/js_modules/dagster-ui/packages/ui-core/src/assets/__tests__/AssetView.test.tsx @@ -2,10 +2,28 @@ import {MockedProvider} from '@apollo/client/testing'; import {act, render, screen, waitFor} from '@testing-library/react'; import {MemoryRouter} from 'react-router-dom'; -import {AssetKeyInput} from '../../graphql/types'; +import { + ASSETS_GRAPH_LIVE_QUERY, + AssetLiveDataProvider, +} from '../../asset-data/AssetLiveDataProvider'; +import { + AssetGraphLiveQuery, + AssetGraphLiveQueryVariables, +} from '../../asset-data/types/AssetLiveDataProvider.types'; +import { + AssetGraphQuery, + AssetGraphQueryVariables, +} from '../../asset-graph/types/useAssetGraphData.types'; +import {ASSET_GRAPH_QUERY} from '../../asset-graph/useAssetGraphData'; +import { + AssetKeyInput, + buildAssetKey, + buildAssetLatestInfo, + buildAssetNode, +} from '../../graphql/types'; +import {buildQueryMock} from '../../testing/mocking'; import {AssetView} from '../AssetView'; import { - AssetGraphEmpty, AssetViewDefinitionNonSDA, AssetViewDefinitionSDA, AssetViewDefinitionSourceAsset, @@ -20,20 +38,45 @@ jest.mock('../../graph/asyncGraphLayout', () => ({})); jest.mock('../AssetPartitions', () => ({AssetPartitions: () =>
})); jest.mock('../AssetEvents', () => ({AssetEvents: () =>
})); +function mockLiveData(key: string) { + const assetKey = {path: [key]}; + return buildQueryMock({ + query: ASSETS_GRAPH_LIVE_QUERY, + variables: { + assetKeys: [assetKey], + }, + data: { + assetNodes: [buildAssetNode({assetKey: buildAssetKey(assetKey)})], + assetsLatestInfo: [buildAssetLatestInfo({assetKey: buildAssetKey(assetKey)})], + }, + }); +} + describe('AssetView', () => { const Test = ({path, assetKey}: {path: string; assetKey: AssetKeyInput}) => { return ( ({ + query: ASSET_GRAPH_QUERY, + variables: {}, + data: { + assetNodes: [buildAssetNode()], + }, + }), ]} > - - - + + + + + ); }; diff --git a/js_modules/dagster-ui/packages/ui-core/src/assets/__tests__/buildAssetTabs.test.tsx b/js_modules/dagster-ui/packages/ui-core/src/assets/__tests__/buildAssetTabs.test.tsx index 22e9ed65c46c6..d850a6da0469f 100644 --- a/js_modules/dagster-ui/packages/ui-core/src/assets/__tests__/buildAssetTabs.test.tsx +++ b/js_modules/dagster-ui/packages/ui-core/src/assets/__tests__/buildAssetTabs.test.tsx @@ -32,7 +32,6 @@ const autoMaterializePolicy = buildAutoMaterializePolicy({ describe('buildAssetTabs', () => { const definitionWithPartition: AssetViewDefinitionNodeFragment = buildAssetNode({ id: 'dagster_test.toys.repo.auto_materialize_repo_2.["eager_downstream_3_partitioned"]', - hasAssetChecks: false, groupName: 'default', partitionDefinition: buildPartitionDefinition({ description: 'Daily, starting 2023-02-01 UTC.', @@ -139,7 +138,6 @@ describe('buildAssetTabs', () => { const definitionWithoutPartition: AssetViewDefinitionNodeFragment = { id: 'dagster_test.toys.repo.auto_materialize_repo_1.["lazy_downstream_1"]', groupName: 'default', - hasAssetChecks: false, partitionDefinition: null, partitionKeysByDimension: [], repository: { @@ -279,6 +277,7 @@ describe('buildAssetTabs', () => { expect(tabKeys).toEqual([ 'partitions', 'events', + 'checks', 'plots', 'definition', 'lineage', @@ -292,7 +291,7 @@ describe('buildAssetTabs', () => { params, }); const tabKeys = tabList.map(({id}) => id); - expect(tabKeys).toEqual(['partitions', 'events', 'plots', 'definition', 'lineage']); + expect(tabKeys).toEqual(['partitions', 'events', 'checks', 'plots', 'definition', 'lineage']); }); it('hides partitions tab if no partitions', () => { @@ -301,7 +300,7 @@ describe('buildAssetTabs', () => { params, }); const tabKeys = tabList.map(({id}) => id); - expect(tabKeys).toEqual(['events', 'plots', 'definition', 'lineage', 'automation']); + expect(tabKeys).toEqual(['events', 'checks', 'plots', 'definition', 'lineage', 'automation']); }); it('hides partitions and auto-materialize tabs if no partitions or auto-materializing', () => { @@ -310,6 +309,6 @@ describe('buildAssetTabs', () => { params, }); const tabKeys = tabList.map(({id}) => id); - expect(tabKeys).toEqual(['events', 'plots', 'definition', 'lineage']); + expect(tabKeys).toEqual(['events', 'checks', 'plots', 'definition', 'lineage']); }); }); diff --git a/js_modules/dagster-ui/packages/ui-core/src/assets/asset-checks/AssetCheckDetailModal.tsx b/js_modules/dagster-ui/packages/ui-core/src/assets/asset-checks/AssetCheckDetailModal.tsx index d9c7a110b904f..93b188ef25a5d 100644 --- a/js_modules/dagster-ui/packages/ui-core/src/assets/asset-checks/AssetCheckDetailModal.tsx +++ b/js_modules/dagster-ui/packages/ui-core/src/assets/asset-checks/AssetCheckDetailModal.tsx @@ -4,177 +4,16 @@ import { Box, Button, Colors, - CursorHistoryControls, Dialog, DialogBody, DialogFooter, Mono, NonIdealState, - Spinner, - Table, } from '@dagster-io/ui-components'; import {useState} from 'react'; -import {Link} from 'react-router-dom'; -import {AssetCheckStatusTag} from './AssetCheckStatusTag'; -import { - AssetCheckDetailsQuery, - AssetCheckDetailsQueryVariables, -} from './types/AssetCheckDetailModal.types'; -import {FIFTEEN_SECONDS, useQueryRefreshAtInterval} from '../../app/QueryRefresh'; -import {useTrackPageView} from '../../app/analytics'; -import {AssetKeyInput} from '../../graphql/types'; -import {useDocumentTitle} from '../../hooks/useDocumentTitle'; import {METADATA_ENTRY_FRAGMENT, MetadataEntries} from '../../metadata/MetadataEntry'; import {MetadataEntryFragment} from '../../metadata/types/MetadataEntry.types'; -import {linkToRunEvent} from '../../runs/RunUtils'; -import {useCursorPaginatedQuery} from '../../runs/useCursorPaginatedQuery'; -import {TimestampDisplay} from '../../schedules/TimestampDisplay'; - -export const AssetCheckDetailModal = ({ - assetKey, - checkName, - onClose, -}: { - assetKey: AssetKeyInput; - checkName: string | undefined | null; - onClose: () => void; -}) => { - return ( - - {checkName ? : null} - - ); -}; - -const PAGE_SIZE = 5; - -const AssetCheckDetailModalImpl = ({ - assetKey, - checkName, -}: { - assetKey: AssetKeyInput; - checkName: string; -}) => { - useTrackPageView(); - useDocumentTitle(`Asset Check | ${checkName}`); - - const {queryResult, paginationProps} = useCursorPaginatedQuery< - AssetCheckDetailsQuery, - AssetCheckDetailsQueryVariables - >({ - query: ASSET_CHECK_DETAILS_QUERY, - variables: { - assetKey, - checkName, - }, - nextCursorForResult: (data) => { - if (!data) { - return undefined; - } - return data.assetCheckExecutions[PAGE_SIZE - 1]?.id.toString(); - }, - getResultArray: (data) => { - if (!data) { - return []; - } - return data.assetCheckExecutions || []; - }, - pageSize: PAGE_SIZE, - }); - - // TODO - in a follow up PR we should have some kind of queryRefresh context that can merge all of the uses of queryRefresh. - useQueryRefreshAtInterval(queryResult, FIFTEEN_SECONDS); - - const executions = queryResult.data?.assetCheckExecutions; - - const runHistory = () => { - if (!executions) { - return ( - - - - ); - } - - if (!executions.length) { - return ; - } - return ( -
- - - - - - - - - - - {executions.map((execution) => { - return ( - - - - - - - ); - })} - -
TimestampTarget materializationResultEvaluation metadata
- {execution.evaluation?.timestamp ? ( - - - - ) : ( - - )} - - {execution.evaluation?.targetMaterialization ? ( - - - - ) : ( - ' - ' - )} - - - - -
-
- -
-
- ); - }; - - if (!executions) { - return ( - - - - ); - } - return {runHistory()}; -}; export function MetadataCell({metadataEntries}: {metadataEntries?: MetadataEntryFragment[]}) { const [showMetadata, setShowMetadata] = useState(false); @@ -336,33 +175,6 @@ export function NoChecks() { ); } -function NoExecutions() { - return ( - - - - No executions found. Materialize this asset and the check will run automatically. - - {/* - Learn more about Asset Checks - - */} - - } - /> - - ); -} - const InlineableTypenames: MetadataEntryFragment['__typename'][] = [ 'BoolMetadataEntry', 'FloatMetadataEntry', diff --git a/js_modules/dagster-ui/packages/ui-core/src/assets/asset-checks/AssetCheckStatusTag.tsx b/js_modules/dagster-ui/packages/ui-core/src/assets/asset-checks/AssetCheckStatusTag.tsx index 412a3e3cf929a..8b534598f94e0 100644 --- a/js_modules/dagster-ui/packages/ui-core/src/assets/asset-checks/AssetCheckStatusTag.tsx +++ b/js_modules/dagster-ui/packages/ui-core/src/assets/asset-checks/AssetCheckStatusTag.tsx @@ -25,7 +25,7 @@ export const AssetCheckStatusTag = ({ } + icon={} label="Not evaluated" /> ); diff --git a/js_modules/dagster-ui/packages/ui-core/src/assets/asset-checks/AssetChecks.tsx b/js_modules/dagster-ui/packages/ui-core/src/assets/asset-checks/AssetChecks.tsx index a207e48063123..94490d470b7a5 100644 --- a/js_modules/dagster-ui/packages/ui-core/src/assets/asset-checks/AssetChecks.tsx +++ b/js_modules/dagster-ui/packages/ui-core/src/assets/asset-checks/AssetChecks.tsx @@ -1,28 +1,53 @@ import {gql, useQuery} from '@apollo/client'; -import {Body2, Box, Tag} from '@dagster-io/ui-components'; -import {useContext} from 'react'; +import { + Body2, + Box, + Caption, + CollapsibleSection, + Colors, + CursorHistoryControls, + Icon, + NonIdealState, + Spinner, + Subtitle1, + Subtitle2, + Table, + TextInput, + useViewport, +} from '@dagster-io/ui-components'; +import {RowProps} from '@dagster-io/ui-components/src/components/VirtualizedTable'; +import {useVirtualizer} from '@tanstack/react-virtual'; +import React, {useContext} from 'react'; import {Link} from 'react-router-dom'; +import styled from 'styled-components'; -import { - AgentUpgradeRequired, - AssetCheckDetailModal, - MigrationRequired, - NeedsUserCodeUpgrade, - NoChecks, -} from './AssetCheckDetailModal'; +import {ASSET_CHECK_DETAILS_QUERY, MetadataCell} from './AssetCheckDetailModal'; +import {AssetCheckStatusTag} from './AssetCheckStatusTag'; import { EXECUTE_CHECKS_BUTTON_ASSET_NODE_FRAGMENT, EXECUTE_CHECKS_BUTTON_CHECK_FRAGMENT, ExecuteChecksButton, } from './ExecuteChecksButton'; -import {ASSET_CHECK_TABLE_FRAGMENT, VirtualizedAssetCheckTable} from './VirtualizedAssetCheckTable'; +import {ASSET_CHECK_TABLE_FRAGMENT} from './VirtualizedAssetCheckTable'; +import { + AssetCheckDetailsQuery, + AssetCheckDetailsQueryVariables, +} from './types/AssetCheckDetailModal.types'; import {AssetChecksQuery, AssetChecksQueryVariables} from './types/AssetChecks.types'; +import {assetCheckStatusDescription, getCheckIcon} from './util'; import {FIFTEEN_SECONDS, useQueryRefreshAtInterval} from '../../app/QueryRefresh'; +import {COMMON_COLLATOR} from '../../app/Util'; import {Timestamp} from '../../app/time/Timestamp'; +import {AssetKeyInput} from '../../graphql/types'; import {useQueryPersistedState} from '../../hooks/useQueryPersistedState'; -import {LoadingSpinner} from '../../ui/Loading'; +import {useStateWithStorage} from '../../hooks/useStateWithStorage'; +import {linkToRunEvent} from '../../runs/RunUtils'; +import {useCursorPaginatedQuery} from '../../runs/useCursorPaginatedQuery'; +import {TimestampDisplay} from '../../schedules/TimestampDisplay'; +import {Container, Inner, Row} from '../../ui/VirtualizedTable'; +import {numberFormatter} from '../../ui/formatters'; import {AssetFeatureContext} from '../AssetFeatureContext'; -import {assetDetailsPathForKey} from '../assetDetailsPathForKey'; +import {PAGE_SIZE} from '../AutoMaterializePolicyPage/useEvaluationsQueryResult'; import {AssetKey} from '../types'; export const AssetChecks = ({ @@ -38,93 +63,399 @@ export const AssetChecks = ({ const {data} = queryResult; useQueryRefreshAtInterval(queryResult, FIFTEEN_SECONDS); - const [openCheck, setOpenCheck] = useQueryPersistedState({ + const [selectedCheckName, setSelectedCheckName] = useQueryPersistedState({ queryKey: 'checkDetail', }); - function content() { - if (!data) { - return ; - } - const assetNode = data.assetNodeOrError; - if (assetNode?.__typename !== 'AssetNode') { - return ; - } - const result = assetNode.assetChecksOrError; - if (result.__typename === 'AssetCheckNeedsMigrationError') { - return ; - } - if (result.__typename === 'AssetCheckNeedsUserCodeUpgrade') { - return ; + const assetNode = + data?.assetNodeOrError.__typename === 'AssetNode' ? data.assetNodeOrError : null; + + const checks = React.useMemo(() => { + if (data?.assetNodeOrError.__typename !== 'AssetNode') { + return []; } - if (result.__typename === 'AssetCheckNeedsAgentUpgradeError') { - return ; + if (data.assetNodeOrError.assetChecksOrError.__typename !== 'AssetChecks') { + return []; } - const checks = result.checks; - if (!checks.length) { - return ; + return [...data.assetNodeOrError.assetChecksOrError.checks].sort((a, b) => + COMMON_COLLATOR.compare(a.name, b.name), + ); + }, [data]); + + const {AssetChecksBanner} = useContext(AssetFeatureContext); + + const [didDismissAssetChecksBanner, setDidDismissAssetChecksBanner] = useStateWithStorage( + 'asset-checks-experimental-banner', + (json) => !!json, + ); + + const [searchValue, setSearchValue] = React.useState(''); + + const filteredChecks = React.useMemo(() => { + return checks.filter((check) => check.name.toLowerCase().includes(searchValue.toLowerCase())); + }, [checks, searchValue]); + + const containerRef = React.useRef(null); + + const rowVirtualizer = useVirtualizer({ + count: filteredChecks.length, + getScrollElement: () => containerRef.current, + estimateSize: () => 48, + overscan: 10, + }); + + const totalHeight = rowVirtualizer.getTotalSize(); + const items = rowVirtualizer.getVirtualItems(); + + const selectedCheck = React.useMemo(() => { + if (!selectedCheckName) { + return checks[0]; } + return checks.find((check) => check.name === selectedCheckName) ?? checks[0]; + }, [selectedCheckName, checks]); + + if (!data) { + return null; + } + + if (!checks.length || !selectedCheck || !assetNode) { return ( - <> - setOpenCheck(undefined)} + + + + Asset checks can verify properties of a data asset, e.g. that there are no null + values in a particular column. + + + Learn more about asset checks + + + } /> - - + ); } - function executeAllButton() { - const assetNode = data?.assetNodeOrError; - if (assetNode?.__typename !== 'AssetNode') { - return ; - } - const checksOrError = assetNode.assetChecksOrError; - if (checksOrError?.__typename !== 'AssetChecks') { - return ; - } - return ; - } - - const {AssetChecksBanner} = useContext(AssetFeatureContext); + const lastExecution = selectedCheck.executionForLatestMaterialization; + const targetMaterialization = lastExecution?.evaluation?.targetMaterialization; return ( -
- - - - - - Latest materialization: - - {lastMaterializationTimestamp ? ( - + {didDismissAssetChecksBanner ? null : ( + + { + setDidDismissAssetChecksBanner(true); + }} + /> + + )} + + + + + Checks {checks.length ? <>({numberFormatter.format(checks.length)}) : null} + + + + + setSearchValue(e.target.value)} + placeholder="Filter checks" + /> + + + + {items.map(({index, size, start}) => { + const check = filteredChecks[index]!; + return ( + { + setSelectedCheckName(check.name); + }} + > + + + + {getCheckIcon(check)} + + {check.name} + + + + {assetCheckStatusDescription(check)} + + + + + ); + })} + + + + + + + + + + {selectedCheck.name} + + + + + About} + headerWrapperProps={headerWrapperProps} + > + + + {selectedCheck.description ?? ( + No description provided + )} + + {/* {selectedCheck.dependencies?.length ? ( + + {assetNode.dependencies.map((dep) => { + const key = dep.asset.assetKey; + return ( + + {displayNameForAssetKey(key)} + + ); + })} + + ) : ( + No dependencies + )} */} + + + Latest execution} + headerWrapperProps={headerWrapperProps} + > + +
+ + Evaluation Result +
+ +
+
+ {lastExecution ? ( + + Timestamp + + + + + ) : null} + {targetMaterialization ? ( + + Target materialization + + + + + ) : null} +
+
+
+ Execution history} + headerWrapperProps={headerWrapperProps} > - - - - - ) : ( - None - )} + + {lastExecution ? ( + + ) : ( + No execution history + )} + + +
- {executeAllButton()}
- {content()} -
+ + ); +}; + +const CheckExecutions = ({assetKey, checkName}: {assetKey: AssetKeyInput; checkName: string}) => { + const {queryResult, paginationProps} = useCursorPaginatedQuery< + AssetCheckDetailsQuery, + AssetCheckDetailsQueryVariables + >({ + query: ASSET_CHECK_DETAILS_QUERY, + variables: { + assetKey, + checkName, + }, + nextCursorForResult: (data) => { + if (!data) { + return undefined; + } + return data.assetCheckExecutions[PAGE_SIZE - 1]?.id.toString(); + }, + getResultArray: (data) => { + if (!data) { + return []; + } + return data.assetCheckExecutions || []; + }, + pageSize: PAGE_SIZE, + }); + + // TODO - in a follow up PR we should have some kind of queryRefresh context that can merge all of the uses of queryRefresh. + useQueryRefreshAtInterval(queryResult, FIFTEEN_SECONDS); + + const executions = queryResult.data?.assetCheckExecutions; + + const runHistory = () => { + if (!executions) { + return; + } + return ( +
+ + + + + + + + + + + {executions.map((execution) => { + return ( + + + + + + + ); + })} + +
Evaluation ResultTimestampTarget materializationMetadata
+ + + {execution.evaluation?.timestamp ? ( + + + + ) : ( + + )} + + {execution.evaluation?.targetMaterialization ? ( + + + + ) : ( + ' - ' + )} + + +
+
+ +
+
+ ); + }; + + if (!executions) { + return ( + + + + ); + } + return {runHistory()}; +}; + +const FixedScrollContainer = ({children}: {children: React.ReactNode}) => { + // This is kind of hacky but basically the height of the parent of this element is dynamic (its parent has flex grow) + // but we don't want it to grow with the content inside of this node, instead we want it only to grow with the content of our sibling node. + // This will effectively give us a height of 0 + const {viewport, containerProps} = useViewport(); + return ( + +
+ {children} +
+
); }; +const CheckRow = styled(Row)<{$selected: boolean} & RowProps>` + padding: 5px 8px 5px 12px; + cursor: pointer; + border-radius: 8px; + &:hover { + background: ${Colors.backgroundBlue()}; + } + ${({$selected}) => ($selected ? `background: ${Colors.backgroundBlue()};` : '')} +`; + +const headerWrapperProps: React.ComponentProps = { + border: 'bottom', + padding: {vertical: 12}, + style: { + cursor: 'pointer', + }, +}; + export const ASSET_CHECKS_QUERY = gql` query AssetChecksQuery($assetKey: AssetKeyInput!) { assetNodeOrError(assetKey: $assetKey) { diff --git a/js_modules/dagster-ui/packages/ui-core/src/assets/asset-checks/AssetChecksBanner.tsx b/js_modules/dagster-ui/packages/ui-core/src/assets/asset-checks/AssetChecksBanner.tsx index b9cb838bf0d29..8e5ed5ed1f115 100644 --- a/js_modules/dagster-ui/packages/ui-core/src/assets/asset-checks/AssetChecksBanner.tsx +++ b/js_modules/dagster-ui/packages/ui-core/src/assets/asset-checks/AssetChecksBanner.tsx @@ -1,17 +1,12 @@ import {Alert, Colors, Icon} from '@dagster-io/ui-components'; -export const AssetChecksBanner = () => { +export const AssetChecksBanner = ({onClose}: {onClose: () => void}) => { return ( } - description={ - - You can learn more about this new feature and provide feedback{' '} - here. - - } + onClose={onClose} /> ); }; diff --git a/js_modules/dagster-ui/packages/ui-core/src/assets/asset-checks/util.tsx b/js_modules/dagster-ui/packages/ui-core/src/assets/asset-checks/util.tsx new file mode 100644 index 0000000000000..6659d9c6c05ec --- /dev/null +++ b/js_modules/dagster-ui/packages/ui-core/src/assets/asset-checks/util.tsx @@ -0,0 +1,61 @@ +import {Colors, Icon, Spinner} from '@dagster-io/ui-components'; + +import {ExecuteChecksButtonCheckFragment} from './types/ExecuteChecksButton.types'; +import {AssetCheckTableFragment} from './types/VirtualizedAssetCheckTable.types'; +import {assertUnreachable} from '../../app/Util'; +import {AssetCheckExecutionResolvedStatus, AssetCheckSeverity} from '../../graphql/types'; + +export function assetCheckStatusDescription( + check: AssetCheckTableFragment & ExecuteChecksButtonCheckFragment, +) { + const lastExecution = check.executionForLatestMaterialization; + if (!lastExecution) { + return 'Not evaluated'; + } + const status = lastExecution.status; + const date = lastExecution.timestamp; + switch (status) { + case AssetCheckExecutionResolvedStatus.EXECUTION_FAILED: + return 'Execution failed'; + case AssetCheckExecutionResolvedStatus.FAILED: + return 'Failed'; + case AssetCheckExecutionResolvedStatus.IN_PROGRESS: + return 'In progress'; + case AssetCheckExecutionResolvedStatus.SKIPPED: + return 'Skipped'; + case AssetCheckExecutionResolvedStatus.SUCCEEDED: + return 'Succeeded'; + default: + assertUnreachable(status); + } +} + +export function getCheckIcon( + check: AssetCheckTableFragment & ExecuteChecksButtonCheckFragment, +): React.ReactNode { + const lastExecution = check.executionForLatestMaterialization; + if (!lastExecution) { + return ; + } + const status = lastExecution.status; + const isWarning = lastExecution.evaluation?.severity === AssetCheckSeverity.WARN; + switch (status) { + case AssetCheckExecutionResolvedStatus.EXECUTION_FAILED: + return ( + + ); + case AssetCheckExecutionResolvedStatus.FAILED: + if (isWarning) { + return ; + } + return ; + case AssetCheckExecutionResolvedStatus.IN_PROGRESS: + return ; + case AssetCheckExecutionResolvedStatus.SKIPPED: + return ; + case AssetCheckExecutionResolvedStatus.SUCCEEDED: + return ; + default: + assertUnreachable(status); + } +} diff --git a/js_modules/dagster-ui/packages/ui-core/src/assets/types/AssetView.types.ts b/js_modules/dagster-ui/packages/ui-core/src/assets/types/AssetView.types.ts index f962d210f0b1a..590bd0a86b808 100644 --- a/js_modules/dagster-ui/packages/ui-core/src/assets/types/AssetView.types.ts +++ b/js_modules/dagster-ui/packages/ui-core/src/assets/types/AssetView.types.ts @@ -22,7 +22,6 @@ export type AssetViewDefinitionQuery = { __typename: 'AssetNode'; id: string; groupName: string | null; - hasAssetChecks: boolean; description: string | null; graphName: string | null; opNames: Array; @@ -15844,7 +15843,6 @@ export type AssetViewDefinitionNodeFragment = { __typename: 'AssetNode'; id: string; groupName: string | null; - hasAssetChecks: boolean; description: string | null; graphName: string | null; opNames: Array;