Skip to content
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

Merged
merged 21 commits into from
Nov 12, 2024

Conversation

@kianamcc kianamcc marked this pull request as ready for review October 28, 2024 19:07
@@ -90,72 +96,168 @@ function _CardContainer(props: CardContainerProps) {
ids,
type: 'ENTITY_HEADER',
})

const observationColIndex = rowSet?.headers?.findIndex(
Copy link
Member

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?)?

Copy link
Collaborator

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.

Copy link
Collaborator

@nickgros nickgros Oct 29, 2024

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.

@nickgros nickgros self-requested a review October 29, 2024 18:37
Comment on lines 264 to 268
const filteredRows = rowData.filter(row =>
selectedObservationTypes.every(type =>
JSON.parse(row.values[schema.observationType] ?? '[]').includes(type),
),
)
Copy link
Collaborator

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.

Copy link
Collaborator

@nickgros nickgros left a 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

Comment on lines 257 to 267
// {
// 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',
// },
Copy link
Collaborator

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) =>
Copy link
Collaborator

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?

Comment on lines 224 to 226
{filterColumnName && (
<ColumnFilter filterColumnName={filterColumnName} />
)}
Copy link
Collaborator

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 => {
Copy link
Collaborator

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)

Suggested change
const ColumnFilter: React.FC<FilterProps> = props => {
function ColumnFilter (props: FilterProps) {

Copy link
Collaborator

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">
Copy link
Collaborator

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.

Comment on lines 65 to 97
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
})
Copy link
Collaborator

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[]>([])
Copy link
Collaborator

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.

Comment on lines 237 to 260
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(', ')})`
: ''
}`
}
Copy link
Collaborator

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

Copy link
Collaborator

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

Suggested change
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!)

Comment on lines 292 to 297
<CardContainerLogic
filterColumnName="observationType"
sql={sql}
type={OBSERVATION_CARD}
initialLimit={3}
/>
Copy link
Collaborator

@nickgros nickgros Nov 5, 2024

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)

Suggested change
<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}
/>

Comment on lines 57 to 61
it('renders the ColumnFilter component correctly', () => {
render(ColumnFilterComponent)

expect(screen.getByTestId('column-filter')).toBeInTheDocument()
})
Copy link
Collaborator

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.

Suggested change
it('renders the ColumnFilter component correctly', () => {
render(ColumnFilterComponent)
expect(screen.getByTestId('column-filter')).toBeInTheDocument()
})

Comment on lines 112 to 114

const columnFilterDiv = screen.getByTestId('column-filter')
expect(columnFilterDiv).toBeInTheDocument()
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
const columnFilterDiv = screen.getByTestId('column-filter')
expect(columnFilterDiv).toBeInTheDocument()

Comment on lines 12 to 20
jest.mock('@tanstack/react-query', () => ({
useQuery: jest.fn(),
QueryClient: jest.fn().mockImplementation(() => ({
queryCache: { clear: jest.fn() },
setQueryData: jest.fn(),
getQueryData: jest.fn(),
})),
}))

Copy link
Collaborator

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

Copy link
Collaborator

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.

Copy link
Collaborator

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

Comment on lines 22 to 29

describe('ColumnFilter tests', () => {
const executeQueryRequest = jest.fn()
const addValueToSelectedFacet = jest.fn()
const removeSelectedFacet = jest.fn()

beforeEach(() => {
;(useQueryContext as jest.Mock).mockReturnValue({
Copy link
Collaborator

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)

Suggested change
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

Comment on lines 49 to 55
const ColumnFilterComponent = (
<ColumnFilter
filterColumnName="program"
removeSelectedFacet={removeSelectedFacet}
addValueToSelectedFacet={addValueToSelectedFacet}
/>
)
Copy link
Collaborator

@nickgros nickgros Nov 7, 2024

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} />)

Comment on lines 31 to 34

// const addValueToSelectedFacet = queryContext.addValueToSelectedFacet
// const addValueToSelectedFacet = queryContext.removeSelectedFacet

Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
// const addValueToSelectedFacet = queryContext.addValueToSelectedFacet
// const addValueToSelectedFacet = queryContext.removeSelectedFacet

Comment on lines 11 to 18
export type FilterProps = {
filterColumnName?: string
addValueToSelectedFacet?: (
facet: { columnName: string },
value: string,
) => void
removeSelectedFacet?: (facet: { columnName: string }) => void
}
Copy link
Collaborator

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

Suggested change
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 || '' },
Copy link
Collaborator

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)

Suggested change
{ columnName: filterColumnName || '' },
{ columnName: filterColumnName },

event: React.SyntheticEvent,
values: string[], // New value for the selected options
) => {
const facetIdentifier = { columnName: filterColumnName || '' }
Copy link
Collaborator

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)

Suggested change
const facetIdentifier = { columnName: filterColumnName || '' }
const facetIdentifier = { columnName: filterColumnName }

}

return (
<Box data-testid="column-filter">
Copy link
Collaborator

@nickgros nickgros Nov 7, 2024

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

Suggested change
<Box data-testid="column-filter">
<div>

)}
/>
)}
</Box>
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
</Box>
</div>

Comment on lines 51 to 59
const facetIdentifier = {
columnName: topLevelEnumeratedFacetToFilter.columnName,
}

removeSelectedFacet(facetIdentifier)

values.forEach(value => addValueToSelectedFacet(facetIdentifier, value))

executeQueryRequest(request => request)
Copy link
Collaborator

Choose a reason for hiding this comment

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

  1. Since we changed the prop to be a UniqueFacetIdentifier, you can just pass it in here
  2. You shouldn't need to call executeQueryRequest after calling removeSelectedFacet/addValueToSelectedFacet
Suggested change
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 },
Copy link
Collaborator

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

Suggested change
{ columnName: topLevelEnumeratedFacetToFilter.columnName },
topLevelEnumeratedFacetToFilter,

Copy link
Collaborator

@nickgros nickgros left a comment

Choose a reason for hiding this comment

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

fantastic work!

@kianamcc kianamcc merged commit ff25970 into Sage-Bionetworks:main Nov 12, 2024
24 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants