From f9f9d28ef0e3bae58b3bdd4af583881f6d830908 Mon Sep 17 00:00:00 2001 From: Yian Shang Date: Mon, 29 Dec 2025 09:23:41 -0800 Subject: [PATCH 1/8] Add and improve filters tab --- .../pages/NamespacePage/NodeModeSelect.jsx | 13 +- .../pages/NamespacePage/NodeTypeSelect.jsx | 19 +- .../src/app/pages/NamespacePage/TagSelect.jsx | 20 +- .../app/pages/NamespacePage/UserSelect.jsx | 21 +- .../src/app/pages/NamespacePage/index.jsx | 446 +++++++++++++++--- datajunction-ui/src/app/services/DJService.js | 21 +- 6 files changed, 443 insertions(+), 97 deletions(-) diff --git a/datajunction-ui/src/app/pages/NamespacePage/NodeModeSelect.jsx b/datajunction-ui/src/app/pages/NamespacePage/NodeModeSelect.jsx index 7e606c209..5d358dc83 100644 --- a/datajunction-ui/src/app/pages/NamespacePage/NodeModeSelect.jsx +++ b/datajunction-ui/src/app/pages/NamespacePage/NodeModeSelect.jsx @@ -1,7 +1,12 @@ import Select from 'react-select'; import Control from './FieldControl'; -export default function NodeModeSelect({ onChange }) { +const options = [ + { value: 'published', label: 'Published' }, + { value: 'draft', label: 'Draft' }, +]; + +export default function NodeModeSelect({ onChange, value }) { return ( onChange(e)} + value={value ? options.find(o => o.value === value) : null} styles={{ control: styles => ({ ...styles, backgroundColor: 'white' }), }} - options={[ - { value: 'published', label: 'Published' }, - { value: 'draft', label: 'Draft' }, - ]} + options={options} /> ); diff --git a/datajunction-ui/src/app/pages/NamespacePage/NodeTypeSelect.jsx b/datajunction-ui/src/app/pages/NamespacePage/NodeTypeSelect.jsx index ddfa06adf..58dd9a511 100644 --- a/datajunction-ui/src/app/pages/NamespacePage/NodeTypeSelect.jsx +++ b/datajunction-ui/src/app/pages/NamespacePage/NodeTypeSelect.jsx @@ -1,7 +1,15 @@ import Select from 'react-select'; import Control from './FieldControl'; -export default function NodeTypeSelect({ onChange }) { +const options = [ + { value: 'source', label: 'Source' }, + { value: 'transform', label: 'Transform' }, + { value: 'dimension', label: 'Dimension' }, + { value: 'metric', label: 'Metric' }, + { value: 'cube', label: 'Cube' }, +]; + +export default function NodeTypeSelect({ onChange, value }) { return ( onChange(e)} + value={value ? options.find(o => o.value === value) : null} styles={{ control: styles => ({ ...styles, backgroundColor: 'white' }), }} - options={[ - { value: 'source', label: 'Source' }, - { value: 'transform', label: 'Transform' }, - { value: 'dimension', label: 'Dimension' }, - { value: 'metric', label: 'Metric' }, - { value: 'cube', label: 'Cube' }, - ]} + options={options} /> ); diff --git a/datajunction-ui/src/app/pages/NamespacePage/TagSelect.jsx b/datajunction-ui/src/app/pages/NamespacePage/TagSelect.jsx index 7fa6d94e4..5cbc1bd37 100644 --- a/datajunction-ui/src/app/pages/NamespacePage/TagSelect.jsx +++ b/datajunction-ui/src/app/pages/NamespacePage/TagSelect.jsx @@ -4,7 +4,7 @@ import Control from './FieldControl'; import Select from 'react-select'; -export default function TagSelect({ onChange }) { +export default function TagSelect({ onChange, value }) { const djClient = useContext(DJClientContext).DataJunctionAPI; const [retrieved, setRetrieved] = useState(false); @@ -19,6 +19,16 @@ export default function TagSelect({ onChange }) { fetchData().catch(console.error); }, [djClient]); + const options = tags?.map(tag => ({ + value: tag.name, + label: tag.display_name, + })) || []; + + // For multi-select, value is an array of tag names + const selectedValues = value?.length + ? options.filter(o => value.includes(o.value)) + : []; + return ( onChange(e)} - options={tags?.map(tag => { - return { - value: tag.name, - label: tag.display_name, - }; - })} + value={selectedValues} + options={options} /> ); diff --git a/datajunction-ui/src/app/pages/NamespacePage/UserSelect.jsx b/datajunction-ui/src/app/pages/NamespacePage/UserSelect.jsx index 008578118..dcc58358a 100644 --- a/datajunction-ui/src/app/pages/NamespacePage/UserSelect.jsx +++ b/datajunction-ui/src/app/pages/NamespacePage/UserSelect.jsx @@ -4,7 +4,7 @@ import Control from './FieldControl'; import Select from 'react-select'; -export default function UserSelect({ onChange, currentUser }) { +export default function UserSelect({ onChange, value, currentUser }) { const djClient = useContext(DJClientContext).DataJunctionAPI; const [retrieved, setRetrieved] = useState(false); const [users, setUsers] = useState([]); @@ -18,6 +18,16 @@ export default function UserSelect({ onChange, currentUser }) { fetchData().catch(console.error); }, [djClient]); + const options = users?.map(user => ({ + value: user.username, + label: user.username, + })) || []; + + // Default to current user if no value specified and currentUser is provided + const selectedValue = value + ? options.find(o => o.value === value) + : (currentUser ? options.find(o => o.value === currentUser) : null); + return ( onChange(e)} - defaultValue={{ - value: currentUser, - label: currentUser, - }} - options={users?.map(user => { - return { value: user.username, label: user.username }; - })} + value={selectedValue} + options={options} /> ) : ( '' diff --git a/datajunction-ui/src/app/pages/NamespacePage/index.jsx b/datajunction-ui/src/app/pages/NamespacePage/index.jsx index f163d4790..7b3e0d81b 100644 --- a/datajunction-ui/src/app/pages/NamespacePage/index.jsx +++ b/datajunction-ui/src/app/pages/NamespacePage/index.jsx @@ -1,19 +1,15 @@ import * as React from 'react'; -import { useParams } from 'react-router-dom'; -import { useContext, useEffect, useState } from 'react'; +import { useParams, useSearchParams } from 'react-router-dom'; +import { useContext, useEffect, useState, useCallback } from 'react'; import NodeStatus from '../NodePage/NodeStatus'; import DJClientContext from '../../providers/djclient'; import { useCurrentUser } from '../../providers/UserProvider'; import Explorer from '../NamespacePage/Explorer'; import AddNodeDropdown from '../../components/AddNodeDropdown'; import NodeListActions from '../../components/NodeListActions'; -import AddNamespacePopover from './AddNamespacePopover'; -import FilterIcon from '../../icons/FilterIcon'; import LoadingIcon from '../../icons/LoadingIcon'; -import UserSelect from './UserSelect'; -import NodeTypeSelect from './NodeTypeSelect'; -import NodeModeSelect from './NodeModeSelect'; -import TagSelect from './TagSelect'; +import FilterIcon from '../../icons/FilterIcon'; +import CompactSelect from './CompactSelect'; import 'styles/node-list.css'; import 'styles/sorted-table.css'; @@ -27,6 +23,122 @@ export function NamespacePage() { const djClient = useContext(DJClientContext).DataJunctionAPI; const { currentUser } = useCurrentUser(); var { namespace } = useParams(); + const [searchParams, setSearchParams] = useSearchParams(); + + // Data for select options + const [users, setUsers] = useState([]); + const [tags, setTags] = useState([]); + const [usersLoading, setUsersLoading] = useState(true); + const [tagsLoading, setTagsLoading] = useState(true); + + // Load users and tags for dropdowns + useEffect(() => { + const fetchUsers = async () => { + const data = await djClient.users(); + setUsers(data || []); + setUsersLoading(false); + }; + const fetchTags = async () => { + const data = await djClient.listTags(); + setTags(data || []); + setTagsLoading(false); + }; + fetchUsers().catch(console.error); + fetchTags().catch(console.error); + }, [djClient]); + + // Parse all filters from URL + const getFiltersFromUrl = useCallback(() => ({ + node_type: searchParams.get('type') || '', + tags: searchParams.get('tags') ? searchParams.get('tags').split(',') : [], + edited_by: searchParams.get('editedBy') || '', + mode: searchParams.get('mode') || '', + ownedBy: searchParams.get('ownedBy') || '', + statuses: searchParams.get('statuses') || '', + missingDescription: searchParams.get('missingDescription') === 'true', + hasMaterialization: searchParams.get('hasMaterialization') === 'true', + orphanedDimension: searchParams.get('orphanedDimension') === 'true', + }), [searchParams]); + + const [filters, setFilters] = useState(getFiltersFromUrl); + const [moreFiltersOpen, setMoreFiltersOpen] = useState(false); + + // Sync filters state when URL changes + useEffect(() => { + setFilters(getFiltersFromUrl()); + }, [searchParams, getFiltersFromUrl]); + + // Update URL when filters change + const updateFilters = useCallback((newFilters) => { + const params = new URLSearchParams(); + + if (newFilters.node_type) params.set('type', newFilters.node_type); + if (newFilters.tags?.length) params.set('tags', newFilters.tags.join(',')); + if (newFilters.edited_by) params.set('editedBy', newFilters.edited_by); + if (newFilters.mode) params.set('mode', newFilters.mode); + if (newFilters.ownedBy) params.set('ownedBy', newFilters.ownedBy); + if (newFilters.statuses) params.set('statuses', newFilters.statuses); + if (newFilters.missingDescription) params.set('missingDescription', 'true'); + if (newFilters.hasMaterialization) params.set('hasMaterialization', 'true'); + if (newFilters.orphanedDimension) params.set('orphanedDimension', 'true'); + + setSearchParams(params); + }, [setSearchParams]); + + const clearAllFilters = () => { + setSearchParams(new URLSearchParams()); + }; + + // Check if any filters are active + const hasActiveFilters = filters.node_type || filters.tags?.length || + filters.edited_by || filters.mode || filters.ownedBy || filters.statuses || + filters.missingDescription || filters.hasMaterialization || filters.orphanedDimension; + + // Quick presets + const presets = [ + { + id: 'my-nodes', + label: 'My Nodes', + filters: { ownedBy: currentUser?.username }, + }, + { + id: 'needs-attention', + label: 'Needs Attention', + filters: { ownedBy: currentUser?.username, statuses: 'INVALID' }, + }, + { + id: 'drafts', + label: 'Drafts', + filters: { mode: 'draft' }, + }, + ]; + + const applyPreset = (preset) => { + const newFilters = { + node_type: '', + tags: [], + edited_by: '', + mode: preset.filters.mode || '', + ownedBy: preset.filters.ownedBy || '', + statuses: preset.filters.statuses || '', + missingDescription: preset.filters.missingDescription || false, + hasMaterialization: preset.filters.hasMaterialization || false, + orphanedDimension: preset.filters.orphanedDimension || false, + }; + updateFilters(newFilters); + }; + + // Check if a preset is active + const isPresetActive = (preset) => { + const pf = preset.filters; + return ( + (pf.ownedBy || '') === (filters.ownedBy || '') && + (pf.statuses || '') === (filters.statuses || '') && + (pf.mode || '') === (filters.mode || '') && + !filters.node_type && !filters.tags?.length && !filters.edited_by && + !filters.missingDescription && !filters.hasMaterialization && !filters.orphanedDimension + ); + }; const [state, setState] = useState({ namespace: namespace ? namespace : '', @@ -34,13 +146,6 @@ export function NamespacePage() { }); const [retrieved, setRetrieved] = useState(false); - const [filters, setFilters] = useState({ - tags: [], - node_type: '', - edited_by: '', - mode: '', - }); - const [namespaceHierarchy, setNamespaceHierarchy] = useState([]); const [sortConfig, setSortConfig] = useState({ @@ -113,6 +218,16 @@ export function NamespacePage() { useEffect(() => { const fetchData = async () => { setRetrieved(false); + + // Build extended filters for API + const extendedFilters = { + ownedBy: filters.ownedBy || null, + statuses: filters.statuses ? [filters.statuses] : null, + missingDescription: filters.missingDescription, + hasMaterialization: filters.hasMaterialization, + orphanedDimension: filters.orphanedDimension, + }; + const nodes = await djClient.listNodesForLanding( namespace, filters.node_type ? [filters.node_type.toUpperCase()] : [], @@ -123,6 +238,7 @@ export function NamespacePage() { 50, sortConfig, filters.mode ? filters.mode.toUpperCase() : null, + extendedFilters, ); setState({ @@ -152,7 +268,7 @@ export function NamespacePage() { setRetrieved(true); }; fetchData().catch(console.error); - }, [djClient, filters, before, after, sortConfig.key, sortConfig.direction]); + }, [djClient, filters, before, after, sortConfig.key, sortConfig.direction, namespace]); const loadNext = () => { if (nextCursor) { @@ -167,6 +283,28 @@ export function NamespacePage() { } }; + // Select options + const typeOptions = [ + { value: 'source', label: 'Source' }, + { value: 'transform', label: 'Transform' }, + { value: 'dimension', label: 'Dimension' }, + { value: 'metric', label: 'Metric' }, + { value: 'cube', label: 'Cube' }, + ]; + + const modeOptions = [ + { value: 'published', label: 'Published' }, + { value: 'draft', label: 'Draft' }, + ]; + + const statusOptions = [ + { value: 'VALID', label: 'Valid' }, + { value: 'INVALID', label: 'Invalid' }, + ]; + + const userOptions = users.map(u => ({ value: u.username, label: u.username })); + const tagOptions = tags.map(t => ({ value: t.name, label: t.display_name })); + const nodesList = retrieved ? ( state.nodes.length > 0 ? ( state.nodes.map(node => ( @@ -234,7 +372,7 @@ export function NamespacePage() { )) ) : ( - + - There are no nodes in{' '} - {namespace} with the above - filters! + No nodes found with the current filters. + {hasActiveFilters && ( + { e.preventDefault(); clearAllFilters(); }} + style={{ marginLeft: '0.5rem' }} + > + Clear filters + + )} @@ -260,63 +405,230 @@ export function NamespacePage() { ); + // Count active quality filters (the ones in the "More" dropdown) + const moreFiltersCount = [ + filters.missingDescription, + filters.hasMaterialization, + filters.orphanedDimension, + ].filter(Boolean).length; + return (
-

Explore

-
-
- -
-
- Filter +
+

Explore

+ +
+ + {/* Unified Filter Bar */} +
+ {/* Top row: Filter label + Quick presets + Clear all */} +
+
+ + Filter +
+ +
+ Quick: + {presets.map(preset => ( + + ))} + {hasActiveFilters && ( + + )} +
- - setFilters({ ...filters, node_type: entry ? entry.value : '' }) - } + + {/* Bottom row: Dropdowns */} +
+ updateFilters({ ...filters, node_type: e?.value || '' })} + flex={1} + minWidth="80px" /> - - setFilters({ - ...filters, - tags: entry ? entry.map(tag => tag.value) : [], - }) - } + updateFilters({ ...filters, tags: e ? e.map(t => t.value) : [] })} + isMulti + isLoading={tagsLoading} + flex={1.5} + minWidth="100px" /> - - setFilters({ ...filters, edited_by: entry ? entry.value : '' }) - } - currentUser={currentUser?.username} + updateFilters({ ...filters, edited_by: e?.value || '' })} + isLoading={usersLoading} + flex={1} + minWidth="80px" /> - - setFilters({ ...filters, mode: entry ? entry.value : '' }) - } + updateFilters({ ...filters, mode: e?.value || '' })} + flex={1} + minWidth="80px" /> - + updateFilters({ ...filters, ownedBy: e?.value || '' })} + isLoading={usersLoading} + flex={1} + minWidth="80px" + /> + updateFilters({ ...filters, statuses: e?.value || '' })} + flex={1} + minWidth="80px" + /> + + {/* More Filters (Quality) */} +
+
+ + +
+ + {moreFiltersOpen && ( +
+ + + +
+ )} +
+
+
Date: Mon, 29 Dec 2025 09:33:51 -0800 Subject: [PATCH 2/8] Support filters resolution in graphql --- .../api/graphql/queries/nodes.py | 43 +++++++++ .../api/graphql/resolvers/nodes.py | 14 ++- .../api/graphql/schema.graphql | 18 ++++ .../datajunction_server/database/node.py | 87 +++++++++++++++++-- 4 files changed, 156 insertions(+), 6 deletions(-) diff --git a/datajunction-server/datajunction_server/api/graphql/queries/nodes.py b/datajunction-server/datajunction_server/api/graphql/queries/nodes.py index 6134a32da..d6c392503 100644 --- a/datajunction-server/datajunction_server/api/graphql/queries/nodes.py +++ b/datajunction-server/datajunction_server/api/graphql/queries/nodes.py @@ -9,6 +9,7 @@ from strawberry.types import Info from datajunction_server.api.graphql.resolvers.nodes import find_nodes_by +from datajunction_server.models.node import NodeCursor, NodeMode, NodeStatus, NodeType from datajunction_server.api.graphql.scalars import Connection from datajunction_server.api.graphql.scalars.node import Node, NodeSortField from datajunction_server.models.node import NodeCursor, NodeMode, NodeType @@ -131,6 +132,42 @@ async def find_nodes_paginated( description="Filter to nodes with this mode (published or draft)", ), ] = None, + owned_by: Annotated[ + str | None, + strawberry.argument( + description="Filter to nodes owned by this user", + ), + ] = None, + missing_description: Annotated[ + bool, + strawberry.argument( + description="Filter to nodes missing descriptions (for data quality checks)", + ), + ] = False, + missing_owner: Annotated[ + bool, + strawberry.argument( + description="Filter to nodes without any owners (for data quality checks)", + ), + ] = False, + statuses: Annotated[ + list[NodeStatus] | None, + strawberry.argument( + description="Filter to nodes with these statuses (e.g., VALID, INVALID)", + ), + ] = None, + has_materialization: Annotated[ + bool, + strawberry.argument( + description="Filter to nodes that have materializations configured", + ), + ] = False, + orphaned_dimension: Annotated[ + bool, + strawberry.argument( + description="Filter to dimension nodes that are not linked to by any other node", + ), + ] = False, after: str | None = None, before: str | None = None, limit: Annotated[ @@ -161,6 +198,12 @@ async def find_nodes_paginated( order_by, ascending, mode, + owned_by, + missing_description, + missing_owner, + statuses, + has_materialization, + orphaned_dimension, ) return Connection.from_list( items=nodes_list, diff --git a/datajunction-server/datajunction_server/api/graphql/resolvers/nodes.py b/datajunction-server/datajunction_server/api/graphql/resolvers/nodes.py index 8d90fe102..32b22ab6b 100644 --- a/datajunction-server/datajunction_server/api/graphql/resolvers/nodes.py +++ b/datajunction-server/datajunction_server/api/graphql/resolvers/nodes.py @@ -17,7 +17,7 @@ from datajunction_server.database.node import Column, ColumnAttribute from datajunction_server.database.node import Node as DBNode from datajunction_server.database.node import NodeRevision as DBNodeRevision -from datajunction_server.models.node import NodeMode, NodeType +from datajunction_server.models.node import NodeMode, NodeStatus, NodeType async def find_nodes_by( @@ -34,7 +34,13 @@ async def find_nodes_by( order_by: NodeSortField = NodeSortField.CREATED_AT, ascending: bool = False, mode: Optional[NodeMode] = None, + owned_by: Optional[str] = None, + missing_description: bool = False, + missing_owner: bool = False, dimensions: Optional[List[str]] = None, + statuses: Optional[List[NodeStatus]] = None, + has_materialization: bool = False, + orphaned_dimension: bool = False, ) -> List[DBNode]: """ Finds nodes based on the search parameters. This function also tries to optimize @@ -64,6 +70,12 @@ async def find_nodes_by( ascending=ascending, options=options, mode=mode, + owned_by=owned_by, + missing_description=missing_description, + missing_owner=missing_owner, + statuses=statuses, + has_materialization=has_materialization, + orphaned_dimension=orphaned_dimension, dimensions=dimensions, ) diff --git a/datajunction-server/datajunction_server/api/graphql/schema.graphql b/datajunction-server/datajunction_server/api/graphql/schema.graphql index 90e46f4f7..dfe76302d 100644 --- a/datajunction-server/datajunction_server/api/graphql/schema.graphql +++ b/datajunction-server/datajunction_server/api/graphql/schema.graphql @@ -435,6 +435,24 @@ type Query { """Filter to nodes with this mode (published or draft)""" mode: NodeMode = null + + """Filter to nodes owned by this user""" + ownedBy: String = null + + """Filter to nodes missing descriptions (for data quality checks)""" + missingDescription: Boolean! = false + + """Filter to nodes without any owners (for data quality checks)""" + missingOwner: Boolean! = false + + """Filter to nodes with these statuses (e.g., VALID, INVALID)""" + statuses: [NodeStatus!] = null + + """Filter to nodes that have materializations configured""" + hasMaterialization: Boolean! = false + + """Filter to dimension nodes that are not linked to by any other node""" + orphanedDimension: Boolean! = false after: String = null before: String = null diff --git a/datajunction-server/datajunction_server/database/node.py b/datajunction-server/datajunction_server/database/node.py index 57e2cc6a8..8462705fa 100644 --- a/datajunction-server/datajunction_server/database/node.py +++ b/datajunction-server/datajunction_server/database/node.py @@ -599,7 +599,13 @@ async def find_by( ascending: bool = False, options: list[ExecutableOption] = None, mode: NodeMode | None = None, + owned_by: str | None = None, + missing_description: bool = False, + missing_owner: bool = False, dimensions: list[str] | None = None, + statuses: list[NodeStatus] | None = None, + has_materialization: bool = False, + orphaned_dimension: bool = False, ) -> List["Node"]: """ Finds a list of nodes by prefix @@ -676,15 +682,86 @@ async def find_by( if edited_by: edited_node_subquery = ( select(History.entity_name) - .where((History.user == edited_by)) + .where(History.user == edited_by) .distinct() .subquery() ) + # Use WHERE IN instead of JOIN + DISTINCT to avoid ORDER BY conflicts + statement = statement.where( + Node.name.in_(select(edited_node_subquery.c.entity_name)), + ) - statement = statement.join( - edited_node_subquery, - onclause=(edited_node_subquery.c.entity_name == Node.name), - ).distinct() + # Filter by owner username + if owned_by: + from datajunction_server.database.nodeowner import NodeOwner + + owned_node_subquery = ( + select(NodeOwner.node_id) + .join(User, NodeOwner.user_id == User.id) + .where(User.username == owned_by) + .distinct() + .subquery() + ) + statement = statement.where(Node.id.in_(select(owned_node_subquery))) + + # Filter nodes missing descriptions (actionable item) + if missing_description: + if not join_revision: + statement = statement.join(NodeRevisionAlias, Node.current) + join_revision = True + statement = statement.where( + or_( + NodeRevisionAlias.description.is_(None), + NodeRevisionAlias.description == "", + ), + ) + + # Filter nodes missing owners (actionable item) + if missing_owner: + from datajunction_server.database.nodeowner import NodeOwner + + nodes_with_owners_subquery = select(NodeOwner.node_id).distinct().subquery() + statement = statement.where( + ~Node.id.in_(select(nodes_with_owners_subquery)), + ) + + # Filter by node statuses + if statuses: + if not join_revision: + statement = statement.join(NodeRevisionAlias, Node.current) + join_revision = True + statement = statement.where(NodeRevisionAlias.status.in_(statuses)) + + # Filter to nodes with materializations configured + if has_materialization: + from datajunction_server.database.materialization import Materialization + + if not join_revision: + statement = statement.join(NodeRevisionAlias, Node.current) + join_revision = True + nodes_with_mat_subquery = ( + select(Materialization.node_revision_id) + .where(Materialization.deactivated_at.is_(None)) + .distinct() + .subquery() + ) + statement = statement.where( + NodeRevisionAlias.id.in_(select(nodes_with_mat_subquery)), + ) + + # Filter to orphaned dimensions (dimension nodes not linked to by any other node) + if orphaned_dimension: + from datajunction_server.database.dimensionlink import DimensionLink + + # Only dimension nodes can be orphaned + statement = statement.where(Node.type == NodeType.DIMENSION) + # Find dimensions that have no DimensionLink pointing to them + linked_dimension_subquery = ( + select(DimensionLink.dimension_id).distinct().subquery() + ) + statement = statement.where( + ~Node.id.in_(select(linked_dimension_subquery)), + ) if after: cursor = NodeCursor.decode(after) From 54472361620e9751c2770ca26a55c4883560b44b Mon Sep 17 00:00:00 2001 From: Yian Shang Date: Mon, 29 Dec 2025 11:06:41 -0800 Subject: [PATCH 3/8] Fix filtering --- .../api/graphql/queries/nodes.py | 41 +- .../api/graphql/resolvers/nodes.py | 1 + .../datajunction_server/database/node.py | 8 +- .../app/pages/NamespacePage/CompactSelect.jsx | 98 ++++ .../src/app/pages/NamespacePage/TagSelect.jsx | 50 -- .../app/pages/NamespacePage/UserSelect.jsx | 52 -- .../src/app/pages/NamespacePage/index.jsx | 517 +++++++++++------- 7 files changed, 441 insertions(+), 326 deletions(-) create mode 100644 datajunction-ui/src/app/pages/NamespacePage/CompactSelect.jsx delete mode 100644 datajunction-ui/src/app/pages/NamespacePage/TagSelect.jsx delete mode 100644 datajunction-ui/src/app/pages/NamespacePage/UserSelect.jsx diff --git a/datajunction-server/datajunction_server/api/graphql/queries/nodes.py b/datajunction-server/datajunction_server/api/graphql/queries/nodes.py index d6c392503..b6b444976 100644 --- a/datajunction-server/datajunction_server/api/graphql/queries/nodes.py +++ b/datajunction-server/datajunction_server/api/graphql/queries/nodes.py @@ -9,10 +9,9 @@ from strawberry.types import Info from datajunction_server.api.graphql.resolvers.nodes import find_nodes_by -from datajunction_server.models.node import NodeCursor, NodeMode, NodeStatus, NodeType from datajunction_server.api.graphql.scalars import Connection from datajunction_server.api.graphql.scalars.node import Node, NodeSortField -from datajunction_server.models.node import NodeCursor, NodeMode, NodeType +from datajunction_server.models.node import NodeCursor, NodeMode, NodeStatus, NodeType DEFAULT_LIMIT = 1000 UPPER_LIMIT = 10000 @@ -185,25 +184,25 @@ async def find_nodes_paginated( if not limit or limit < 0: limit = 100 nodes_list = await find_nodes_by( - info, - names, - fragment, - node_types, - tags, - edited_by, - namespace, - limit + 1, - before, - after, - order_by, - ascending, - mode, - owned_by, - missing_description, - missing_owner, - statuses, - has_materialization, - orphaned_dimension, + info=info, + names=names, + fragment=fragment, + node_types=node_types, + tags=tags, + edited_by=edited_by, + namespace=namespace, + limit=limit + 1, + before=before, + after=after, + order_by=order_by, + ascending=ascending, + mode=mode, + owned_by=owned_by, + missing_description=missing_description, + missing_owner=missing_owner, + statuses=statuses, + has_materialization=has_materialization, + orphaned_dimension=orphaned_dimension, ) return Connection.from_list( items=nodes_list, diff --git a/datajunction-server/datajunction_server/api/graphql/resolvers/nodes.py b/datajunction-server/datajunction_server/api/graphql/resolvers/nodes.py index 32b22ab6b..c5e98a190 100644 --- a/datajunction-server/datajunction_server/api/graphql/resolvers/nodes.py +++ b/datajunction-server/datajunction_server/api/graphql/resolvers/nodes.py @@ -55,6 +55,7 @@ async def find_nodes_by( if "edges" in fields else fields, ) + print("statuses", statuses) return await DBNode.find_by( session, names, diff --git a/datajunction-server/datajunction_server/database/node.py b/datajunction-server/datajunction_server/database/node.py index 8462705fa..7af8e77d3 100644 --- a/datajunction-server/datajunction_server/database/node.py +++ b/datajunction-server/datajunction_server/database/node.py @@ -727,10 +727,16 @@ async def find_by( # Filter by node statuses if statuses: + print("statuses", statuses) if not join_revision: statement = statement.join(NodeRevisionAlias, Node.current) join_revision = True - statement = statement.where(NodeRevisionAlias.status.in_(statuses)) + # Strawberry enums need to be converted to their lowercase values for DB comparison + status_values = [ + s.value.lower() if hasattr(s, "value") else str(s).lower() + for s in statuses + ] + statement = statement.where(NodeRevisionAlias.status.in_(status_values)) # Filter to nodes with materializations configured if has_materialization: diff --git a/datajunction-ui/src/app/pages/NamespacePage/CompactSelect.jsx b/datajunction-ui/src/app/pages/NamespacePage/CompactSelect.jsx new file mode 100644 index 000000000..de50be71f --- /dev/null +++ b/datajunction-ui/src/app/pages/NamespacePage/CompactSelect.jsx @@ -0,0 +1,98 @@ +import Select from 'react-select'; + +// Compact select with label above - saves horizontal space +export default function CompactSelect({ + label, + name, + options, + value, + onChange, + isMulti = false, + isClearable = true, + placeholder = 'Select...', + minWidth = '100px', + flex = 1, + isLoading = false, +}) { + // For single select, find the matching option + // For multi select, filter to matching options + const selectedValue = isMulti + ? value?.length + ? options.filter(o => value.includes(o.value)) + : [] + : value + ? options.find(o => o.value === value) + : null; + + return ( +
+ + onChange(e)} - value={selectedValues} - options={options} - /> - - ); -} diff --git a/datajunction-ui/src/app/pages/NamespacePage/UserSelect.jsx b/datajunction-ui/src/app/pages/NamespacePage/UserSelect.jsx deleted file mode 100644 index dcc58358a..000000000 --- a/datajunction-ui/src/app/pages/NamespacePage/UserSelect.jsx +++ /dev/null @@ -1,52 +0,0 @@ -import { useContext, useEffect, useState } from 'react'; -import DJClientContext from '../../providers/djclient'; -import Control from './FieldControl'; - -import Select from 'react-select'; - -export default function UserSelect({ onChange, value, currentUser }) { - const djClient = useContext(DJClientContext).DataJunctionAPI; - const [retrieved, setRetrieved] = useState(false); - const [users, setUsers] = useState([]); - - useEffect(() => { - const fetchData = async () => { - const users = await djClient.users(); - setUsers(users); - setRetrieved(true); - }; - fetchData().catch(console.error); - }, [djClient]); - - const options = users?.map(user => ({ - value: user.username, - label: user.username, - })) || []; - - // Default to current user if no value specified and currentUser is provided - const selectedValue = value - ? options.find(o => o.value === value) - : (currentUser ? options.find(o => o.value === currentUser) : null); - - return ( - - {retrieved ? ( - updateFilters({ ...filters, missingDescription: e.target.checked })} - /> - Missing Description - - -
- )} -
+ + {moreFiltersOpen && ( +
+ + + +
+ )} +
- +
Date: Mon, 29 Dec 2025 20:28:05 -0800 Subject: [PATCH 4/8] Clean up UI and graphql APIs --- .../api/graphql/queries/nodes.py | 81 ++- .../api/graphql/resolvers/nodes.py | 1 - .../api/graphql/schema.graphql | 32 + .../datajunction_server/database/node.py | 14 +- .../tests/api/graphql/find_nodes_test.py | 560 ++++++++++++++++++ datajunction-ui/src/app/icons/FilterIcon.jsx | 7 - .../app/pages/NamespacePage/CompactSelect.jsx | 2 + .../app/pages/NamespacePage/FieldControl.jsx | 21 - .../pages/NamespacePage/NodeTypeSelect.jsx | 33 -- .../__tests__/CompactSelect.test.jsx | 190 ++++++ .../NamespacePage/__tests__/index.test.jsx | 305 +++++++++- .../src/app/pages/NamespacePage/index.jsx | 10 +- datajunction-ui/src/styles/index.css | 4 +- 13 files changed, 1169 insertions(+), 91 deletions(-) delete mode 100644 datajunction-ui/src/app/icons/FilterIcon.jsx delete mode 100644 datajunction-ui/src/app/pages/NamespacePage/FieldControl.jsx delete mode 100644 datajunction-ui/src/app/pages/NamespacePage/NodeTypeSelect.jsx create mode 100644 datajunction-ui/src/app/pages/NamespacePage/__tests__/CompactSelect.test.jsx diff --git a/datajunction-server/datajunction_server/api/graphql/queries/nodes.py b/datajunction-server/datajunction_server/api/graphql/queries/nodes.py index b6b444976..b014db402 100644 --- a/datajunction-server/datajunction_server/api/graphql/queries/nodes.py +++ b/datajunction-server/datajunction_server/api/graphql/queries/nodes.py @@ -51,6 +51,60 @@ async def find_nodes( "Accepts dimension node names or dimension attributes", ), ] = None, + edited_by: Annotated[ + str | None, + strawberry.argument( + description="Filter to nodes edited by this user", + ), + ] = None, + namespace: Annotated[ + str | None, + strawberry.argument( + description="Filter to nodes in this namespace", + ), + ] = None, + mode: Annotated[ + NodeMode | None, + strawberry.argument( + description="Filter to nodes with this mode (published or draft)", + ), + ] = None, + owned_by: Annotated[ + str | None, + strawberry.argument( + description="Filter to nodes owned by this user", + ), + ] = None, + missing_description: Annotated[ + bool, + strawberry.argument( + description="Filter to nodes missing descriptions (for data quality checks)", + ), + ] = False, + missing_owner: Annotated[ + bool, + strawberry.argument( + description="Filter to nodes without any owners (for data quality checks)", + ), + ] = False, + statuses: Annotated[ + list[NodeStatus] | None, + strawberry.argument( + description="Filter to nodes with these statuses (e.g., VALID, INVALID)", + ), + ] = None, + has_materialization: Annotated[ + bool, + strawberry.argument( + description="Filter to nodes that have materializations configured", + ), + ] = False, + orphaned_dimension: Annotated[ + bool, + strawberry.argument( + description="Filter to dimension nodes that are not linked to by any other node", + ), + ] = False, limit: Annotated[ int | None, strawberry.argument(description="Limit nodes"), @@ -76,12 +130,21 @@ async def find_nodes( limit = UPPER_LIMIT return await find_nodes_by( # type: ignore - info, - names, - fragment, - node_types, - tags, + info=info, + names=names, + fragment=fragment, + node_types=node_types, + tags=tags, dimensions=dimensions, + edited_by=edited_by, + namespace=namespace, + mode=mode, + owned_by=owned_by, + missing_description=missing_description, + missing_owner=missing_owner, + statuses=statuses, + has_materialization=has_materialization, + orphaned_dimension=orphaned_dimension, limit=limit, order_by=order_by, ascending=ascending, @@ -113,6 +176,13 @@ async def find_nodes_paginated( description="Filter to nodes tagged with these tags", ), ] = None, + dimensions: Annotated[ + list[str] | None, + strawberry.argument( + description="Filter to nodes that have ALL of these dimensions. " + "Accepts dimension node names or dimension attributes", + ), + ] = None, edited_by: Annotated[ str | None, strawberry.argument( @@ -189,6 +259,7 @@ async def find_nodes_paginated( fragment=fragment, node_types=node_types, tags=tags, + dimensions=dimensions, edited_by=edited_by, namespace=namespace, limit=limit + 1, diff --git a/datajunction-server/datajunction_server/api/graphql/resolvers/nodes.py b/datajunction-server/datajunction_server/api/graphql/resolvers/nodes.py index c5e98a190..32b22ab6b 100644 --- a/datajunction-server/datajunction_server/api/graphql/resolvers/nodes.py +++ b/datajunction-server/datajunction_server/api/graphql/resolvers/nodes.py @@ -55,7 +55,6 @@ async def find_nodes_by( if "edges" in fields else fields, ) - print("statuses", statuses) return await DBNode.find_by( session, names, diff --git a/datajunction-server/datajunction_server/api/graphql/schema.graphql b/datajunction-server/datajunction_server/api/graphql/schema.graphql index dfe76302d..bed655f4d 100644 --- a/datajunction-server/datajunction_server/api/graphql/schema.graphql +++ b/datajunction-server/datajunction_server/api/graphql/schema.graphql @@ -407,6 +407,33 @@ type Query { """ dimensions: [String!] = null + """Filter to nodes edited by this user""" + editedBy: String = null + + """Filter to nodes in this namespace""" + namespace: String = null + + """Filter to nodes with this mode (published or draft)""" + mode: NodeMode = null + + """Filter to nodes owned by this user""" + ownedBy: String = null + + """Filter to nodes missing descriptions (for data quality checks)""" + missingDescription: Boolean! = false + + """Filter to nodes without any owners (for data quality checks)""" + missingOwner: Boolean! = false + + """Filter to nodes with these statuses (e.g., VALID, INVALID)""" + statuses: [NodeStatus!] = null + + """Filter to nodes that have materializations configured""" + hasMaterialization: Boolean! = false + + """Filter to dimension nodes that are not linked to by any other node""" + orphanedDimension: Boolean! = false + """Limit nodes""" limit: Int = 1000 orderBy: NodeSortField! = CREATED_AT @@ -427,6 +454,11 @@ type Query { """Filter to nodes tagged with these tags""" tags: [String!] = null + """ + Filter to nodes that have ALL of these dimensions. Accepts dimension node names or dimension attributes + """ + dimensions: [String!] = null + """Filter to nodes edited by this user""" editedBy: String = null diff --git a/datajunction-server/datajunction_server/database/node.py b/datajunction-server/datajunction_server/database/node.py index 7af8e77d3..b94f34512 100644 --- a/datajunction-server/datajunction_server/database/node.py +++ b/datajunction-server/datajunction_server/database/node.py @@ -45,6 +45,7 @@ from datajunction_server.database.history import History from datajunction_server.database.materialization import Materialization from datajunction_server.database.metricmetadata import MetricMetadata +from datajunction_server.database.nodeowner import NodeOwner from datajunction_server.database.tag import Tag from datajunction_server.database.user import User from datajunction_server.errors import ( @@ -693,8 +694,6 @@ async def find_by( # Filter by owner username if owned_by: - from datajunction_server.database.nodeowner import NodeOwner - owned_node_subquery = ( select(NodeOwner.node_id) .join(User, NodeOwner.user_id == User.id) @@ -706,7 +705,7 @@ async def find_by( # Filter nodes missing descriptions (actionable item) if missing_description: - if not join_revision: + if not join_revision: # pragma: no branch statement = statement.join(NodeRevisionAlias, Node.current) join_revision = True statement = statement.where( @@ -718,8 +717,6 @@ async def find_by( # Filter nodes missing owners (actionable item) if missing_owner: - from datajunction_server.database.nodeowner import NodeOwner - nodes_with_owners_subquery = select(NodeOwner.node_id).distinct().subquery() statement = statement.where( ~Node.id.in_(select(nodes_with_owners_subquery)), @@ -727,8 +724,7 @@ async def find_by( # Filter by node statuses if statuses: - print("statuses", statuses) - if not join_revision: + if not join_revision: # pragma: no branch statement = statement.join(NodeRevisionAlias, Node.current) join_revision = True # Strawberry enums need to be converted to their lowercase values for DB comparison @@ -740,9 +736,7 @@ async def find_by( # Filter to nodes with materializations configured if has_materialization: - from datajunction_server.database.materialization import Materialization - - if not join_revision: + if not join_revision: # pragma: no branch statement = statement.join(NodeRevisionAlias, Node.current) join_revision = True nodes_with_mat_subquery = ( diff --git a/datajunction-server/tests/api/graphql/find_nodes_test.py b/datajunction-server/tests/api/graphql/find_nodes_test.py index 3aa1a53f0..8193e8d3b 100644 --- a/datajunction-server/tests/api/graphql/find_nodes_test.py +++ b/datajunction-server/tests/api/graphql/find_nodes_test.py @@ -1630,3 +1630,563 @@ async def test_find_nodes_with_mixed_dimension_formats( assert len(node_names) > 0 # All results should have both dimensions available assert "default.repair_orders_fact" in node_names + + +@pytest.mark.asyncio +async def test_find_nodes_filter_by_owner( + module__client_with_roads: AsyncClient, +) -> None: + """ + Test filtering nodes by owner (ownedBy). + """ + # Query for nodes owned by the 'dj' user + query = """ + { + findNodes(ownedBy: "dj") { + name + owners { + username + } + } + } + """ + response = await module__client_with_roads.post("/graphql", json={"query": query}) + assert response.status_code == 200 + data = response.json() + + # All returned nodes should have 'dj' as an owner + for node in data["data"]["findNodes"]: + owner_usernames = [owner["username"] for owner in node["owners"]] + assert "dj" in owner_usernames + + # Verify we got some results + assert len(data["data"]["findNodes"]) > 0 + + +@pytest.mark.asyncio +async def test_find_nodes_paginated_filter_by_owner( + module__client_with_roads: AsyncClient, +) -> None: + """ + Test filtering nodes by owner (ownedBy) using paginated endpoint. + """ + query = """ + { + findNodesPaginated(ownedBy: "dj", limit: 10) { + edges { + node { + name + owners { + username + } + } + } + } + } + """ + response = await module__client_with_roads.post("/graphql", json={"query": query}) + assert response.status_code == 200 + data = response.json() + + # All returned nodes should have 'dj' as an owner + for edge in data["data"]["findNodesPaginated"]["edges"]: + owner_usernames = [owner["username"] for owner in edge["node"]["owners"]] + assert "dj" in owner_usernames + + +@pytest.mark.asyncio +async def test_find_nodes_filter_by_status_valid( + module__client_with_roads: AsyncClient, +) -> None: + """ + Test filtering nodes by status (VALID). + """ + query = """ + { + findNodes(statuses: [VALID]) { + name + current { + status + } + } + } + """ + response = await module__client_with_roads.post("/graphql", json={"query": query}) + assert response.status_code == 200 + data = response.json() + + # All returned nodes should have VALID status + for node in data["data"]["findNodes"]: + assert node["current"]["status"] == "VALID" + + # Verify we got some results + assert len(data["data"]["findNodes"]) > 0 + + +@pytest.mark.asyncio +async def test_find_nodes_filter_by_status_invalid( + module__client_with_roads: AsyncClient, +) -> None: + """ + Test filtering nodes by status (INVALID). + First create an invalid node, then filter for it. + """ + # Create a node that references a non-existent parent (will be invalid) + response = await module__client_with_roads.post( + "/nodes/transform/", + json={ + "name": "default.invalid_test_node", + "description": "An invalid test node", + "query": "SELECT * FROM default.nonexistent_table", + "mode": "published", + }, + ) + # This should fail or create an invalid node + + query = """ + { + findNodes(statuses: [INVALID]) { + name + current { + status + } + } + } + """ + response = await module__client_with_roads.post("/graphql", json={"query": query}) + assert response.status_code == 200 + data = response.json() + + # All returned nodes should have INVALID status + for node in data["data"]["findNodes"]: + assert node["current"]["status"] == "INVALID" + + +@pytest.mark.asyncio +async def test_find_nodes_paginated_filter_by_status( + module__client_with_roads: AsyncClient, +) -> None: + """ + Test filtering nodes by status using paginated endpoint. + """ + query = """ + { + findNodesPaginated(statuses: [VALID], limit: 10) { + edges { + node { + name + current { + status + } + } + } + } + } + """ + response = await module__client_with_roads.post("/graphql", json={"query": query}) + assert response.status_code == 200 + data = response.json() + + # All returned nodes should have VALID status + for edge in data["data"]["findNodesPaginated"]["edges"]: + assert edge["node"]["current"]["status"] == "VALID" + + +@pytest.mark.asyncio +async def test_find_nodes_filter_missing_description( + module__client_with_roads: AsyncClient, +) -> None: + """ + Test filtering nodes that are missing descriptions. + """ + # First create a node without a description + response = await module__client_with_roads.post( + "/nodes/transform/", + json={ + "name": "default.no_description_node", + "description": "", # Empty description + "query": "SELECT 1 as id", + "mode": "published", + }, + ) + + query = """ + { + findNodes(missingDescription: true) { + name + current { + description + } + } + } + """ + response = await module__client_with_roads.post("/graphql", json={"query": query}) + assert response.status_code == 200 + data = response.json() + + # All returned nodes should have empty or null descriptions + for node in data["data"]["findNodes"]: + desc = node["current"]["description"] + assert desc is None or desc == "" + + +@pytest.mark.asyncio +async def test_find_nodes_paginated_filter_missing_description( + module__client_with_roads: AsyncClient, +) -> None: + """ + Test filtering nodes that are missing descriptions using paginated endpoint. + """ + query = """ + { + findNodesPaginated(missingDescription: true, limit: 10) { + edges { + node { + name + current { + description + } + } + } + } + } + """ + response = await module__client_with_roads.post("/graphql", json={"query": query}) + assert response.status_code == 200 + data = response.json() + + # All returned nodes should have empty or null descriptions + for edge in data["data"]["findNodesPaginated"]["edges"]: + desc = edge["node"]["current"]["description"] + assert desc is None or desc == "" + + +@pytest.mark.asyncio +async def test_find_nodes_filter_missing_owner( + module__client_with_roads: AsyncClient, +) -> None: + """ + Test filtering nodes that are missing owners. + """ + query = """ + { + findNodes(missingOwner: true) { + name + owners { + username + } + } + } + """ + response = await module__client_with_roads.post("/graphql", json={"query": query}) + assert response.status_code == 200 + data = response.json() + + # All returned nodes should have no owners + for node in data["data"]["findNodes"]: + assert node["owners"] == [] or node["owners"] is None + + +@pytest.mark.asyncio +async def test_find_nodes_paginated_filter_missing_owner( + module__client_with_roads: AsyncClient, +) -> None: + """ + Test filtering nodes that are missing owners using paginated endpoint. + """ + query = """ + { + findNodesPaginated(missingOwner: true, limit: 10) { + edges { + node { + name + owners { + username + } + } + } + } + } + """ + response = await module__client_with_roads.post("/graphql", json={"query": query}) + assert response.status_code == 200 + data = response.json() + + # All returned nodes should have no owners + for edge in data["data"]["findNodesPaginated"]["edges"]: + owners = edge["node"]["owners"] + assert owners == [] or owners is None + + +@pytest.mark.asyncio +async def test_find_nodes_filter_orphaned_dimension( + module__client_with_roads: AsyncClient, +) -> None: + """ + Test filtering for orphaned dimension nodes (dimensions not linked to by any other node). + """ + # First, create an orphaned dimension (a dimension that no other node links to) + response = await module__client_with_roads.post( + "/nodes/dimension/", + json={ + "name": "default.orphaned_dimension_test", + "description": "An orphaned dimension for testing", + "query": "SELECT 1 as orphan_id, 'test' as orphan_name", + "primary_key": ["orphan_id"], + "mode": "published", + }, + ) + + query = """ + { + findNodes(orphanedDimension: true) { + name + type + } + } + """ + response = await module__client_with_roads.post("/graphql", json={"query": query}) + assert response.status_code == 200 + data = response.json() + + # All returned nodes should be dimensions + for node in data["data"]["findNodes"]: + assert node["type"] == "DIMENSION" + + # The orphaned dimension we created should be in the results + node_names = {node["name"] for node in data["data"]["findNodes"]} + assert "default.orphaned_dimension_test" in node_names + + +@pytest.mark.asyncio +async def test_find_nodes_paginated_filter_orphaned_dimension( + module__client_with_roads: AsyncClient, +) -> None: + """ + Test filtering for orphaned dimension nodes using paginated endpoint. + """ + query = """ + { + findNodesPaginated(orphanedDimension: true, limit: 10) { + edges { + node { + name + type + } + } + } + } + """ + response = await module__client_with_roads.post("/graphql", json={"query": query}) + assert response.status_code == 200 + data = response.json() + + # All returned nodes should be dimensions + for edge in data["data"]["findNodesPaginated"]["edges"]: + assert edge["node"]["type"] == "DIMENSION" + + +@pytest.mark.asyncio +async def test_find_nodes_combined_filters( + module__client_with_roads: AsyncClient, +) -> None: + """ + Test combining multiple filters together. + """ + # Combine ownedBy with status filter + query = """ + { + findNodes(ownedBy: "dj", statuses: [VALID], nodeTypes: [METRIC]) { + name + type + owners { + username + } + current { + status + } + } + } + """ + response = await module__client_with_roads.post("/graphql", json={"query": query}) + assert response.status_code == 200 + data = response.json() + + # All returned nodes should match all filters + for node in data["data"]["findNodes"]: + assert node["type"] == "METRIC" + assert node["current"]["status"] == "VALID" + owner_usernames = [owner["username"] for owner in node["owners"]] + assert "dj" in owner_usernames + + +@pytest.mark.asyncio +async def test_find_nodes_paginated_combined_filters( + module__client_with_roads: AsyncClient, +) -> None: + """ + Test combining multiple filters together using paginated endpoint. + """ + query = """ + { + findNodesPaginated(ownedBy: "dj", statuses: [VALID], nodeTypes: [SOURCE], limit: 10) { + edges { + node { + name + type + owners { + username + } + current { + status + } + } + } + } + } + """ + response = await module__client_with_roads.post("/graphql", json={"query": query}) + assert response.status_code == 200 + data = response.json() + + # All returned nodes should match all filters + for edge in data["data"]["findNodesPaginated"]["edges"]: + node = edge["node"] + assert node["type"] == "SOURCE" + assert node["current"]["status"] == "VALID" + owner_usernames = [owner["username"] for owner in node["owners"]] + assert "dj" in owner_usernames + + +@pytest.mark.asyncio +async def test_find_nodes_filter_by_nonexistent_owner( + module__client_with_roads: AsyncClient, +) -> None: + """ + Test that filtering by a nonexistent owner returns empty results. + """ + query = """ + { + findNodes(ownedBy: "nonexistent_user_12345") { + name + } + } + """ + response = await module__client_with_roads.post("/graphql", json={"query": query}) + assert response.status_code == 200 + data = response.json() + assert data["data"]["findNodes"] == [] + + +@pytest.mark.asyncio +async def test_find_nodes_filter_multiple_statuses( + module__client_with_roads: AsyncClient, +) -> None: + """ + Test filtering nodes by multiple statuses. + """ + query = """ + { + findNodes(statuses: [VALID, INVALID]) { + name + current { + status + } + } + } + """ + response = await module__client_with_roads.post("/graphql", json={"query": query}) + assert response.status_code == 200 + data = response.json() + + # All returned nodes should have either VALID or INVALID status + for node in data["data"]["findNodes"]: + assert node["current"]["status"] in ["VALID", "INVALID"] + + # Verify we got some results + assert len(data["data"]["findNodes"]) > 0 + + +@pytest.mark.asyncio +async def test_find_nodes_paginated_filter_has_materialization( + module__client_with_roads: AsyncClient, +) -> None: + """ + Test filtering nodes that have materializations configured. + """ + # First, set up a partition column on a node so we can create a materialization + await module__client_with_roads.post( + "/nodes/default.repair_orders_fact/columns/repair_order_id/partition", + json={"type_": "categorical"}, + ) + + # Create a materialization on a node + response = await module__client_with_roads.post( + "/nodes/default.repair_orders_fact/materialization", + json={ + "job": "spark_sql", + "strategy": "full", + "schedule": "@daily", + "config": {}, + }, + ) + # Note: materialization creation may fail in test environment without query service, + # but the node should still be marked as having materialization configured + + # Query for nodes with materializations + query = """ + { + findNodesPaginated(hasMaterialization: true, limit: 10) { + edges { + node { + name + type + current { + materializations { + name + } + } + } + } + } + } + """ + response = await module__client_with_roads.post("/graphql", json={"query": query}) + assert response.status_code == 200 + data = response.json() + + # If we got results, all returned nodes should have materializations + for edge in data["data"]["findNodesPaginated"]["edges"]: + node = edge["node"] + materializations = node["current"]["materializations"] + assert materializations is not None and len(materializations) > 0 + + +@pytest.mark.asyncio +async def test_find_nodes_filter_has_materialization( + module__client_with_roads: AsyncClient, +) -> None: + """ + Test filtering nodes that have materializations using non-paginated endpoint. + """ + query = """ + { + findNodes(hasMaterialization: true) { + name + type + current { + materializations { + name + } + } + } + } + """ + response = await module__client_with_roads.post("/graphql", json={"query": query}) + assert response.status_code == 200 + data = response.json() + + # If we got results, all returned nodes should have materializations + for node in data["data"]["findNodes"]: + materializations = node["current"]["materializations"] + assert materializations is not None and len(materializations) > 0 diff --git a/datajunction-ui/src/app/icons/FilterIcon.jsx b/datajunction-ui/src/app/icons/FilterIcon.jsx deleted file mode 100644 index 3dd7f29d3..000000000 --- a/datajunction-ui/src/app/icons/FilterIcon.jsx +++ /dev/null @@ -1,7 +0,0 @@ -const FilterIcon = props => ( - - - - -); -export default FilterIcon; diff --git a/datajunction-ui/src/app/pages/NamespacePage/CompactSelect.jsx b/datajunction-ui/src/app/pages/NamespacePage/CompactSelect.jsx index de50be71f..f1e54619e 100644 --- a/datajunction-ui/src/app/pages/NamespacePage/CompactSelect.jsx +++ b/datajunction-ui/src/app/pages/NamespacePage/CompactSelect.jsx @@ -13,6 +13,7 @@ export default function CompactSelect({ minWidth = '100px', flex = 1, isLoading = false, + testId = null, }) { // For single select, find the matching option // For multi select, filter to matching options @@ -33,6 +34,7 @@ export default function CompactSelect({ flex, minWidth, }} + data-testid={testId} >