Skip to content

Commit

Permalink
(feat.) Add tag filtering to jobs page (#25196)
Browse files Browse the repository at this point in the history
## Summary & Motivation

Also consolidate the search/ code location filtering to use our
filtering component.
<img width="329" alt="Screenshot 2024-10-10 at 2 32 00 PM"
src="https://github.com/user-attachments/assets/5b6cc807-2513-459b-85cc-106d8a33565b">

## How I Tested These Changes

I used each of the filters in various combinations.


## Changelog

[UI] Added tag filter to the jobs page

---------

Co-authored-by: Isaac Hellendag <[email protected]>
  • Loading branch information
salazarm and hellendag authored Oct 21, 2024
1 parent 183b6ee commit 792dc10
Show file tree
Hide file tree
Showing 4 changed files with 120 additions and 220 deletions.
1 change: 0 additions & 1 deletion js_modules/dagster-ui/packages/ui-core/client.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

269 changes: 115 additions & 154 deletions js_modules/dagster-ui/packages/ui-core/src/jobs/JobsPageContent.tsx
Original file line number Diff line number Diff line change
@@ -1,93 +1,132 @@
import {
Box,
Colors,
Icon,
NonIdealState,
Spinner,
SpinnerWithText,
TextInput,
} from '@dagster-io/ui-components';
import {useContext, useMemo} from 'react';
import {useCallback, useContext, useMemo} from 'react';

import {gql, useQuery} from '../apollo-client';
import {OverviewJobsQuery, OverviewJobsQueryVariables} from './types/JobsPageContent.types';
import {PYTHON_ERROR_FRAGMENT} from '../app/PythonErrorFragment';
import {
FIFTEEN_SECONDS,
QueryRefreshCountdown,
useQueryRefreshAtInterval,
} from '../app/QueryRefresh';
import {isHiddenAssetGroupJob} from '../asset-graph/Utils';
import {useQueryPersistedState} from '../hooks/useQueryPersistedState';
import {RepoFilterButton} from '../instance/RepoFilterButton';
import {useQueryPersistedFilterState} from '../hooks/useQueryPersistedFilterState';
import {TruncatedTextWithFullTextOnHover} from '../nav/getLeftNavItemsForOption';
import {OverviewJobsTable} from '../overview/OverviewJobsTable';
import {sortRepoBuckets} from '../overview/sortRepoBuckets';
import {visibleRepoKeys} from '../overview/visibleRepoKeys';
import {useBlockTraceUntilTrue} from '../performance/TraceContext';
import {SearchInputSpinner} from '../ui/SearchInputSpinner';
import {useFilters} from '../ui/BaseFilters/useFilters';
import {useStaticSetFilter} from '../ui/BaseFilters/useStaticSetFilter';
import {useCodeLocationFilter} from '../ui/Filters/useCodeLocationFilter';
import {
Tag,
doesFilterArrayMatchValueArray,
useDefinitionTagFilter,
useTagsForObjects,
} from '../ui/Filters/useDefinitionTagFilter';
import {WorkspaceContext} from '../workspace/WorkspaceContext/WorkspaceContext';
import {WorkspaceLocationNodeFragment} from '../workspace/WorkspaceContext/types/WorkspaceQueries.types';
import {
WorkspaceLocationNodeFragment,
WorkspacePipelineFragment,
} from '../workspace/WorkspaceContext/types/WorkspaceQueries.types';
import {buildRepoAddress} from '../workspace/buildRepoAddress';
import {repoAddressAsHumanString} from '../workspace/repoAddressAsString';
import {RepoAddress} from '../workspace/types';

export const JobsPageContent = () => {
const {
allRepos,
visibleRepos,
loading: workspaceLoading,
data: cachedData,
} = useContext(WorkspaceContext);
const [searchValue, setSearchValue] = useQueryPersistedState<string>({
queryKey: 'search',
defaults: {search: ''},
});
const FILTER_FIELDS = ['jobs', 'tags', 'codeLocations'] as const;

export const JobsPageContent = () => {
const {allRepos, visibleRepos, loading, data: cachedData} = useContext(WorkspaceContext);
const repoCount = allRepos.length;

const queryResultOverview = useQuery<OverviewJobsQuery, OverviewJobsQueryVariables>(
OVERVIEW_JOBS_QUERY,
{
fetchPolicy: 'network-only',
notifyOnNetworkStatusChange: true,
},
);
const {data, loading: queryLoading} = queryResultOverview;

const refreshState = useQueryRefreshAtInterval(queryResultOverview, FIFTEEN_SECONDS);

// Batch up the data and bucket by repo.
const repoBuckets = useMemo(() => {
const cachedEntries = Object.values(cachedData).filter(
(location): location is Extract<typeof location, {__typename: 'WorkspaceLocationEntry'}> =>
location.__typename === 'WorkspaceLocationEntry',
);
const workspaceOrError = data?.workspaceOrError;
const entries =
workspaceOrError?.__typename === 'Workspace'
? workspaceOrError.locationEntries
: cachedEntries;
const visibleKeys = visibleRepoKeys(visibleRepos);
return buildBuckets(entries).filter(({repoAddress}) =>
return buildBuckets(cachedEntries).filter(({repoAddress}) =>
visibleKeys.has(repoAddressAsHumanString(repoAddress)),
);
}, [cachedData, data, visibleRepos]);

const loading = !data && workspaceLoading;
}, [cachedData, visibleRepos]);

const allJobs = useMemo(() => repoBuckets.flatMap((bucket) => bucket.jobs), [repoBuckets]);
const allTags = useTagsForObjects(allJobs, (job) => job.tags);

const {state: _state, setters} = useQueryPersistedFilterState<{
jobs: string[];
tags: Tag[];
codeLocations: RepoAddress[];
}>(FILTER_FIELDS);

const state = useMemo(() => {
return {
..._state,
codeLocations: _state.codeLocations.map(({name, location}) =>
buildRepoAddress(name, location),
),
};
}, [_state]);

const codeLocationFilter = useCodeLocationFilter({
codeLocations: state.codeLocations,
setCodeLocations: setters.setCodeLocations,
});

useBlockTraceUntilTrue('OverviewJobs', !loading);
const tagsFilter = useDefinitionTagFilter({allTags, tags: state.tags, setTags: setters.setTags});

const jobFilter = useStaticSetFilter<string>({
name: 'Job',
icon: 'job',
allValues: useMemo(
() =>
allJobs.map((job) => ({
key: job.name,
value: job.name,
match: [job.name],
})),
[allJobs],
),
renderLabel: ({value}) => (
<Box flex={{direction: 'row', gap: 4, alignItems: 'center'}}>
<Icon name="job" />
<TruncatedTextWithFullTextOnHover text={value} />
</Box>
),
getStringValue: (x) => x,
state: state.jobs,
onStateChanged: useCallback(
(values) => {
setters.setJobs(Array.from(values));
},
[setters],
),
});

const sanitizedSearch = searchValue.trim().toLocaleLowerCase();
const anySearch = sanitizedSearch.length > 0;
const filters = useMemo(
() => [codeLocationFilter, jobFilter, tagsFilter],
[codeLocationFilter, jobFilter, tagsFilter],
);
const {button: filterButton, activeFiltersJsx} = useFilters({filters});

const filteredBySearch = useMemo(() => {
const searchToLower = sanitizedSearch.toLocaleLowerCase();
const filteredRepoBuckets = useMemo(() => {
return repoBuckets
.map(({repoAddress, jobs}) => ({
repoAddress,
jobs: jobs.filter(({name}) => name.toLocaleLowerCase().includes(searchToLower)),
.filter((bucket) => {
return !state.codeLocations.length || state.codeLocations.includes(bucket.repoAddress);
})
.map((bucket) => ({
...bucket,
jobs: bucket.jobs.filter((job) => {
if (state.jobs.length && !state.jobs.includes(job.name)) {
return false;
}
if (state.tags.length && !doesFilterArrayMatchValueArray(state.tags, job.tags)) {
return false;
}
return true;
}),
}))
.filter(({jobs}) => jobs.length > 0);
}, [repoBuckets, sanitizedSearch]);
.filter((bucket) => !!bucket.jobs.length);
}, [repoBuckets, state]);

const content = () => {
if (loading) {
Expand All @@ -101,73 +140,43 @@ export const JobsPageContent = () => {
);
}

const anyReposHidden = allRepos.length > visibleRepos.length;

if (!filteredBySearch.length) {
if (anySearch) {
return (
<Box padding={{top: 20}}>
<NonIdealState
icon="search"
title="No matching jobs"
description={
anyReposHidden ? (
<div>
No jobs matching <strong>{searchValue}</strong> were found in the selected code
locations
</div>
) : (
<div>
No jobs matching <strong>{searchValue}</strong> were found in your definitions
</div>
)
}
/>
</Box>
);
}

if (!filteredRepoBuckets.length) {
return (
<Box padding={{top: 20}}>
<NonIdealState
icon="search"
title="No jobs"
description={
anyReposHidden
? 'No jobs were found in the selected code locations'
repoBuckets.length
? 'No jobs were found that match your filters'
: 'No jobs were found in your definitions'
}
/>
</Box>
);
}

return <OverviewJobsTable repos={filteredBySearch} />;
return <OverviewJobsTable repos={filteredRepoBuckets} />;
};

const showSearchSpinner = queryLoading && !data;

return (
<>
<Box
padding={{horizontal: 24, vertical: 12}}
padding={{horizontal: 24, vertical: 8}}
flex={{direction: 'row', alignItems: 'center', justifyContent: 'space-between', grow: 0}}
border="bottom"
>
<Box flex={{direction: 'row', gap: 12, alignItems: 'center'}}>
{repoCount > 1 ? <RepoFilterButton /> : null}
<TextInput
icon="search"
value={searchValue}
rightElement={
showSearchSpinner ? <SearchInputSpinner tooltipContent="Loading jobs…" /> : undefined
}
onChange={(e) => setSearchValue(e.target.value)}
placeholder="Filter by job name…"
style={{width: '340px'}}
/>
</Box>
<QueryRefreshCountdown refreshState={refreshState} />
{filterButton}
</Box>
{activeFiltersJsx.length ? (
<Box
padding={{vertical: 8, horizontal: 24}}
border="bottom"
flex={{direction: 'row', gap: 8}}
>
{activeFiltersJsx}
</Box>
) : null}
{loading && !repoCount ? (
<Box padding={64}>
<SpinnerWithText label="Loading jobs…" />
Expand All @@ -181,16 +190,11 @@ export const JobsPageContent = () => {

type RepoBucket = {
repoAddress: RepoAddress;
jobs: {
isJob: boolean;
name: string;
}[];
jobs: WorkspacePipelineFragment[];
};

const buildBuckets = (
locationEntries:
| Extract<OverviewJobsQuery['workspaceOrError'], {__typename: 'Workspace'}>['locationEntries']
| Extract<WorkspaceLocationNodeFragment, {__typename: 'WorkspaceLocationEntry'}>[],
locationEntries: Extract<WorkspaceLocationNodeFragment, {__typename: 'WorkspaceLocationEntry'}>[],
): RepoBucket[] => {
const entries = locationEntries.map((entry) => entry.locationOrLoadError);
const buckets = [];
Expand All @@ -203,14 +207,7 @@ const buildBuckets = (
for (const repo of entry.repositories) {
const {name, pipelines} = repo;
const repoAddress = buildRepoAddress(name, entry.name);
const jobs = pipelines
.filter(({name}) => !isHiddenAssetGroupJob(name))
.map((pipeline) => {
return {
isJob: pipeline.isJob,
name: pipeline.name,
};
});
const jobs = pipelines.filter(({name}) => !isHiddenAssetGroupJob(name));

if (jobs.length > 0) {
buckets.push({
Expand All @@ -223,39 +220,3 @@ const buildBuckets = (

return sortRepoBuckets(buckets);
};

const OVERVIEW_JOBS_QUERY = gql`
query OverviewJobsQuery {
workspaceOrError {
... on Workspace {
id
locationEntries {
id
locationOrLoadError {
... on RepositoryLocation {
id
name
repositories {
id
name
pipelines {
tags {
key
value
}
id
name
isJob
}
}
}
...PythonErrorFragment
}
}
}
...PythonErrorFragment
}
}
${PYTHON_ERROR_FRAGMENT}
`;
Loading

1 comment on commit 792dc10

@github-actions
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deploy preview for dagit-core-storybook ready!

✅ Preview
https://dagit-core-storybook-aqxz3x68q-elementl.vercel.app

Built with commit 792dc10.
This pull request is being automatically deployed with vercel-action

Please sign in to comment.