diff --git a/js_modules/dagster-ui/packages/ui-core/src/instance/CodeLocationsPage.tsx b/js_modules/dagster-ui/packages/ui-core/src/instance/CodeLocationsPage.tsx index 6a0e3ef7adb5f..1a3fcc4f62d22 100644 --- a/js_modules/dagster-ui/packages/ui-core/src/instance/CodeLocationsPage.tsx +++ b/js_modules/dagster-ui/packages/ui-core/src/instance/CodeLocationsPage.tsx @@ -3,12 +3,11 @@ import * as React from 'react'; import {InstancePageContext} from './InstancePageContext'; import {InstanceTabs} from './InstanceTabs'; -import {flattenCodeLocationRows} from './flattenCodeLocationRows'; +import {useCodeLocationPageFilters} from './useCodeLocationPageFilters'; import {useTrackPageView} from '../app/analytics'; import {useDocumentTitle} from '../hooks/useDocumentTitle'; import {ReloadAllButton} from '../workspace/ReloadAllButton'; import {RepositoryLocationsList} from '../workspace/RepositoryLocationsList'; -import {WorkspaceContext} from '../workspace/WorkspaceContext'; const SEARCH_THRESHOLD = 10; @@ -16,18 +15,8 @@ export const CodeLocationsPageContent = () => { useTrackPageView(); useDocumentTitle('Code locations'); - const {locationEntries, loading} = React.useContext(WorkspaceContext); - - const [searchValue, setSearchValue] = React.useState(''); - - const onChangeSearch = React.useCallback((e: React.ChangeEvent) => { - setSearchValue(e.target.value); - }, []); - - const queryString = searchValue.toLocaleLowerCase(); - const {flattened, filtered} = React.useMemo(() => { - return flattenCodeLocationRows(locationEntries, queryString); - }, [locationEntries, queryString]); + const {activeFiltersJsx, flattened, button, loading, filtered, onChangeSearch, searchValue} = + useCodeLocationPageFilters(); const entryCount = flattened.length; const showSearch = entryCount > SEARCH_THRESHOLD; @@ -47,17 +36,20 @@ export const CodeLocationsPageContent = () => { flex={{direction: 'row', justifyContent: 'space-between', alignItems: 'center'}} style={{height: '64px'}} > - {showSearch ? ( - - ) : ( - {subheadingText()} - )} + + {button} + {showSearch ? ( + + ) : ( + {subheadingText()} + )} + {showSearch ?
{`${entryCount} code locations`}
: null} @@ -68,6 +60,14 @@ export const CodeLocationsPageContent = () => { codeLocations={filtered} searchValue={searchValue} /> + {activeFiltersJsx.length ? ( + + {activeFiltersJsx} + + ) : null} ); }; diff --git a/js_modules/dagster-ui/packages/ui-core/src/instance/flattenCodeLocationRows.tsx b/js_modules/dagster-ui/packages/ui-core/src/instance/flattenCodeLocationRows.tsx index b9d7513e2dd71..392b0b1ee93e8 100644 --- a/js_modules/dagster-ui/packages/ui-core/src/instance/flattenCodeLocationRows.tsx +++ b/js_modules/dagster-ui/packages/ui-core/src/instance/flattenCodeLocationRows.tsx @@ -1,4 +1,7 @@ -import {CodeLocationRowType} from '../workspace/VirtualizedCodeLocationRow'; +import { + CodeLocationRowStatusType, + CodeLocationRowType, +} from '../workspace/VirtualizedCodeLocationRow'; import {WorkspaceLocationNodeFragment} from '../workspace/types/WorkspaceQueries.types'; const flatten = (locationEntries: WorkspaceLocationNodeFragment[]) => { @@ -6,20 +9,48 @@ const flatten = (locationEntries: WorkspaceLocationNodeFragment[]) => { const all: CodeLocationRowType[] = []; for (const locationNode of locationEntries) { const {locationOrLoadError} = locationNode; + let status: CodeLocationRowStatusType; + if (locationNode.loadStatus === 'LOADING') { + if (locationOrLoadError) { + status = 'Updating'; + } else { + status = 'Loading'; + } + } else { + if (locationOrLoadError?.__typename === 'PythonError') { + status = 'Failed'; + } else { + status = 'Loaded'; + } + } if (!locationOrLoadError || locationOrLoadError?.__typename === 'PythonError') { - all.push({type: 'error' as const, node: locationNode}); + all.push({type: 'error' as const, node: locationNode, status}); } else { locationOrLoadError.repositories.forEach((repo) => { - all.push({type: 'repository' as const, codeLocation: locationNode, repository: repo}); + all.push({ + type: 'repository' as const, + codeLocation: locationNode, + repository: repo, + status, + }); }); } } return all; }; -const filterBySearch = (flattened: CodeLocationRowType[], searchValue: string) => { +const filterRows = ( + flattened: CodeLocationRowType[], + searchValue: string, + filters: CodeLocationFilters, +) => { const queryString = searchValue.toLocaleLowerCase(); return flattened.filter((row) => { + if (filters.status?.length) { + if (!filters.status.includes(row.status)) { + return false; + } + } if (row.type === 'error') { return row.node.name.toLocaleLowerCase().includes(queryString); } @@ -30,12 +61,15 @@ const filterBySearch = (flattened: CodeLocationRowType[], searchValue: string) = }); }; +export type CodeLocationFilters = Partial<{status: CodeLocationRowStatusType[]}>; + export const flattenCodeLocationRows = ( locationEntries: WorkspaceLocationNodeFragment[], searchValue: string = '', + filters: CodeLocationFilters = {}, ) => { const flattened = flatten(locationEntries); - const filtered = filterBySearch(flattened, searchValue); + const filtered = filterRows(flattened, searchValue, filters); return { flattened, diff --git a/js_modules/dagster-ui/packages/ui-core/src/instance/useCodeLocationPageFilters.tsx b/js_modules/dagster-ui/packages/ui-core/src/instance/useCodeLocationPageFilters.tsx new file mode 100644 index 0000000000000..0c502fcc814bb --- /dev/null +++ b/js_modules/dagster-ui/packages/ui-core/src/instance/useCodeLocationPageFilters.tsx @@ -0,0 +1,74 @@ +import React, {useCallback, useContext, useMemo, useState} from 'react'; + +import {CodeLocationFilters, flattenCodeLocationRows} from './flattenCodeLocationRows'; +import {useQueryPersistedState} from '../hooks/useQueryPersistedState'; +import {TruncatedTextWithFullTextOnHover} from '../nav/getLeftNavItemsForOption'; +import {useFilters} from '../ui/Filters'; +import {useStaticSetFilter} from '../ui/Filters/useStaticSetFilter'; +import {CodeLocationRowStatusType} from '../workspace/VirtualizedCodeLocationRow'; +import {WorkspaceContext} from '../workspace/WorkspaceContext'; + +export const useCodeLocationPageFilters = () => { + const {locationEntries, loading} = useContext(WorkspaceContext); + + const [searchValue, setSearchValue] = useState(''); + + const onChangeSearch = useCallback((e: React.ChangeEvent) => { + setSearchValue(e.target.value); + }, []); + + const queryString = searchValue.toLocaleLowerCase(); + + const [filters, setFilters] = useQueryPersistedState({ + encode: ({status}) => ({ + status: status?.length ? JSON.stringify(status) : undefined, + }), + decode: (qs) => { + return { + status: qs.status ? JSON.parse(qs.status) : [], + }; + }, + }); + + const {flattened, filtered} = useMemo(() => { + return flattenCodeLocationRows(locationEntries, queryString, filters); + }, [locationEntries, queryString, filters]); + + const statusFilter = useStaticSetFilter({ + name: 'Status', + icon: 'tag', + allValues: useMemo( + () => + (['Failed', 'Loaded', 'Updating', 'Loading'] as const).map((value) => ({ + key: value, + value, + match: [value], + })), + [], + ), + menuWidth: '300px', + renderLabel: ({value}) => { + return ; + }, + getStringValue: (value) => value, + state: filters.status, + onStateChanged: (values) => { + setFilters({status: Array.from(values)}); + }, + matchType: 'all-of', + canSelectAll: false, + allowMultipleSelections: true, + }); + + const {button, activeFiltersJsx} = useFilters({filters: [statusFilter]}); + + return { + button, + activeFiltersJsx, + onChangeSearch, + loading, + flattened, + filtered, + searchValue, + }; +}; diff --git a/js_modules/dagster-ui/packages/ui-core/src/nav/useCodeLocationsStatus.tsx b/js_modules/dagster-ui/packages/ui-core/src/nav/useCodeLocationsStatus.tsx index 98e523672ea8d..0ba91a5008265 100644 --- a/js_modules/dagster-ui/packages/ui-core/src/nav/useCodeLocationsStatus.tsx +++ b/js_modules/dagster-ui/packages/ui-core/src/nav/useCodeLocationsStatus.tsx @@ -1,5 +1,5 @@ import {Box, ButtonLink, Colors} from '@dagster-io/ui-components'; -import {useCallback, useContext, useLayoutEffect, useRef, useState} from 'react'; +import {useCallback, useContext, useLayoutEffect, useMemo, useRef, useState} from 'react'; import {useHistory} from 'react-router-dom'; import {atom, useRecoilValue} from 'recoil'; import styled from 'styled-components'; @@ -7,6 +7,7 @@ import styled from 'styled-components'; import {showSharedToaster} from '../app/DomUtils'; import {RepositoryLocationLoadStatus} from '../graphql/types'; import {StatusAndMessage} from '../instance/DeploymentStatusType'; +import {CodeLocationRowStatusType} from '../workspace/VirtualizedCodeLocationRow'; import {WorkspaceContext} from '../workspace/WorkspaceContext'; import {CodeLocationStatusQuery} from '../workspace/types/WorkspaceQueries.types'; @@ -24,7 +25,7 @@ export const codeLocationStatusAtom = atom( }); export const useCodeLocationsStatus = (): StatusAndMessage | null => { - const {locationEntries, data} = useContext(WorkspaceContext); + const {locationEntries, loading, data} = useContext(WorkspaceContext); const [previousEntriesById, setPreviousEntriesById] = useState(null); const history = useHistory(); @@ -33,35 +34,71 @@ export const useCodeLocationsStatus = (): StatusAndMessage | null => { const [showSpinner, setShowSpinner] = useState(false); - const onClickViewButton = useCallback(() => { - historyRef.current.push('/locations'); + const onClickViewButton = useCallback((statuses: CodeLocationRowStatusType[]) => { + historyRef.current.push(`/locations?status=${JSON.stringify(statuses)}`); }, []); // Reload the workspace, but don't toast about it. + const previousErroredLocationEntries = useRef(null); + let erroredLocationEntries = useMemo( + () => + Object.values(data) + .map((entry) => { + if (entry.__typename === 'PythonError') { + return entry.__typename; + } + if (entry.locationOrLoadError?.__typename === 'PythonError') { + return entry.updatedTimestamp; + } + return null; + }) + .filter(Boolean), + [data], + ); + if ( + !previousErroredLocationEntries.current || + previousErroredLocationEntries.current.length !== erroredLocationEntries.length || + previousErroredLocationEntries.current?.some( + (entry, index) => entry !== erroredLocationEntries[index], + ) + ) { + previousErroredLocationEntries.current = erroredLocationEntries; + } else { + // We need preserve the previous reference to avoid firing the error layout effect more than necessary. + // This is due to their being multiple updates to `data` due to locations being fetched individually + // and each updating `data` as they come in. + erroredLocationEntries = previousErroredLocationEntries.current; + } + // Reload the workspace, and show a success or error toast upon completion. useLayoutEffect(() => { - const anyErrors = Object.values(data).some( - (entry) => - entry.__typename === 'PythonError' || - entry.locationOrLoadError?.__typename === 'PythonError', - ); - - const showViewButton = !alreadyViewingCodeLocations(); + if (loading) { + return; + } - if (anyErrors) { + if (erroredLocationEntries.length) { showSharedToaster({ intent: 'warning', message: (
Definitions loaded with errors
- {showViewButton ? : null} + { + onClickViewButton(['Failed']); + }} + />
), icon: 'check_circle', }); } + }, [erroredLocationEntries, loading, onClickViewButton]); + useLayoutEffect(() => { + if (loading) { + return; + } const anyLoading = Object.values(data).some( (entry) => entry.__typename === 'WorkspaceLocationEntry' && @@ -70,14 +107,12 @@ export const useCodeLocationsStatus = (): StatusAndMessage | null => { if (!anyLoading) { setShowSpinner(false); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [data, onClickViewButton]); + }, [loading, data]); const codeLocationStatusQueryData = useRecoilValue(codeLocationStatusAtom); useLayoutEffect(() => { const isFreshPageload = previousEntriesById === null; - const showViewButton = !alreadyViewingCodeLocations(); // Given the previous and current code locations, determine whether to show a) a loading spinner // and/or b) a toast indicating that a code location is being reloaded. @@ -95,7 +130,11 @@ export const useCodeLocationsStatus = (): StatusAndMessage | null => { message: (
Definitions reloaded
- {showViewButton ? : null} + { + onClickViewButton([]); + }} + />
), icon: 'check_circle', @@ -181,7 +220,11 @@ export const useCodeLocationsStatus = (): StatusAndMessage | null => { message: ( {toastContent()} - {showViewButton ? : null} + { + onClickViewButton([]); + }} + /> ), icon: 'add_circle', @@ -210,7 +253,11 @@ export const useCodeLocationsStatus = (): StatusAndMessage | null => { ) : ( Updating {currentlyLoading.length} code locations )} - {showViewButton ? : null} + { + onClickViewButton([]); + }} + />
), icon: 'refresh', @@ -246,8 +293,6 @@ export const useCodeLocationsStatus = (): StatusAndMessage | null => { return null; }; -const alreadyViewingCodeLocations = () => document.location.pathname.endsWith('/locations'); - const ViewCodeLocationsButton = ({onClick}: {onClick: () => void}) => { return ( diff --git a/js_modules/dagster-ui/packages/ui-core/src/workspace/VirtualizedCodeLocationRow.tsx b/js_modules/dagster-ui/packages/ui-core/src/workspace/VirtualizedCodeLocationRow.tsx index 109e95b561d7a..139e9cf925c91 100644 --- a/js_modules/dagster-ui/packages/ui-core/src/workspace/VirtualizedCodeLocationRow.tsx +++ b/js_modules/dagster-ui/packages/ui-core/src/workspace/VirtualizedCodeLocationRow.tsx @@ -17,13 +17,16 @@ import {workspacePathFromAddress} from './workspacePath'; import {TimeFromNow} from '../ui/TimeFromNow'; import {HeaderCell, HeaderRow, RowCell} from '../ui/VirtualizedTable'; +export type CodeLocationRowStatusType = 'Failed' | 'Updating' | 'Loaded' | 'Loading'; + export type CodeLocationRowType = | { type: 'repository'; codeLocation: WorkspaceLocationNodeFragment; repository: WorkspaceRepositoryFragment; + status: CodeLocationRowStatusType; } - | {type: 'error'; node: WorkspaceLocationNodeFragment}; + | {type: 'error'; node: WorkspaceLocationNodeFragment; status: CodeLocationRowStatusType}; const TEMPLATE_COLUMNS = '3fr 1fr 1fr 240px 160px';