-
Notifications
You must be signed in to change notification settings - Fork 23
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
PORTALS-3272: Add multi-select dropdown menu to observation pages to filter #1322
Conversation
@@ -90,72 +96,168 @@ function _CardContainer(props: CardContainerProps) { | |||
ids, | |||
type: 'ENTITY_HEADER', | |||
}) | |||
|
|||
const observationColIndex = rowSet?.headers?.findIndex( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think this logic belongs here. Am I correct in assuming that in this case it will render many ObservationCard components? Could this logic be moved to the ObservationCard (if it should be filtered out, then may it returns an empty react fragment?)?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
At the very least, this logic should be less specific to ObservationCards, and should utilize the Synapse tables faceted column features to get filter values and apply the filter to the rows server-side.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think a good example of a similar component is the QuerySortSelector
, which renders a similar control to toggle the sort of data rendered by CardContainerLogic
. A similar pattern could be followed to render this filter control.
const filteredRows = rowData.filter(row => | ||
selectedObservationTypes.every(type => | ||
JSON.parse(row.values[schema.observationType] ?? '[]').includes(type), | ||
), | ||
) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Instead of filtering client-side, we should make this new UI control apply a facet filter and pass it to Synapse in the QueryBundleRequest.
To actually get the valid observationType, the Observations MaterializedView should be updated to make observationType
a faceted column. Once that's done, Synapse will give us the set of observationType values in the QueryResultBundle.facets object.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
New file is very close to what I was expecting and is so much less code than the original implementation! I had some questions and pointed out opportunities to clean up
// { | ||
// name: 'CardContainerLogic', | ||
// props: { | ||
// sql: `${observationsSql} WHERE observationTime IS NOT NULL ORDER BY observationTime DESC`, | ||
// type: SynapseConstants.OBSERVATION_CARD, | ||
// limit: 3, | ||
// }, | ||
// title: 'Natural History Observations', | ||
// tableSqlKeys: ['resourceId'], | ||
// columnName: 'resourceId', | ||
// }, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Commented out on purpose?
...rest, | ||
}) | ||
|
||
const renderCards = (rows: any) => |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is the only change here to extract this renderCards
method and change the if
/else
to switch
? Is this necessary?
{filterColumnName && ( | ||
<ColumnFilter filterColumnName={filterColumnName} /> | ||
)} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice! Super simple addition here.
) | ||
} | ||
|
||
const ColumnFilter: React.FC<FilterProps> = props => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Prefer using function
and removing React.FC
(which I think is deprecated)
const ColumnFilter: React.FC<FilterProps> = props => { | |
function ColumnFilter (props: FilterProps) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could you add some tests for this component? The QuerySortSelector has a corresponding QuerySortSelector.test.tsx file that you can use as a base. Unfortunately, that one is older, so it's not type-checked and co-located with the QuerySortSelector component file.
For the ColumnFilter.test.tsx
, could you co-locate the file with your new component file, and ensure it passes typechecking?
...rest | ||
}: ControlProps<any, boolean, GroupBase<any>>) => { | ||
return ( | ||
<components.Control {...rest} className="form-control"> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could you convert this to use MUI's Autocomplete
instead of using Bootstrap 4 + react-select? In general we are trying to migrate away from Bootstrap, and while we still use react-select, prefer to use MUI when possible.
const newFilter: ColumnMultiValueFunctionQueryFilter | null = | ||
selectedValues.length > 0 | ||
? { | ||
concreteType: | ||
'org.sagebionetworks.repo.model.table.ColumnMultiValueFunctionQueryFilter', | ||
columnName: filterColumnName ? filterColumnName : '', | ||
function: ColumnMultiValueFunction.HAS, | ||
values: selectedValues, | ||
} | ||
: null | ||
|
||
executeQueryRequest(request => { | ||
const currentFilters = currentQuery.query.additionalFilters || [] | ||
|
||
const isColumnMultiValueFilter = ( | ||
filter: QueryFilter, | ||
): filter is ColumnMultiValueFunctionQueryFilter => 'columnName' in filter | ||
|
||
const updatedFilters = currentFilters.filter( | ||
filter => | ||
!isColumnMultiValueFilter(filter) || | ||
filter.columnName !== filterColumnName, | ||
) | ||
|
||
if (newFilter) { | ||
updatedFilters.push(newFilter) | ||
} | ||
|
||
request.query.additionalFilters = updatedFilters | ||
|
||
setSelectedTypes(selectedValues) | ||
return request | ||
}) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think you can just do something like
removeSelectedFacet(columnName)
values.forEach(value => addValueToSelectedFacet(columnName, value))
Where removeSelectedFacet
and addValueToSelectedFacet
can be retrieved from the queryContext object.
getCurrentQueryRequest, | ||
} = queryContext | ||
const { data: queryMetadata } = useQuery(queryMetadataQueryOptions) | ||
const [selectedTypes, setSelectedTypes] = useState<string[]>([]) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Instead of storing the selection in state, we can make the source of truth the current query and eliminate this state variable.
You can do something like
const selectedFacet = getCorrespondingSelectedFacet({columnName}, currentQuery.query.selectedFacets)
const selectedValues = (selectedFacet as FacetColumnValuesRequest).facetValues
getCorrespondingSelectedFacet
is a utility function you can import.
const observationIds = selectedRows | ||
.map(row => row.values[0]) // Get observationId - identifier but not all rows have this | ||
.filter(observationId => observationId) | ||
.map(observationId => `'${observationId}'`) | ||
|
||
const observationTexts = selectedRows | ||
.filter(row => !row.values[0]) // Only take rows without observationId | ||
.map(row => row.values[4]) // Get observationText - all rows have this | ||
.filter(text => text) | ||
.map(text => `'${text}'`) | ||
|
||
let sql = '' | ||
|
||
if (!observationIds.length) { | ||
sql = `SELECT * FROM syn51735464 | ||
WHERE observationText IN (${observationTexts.join(', ')})` | ||
} else { | ||
sql = `SELECT * FROM syn51735464 | ||
WHERE observationId IN (${observationIds.join(', ')})${ | ||
observationTexts.length > 0 | ||
? ` OR observationText IN (${observationTexts.join(', ')})` | ||
: '' | ||
}` | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why do you need this SQL? Sorry, I don't totally understand the feature
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We can use the ROW_ID
meta-column, so here we can collect the row IDs
const observationIds = selectedRows | |
.map(row => row.values[0]) // Get observationId - identifier but not all rows have this | |
.filter(observationId => observationId) | |
.map(observationId => `'${observationId}'`) | |
const observationTexts = selectedRows | |
.filter(row => !row.values[0]) // Only take rows without observationId | |
.map(row => row.values[4]) // Get observationText - all rows have this | |
.filter(text => text) | |
.map(text => `'${text}'`) | |
let sql = '' | |
if (!observationIds.length) { | |
sql = `SELECT * FROM syn51735464 | |
WHERE observationText IN (${observationTexts.join(', ')})` | |
} else { | |
sql = `SELECT * FROM syn51735464 | |
WHERE observationId IN (${observationIds.join(', ')})${ | |
observationTexts.length > 0 | |
? ` OR observationText IN (${observationTexts.join(', ')})` | |
: '' | |
}` | |
} | |
const selectedRowIds = selectedRows.map(row => row.rowId!) |
<CardContainerLogic | ||
filterColumnName="observationType" | ||
sql={sql} | ||
type={OBSERVATION_CARD} | ||
initialLimit={3} | ||
/> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We can add additional filters using the searchParams
prop (which needs a better name/API design, but that would be a massive change).
We add the sqlOperator
prop to tell our code to add the searchParams as a filter with the IN
clause.
And we can also add the lockedColumn
to hide any UI that would indicate the ROW_ID filter (it may not make a difference now, but we can add it to be safe)
<CardContainerLogic | |
filterColumnName="observationType" | |
sql={sql} | |
type={OBSERVATION_CARD} | |
initialLimit={3} | |
/> | |
<CardContainerLogic | |
filterColumnName="observationType" | |
sql={observationsSql} | |
searchParams={{["ROW_ID"]: selectedRowIds}} | |
sqlOperator="IN" | |
lockedColumn={{ columnName: "ROW_ID" }} | |
type={OBSERVATION_CARD} | |
initialLimit={3} | |
/> |
it('renders the ColumnFilter component correctly', () => { | ||
render(ColumnFilterComponent) | ||
|
||
expect(screen.getByTestId('column-filter')).toBeInTheDocument() | ||
}) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This test isn't necessary--we have a few existing tests like these but most of them are vestiges of when our component tests were very basic.
You may also want to remove the column-filter
test ID. It seems like it isn't necessary.
it('renders the ColumnFilter component correctly', () => { | |
render(ColumnFilterComponent) | |
expect(screen.getByTestId('column-filter')).toBeInTheDocument() | |
}) |
|
||
const columnFilterDiv = screen.getByTestId('column-filter') | ||
expect(columnFilterDiv).toBeInTheDocument() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
const columnFilterDiv = screen.getByTestId('column-filter') | |
expect(columnFilterDiv).toBeInTheDocument() |
jest.mock('@tanstack/react-query', () => ({ | ||
useQuery: jest.fn(), | ||
QueryClient: jest.fn().mockImplementation(() => ({ | ||
queryCache: { clear: jest.fn() }, | ||
setQueryData: jest.fn(), | ||
getQueryData: jest.fn(), | ||
})), | ||
})) | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I like how you approached making this a unit test. That said, we've tended to avoid mocking the entire react-query module because it becomes difficult to create mocks in components that contain multiple instances of useQuery
. If we were to extend this component with another query, we'd have to totally change this mock approach, building dynamic logic into useQuery
mock.
Instead, I think you could continue to extend the QueryContext mock by making the queryMetadataQueryOptions something like this:
{
queryKey: ['queryMetadataQueryOptions']
queryFn: () => data
}
It looks like we've actually done this in another test, FullTextSearch.test.ts
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We don't typically do it, but I like how you mocked useQueryContext
instead of using the real one, so no need to change that.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When you use the real react-query, you'll need to wrap the component under test in a QueryContext. We created a context wrapper for all tests, so when you call render
, you can just do
render(<ColumnFilter {...props} />, { wrapper: createWrapper() })
createWrapper
will come from TestingLibraryUtils.tsx
|
||
describe('ColumnFilter tests', () => { | ||
const executeQueryRequest = jest.fn() | ||
const addValueToSelectedFacet = jest.fn() | ||
const removeSelectedFacet = jest.fn() | ||
|
||
beforeEach(() => { | ||
;(useQueryContext as jest.Mock).mockReturnValue({ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You can get type-safe mocks with the following pattern:
const mockUseQueryContext = jest.mocked(useQueryContext)
describe('ColumnFilter tests', () => { | |
const executeQueryRequest = jest.fn() | |
const addValueToSelectedFacet = jest.fn() | |
const removeSelectedFacet = jest.fn() | |
beforeEach(() => { | |
;(useQueryContext as jest.Mock).mockReturnValue({ | |
describe('ColumnFilter tests', () => { | |
const executeQueryRequest = jest.fn() | |
const addValueToSelectedFacet = jest.fn() | |
const removeSelectedFacet = jest.fn() | |
beforeEach(() => { | |
mockUseQueryContext.mockReturnValue({ |
Please remove all of these unnecessary casts where you could use jest.mocked
const ColumnFilterComponent = ( | ||
<ColumnFilter | ||
filterColumnName="program" | ||
removeSelectedFacet={removeSelectedFacet} | ||
addValueToSelectedFacet={addValueToSelectedFacet} | ||
/> | ||
) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You should recreate the React element in each test. You can share props between tests if you want to keep the verbosity down, e.g.
const props = (
filterColumnName: "program",
removeSelectedFacet: removeSelectedFacet,
addValueToSelectedFacet: addValueToSelectedFacet
}
render(<ColumnFilter {...props} />)
|
||
// const addValueToSelectedFacet = queryContext.addValueToSelectedFacet | ||
// const addValueToSelectedFacet = queryContext.removeSelectedFacet | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
// const addValueToSelectedFacet = queryContext.addValueToSelectedFacet | |
// const addValueToSelectedFacet = queryContext.removeSelectedFacet |
export type FilterProps = { | ||
filterColumnName?: string | ||
addValueToSelectedFacet?: ( | ||
facet: { columnName: string }, | ||
value: string, | ||
) => void | ||
removeSelectedFacet?: (facet: { columnName: string }) => void | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
filterColumnName
will always be defined.
Also, remove the other unused props for the functions that are coming from context
export type FilterProps = { | |
filterColumnName?: string | |
addValueToSelectedFacet?: ( | |
facet: { columnName: string }, | |
value: string, | |
) => void | |
removeSelectedFacet?: (facet: { columnName: string }) => void | |
} | |
export type FilterProps = { | |
filterColumnName: string | |
} |
const filterOptions = activeFacet?.facetValues.map(({ value }) => value) ?? [] | ||
|
||
const selectedFacetFromQuery = getCorrespondingSelectedFacet( | ||
{ columnName: filterColumnName || '' }, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
filterColumnName
is always defined (after you update the prop type)
{ columnName: filterColumnName || '' }, | |
{ columnName: filterColumnName }, |
event: React.SyntheticEvent, | ||
values: string[], // New value for the selected options | ||
) => { | ||
const facetIdentifier = { columnName: filterColumnName || '' } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
filterColumnName
is always defined (after you update the prop type)
const facetIdentifier = { columnName: filterColumnName || '' } | |
const facetIdentifier = { columnName: filterColumnName } |
} | ||
|
||
return ( | ||
<Box data-testid="column-filter"> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If you aren't using MUI-specific styling logic, div
is the same as Box
with less overhead. The testid is unnecessary after you make suggested text changes
<Box data-testid="column-filter"> | |
<div> |
)} | ||
/> | ||
)} | ||
</Box> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
</Box> | |
</div> |
packages/synapse-react-client/src/components/TimelinePlot/TimelinePhase.tsx
Outdated
Show resolved
Hide resolved
packages/synapse-react-client/src/components/ColumnFilter/ColumnFilter.tsx
Outdated
Show resolved
Hide resolved
packages/synapse-react-client/src/components/ColumnFilter/ColumnFilter.tsx
Outdated
Show resolved
Hide resolved
const facetIdentifier = { | ||
columnName: topLevelEnumeratedFacetToFilter.columnName, | ||
} | ||
|
||
removeSelectedFacet(facetIdentifier) | ||
|
||
values.forEach(value => addValueToSelectedFacet(facetIdentifier, value)) | ||
|
||
executeQueryRequest(request => request) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
- Since we changed the prop to be a UniqueFacetIdentifier, you can just pass it in here
- You shouldn't need to call
executeQueryRequest
after callingremoveSelectedFacet
/addValueToSelectedFacet
const facetIdentifier = { | |
columnName: topLevelEnumeratedFacetToFilter.columnName, | |
} | |
removeSelectedFacet(facetIdentifier) | |
values.forEach(value => addValueToSelectedFacet(facetIdentifier, value)) | |
executeQueryRequest(request => request) | |
removeSelectedFacet(topLevelEnumeratedFacetToFilter) | |
values.forEach(value => addValueToSelectedFacet(topLevelEnumeratedFacetToFilter, value)) |
const filterOptions = activeFacet?.facetValues.map(({ value }) => value) ?? [] | ||
|
||
const selectedFacetFromQuery = getCorrespondingSelectedFacet( | ||
{ columnName: topLevelEnumeratedFacetToFilter.columnName }, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You can just pass this since it is a facet identifier
{ columnName: topLevelEnumeratedFacetToFilter.columnName }, | |
topLevelEnumeratedFacetToFilter, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
fantastic work!
Jira Ticket: https://sagebionetworks.jira.com/browse/PORTALS-3272?atlOrigin=eyJpIjoiMjFjMGU0MDJiZWZmNDU2NmI1NmE3YTBlYzRkOGVhY2UiLCJwIjoiaiJ9