diff --git a/js_modules/dagster-ui/packages/app-oss/src/InjectedComponents.tsx b/js_modules/dagster-ui/packages/app-oss/src/InjectedComponents.tsx index f997d4f42a5ad..450522217f239 100644 --- a/js_modules/dagster-ui/packages/app-oss/src/InjectedComponents.tsx +++ b/js_modules/dagster-ui/packages/app-oss/src/InjectedComponents.tsx @@ -5,6 +5,7 @@ import {UserPreferences} from '@dagster-io/ui-core/app/UserSettingsDialog/UserPr import {useAssetGraphExplorerFilters} from '@dagster-io/ui-core/asset-graph/useAssetGraphExplorerFilters.oss'; import {AssetCatalogTableBottomActionBar} from '@dagster-io/ui-core/assets/AssetCatalogTableBottomActionBar.oss'; import {AssetPageHeader} from '@dagster-io/ui-core/assets/AssetPageHeader.oss'; +import {AssetWipeDialog} from '@dagster-io/ui-core/assets/AssetWipeDialog.oss'; import {AssetsGraphHeader} from '@dagster-io/ui-core/assets/AssetsGraphHeader.oss'; import AssetsOverviewRoot from '@dagster-io/ui-core/assets/AssetsOverviewRoot.oss'; import {useAssetCatalogFiltering} from '@dagster-io/ui-core/assets/useAssetCatalogFiltering.oss'; @@ -21,6 +22,7 @@ export const InjectedComponents = ({children}: {children: React.ReactNode}) => { AssetsOverview: AssetsOverviewRoot, FallthroughRoot, AssetsGraphHeader, + AssetWipeDialog, OverviewPageAlerts: null, RunMetricsDialog: null, AssetCatalogTableBottomActionBar, diff --git a/js_modules/dagster-ui/packages/ui-components/src/components/ButtonGroup.tsx b/js_modules/dagster-ui/packages/ui-components/src/components/ButtonGroup.tsx index e88f9d13e63f4..731ae3796c4ac 100644 --- a/js_modules/dagster-ui/packages/ui-components/src/components/ButtonGroup.tsx +++ b/js_modules/dagster-ui/packages/ui-components/src/components/ButtonGroup.tsx @@ -33,6 +33,7 @@ export const ButtonGroup = (props: Props) => { const buttonElement = ( | null; FallthroughRoot: AComponentFromComponent | null; AssetsGraphHeader: AComponentFromComponent | null; + AssetWipeDialog: AComponentFromComponent | null; RunMetricsDialog: AComponentWithProps<{ runId: string; diff --git a/js_modules/dagster-ui/packages/ui-core/src/assets/AssetTable.tsx b/js_modules/dagster-ui/packages/ui-core/src/assets/AssetTable.tsx index dadaf40d390d5..f3e83488a246a 100644 --- a/js_modules/dagster-ui/packages/ui-core/src/assets/AssetTable.tsx +++ b/js_modules/dagster-ui/packages/ui-core/src/assets/AssetTable.tsx @@ -38,7 +38,6 @@ interface Props { belowActionBarComponents: React.ReactNode; prefixPath: string[]; displayPathForAsset: (asset: Asset) => string[]; - requery?: RefetchQueriesFunction; searchPath: string; isFiltered: boolean; computeKindFilter?: StaticSetFilter; @@ -52,7 +51,6 @@ export const AssetTable = ({ refreshState, prefixPath, displayPathForAsset, - requery, searchPath, isFiltered, view, @@ -194,7 +192,7 @@ export const AssetTable = ({ assetKeys={toWipe || []} isOpen={!!toWipe} onClose={() => setToWipe(undefined)} - requery={requery} + onComplete={() => refreshState.refetch()} /> ); diff --git a/js_modules/dagster-ui/packages/ui-core/src/assets/AssetView.tsx b/js_modules/dagster-ui/packages/ui-core/src/assets/AssetView.tsx index e0391d2204eec..14e5a16d6c1f6 100644 --- a/js_modules/dagster-ui/packages/ui-core/src/assets/AssetView.tsx +++ b/js_modules/dagster-ui/packages/ui-core/src/assets/AssetView.tsx @@ -31,6 +31,7 @@ import { } from './types/AssetView.types'; import {healthRefreshHintFromLiveData} from './usePartitionHealthData'; import {useReportEventsModal} from './useReportEventsModal'; +import {useWipeModal} from './useWipeModal'; import {currentPageAtom} from '../app/analytics'; import {Timestamp} from '../app/time/Timestamp'; import {AssetLiveDataRefreshButton, useAssetLiveData} from '../asset-data/AssetLiveDataProvider'; @@ -276,6 +277,15 @@ export const AssetView = ({ setCurrentPage(({specificPath}) => ({specificPath, path: `${path}?view=${selectedTab}`})); }, [path, selectedTab, setCurrentPage]); + const wipe = useWipeModal( + definition + ? { + assetKey: definition.assetKey, + repository: definition.repository, + } + : null, + refresh, + ); const reportEvents = useReportEventsModal( definition ? { @@ -327,10 +337,14 @@ export const AssetView = ({ ) : undefined} {reportEvents.element} + {wipe.element} } /> diff --git a/js_modules/dagster-ui/packages/ui-core/src/assets/AssetWipeDialog.oss.tsx b/js_modules/dagster-ui/packages/ui-core/src/assets/AssetWipeDialog.oss.tsx new file mode 100644 index 0000000000000..fcc98bc24314d --- /dev/null +++ b/js_modules/dagster-ui/packages/ui-core/src/assets/AssetWipeDialog.oss.tsx @@ -0,0 +1,118 @@ +import {RefetchQueriesFunction} from '@apollo/client'; +// eslint-disable-next-line no-restricted-imports +import {ProgressBar} from '@blueprintjs/core'; +import { + Body1, + Box, + Button, + Dialog, + DialogBody, + DialogFooter, + Group, + ifPlural, +} from '@dagster-io/ui-components'; +import {memo, useMemo} from 'react'; + +import {VirtualizedSimpleAssetKeyList} from './VirtualizedSimpleAssetKeyList'; +import {asAssetPartitionRangeInput} from './asInput'; +import {useWipeAssets} from './useWipeAssets'; +import {AssetKeyInput} from '../graphql/types'; +import {NavigationBlock} from '../runs/NavigationBlock'; +import {numberFormatter} from '../ui/formatters'; + +export const AssetWipeDialog = memo( + (props: { + assetKeys: AssetKeyInput[]; + isOpen: boolean; + onClose: () => void; + onComplete?: () => void; + requery?: RefetchQueriesFunction; + }) => { + return ( + + + + ); + }, +); + +export const AssetWipeDialogInner = memo( + ({ + assetKeys, + onClose, + onComplete, + requery, + }: { + assetKeys: AssetKeyInput[]; + onClose: () => void; + onComplete?: () => void; + requery?: RefetchQueriesFunction; + }) => { + const {wipeAssets, isWiping, isDone, wipedCount, failedCount} = useWipeAssets({ + refetchQueries: requery, + onClose, + onComplete, + }); + + const content = useMemo(() => { + if (isDone) { + return ( + + {wipedCount ? {numberFormatter.format(wipedCount)} Wiped : null} + {failedCount ? {numberFormatter.format(failedCount)} Failed : null} + + ); + } else if (!isWiping) { + return ( + +
+ Are you sure you want to wipe materializations for{' '} + {numberFormatter.format(assetKeys.length)}{' '} + {ifPlural(assetKeys.length, 'asset', 'assets')}? +
+ +
+ Assets defined only by their historical materializations will disappear from the Asset + Catalog. Software-defined assets will remain unless their definition is also deleted. +
+ This action cannot be undone. +
+ ); + } + const value = assetKeys.length > 0 ? (wipedCount + failedCount) / assetKeys.length : 1; + return ( + +
Wiping...
+ + +
+ ); + }, [isDone, isWiping, assetKeys, wipedCount, failedCount]); + + return ( + <> + {content} + + + {isDone ? null : ( + + )} + + + ); + }, +); diff --git a/js_modules/dagster-ui/packages/ui-core/src/assets/AssetWipeDialog.tsx b/js_modules/dagster-ui/packages/ui-core/src/assets/AssetWipeDialog.tsx index 86193d96a745a..33e74cc692673 100644 --- a/js_modules/dagster-ui/packages/ui-core/src/assets/AssetWipeDialog.tsx +++ b/js_modules/dagster-ui/packages/ui-core/src/assets/AssetWipeDialog.tsx @@ -1,216 +1,3 @@ -import {RefetchQueriesFunction, gql, useMutation} from '@apollo/client'; -// eslint-disable-next-line no-restricted-imports -import {ProgressBar} from '@blueprintjs/core'; -import { - Body1, - Box, - Button, - Dialog, - DialogBody, - DialogFooter, - Group, - ifPlural, -} from '@dagster-io/ui-components'; -import {useVirtualizer} from '@tanstack/react-virtual'; -import {memo, useLayoutEffect, useMemo, useRef, useState} from 'react'; +import {componentStub} from '../app/InjectedComponentContext'; -import {asAssetPartitionRangeInput} from './asInput'; -import {AssetWipeMutation, AssetWipeMutationVariables} from './types/AssetWipeDialog.types'; -import {showCustomAlert} from '../app/CustomAlertProvider'; -import {PYTHON_ERROR_FRAGMENT} from '../app/PythonErrorFragment'; -import {displayNameForAssetKey} from '../asset-graph/Utils'; -import {NavigationBlock} from '../runs/NavigationBlock'; -import {Inner, Row} from '../ui/VirtualizedTable'; -import {numberFormatter} from '../ui/formatters'; - -interface AssetKey { - path: string[]; -} - -const CHUNK_SIZE = 100; - -export const AssetWipeDialog = memo( - (props: { - assetKeys: AssetKey[]; - isOpen: boolean; - onClose: () => void; - onComplete?: () => void; - requery?: RefetchQueriesFunction; - }) => { - return ( - - - - ); - }, -); - -export const AssetWipeDialogInner = memo( - ({ - assetKeys, - onClose, - onComplete, - requery, - }: { - assetKeys: AssetKey[]; - onClose: () => void; - onComplete?: () => void; - requery?: RefetchQueriesFunction; - }) => { - const [requestWipe] = useMutation( - ASSET_WIPE_MUTATION, - { - refetchQueries: requery, - }, - ); - - const [isWiping, setIsWiping] = useState(false); - const [wipedCount, setWipedCount] = useState(0); - const [failedCount, setFailedCount] = useState(0); - - const isDone = !isWiping && (wipedCount || failedCount); - - const didCancel = useRef(false); - const wipe = async () => { - if (!assetKeys.length) { - return; - } - setIsWiping(true); - for (let i = 0, l = assetKeys.length; i < l; i += CHUNK_SIZE) { - if (didCancel.current) { - return; - } - const nextChunk = assetKeys.slice(i, i + CHUNK_SIZE); - const result = await requestWipe({ - variables: {assetPartitionRanges: nextChunk.map((x) => asAssetPartitionRangeInput(x))}, - refetchQueries: requery, - }); - const data = result.data?.wipeAssets; - switch (data?.__typename) { - case 'AssetNotFoundError': - case 'PythonError': - setFailedCount((failed) => failed + nextChunk.length); - break; - case 'AssetWipeSuccess': - setWipedCount((wiped) => wiped + nextChunk.length); - break; - case 'UnauthorizedError': - showCustomAlert({ - title: 'Could not wipe asset materializations', - body: 'You do not have permission to do this.', - }); - onClose(); - return; - } - } - onComplete?.(); - setIsWiping(false); - }; - - useLayoutEffect(() => { - return () => { - didCancel.current = true; - }; - }, []); - - const parentRef = useRef(null); - - const rowVirtualizer = useVirtualizer({ - count: assetKeys.length, - getScrollElement: () => parentRef.current, - estimateSize: () => 24, - overscan: 10, - }); - - const totalHeight = rowVirtualizer.getTotalSize(); - const items = rowVirtualizer.getVirtualItems(); - - const content = useMemo(() => { - if (isDone) { - return ( - - {wipedCount ? {numberFormatter.format(wipedCount)} Wiped : null} - {failedCount ? {numberFormatter.format(failedCount)} Failed : null} - - ); - } else if (!isWiping) { - return ( - -
- Are you sure you want to wipe materializations for{' '} - {numberFormatter.format(assetKeys.length)}{' '} - {ifPlural(assetKeys.length, 'asset', 'assets')}? -
-
- - {items.map(({index, key, size, start}) => { - const assetKey = assetKeys[index]!; - return ( - - {displayNameForAssetKey(assetKey)} - - ); - })} - -
-
- Assets defined only by their historical materializations will disappear from the Asset - Catalog. Software-defined assets will remain unless their definition is also deleted. -
- This action cannot be undone. -
- ); - } - const value = assetKeys.length > 0 ? (wipedCount + failedCount) / assetKeys.length : 1; - return ( - -
Wiping...
- - -
- ); - }, [isDone, isWiping, assetKeys, wipedCount, failedCount, totalHeight, items]); - - return ( - <> - {content} - - - {isDone ? null : ( - - )} - - - ); - }, -); - -const ASSET_WIPE_MUTATION = gql` - mutation AssetWipeMutation($assetPartitionRanges: [PartitionsByAssetSelector!]!) { - wipeAssets(assetPartitionRanges: $assetPartitionRanges) { - ... on AssetWipeSuccess { - assetPartitionRanges { - assetKey { - path - } - partitionRange { - start - end - } - } - } - ...PythonErrorFragment - } - } - - ${PYTHON_ERROR_FRAGMENT} -`; +export const AssetWipeDialog = componentStub('AssetWipeDialog'); diff --git a/js_modules/dagster-ui/packages/ui-core/src/assets/AssetsCatalogTable.tsx b/js_modules/dagster-ui/packages/ui-core/src/assets/AssetsCatalogTable.tsx index ff6875a1afeb1..b3534b21b11ae 100644 --- a/js_modules/dagster-ui/packages/ui-core/src/assets/AssetsCatalogTable.tsx +++ b/js_modules/dagster-ui/packages/ui-core/src/assets/AssetsCatalogTable.tsx @@ -62,7 +62,6 @@ export function useCachedAssets({ return {cacheManager}; } -const requery = () => [{query: ASSET_CATALOG_TABLE_QUERY, fetchPolicy: 'no-cache' as const}]; export function useAllAssets({ batchLimit = DEFAULT_BATCH_LIMIT, @@ -286,7 +285,6 @@ export const AssetsCatalogTable = ({ prefixPath={prefixPath || emptyArray} searchPath={searchPath} displayPathForAsset={displayPathForAsset} - requery={requery} computeKindFilter={computeKindFilter} storageKindFilter={storageKindFilter} /> diff --git a/js_modules/dagster-ui/packages/ui-core/src/assets/LaunchAssetChoosePartitionsDialog.tsx b/js_modules/dagster-ui/packages/ui-core/src/assets/LaunchAssetChoosePartitionsDialog.tsx index f03cd7e865055..6330d8f7455b3 100644 --- a/js_modules/dagster-ui/packages/ui-core/src/assets/LaunchAssetChoosePartitionsDialog.tsx +++ b/js_modules/dagster-ui/packages/ui-core/src/assets/LaunchAssetChoosePartitionsDialog.tsx @@ -76,7 +76,7 @@ import { showBackfillErrorToast, showBackfillSuccessToast, } from '../partitions/BackfillMessaging'; -import {DimensionRangeWizard} from '../partitions/DimensionRangeWizard'; +import {DimensionRangeWizards} from '../partitions/DimensionRangeWizards'; import {assembleIntoSpans, stringForSpan} from '../partitions/SpanRepresentation'; import {DagsterTag} from '../runs/RunTag'; import {testId} from '../testing/testId'; @@ -502,50 +502,14 @@ const LaunchAssetChoosePartitionsDialogBody = ({ {displayNameForAssetKey(target.anchorAssetKey)} )} - {selections.map((range, idx) => ( - - - - {range.dimension.name} - - - Select partitions to materialize.{' '} - {range.dimension.type === PartitionDefinitionType.TIME_WINDOW - ? 'Click and drag to select a range on the timeline.' - : null} - - - setSelections((selections) => - selections.map((r) => - r.dimension === range.dimension ? {...r, selectedKeys} : r, - ), - ) - } - partitionDefinitionName={ - displayedPartitionDefinition?.name || - displayedBaseAsset?.partitionDefinition?.dimensionTypes.find( - (d) => d.name === range.dimension.name, - )?.dynamicPartitionsDefinitionName - } - repoAddress={repoAddress} - refetch={refetch} - /> - - ))} + )} void; - }[]; + additionalDropdownOptions?: ( + | JSX.Element + | { + label: string; + icon?: JSX.Element; + onClick: () => void; + } + )[]; }) => { const {onClick, loading, launchpadElement} = useMaterializationAction(preferredJobName); const [isOpen, setIsOpen] = React.useState(false); @@ -290,14 +293,18 @@ export const LaunchAssetExecutionButton = ({ onClick(firstOption.assetKeys, e, true); }} /> - {additionalDropdownOptions?.map((option) => ( - - ))} + {additionalDropdownOptions?.map((option) => + 'label' in option ? ( + + ) : ( + option + ), + )} } > diff --git a/js_modules/dagster-ui/packages/ui-core/src/assets/VirtualizedSimpleAssetKeyList.tsx b/js_modules/dagster-ui/packages/ui-core/src/assets/VirtualizedSimpleAssetKeyList.tsx new file mode 100644 index 0000000000000..7404111fc5014 --- /dev/null +++ b/js_modules/dagster-ui/packages/ui-core/src/assets/VirtualizedSimpleAssetKeyList.tsx @@ -0,0 +1,43 @@ +// eslint-disable-next-line no-restricted-imports + +import {CaptionMono} from '@dagster-io/ui-components'; +import {useVirtualizer} from '@tanstack/react-virtual'; +import {CSSProperties, useRef} from 'react'; + +import {displayNameForAssetKey} from '../asset-graph/Utils'; +import {AssetKeyInput} from '../graphql/types'; +import {Inner, Row} from '../ui/VirtualizedTable'; + +export const VirtualizedSimpleAssetKeyList = ({ + assetKeys, + style, +}: { + assetKeys: AssetKeyInput[]; + style: CSSProperties; +}) => { + const parentRef = useRef(null); + const rowVirtualizer = useVirtualizer({ + count: assetKeys.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 18, + overscan: 10, + }); + + const totalHeight = rowVirtualizer.getTotalSize(); + const items = rowVirtualizer.getVirtualItems(); + + return ( +
+ + {items.map(({index, key, size, start}) => { + const assetKey = assetKeys[index]!; + return ( + + {displayNameForAssetKey(assetKey)} + + ); + })} + +
+ ); +}; diff --git a/js_modules/dagster-ui/packages/ui-core/src/assets/__fixtures__/PartitionHealth.fixtures.ts b/js_modules/dagster-ui/packages/ui-core/src/assets/__fixtures__/PartitionHealth.fixtures.ts new file mode 100644 index 0000000000000..309630af16bcf --- /dev/null +++ b/js_modules/dagster-ui/packages/ui-core/src/assets/__fixtures__/PartitionHealth.fixtures.ts @@ -0,0 +1,306 @@ +import {PartitionDefinitionType, PartitionRangeStatus} from '../../graphql/types'; +import {PartitionHealthQuery} from '../types/usePartitionHealthData.types'; + +export const DIMENSION_ONE_KEYS = [ + '2022-01-01', + '2022-01-02', + '2022-01-03', + '2022-01-04', + '2022-01-05', + '2022-01-06', +]; + +export const DIMENSION_TWO_KEYS = ['TN', 'CA', 'VA', 'NY', 'MN']; + +export const NO_DIMENSIONAL_ASSET: PartitionHealthQuery = { + __typename: 'Query', + assetNodeOrError: { + __typename: 'AssetNode', + id: '1234', + partitionKeysByDimension: [], + assetPartitionStatuses: { + __typename: 'DefaultPartitionStatuses', + materializedPartitions: [], + materializingPartitions: [], + failedPartitions: [], + }, + }, +}; + +export const ONE_DIMENSIONAL_ASSET: PartitionHealthQuery = { + __typename: 'Query', + assetNodeOrError: { + __typename: 'AssetNode', + id: '1234', + partitionKeysByDimension: [ + { + __typename: 'DimensionPartitionKeys', + name: 'default', + partitionKeys: DIMENSION_ONE_KEYS, + type: PartitionDefinitionType.TIME_WINDOW, + }, + ], + assetPartitionStatuses: { + __typename: 'TimePartitionStatuses', + ranges: [ + { + __typename: 'TimePartitionRangeStatus', + status: PartitionRangeStatus.MATERIALIZED, + startKey: '2022-01-04', + startTime: new Date('2022-01-04').getTime(), + endKey: '2022-01-05', + endTime: new Date('2022-01-04').getTime(), + }, + { + __typename: 'TimePartitionRangeStatus', + status: PartitionRangeStatus.FAILED, + startKey: '2022-01-05', + startTime: new Date('2022-01-05').getTime(), + endKey: '2022-01-06', + endTime: new Date('2022-01-06').getTime(), + }, + ], + }, + }, +}; + +export const TWO_DIMENSIONAL_ASSET: PartitionHealthQuery = { + __typename: 'Query', + assetNodeOrError: { + __typename: 'AssetNode', + id: '1234', + partitionKeysByDimension: [ + { + __typename: 'DimensionPartitionKeys', + name: 'time', + partitionKeys: DIMENSION_ONE_KEYS, + type: PartitionDefinitionType.TIME_WINDOW, + }, + { + __typename: 'DimensionPartitionKeys', + name: 'state', + partitionKeys: DIMENSION_TWO_KEYS, + type: PartitionDefinitionType.STATIC, + }, + ], + assetPartitionStatuses: { + __typename: 'MultiPartitionStatuses', + primaryDimensionName: 'time', + ranges: [ + { + __typename: 'MaterializedPartitionRangeStatuses2D', + primaryDimStartKey: '2022-01-01', + primaryDimStartTime: new Date('2022-01-01').getTime(), + primaryDimEndKey: '2022-01-01', + primaryDimEndTime: new Date('2022-01-01').getTime(), + secondaryDim: { + __typename: 'DefaultPartitionStatuses', + materializedPartitions: ['NY', 'MN'], + materializingPartitions: [], + failedPartitions: [], + }, + }, + { + __typename: 'MaterializedPartitionRangeStatuses2D', + primaryDimStartKey: '2022-01-02', + primaryDimStartTime: new Date('2022-01-02').getTime(), + primaryDimEndKey: '2022-01-03', + primaryDimEndTime: new Date('2022-01-03').getTime(), + secondaryDim: { + __typename: 'DefaultPartitionStatuses', + materializedPartitions: ['MN'], + materializingPartitions: [], + failedPartitions: [], + }, + }, + { + __typename: 'MaterializedPartitionRangeStatuses2D', + primaryDimStartKey: '2022-01-04', + primaryDimStartTime: new Date('2022-01-04').getTime(), + primaryDimEndKey: '2022-01-04', + primaryDimEndTime: new Date('2022-01-04').getTime(), + secondaryDim: { + __typename: 'DefaultPartitionStatuses', + materializedPartitions: ['TN', 'CA', 'VA', 'NY', 'MN'], + materializingPartitions: [], + failedPartitions: [], + }, + }, + { + __typename: 'MaterializedPartitionRangeStatuses2D', + primaryDimStartKey: '2022-01-05', + primaryDimStartTime: new Date('2022-01-05').getTime(), + primaryDimEndKey: '2022-01-06', + primaryDimEndTime: new Date('2022-01-06').getTime(), + secondaryDim: { + __typename: 'DefaultPartitionStatuses', + materializedPartitions: ['MN'], + materializingPartitions: [], + failedPartitions: ['NY', 'VA'], + }, + }, + ], + }, + }, +}; + +export const TWO_DIMENSIONAL_ASSET_REVERSED_DIMENSIONS: PartitionHealthQuery = { + __typename: 'Query', + assetNodeOrError: { + __typename: 'AssetNode', + id: '1234', + partitionKeysByDimension: [ + { + __typename: 'DimensionPartitionKeys', + name: 'state', + partitionKeys: DIMENSION_TWO_KEYS, + type: PartitionDefinitionType.STATIC, + }, + { + __typename: 'DimensionPartitionKeys', + name: 'time', + partitionKeys: DIMENSION_ONE_KEYS, + type: PartitionDefinitionType.TIME_WINDOW, + }, + ], + assetPartitionStatuses: { + __typename: 'MultiPartitionStatuses', + primaryDimensionName: 'time', + ranges: [ + { + __typename: 'MaterializedPartitionRangeStatuses2D', + primaryDimStartKey: '2022-01-01', + primaryDimStartTime: new Date('2022-01-01').getTime(), + primaryDimEndKey: '2022-01-01', + primaryDimEndTime: new Date('2022-01-01').getTime(), + secondaryDim: { + __typename: 'DefaultPartitionStatuses', + materializedPartitions: ['NY', 'MN'], + materializingPartitions: [], + failedPartitions: [], + }, + }, + { + __typename: 'MaterializedPartitionRangeStatuses2D', + primaryDimStartKey: '2022-01-02', + primaryDimStartTime: new Date('2022-01-02').getTime(), + primaryDimEndKey: '2022-01-03', + primaryDimEndTime: new Date('2022-01-03').getTime(), + secondaryDim: { + __typename: 'DefaultPartitionStatuses', + materializedPartitions: ['MN'], + materializingPartitions: [], + failedPartitions: [], + }, + }, + { + __typename: 'MaterializedPartitionRangeStatuses2D', + primaryDimStartKey: '2022-01-04', + primaryDimStartTime: new Date('2022-01-04').getTime(), + primaryDimEndKey: '2022-01-04', + primaryDimEndTime: new Date('2022-01-04').getTime(), + secondaryDim: { + __typename: 'DefaultPartitionStatuses', + materializedPartitions: ['TN', 'CA', 'VA', 'NY', 'MN'], + materializingPartitions: [], + failedPartitions: [], + }, + }, + { + __typename: 'MaterializedPartitionRangeStatuses2D', + primaryDimStartKey: '2022-01-05', + primaryDimStartTime: new Date('2022-01-05').getTime(), + primaryDimEndKey: '2022-01-06', + primaryDimEndTime: new Date('2022-01-06').getTime(), + secondaryDim: { + __typename: 'DefaultPartitionStatuses', + materializedPartitions: ['MN'], + materializingPartitions: [], + failedPartitions: ['NY', 'VA'], + }, + }, + ], + }, + }, +}; + +export const TWO_DIMENSIONAL_ASSET_BOTH_STATIC: PartitionHealthQuery = { + __typename: 'Query', + assetNodeOrError: { + __typename: 'AssetNode', + id: '1234', + partitionKeysByDimension: [ + { + __typename: 'DimensionPartitionKeys', + name: 'state1', + partitionKeys: DIMENSION_TWO_KEYS, + type: PartitionDefinitionType.STATIC, + }, + { + __typename: 'DimensionPartitionKeys', + name: 'state2', + partitionKeys: DIMENSION_TWO_KEYS, + type: PartitionDefinitionType.STATIC, + }, + ], + assetPartitionStatuses: { + __typename: 'MultiPartitionStatuses', + primaryDimensionName: 'state1', + ranges: [ + { + __typename: 'MaterializedPartitionRangeStatuses2D', + primaryDimStartKey: 'TN', + primaryDimEndKey: 'CA', + primaryDimEndTime: null, + primaryDimStartTime: null, + secondaryDim: { + __typename: 'DefaultPartitionStatuses', + materializedPartitions: ['TN', 'CA', 'VA'], + materializingPartitions: [], + failedPartitions: ['MN'], + }, + }, + { + __typename: 'MaterializedPartitionRangeStatuses2D', + primaryDimStartKey: 'VA', + primaryDimEndKey: 'MN', + primaryDimEndTime: null, + primaryDimStartTime: null, + secondaryDim: { + __typename: 'DefaultPartitionStatuses', + materializedPartitions: ['CA', 'MN'], + materializingPartitions: [], + failedPartitions: [], + }, + }, + ], + }, + }, +}; + +export const TWO_DIMENSIONAL_ASSET_EMPTY: PartitionHealthQuery = { + __typename: 'Query', + assetNodeOrError: { + __typename: 'AssetNode', + id: '1234', + partitionKeysByDimension: [ + { + __typename: 'DimensionPartitionKeys', + name: 'time', + partitionKeys: DIMENSION_ONE_KEYS, + type: PartitionDefinitionType.STATIC, + }, + { + __typename: 'DimensionPartitionKeys', + name: 'state', + partitionKeys: DIMENSION_TWO_KEYS, + type: PartitionDefinitionType.STATIC, + }, + ], + assetPartitionStatuses: { + __typename: 'MultiPartitionStatuses', + primaryDimensionName: 'time', + ranges: [], + }, + }, +}; diff --git a/js_modules/dagster-ui/packages/ui-core/src/assets/__tests__/AssetTables.test.tsx b/js_modules/dagster-ui/packages/ui-core/src/assets/__tests__/AssetTables.test.tsx index 7a8a0c2e10010..916a7928a027e 100644 --- a/js_modules/dagster-ui/packages/ui-core/src/assets/__tests__/AssetTables.test.tsx +++ b/js_modules/dagster-ui/packages/ui-core/src/assets/__tests__/AssetTables.test.tsx @@ -12,6 +12,7 @@ import {mockViewportClientRect, restoreViewportClientRect} from '../../testing/m import {WorkspaceProvider} from '../../workspace/WorkspaceContext'; import {buildWorkspaceMocks} from '../../workspace/__fixtures__/Workspace.fixtures'; import {AssetPageHeader} from '../AssetPageHeader.oss'; +import {AssetWipeDialog} from '../AssetWipeDialog.oss'; import {AssetsCatalogTable} from '../AssetsCatalogTable'; import {AssetsGraphHeader} from '../AssetsGraphHeader.oss'; import AssetsOverviewRoot from '../AssetsOverviewRoot.oss'; @@ -60,6 +61,7 @@ describe('AssetTable', () => { value={{ components: { AssetPageHeader, + AssetWipeDialog, AppTopNavRightOfLogo, UserPreferences, AssetsOverview: AssetsOverviewRoot, 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 61b2a4a05018d..1e5e6e4ec60de 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 @@ -28,6 +28,7 @@ import {buildQueryMock} from '../../testing/mocking'; import {WorkspaceProvider} from '../../workspace/WorkspaceContext'; import {AssetPageHeader} from '../AssetPageHeader.oss'; import {AssetView} from '../AssetView'; +import {AssetWipeDialog} from '../AssetWipeDialog.oss'; import {AssetsGraphHeader} from '../AssetsGraphHeader.oss'; import AssetsOverviewRoot from '../AssetsOverviewRoot.oss'; import { @@ -70,6 +71,7 @@ describe('AssetView', () => { value={{ components: { AssetPageHeader, + AssetWipeDialog, AppTopNavRightOfLogo, UserPreferences, AssetsOverview: AssetsOverviewRoot, diff --git a/js_modules/dagster-ui/packages/ui-core/src/assets/__tests__/usePartitionHealthData.test.tsx b/js_modules/dagster-ui/packages/ui-core/src/assets/__tests__/usePartitionHealthData.test.tsx index ab2b47547a40a..2a78036bb46cc 100644 --- a/js_modules/dagster-ui/packages/ui-core/src/assets/__tests__/usePartitionHealthData.test.tsx +++ b/js_modules/dagster-ui/packages/ui-core/src/assets/__tests__/usePartitionHealthData.test.tsx @@ -1,6 +1,14 @@ -import {PartitionDefinitionType, PartitionRangeStatus} from '../../graphql/types'; import {AssetPartitionStatus, emptyAssetPartitionStatusCounts} from '../AssetPartitionStatus'; -import {PartitionHealthQuery} from '../types/usePartitionHealthData.types'; +import { + DIMENSION_ONE_KEYS, + DIMENSION_TWO_KEYS, + NO_DIMENSIONAL_ASSET, + ONE_DIMENSIONAL_ASSET, + TWO_DIMENSIONAL_ASSET, + TWO_DIMENSIONAL_ASSET_BOTH_STATIC, + TWO_DIMENSIONAL_ASSET_EMPTY, + TWO_DIMENSIONAL_ASSET_REVERSED_DIMENSIONS, +} from '../__fixtures__/PartitionHealth.fixtures'; import { PartitionDimensionSelection, PartitionDimensionSelectionRange, @@ -17,310 +25,6 @@ import { const {MATERIALIZED, FAILED, MISSING} = AssetPartitionStatus; -const DIMENSION_ONE_KEYS = [ - '2022-01-01', - '2022-01-02', - '2022-01-03', - '2022-01-04', - '2022-01-05', - '2022-01-06', -]; - -const DIMENSION_TWO_KEYS = ['TN', 'CA', 'VA', 'NY', 'MN']; - -const NO_DIMENSIONAL_ASSET: PartitionHealthQuery = { - __typename: 'Query', - assetNodeOrError: { - __typename: 'AssetNode', - id: '1234', - partitionKeysByDimension: [], - assetPartitionStatuses: { - __typename: 'DefaultPartitionStatuses', - materializedPartitions: [], - materializingPartitions: [], - failedPartitions: [], - }, - }, -}; - -const ONE_DIMENSIONAL_ASSET: PartitionHealthQuery = { - __typename: 'Query', - assetNodeOrError: { - __typename: 'AssetNode', - id: '1234', - partitionKeysByDimension: [ - { - __typename: 'DimensionPartitionKeys', - name: 'default', - partitionKeys: DIMENSION_ONE_KEYS, - type: PartitionDefinitionType.TIME_WINDOW, - }, - ], - assetPartitionStatuses: { - __typename: 'TimePartitionStatuses', - ranges: [ - { - __typename: 'TimePartitionRangeStatus', - status: PartitionRangeStatus.MATERIALIZED, - startKey: '2022-01-04', - startTime: new Date('2022-01-04').getTime(), - endKey: '2022-01-05', - endTime: new Date('2022-01-04').getTime(), - }, - { - __typename: 'TimePartitionRangeStatus', - status: PartitionRangeStatus.FAILED, - startKey: '2022-01-05', - startTime: new Date('2022-01-05').getTime(), - endKey: '2022-01-06', - endTime: new Date('2022-01-06').getTime(), - }, - ], - }, - }, -}; - -const TWO_DIMENSIONAL_ASSET: PartitionHealthQuery = { - __typename: 'Query', - assetNodeOrError: { - __typename: 'AssetNode', - id: '1234', - partitionKeysByDimension: [ - { - __typename: 'DimensionPartitionKeys', - name: 'time', - partitionKeys: DIMENSION_ONE_KEYS, - type: PartitionDefinitionType.TIME_WINDOW, - }, - { - __typename: 'DimensionPartitionKeys', - name: 'state', - partitionKeys: DIMENSION_TWO_KEYS, - type: PartitionDefinitionType.STATIC, - }, - ], - assetPartitionStatuses: { - __typename: 'MultiPartitionStatuses', - primaryDimensionName: 'time', - ranges: [ - { - __typename: 'MaterializedPartitionRangeStatuses2D', - primaryDimStartKey: '2022-01-01', - primaryDimStartTime: new Date('2022-01-01').getTime(), - primaryDimEndKey: '2022-01-01', - primaryDimEndTime: new Date('2022-01-01').getTime(), - secondaryDim: { - __typename: 'DefaultPartitionStatuses', - materializedPartitions: ['NY', 'MN'], - materializingPartitions: [], - failedPartitions: [], - }, - }, - { - __typename: 'MaterializedPartitionRangeStatuses2D', - primaryDimStartKey: '2022-01-02', - primaryDimStartTime: new Date('2022-01-02').getTime(), - primaryDimEndKey: '2022-01-03', - primaryDimEndTime: new Date('2022-01-03').getTime(), - secondaryDim: { - __typename: 'DefaultPartitionStatuses', - materializedPartitions: ['MN'], - materializingPartitions: [], - failedPartitions: [], - }, - }, - { - __typename: 'MaterializedPartitionRangeStatuses2D', - primaryDimStartKey: '2022-01-04', - primaryDimStartTime: new Date('2022-01-04').getTime(), - primaryDimEndKey: '2022-01-04', - primaryDimEndTime: new Date('2022-01-04').getTime(), - secondaryDim: { - __typename: 'DefaultPartitionStatuses', - materializedPartitions: ['TN', 'CA', 'VA', 'NY', 'MN'], - materializingPartitions: [], - failedPartitions: [], - }, - }, - { - __typename: 'MaterializedPartitionRangeStatuses2D', - primaryDimStartKey: '2022-01-05', - primaryDimStartTime: new Date('2022-01-05').getTime(), - primaryDimEndKey: '2022-01-06', - primaryDimEndTime: new Date('2022-01-06').getTime(), - secondaryDim: { - __typename: 'DefaultPartitionStatuses', - materializedPartitions: ['MN'], - materializingPartitions: [], - failedPartitions: ['NY', 'VA'], - }, - }, - ], - }, - }, -}; - -const TWO_DIMENSIONAL_ASSET_REVERSED_DIMENSIONS: PartitionHealthQuery = { - __typename: 'Query', - assetNodeOrError: { - __typename: 'AssetNode', - id: '1234', - partitionKeysByDimension: [ - { - __typename: 'DimensionPartitionKeys', - name: 'state', - partitionKeys: DIMENSION_TWO_KEYS, - type: PartitionDefinitionType.STATIC, - }, - { - __typename: 'DimensionPartitionKeys', - name: 'time', - partitionKeys: DIMENSION_ONE_KEYS, - type: PartitionDefinitionType.TIME_WINDOW, - }, - ], - assetPartitionStatuses: { - __typename: 'MultiPartitionStatuses', - primaryDimensionName: 'time', - ranges: [ - { - __typename: 'MaterializedPartitionRangeStatuses2D', - primaryDimStartKey: '2022-01-01', - primaryDimStartTime: new Date('2022-01-01').getTime(), - primaryDimEndKey: '2022-01-01', - primaryDimEndTime: new Date('2022-01-01').getTime(), - secondaryDim: { - __typename: 'DefaultPartitionStatuses', - materializedPartitions: ['NY', 'MN'], - materializingPartitions: [], - failedPartitions: [], - }, - }, - { - __typename: 'MaterializedPartitionRangeStatuses2D', - primaryDimStartKey: '2022-01-02', - primaryDimStartTime: new Date('2022-01-02').getTime(), - primaryDimEndKey: '2022-01-03', - primaryDimEndTime: new Date('2022-01-03').getTime(), - secondaryDim: { - __typename: 'DefaultPartitionStatuses', - materializedPartitions: ['MN'], - materializingPartitions: [], - failedPartitions: [], - }, - }, - { - __typename: 'MaterializedPartitionRangeStatuses2D', - primaryDimStartKey: '2022-01-04', - primaryDimStartTime: new Date('2022-01-04').getTime(), - primaryDimEndKey: '2022-01-04', - primaryDimEndTime: new Date('2022-01-04').getTime(), - secondaryDim: { - __typename: 'DefaultPartitionStatuses', - materializedPartitions: ['TN', 'CA', 'VA', 'NY', 'MN'], - materializingPartitions: [], - failedPartitions: [], - }, - }, - { - __typename: 'MaterializedPartitionRangeStatuses2D', - primaryDimStartKey: '2022-01-05', - primaryDimStartTime: new Date('2022-01-05').getTime(), - primaryDimEndKey: '2022-01-06', - primaryDimEndTime: new Date('2022-01-06').getTime(), - secondaryDim: { - __typename: 'DefaultPartitionStatuses', - materializedPartitions: ['MN'], - materializingPartitions: [], - failedPartitions: ['NY', 'VA'], - }, - }, - ], - }, - }, -}; - -const TWO_DIMENSIONAL_ASSET_BOTH_STATIC: PartitionHealthQuery = { - __typename: 'Query', - assetNodeOrError: { - __typename: 'AssetNode', - id: '1234', - partitionKeysByDimension: [ - { - __typename: 'DimensionPartitionKeys', - name: 'state1', - partitionKeys: DIMENSION_TWO_KEYS, - type: PartitionDefinitionType.STATIC, - }, - { - __typename: 'DimensionPartitionKeys', - name: 'state2', - partitionKeys: DIMENSION_TWO_KEYS, - type: PartitionDefinitionType.STATIC, - }, - ], - assetPartitionStatuses: { - __typename: 'MultiPartitionStatuses', - primaryDimensionName: 'state1', - ranges: [ - { - __typename: 'MaterializedPartitionRangeStatuses2D', - primaryDimStartKey: 'TN', - primaryDimEndKey: 'CA', - primaryDimEndTime: null, - primaryDimStartTime: null, - secondaryDim: { - __typename: 'DefaultPartitionStatuses', - materializedPartitions: ['TN', 'CA', 'VA'], - materializingPartitions: [], - failedPartitions: ['MN'], - }, - }, - { - __typename: 'MaterializedPartitionRangeStatuses2D', - primaryDimStartKey: 'VA', - primaryDimEndKey: 'MN', - primaryDimEndTime: null, - primaryDimStartTime: null, - secondaryDim: { - __typename: 'DefaultPartitionStatuses', - materializedPartitions: ['CA', 'MN'], - materializingPartitions: [], - failedPartitions: [], - }, - }, - ], - }, - }, -}; - -const TWO_DIMENSIONAL_ASSET_EMPTY: PartitionHealthQuery = { - __typename: 'Query', - assetNodeOrError: { - __typename: 'AssetNode', - id: '1234', - partitionKeysByDimension: [ - { - __typename: 'DimensionPartitionKeys', - name: 'time', - partitionKeys: DIMENSION_ONE_KEYS, - type: PartitionDefinitionType.STATIC, - }, - { - __typename: 'DimensionPartitionKeys', - name: 'state', - partitionKeys: DIMENSION_TWO_KEYS, - type: PartitionDefinitionType.STATIC, - }, - ], - assetPartitionStatuses: { - __typename: 'MultiPartitionStatuses', - primaryDimensionName: 'time', - ranges: [], - }, - }, -}; - function selectionWithSlice( dim: PartitionHealthDimension, start: number, diff --git a/js_modules/dagster-ui/packages/ui-core/src/assets/types/AssetWipeDialog.types.ts b/js_modules/dagster-ui/packages/ui-core/src/assets/types/useWipeAssets.types.ts similarity index 100% rename from js_modules/dagster-ui/packages/ui-core/src/assets/types/AssetWipeDialog.types.ts rename to js_modules/dagster-ui/packages/ui-core/src/assets/types/useWipeAssets.types.ts diff --git a/js_modules/dagster-ui/packages/ui-core/src/assets/useWipeAssets.tsx b/js_modules/dagster-ui/packages/ui-core/src/assets/useWipeAssets.tsx new file mode 100644 index 0000000000000..4edbc499eb7ab --- /dev/null +++ b/js_modules/dagster-ui/packages/ui-core/src/assets/useWipeAssets.tsx @@ -0,0 +1,97 @@ +import {RefetchQueriesFunction, gql, useMutation} from '@apollo/client'; +import {useLayoutEffect, useRef, useState} from 'react'; + +import {AssetWipeMutation, AssetWipeMutationVariables} from './types/useWipeAssets.types'; +import {showCustomAlert} from '../app/CustomAlertProvider'; +import {PYTHON_ERROR_FRAGMENT} from '../app/PythonErrorFragment'; +import {PartitionsByAssetSelector} from '../graphql/types'; + +const CHUNK_SIZE = 100; + +export function useWipeAssets({ + refetchQueries, + onClose, + onComplete, +}: { + refetchQueries: RefetchQueriesFunction | undefined; + onClose: () => void; + onComplete?: () => void; +}) { + const [requestWipe] = useMutation( + ASSET_WIPE_MUTATION, + {refetchQueries}, + ); + + const [isWiping, setIsWiping] = useState(false); + const [wipedCount, setWipedCount] = useState(0); + const [failedCount, setFailedCount] = useState(0); + + const isDone = !isWiping && (wipedCount || failedCount); + + const didCancel = useRef(false); + + const wipeAssets = async (assetPartitionRanges: PartitionsByAssetSelector[]) => { + if (!assetPartitionRanges.length) { + return; + } + setIsWiping(true); + for (let i = 0, l = assetPartitionRanges.length; i < l; i += CHUNK_SIZE) { + if (didCancel.current) { + return; + } + const nextChunk = assetPartitionRanges.slice(i, i + CHUNK_SIZE); + const result = await requestWipe({ + variables: {assetPartitionRanges: nextChunk}, + refetchQueries, + }); + const data = result.data?.wipeAssets; + switch (data?.__typename) { + case 'AssetNotFoundError': + case 'PythonError': + setFailedCount((failed) => failed + nextChunk.length); + break; + case 'AssetWipeSuccess': + setWipedCount((wiped) => wiped + nextChunk.length); + break; + case 'UnauthorizedError': + showCustomAlert({ + title: 'Could not wipe asset materializations', + body: 'You do not have permission to do this.', + }); + onClose(); + return; + } + } + onComplete?.(); + setIsWiping(false); + }; + + useLayoutEffect(() => { + return () => { + didCancel.current = true; + }; + }, []); + + return {wipeAssets, isWiping, isDone, wipedCount, failedCount}; +} + +const ASSET_WIPE_MUTATION = gql` + mutation AssetWipeMutation($assetPartitionRanges: [PartitionsByAssetSelector!]!) { + wipeAssets(assetPartitionRanges: $assetPartitionRanges) { + ... on AssetWipeSuccess { + assetPartitionRanges { + assetKey { + path + } + partitionRange { + start + end + } + } + } + ...PythonErrorFragment + } + } + + ${PYTHON_ERROR_FRAGMENT} +`; diff --git a/js_modules/dagster-ui/packages/ui-core/src/assets/useWipeModal.tsx b/js_modules/dagster-ui/packages/ui-core/src/assets/useWipeModal.tsx new file mode 100644 index 0000000000000..a309c459435c3 --- /dev/null +++ b/js_modules/dagster-ui/packages/ui-core/src/assets/useWipeModal.tsx @@ -0,0 +1,45 @@ +import {Colors, Icon, MenuDivider, MenuItem} from '@dagster-io/ui-components'; +import {useContext, useState} from 'react'; + +import {AssetWipeDialog} from './AssetWipeDialog'; +import {CloudOSSContext} from '../app/CloudOSSContext'; +import {usePermissionsForLocation} from '../app/Permissions'; +import {AssetKeyInput} from '../graphql/types'; + +export function useWipeModal( + opts: {assetKey: AssetKeyInput; repository: {location: {name: string}}} | null, + refresh: () => void, +) { + const [showing, setShowing] = useState(false); + const { + permissions: {canWipeAssets}, + } = usePermissionsForLocation(opts ? opts.repository.location.name : null); + + const { + featureContext: {canSeeWipeMaterializationAction}, + } = useContext(CloudOSSContext); + + return { + element: ( + setShowing(false)} + onComplete={refresh} + /> + ), + dropdownOptions: canSeeWipeMaterializationAction + ? [ + , + } + disabled={!canWipeAssets} + intent="danger" + onClick={() => setShowing(true)} + />, + ] + : ([] as JSX.Element[]), + }; +} diff --git a/js_modules/dagster-ui/packages/ui-core/src/partitions/DimensionRangeWizard.tsx b/js_modules/dagster-ui/packages/ui-core/src/partitions/DimensionRangeWizard.tsx index bb9080a688e88..0f11d997249fb 100644 --- a/js_modules/dagster-ui/packages/ui-core/src/partitions/DimensionRangeWizard.tsx +++ b/js_modules/dagster-ui/packages/ui-core/src/partitions/DimensionRangeWizard.tsx @@ -1,27 +1,12 @@ -import { - Box, - Button, - Checkbox, - Colors, - Icon, - Menu, - MenuDivider, - MenuItem, - MiddleTruncate, - TagSelectorDropdownItemProps, - TagSelectorDropdownProps, - TagSelectorWithSearch, -} from '@dagster-io/ui-components'; +import {Box, Button, Colors, Icon} from '@dagster-io/ui-components'; import * as React from 'react'; import styled from 'styled-components'; import {CreatePartitionDialog} from './CreatePartitionDialog'; import {DimensionRangeInput} from './DimensionRangeInput'; +import {OrdinalPartitionSelector} from './OrdinalPartitionSelector'; import {PartitionStatus, PartitionStatusHealthSource} from './PartitionStatus'; -import {AssetPartitionStatusDot} from '../assets/AssetPartitionList'; -import {partitionStatusAtIndex} from '../assets/usePartitionHealthData'; -import {PartitionDefinitionType, RunStatus} from '../graphql/types'; -import {RunStatusDot} from '../runs/RunStatusDots'; +import {PartitionDefinitionType} from '../graphql/types'; import {testId} from '../testing/testId'; import {RepoAddress} from '../workspace/types'; @@ -126,152 +111,6 @@ export const DimensionRangeWizard = ({ ); }; -const OrdinalPartitionSelector = ({ - allPartitions, - selectedPartitions, - setSelectedPartitions, - setShowCreatePartition, - isDynamic, - health, -}: { - allPartitions: string[]; - selectedPartitions: string[]; - setSelectedPartitions: (tags: string[]) => void; - health: PartitionStatusHealthSource; - setShowCreatePartition: (show: boolean) => void; - isDynamic: boolean; -}) => { - const dotForPartitionKey = React.useCallback( - (partitionKey: string) => { - const index = allPartitions.indexOf(partitionKey); - if ('ranges' in health) { - return ; - } else { - return ( - - ); - } - }, - [allPartitions, health], - ); - - return ( - <> - { - return ( - - ); - }, - [dotForPartitionKey], - )} - renderDropdown={React.useCallback( - (dropdown: React.ReactNode, {width, allTags}: TagSelectorDropdownProps) => { - const isAllSelected = allTags.every((t) => selectedPartitions.includes(t)); - return ( - - - {isDynamic && ( - <> - - - - Add partition - - } - onClick={() => { - setShowCreatePartition(true); - }} - /> - - - - )} - {allTags.length ? ( - <> - - {dropdown} - - ) : ( -
- No matching partitions found -
- )} - -
- ); - }, - [isDynamic, selectedPartitions, setSelectedPartitions, setShowCreatePartition], - )} - renderTagList={(tags) => { - if (tags.length > 4) { - return {tags.length} partitions selected; - } - return tags; - }} - searchPlaceholder="Filter partitions" - /> - - ); -}; - -const StyledIcon = styled(Icon)` - font-weight: 500; -`; - const LinkText = styled(Box)` color: ${Colors.linkDefault()}; cursor: pointer; @@ -285,9 +124,6 @@ const LinkText = styled(Box)` } `; -const DropdownItemTooltipStyle = JSON.stringify({ - background: Colors.backgroundLight(), - border: `1px solid ${Colors.borderDefault()}`, - color: Colors.textDefault(), - fontSize: '14px', -}); +const StyledIcon = styled(Icon)` + font-weight: 500; +`; diff --git a/js_modules/dagster-ui/packages/ui-core/src/partitions/DimensionRangeWizards.tsx b/js_modules/dagster-ui/packages/ui-core/src/partitions/DimensionRangeWizards.tsx new file mode 100644 index 0000000000000..21206d7230bcd --- /dev/null +++ b/js_modules/dagster-ui/packages/ui-core/src/partitions/DimensionRangeWizards.tsx @@ -0,0 +1,79 @@ +import {Box, Icon, Subheading} from '@dagster-io/ui-components'; +import {Dispatch, SetStateAction} from 'react'; + +import {DimensionRangeWizard} from './DimensionRangeWizard'; +import { + PartitionDimensionSelection, + PartitionHealthDataMerged, +} from '../assets/usePartitionHealthData'; +import {PartitionDefinitionType} from '../graphql/types'; +import {RepoAddress} from '../workspace/types'; + +export const DimensionRangeWizards = ({ + selections, + setSelections, + displayedHealth, + displayedPartitionDefinition, + repoAddress, + refetch, +}: { + selections: PartitionDimensionSelection[]; + setSelections: Dispatch>; + displayedHealth: Pick; + displayedPartitionDefinition?: { + name: string | null; + dimensionTypes: { + name: string | undefined; + dynamicPartitionsDefinitionName: string | null; + }[]; + } | null; + repoAddress?: RepoAddress; + refetch?: () => Promise; +}) => { + return ( + <> + {selections.map((range, idx) => ( + + + + {range.dimension.name} + + + Select partitions to materialize.{' '} + {range.dimension.type === PartitionDefinitionType.TIME_WINDOW + ? 'Click and drag to select a range on the timeline.' + : null} + + + setSelections((selections) => + selections.map((r) => (r.dimension === range.dimension ? {...r, selectedKeys} : r)), + ) + } + partitionDefinitionName={ + displayedPartitionDefinition?.name || + displayedPartitionDefinition?.dimensionTypes.find( + (d) => d.name === range.dimension.name, + )?.dynamicPartitionsDefinitionName + } + /> + + ))} + + ); +}; diff --git a/js_modules/dagster-ui/packages/ui-core/src/partitions/OrdinalOrSingleRangePartitionSelector.tsx b/js_modules/dagster-ui/packages/ui-core/src/partitions/OrdinalOrSingleRangePartitionSelector.tsx new file mode 100644 index 0000000000000..bd5efb142f9af --- /dev/null +++ b/js_modules/dagster-ui/packages/ui-core/src/partitions/OrdinalOrSingleRangePartitionSelector.tsx @@ -0,0 +1,141 @@ +// eslint-disable-next-line no-restricted-imports +import {Box, ButtonGroup, Icon} from '@dagster-io/ui-components'; +import {useState} from 'react'; + +import {OrdinalPartitionSelector} from './OrdinalPartitionSelector'; +import {PartitionStatusHealthSource} from './PartitionStatus'; +import { + PartitionDimensionSelection, + PartitionHealthDimension, +} from '../assets/usePartitionHealthData'; +import {PartitionDefinitionType} from '../graphql/types'; + +const NONE = {key: '', idx: -1}; + +export function isPartitionDimensionSelectionValid(s: PartitionDimensionSelection) { + return ( + s.selectedKeys.length > 0 || + (s.selectedRanges.length === 1 && + s.selectedRanges[0]?.start.key && + s.selectedRanges[0]?.end.key) + ); +} + +/** + * This component allows the selection of a single range or list of keys in the dimension. + * The `selection` can be null (all keys), a valid PartitionDimensionSelection, or an + * or an invalid (incomplete) PartitionDimensionSelection. + * + * This component does not yet support creating new partitions of dynamic dimensions. + */ +export const OrdinalOrSingleRangePartitionSelector = ({ + dimension, + selection, + setSelection, + health, +}: { + dimension: PartitionHealthDimension; + selection?: PartitionDimensionSelection | null; + setSelection: (selected: PartitionDimensionSelection | null) => void; + health: PartitionStatusHealthSource; +}) => { + const keys = selection?.selectedKeys || []; + const range = selection?.selectedRanges[0] || {start: NONE, end: NONE}; + const rangeAllowed = dimension.type === PartitionDefinitionType.TIME_WINDOW; + const partitionKeys = dimension.partitionKeys; + + const [mode, setMode] = useState<'all' | 'ordinal' | 'range'>( + keys.length ? 'ordinal' : rangeAllowed && range ? 'range' : 'all', + ); + + return ( + + { + setSelection(id === 'all' ? null : {dimension, selectedKeys: [], selectedRanges: []}); + setMode(id); + }} + /> + {mode === 'ordinal' || (mode === 'all' && !rangeAllowed) ? ( + { + setSelection({dimension, selectedKeys, selectedRanges: []}); + setMode('ordinal'); + }} + /> + ) : ( + + { + setMode('range'); + setSelection({ + dimension, + selectedKeys: [], + selectedRanges: [ + { + start: selectedKey + ? {key: selectedKey, idx: partitionKeys.indexOf(selectedKey)} + : NONE, + end: range.end, + }, + ], + }); + }} + /> + + { + setMode('range'); + setSelection({ + dimension, + selectedKeys: [], + selectedRanges: [ + { + start: range.start, + end: selectedKey + ? {key: selectedKey, idx: partitionKeys.indexOf(selectedKey)} + : NONE, + }, + ], + }); + }} + /> + + )} + + ); +}; diff --git a/js_modules/dagster-ui/packages/ui-core/src/partitions/OrdinalPartitionSelector.tsx b/js_modules/dagster-ui/packages/ui-core/src/partitions/OrdinalPartitionSelector.tsx new file mode 100644 index 0000000000000..074a32d420fe2 --- /dev/null +++ b/js_modules/dagster-ui/packages/ui-core/src/partitions/OrdinalPartitionSelector.tsx @@ -0,0 +1,189 @@ +import { + Box, + Checkbox, + Colors, + Icon, + Menu, + MenuDivider, + MenuItem, + MiddleTruncate, + TagSelectorDropdownItemProps, + TagSelectorDropdownProps, + TagSelectorWithSearch, +} from '@dagster-io/ui-components'; +import * as React from 'react'; +import styled from 'styled-components'; + +import {PartitionStatusHealthSource} from './PartitionStatus'; +import {AssetPartitionStatusDot} from '../assets/AssetPartitionList'; +import {partitionStatusAtIndex} from '../assets/usePartitionHealthData'; +import {RunStatus} from '../graphql/types'; +import {RunStatusDot} from '../runs/RunStatusDots'; +import {testId} from '../testing/testId'; + +export const OrdinalPartitionSelector = ({ + allPartitions, + selectedPartitions, + setSelectedPartitions, + setShowCreatePartition, + isDynamic, + health, + placeholder, + mode = 'multiple', +}: { + allPartitions: string[]; + selectedPartitions: string[]; + setSelectedPartitions: (tags: string[]) => void; + health: PartitionStatusHealthSource; + setShowCreatePartition?: (show: boolean) => void; + isDynamic: boolean; + placeholder?: string; + mode?: 'single' | 'multiple'; +}) => { + const dotForPartitionKey = React.useCallback( + (partitionKey: string) => { + const index = allPartitions.indexOf(partitionKey); + if ('ranges' in health) { + return ; + } else { + return ( + + ); + } + }, + [allPartitions, health], + ); + + return ( + { + return ( + + ); + }, + [dotForPartitionKey, mode], + )} + renderDropdown={React.useCallback( + (dropdown: React.ReactNode, {width, allTags}: TagSelectorDropdownProps) => { + const isAllSelected = allTags.every((t) => selectedPartitions.includes(t)); + return ( + + + {isDynamic && setShowCreatePartition && ( + <> + + + + Add partition + + } + onClick={() => { + setShowCreatePartition(true); + }} + /> + + + + )} + {allTags.length ? ( + <> + {mode === 'multiple' && ( + + )} + {dropdown} + + ) : ( +
+ No matching partitions found +
+ )} + +
+ ); + }, + [isDynamic, selectedPartitions, setSelectedPartitions, setShowCreatePartition, mode], + )} + renderTagList={(tags) => { + if (tags.length > 4) { + return {tags.length} partitions selected; + } + return tags; + }} + searchPlaceholder="Filter partitions" + /> + ); +}; + +const StyledIcon = styled(Icon)` + font-weight: 500; +`; + +const DropdownItemTooltipStyle = JSON.stringify({ + background: Colors.backgroundLight(), + border: `1px solid ${Colors.borderDefault()}`, + color: Colors.textDefault(), + fontSize: '14px', +}); diff --git a/js_modules/dagster-ui/packages/ui-core/src/partitions/__tests__/OrdinalOrSingleRangePartitionSelector.test.tsx b/js_modules/dagster-ui/packages/ui-core/src/partitions/__tests__/OrdinalOrSingleRangePartitionSelector.test.tsx new file mode 100644 index 0000000000000..1a3d16e770d32 --- /dev/null +++ b/js_modules/dagster-ui/packages/ui-core/src/partitions/__tests__/OrdinalOrSingleRangePartitionSelector.test.tsx @@ -0,0 +1,178 @@ +import {render, waitFor} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import * as React from 'react'; + +import { + ONE_DIMENSIONAL_ASSET, + TWO_DIMENSIONAL_ASSET_BOTH_STATIC, +} from '../../assets/__fixtures__/PartitionHealth.fixtures'; +import {PartitionHealthQuery} from '../../assets/types/usePartitionHealthData.types'; +import { + PartitionDimensionSelection, + buildPartitionHealthData, +} from '../../assets/usePartitionHealthData'; +import {mockViewportClientRect, restoreViewportClientRect} from '../../testing/mocking'; +import {OrdinalOrSingleRangePartitionSelector} from '../OrdinalOrSingleRangePartitionSelector'; + +const Wrapper = ({ + queryResult, + initialSelection, +}: { + queryResult: PartitionHealthQuery; + initialSelection?: PartitionDimensionSelection | null; +}) => { + const assetHealth = buildPartitionHealthData(queryResult, {path: ['asset']}); + const [selection, setSelection] = React.useState( + initialSelection, + ); + return ( + <> + +
+ {selection + ? JSON.stringify({ + selectedKeys: selection.selectedKeys, + selectedRanges: selection.selectedRanges, + }) + : selection === null + ? 'null' + : selection === undefined + ? 'undefined' + : ''} +
+ + ); +}; +describe('OrdinalOrSingleRangePartitionSelector', () => { + beforeAll(() => { + mockViewportClientRect(); + }); + afterAll(() => { + restoreViewportClientRect(); + }); + + it('should populate with an existing value (selectedKeys)', async () => { + const {baseElement, getByTitle} = render( + , + ); + expect(baseElement.querySelector('[aria-selected=true]')?.textContent).toEqual('Single'); + expect(getByTitle('CA')).toBeVisible(); // tags are in the element + expect(getByTitle('MN')).toBeVisible(); + }); + + it('should populate with an existing value (null)', async () => { + const {getByText, baseElement} = render( + , + ); + expect(baseElement.querySelector('[aria-selected=true]')?.textContent).toEqual('All'); + expect(getByText('5 partitions')).toBeVisible(); + }); + + it('should support All or Single entry for static partition dimensions', async () => { + const {getByText, getByTestId, queryByText} = render( + , + ); + expect(getByText('All')).toBeVisible(); + expect(getByText('Single')).toBeVisible(); + expect(queryByText('Range')).not.toBeInTheDocument(); + + expect(getByTestId('selection').textContent).toEqual('undefined'); + + const user = userEvent.setup(); + + // Click All, verify the value changes to `null` + await user.click(getByText('All')); + await waitFor(() => { + expect(getByTestId('selection').textContent).toEqual('null'); + }); + + // Click Single, verify the value changes to an empty selection + await user.click(getByText('Single')); + await waitFor(() => { + expect(getByTestId('selection').textContent).toEqual( + '{"selectedKeys":[],"selectedRanges":[]}', + ); + }); + + // Click a few partitions, verify the values are added to the selection + await user.click(getByText('Select a partition')); + await user.click(getByTestId(`menu-item-CA`)); + await user.click(getByTestId(`menu-item-MN`)); + expect(getByTestId('selection').textContent).toEqual( + '{"selectedKeys":["CA","MN"],"selectedRanges":[]}', + ); + }); + + it('should support All, Single or Range entry for time partition dimensions', async () => { + const {getByText, getByTestId, getAllByTestId, queryByText} = render( + , + ); + expect(getByText('All')).toBeVisible(); + expect(getByText('Single')).toBeVisible(); + expect(queryByText('Range')).toBeVisible(); + + expect(getByTestId('selection').textContent).toEqual('undefined'); + + const user = userEvent.setup(); + + // Click All, verify the value changes to `null` (meta value for all) + await user.click(getByText('All')); + await waitFor(() => { + expect(getByTestId('selection').textContent).toEqual('null'); + }); + + // Click Single, verify the value changes to an empty selection + await user.click(getByText('Single')); + await waitFor(() => { + expect(getByTestId('selection').textContent).toEqual( + '{"selectedKeys":[],"selectedRanges":[]}', + ); + }); + + // Click a few partitions, verify the values are added to the selection + await user.click(getByText('Select a partition')); + await user.click(getByTestId(`menu-item-2022-01-02`)); + await user.click(getByTestId(`menu-item-2022-01-03`)); + expect(getByTestId('selection').textContent).toEqual( + '{"selectedKeys":["2022-01-02","2022-01-03"],"selectedRanges":[]}', + ); + + // Click Range, verify the value resets to an empty selection + await user.click(getByText('Range')); + await waitFor(() => { + expect(getByTestId('selection').textContent).toEqual( + '{"selectedKeys":[],"selectedRanges":[]}', + ); + }); + + // Click a start partition, verify the value is saved but incomplete + await user.click(getByText('Select a starting partition')); + await user.click(getAllByTestId(`menu-item-2022-01-02`)[0]!); + expect(getByTestId('selection').textContent).toEqual( + '{"selectedKeys":[],"selectedRanges":[{"start":{"key":"2022-01-02","idx":1},"end":{"key":"","idx":-1}}]}', + ); + + // Click an end partition, verify the value is saved + await user.click(getByText('Select an ending partition')); + await user.click(getAllByTestId(`menu-item-2022-01-03`)[1]!); + expect(getByTestId('selection').textContent).toEqual( + '{"selectedKeys":[],"selectedRanges":[{"start":{"key":"2022-01-02","idx":1},"end":{"key":"2022-01-03","idx":2}}]}', + ); + + // Click Single, verify the value resets to an empty selection + await user.click(getByText('Single')); + await waitFor(() => { + expect(getByTestId('selection').textContent).toEqual( + '{"selectedKeys":[],"selectedRanges":[]}', + ); + }); + }); +});