-
Notifications
You must be signed in to change notification settings - Fork 53
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(dashboard): add advanced search using knowledge graph to query e…
…ditor
- Loading branch information
Showing
8 changed files
with
628 additions
and
18 deletions.
There are no files selected for viewing
299 changes: 299 additions & 0 deletions
299
packages/dashboard/src/components/queryEditor/__tests__/advancedSearch.spec.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,299 @@ | ||
import { QueryEditor } from '../queryEditor'; | ||
import { type IoTSiteWiseClient } from '@aws-sdk/client-iotsitewise'; | ||
import React from 'react'; | ||
import { render, screen, waitFor, fireEvent } from '@testing-library/react'; | ||
import userEvent from '@testing-library/user-event'; | ||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; | ||
import { TwinMakerKGQueryDataModule } from '@iot-app-kit/source-iottwinmaker'; | ||
|
||
const queryClient = new QueryClient(); | ||
|
||
const Wrapper = ({ children }: React.PropsWithChildren) => ( | ||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider> | ||
); | ||
|
||
const mockResults = { | ||
columnDescriptions: [ | ||
{ | ||
name: 'assetName', | ||
type: 'VALUE', | ||
}, | ||
{ | ||
name: 'propertyId', | ||
type: 'VALUE', | ||
}, | ||
{ | ||
name: 'propertyName', | ||
type: 'VALUE', | ||
}, | ||
{ | ||
name: 'displayName', | ||
type: 'VALUE', | ||
}, | ||
], | ||
rows: [ | ||
{ | ||
rowData: ['asset1', 'propertyId1', 'property1', 'temperature'], | ||
}, | ||
{ | ||
rowData: ['Asset2', 'PropertyId2', 'property2', 'speed'], | ||
}, | ||
{ | ||
rowData: ['Asset3', 'PropertyId3', 'property3'], | ||
}, | ||
{ | ||
rowData: ['TestAsset', null, 'sitewiseAssetId', null], | ||
}, | ||
{ | ||
rowData: ['TestAsset', null, 'sitewiseAssetModelId', null], | ||
}, | ||
], | ||
}; | ||
|
||
describe('Advanced Search', () => { | ||
afterEach(() => { | ||
// don't cache between tests | ||
queryClient.clear(); | ||
}); | ||
|
||
it('should display advanced search table when kGDatamodule is provided', async () => { | ||
const mockClient = { | ||
send: jest.fn().mockResolvedValueOnce([]), | ||
} as unknown as IoTSiteWiseClient; | ||
|
||
const mockDataModule = {} as TwinMakerKGQueryDataModule; | ||
const user = userEvent.setup(); | ||
|
||
render( | ||
<Wrapper> | ||
<QueryEditor client={mockClient} kGDatamodule={mockDataModule} /> | ||
</Wrapper> | ||
); | ||
|
||
await waitFor(() => expect(screen.queryByText('Loading assets...')).not.toBeInTheDocument()); | ||
expect(screen.getByRole('tab', { name: 'Advanced search' })).toBeVisible(); | ||
await user.click(screen.getByRole('tab', { name: 'Advanced search' })); | ||
expect(screen.getByRole('textbox', { name: 'Search Term' })).toBeVisible(); | ||
}); | ||
|
||
it('should display results as displayName when searching for a property', async () => { | ||
const mockClient = { | ||
send: jest.fn().mockResolvedValueOnce([]), | ||
} as unknown as IoTSiteWiseClient; | ||
|
||
const mockDataModule = { | ||
executeQuery: jest.fn().mockResolvedValueOnce(mockResults), | ||
} as TwinMakerKGQueryDataModule; | ||
const user = userEvent.setup(); | ||
|
||
render( | ||
<Wrapper> | ||
<QueryEditor client={mockClient} kGDatamodule={mockDataModule} /> | ||
</Wrapper> | ||
); | ||
|
||
await waitFor(() => expect(screen.queryByText('Loading assets...')).not.toBeInTheDocument()); | ||
expect(screen.getByRole('tab', { name: 'Advanced search' })).toBeVisible(); | ||
await user.click(screen.getByRole('tab', { name: 'Advanced search' })); | ||
|
||
const input = screen.getByRole('textbox'); | ||
fireEvent.change(input, { target: { value: 'test' } }); | ||
|
||
await user.click(screen.getByRole('button', { name: 'Search' })); | ||
|
||
await waitFor(() => expect(screen.queryByText('No results')).not.toBeInTheDocument()); | ||
expect(screen.queryByText('temperature')).toBeVisible(); | ||
expect(screen.queryByText('speed')).toBeVisible(); | ||
}); | ||
|
||
it('should allow selection of multiple results with pagination', async () => { | ||
const mockPaginatedResults = { | ||
columnDescriptions: [ | ||
{ | ||
name: 'assetName', | ||
type: 'VALUE', | ||
}, | ||
{ | ||
name: 'propertyId', | ||
type: 'VALUE', | ||
}, | ||
{ | ||
name: 'propertyName', | ||
type: 'VALUE', | ||
}, | ||
{ | ||
name: 'displayName', | ||
type: 'VALUE', | ||
}, | ||
], | ||
rows: [ | ||
{ | ||
rowData: ['asset1', 'propertyId1', 'propertyName1', 'temperature1'], | ||
}, | ||
{ | ||
rowData: ['asset2', 'propertyId2', 'propertyName2', 'temperature2'], | ||
}, | ||
{ | ||
rowData: ['asset3', 'propertyId3', 'propertyName3', 'temperature3'], | ||
}, | ||
{ | ||
rowData: ['asset4', 'propertyId4', 'propertyName4', 'temperature4'], | ||
}, | ||
{ | ||
rowData: ['asset5', 'propertyId5', 'propertyName5', 'temperature15'], | ||
}, | ||
{ | ||
rowData: ['asset6', 'propertyId6', 'propertyName6', 'temperature16'], | ||
}, | ||
{ | ||
rowData: ['asset7', 'propertyId7', 'propertyName7', 'temperature17'], | ||
}, | ||
{ | ||
rowData: ['asset8', 'propertyId8', 'propertyName8', 'temperature18'], | ||
}, | ||
{ | ||
rowData: ['asset9', 'propertyId9', 'propertyName9', 'temperature19'], | ||
}, | ||
{ | ||
rowData: ['asset10', 'propertyId10', 'propertyName10', 'temperature110'], | ||
}, | ||
{ | ||
rowData: ['asset11', 'propertyId11', 'propertyName11', 'temperature11'], | ||
}, | ||
{ | ||
rowData: ['TestAsset', null, 'sitewiseAssetId', null], | ||
}, | ||
{ | ||
rowData: ['TestAsset', null, 'sitewiseAssetModelId', null], | ||
}, | ||
], | ||
}; | ||
const mockClient = { | ||
send: jest.fn().mockResolvedValueOnce([]), | ||
} as unknown as IoTSiteWiseClient; | ||
|
||
const mockDataModule = { | ||
executeQuery: jest.fn().mockResolvedValueOnce(mockPaginatedResults), | ||
} as TwinMakerKGQueryDataModule; | ||
const user = userEvent.setup(); | ||
|
||
render( | ||
<Wrapper> | ||
<QueryEditor client={mockClient} kGDatamodule={mockDataModule} /> | ||
</Wrapper> | ||
); | ||
|
||
await waitFor(() => expect(screen.queryByText('Loading assets...')).not.toBeInTheDocument()); | ||
expect(screen.getByRole('tab', { name: 'Advanced search' })).toBeVisible(); | ||
await user.click(screen.getByRole('tab', { name: 'Advanced search' })); | ||
|
||
const input = screen.getByRole('textbox'); | ||
fireEvent.change(input, { target: { value: 'test' } }); | ||
|
||
await user.click(screen.getByRole('button', { name: 'Search' })); | ||
|
||
await waitFor(() => expect(screen.queryByText('No results')).not.toBeInTheDocument()); | ||
await user.click(screen.getByRole('checkbox', { name: 'Select property temperature1' })); | ||
await user.click(screen.getByRole('checkbox', { name: 'Select property temperature2' })); | ||
|
||
await user.click(screen.getByRole('button', { name: 'Next page' })); | ||
await user.click(screen.getByRole('checkbox', { name: 'Select property temperature11' })); | ||
}); | ||
|
||
it('should allow changing preferences like enabling propertyName column', async () => { | ||
const mockClient = { | ||
send: jest.fn().mockResolvedValueOnce([]), | ||
} as unknown as IoTSiteWiseClient; | ||
|
||
const mockDataModule = { | ||
executeQuery: jest.fn().mockResolvedValueOnce(mockResults), | ||
} as TwinMakerKGQueryDataModule; | ||
const user = userEvent.setup(); | ||
|
||
render( | ||
<Wrapper> | ||
<QueryEditor client={mockClient} kGDatamodule={mockDataModule} /> | ||
</Wrapper> | ||
); | ||
|
||
await waitFor(() => expect(screen.queryByText('Loading assets...')).not.toBeInTheDocument()); | ||
expect(screen.getByRole('tab', { name: 'Advanced search' })).toBeVisible(); | ||
await user.click(screen.getByRole('tab', { name: 'Advanced search' })); | ||
await user.click(screen.getByRole('button', { name: 'Preferences' })); | ||
await user.click(screen.getByRole('checkbox', { name: 'Property Name' })); | ||
await user.click(screen.getByRole('button', { name: 'Confirm' })); | ||
|
||
const input = screen.getByRole('textbox'); | ||
fireEvent.change(input, { target: { value: 'test' } }); | ||
|
||
await user.click(screen.getByRole('button', { name: 'Search' })); | ||
|
||
await waitFor(() => expect(screen.queryByText('No results')).not.toBeInTheDocument()); | ||
|
||
expect(screen.queryByText('property1')).toBeVisible(); | ||
expect(screen.queryByText('property2')).toBeVisible(); | ||
expect(screen.queryByText('-')).toBeVisible(); // property without DisplayName | ||
}); | ||
|
||
it('should handle an error if KG resposne cannot be parsed', async () => { | ||
// No assetName column should result in an error | ||
const mockInvalidResults = { | ||
columnDescriptions: [ | ||
{ | ||
name: 'propertyId', | ||
type: 'VALUE', | ||
}, | ||
{ | ||
name: 'propertyName', | ||
type: 'VALUE', | ||
}, | ||
{ | ||
name: 'displayName', | ||
type: 'VALUE', | ||
}, | ||
], | ||
rows: [ | ||
{ | ||
rowData: ['asset1', 'propertyId1', 'property1', 'temperature'], | ||
}, | ||
{ | ||
rowData: ['Asset2', 'PropertyId2', 'property2', 'speed'], | ||
}, | ||
{ | ||
rowData: ['Asset3', 'PropertyId3', 'property3'], | ||
}, | ||
{ | ||
rowData: ['TestAsset', null, 'sitewiseAssetId', null], | ||
}, | ||
{ | ||
rowData: ['TestAsset', null, 'sitewiseAssetModelId', null], | ||
}, | ||
], | ||
}; | ||
const mockClient = { | ||
send: jest.fn().mockResolvedValueOnce([]), | ||
} as unknown as IoTSiteWiseClient; | ||
|
||
const mockDataModule = { | ||
executeQuery: jest.fn().mockResolvedValueOnce(mockInvalidResults), | ||
} as TwinMakerKGQueryDataModule; | ||
const user = userEvent.setup(); | ||
|
||
render( | ||
<Wrapper> | ||
<QueryEditor client={mockClient} kGDatamodule={mockDataModule} /> | ||
</Wrapper> | ||
); | ||
|
||
await waitFor(() => expect(screen.queryByText('Loading assets...')).not.toBeInTheDocument()); | ||
expect(screen.getByRole('tab', { name: 'Advanced search' })).toBeVisible(); | ||
await user.click(screen.getByRole('tab', { name: 'Advanced search' })); | ||
|
||
const input = screen.getByRole('textbox'); | ||
fireEvent.change(input, { target: { value: 'test' } }); | ||
|
||
await user.click(screen.getByRole('button', { name: 'Search' })); | ||
|
||
await waitFor(() => expect(screen.queryByText('No results')).toBeVisible()); | ||
}); | ||
}); |
70 changes: 70 additions & 0 deletions
70
...d/src/components/queryEditor/advancedSearch/advancedResultsTable/advancedResultsTable.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,70 @@ | ||
import React, { useState } from 'react'; | ||
import Table from '@cloudscape-design/components/table'; | ||
import Box from '@cloudscape-design/components/box'; | ||
import Pagination from '@cloudscape-design/components/pagination'; | ||
import Header from '@cloudscape-design/components/header'; | ||
import { Property } from '../model/property'; | ||
import { useCollection } from '@cloudscape-design/collection-hooks'; | ||
import { CollectionPreferences, CollectionPreferencesProps } from '@cloudscape-design/components'; | ||
import { columnDefinitions, collectionPreferencesProps, paginationLabels } from './tableConfig'; | ||
|
||
interface AdvancedResultsTableProps { | ||
data: Property[]; | ||
onSelect: (properties: Property[]) => void; | ||
} | ||
|
||
export function AdvancedResultsTable({ data, onSelect }: AdvancedResultsTableProps) { | ||
const [preferences, setPreferences] = useState<CollectionPreferencesProps.Preferences>({ | ||
pageSize: 10, | ||
visibleContent: ['displayName', 'assetName'], | ||
}); | ||
const [selectedProperties, setSelectedProperties] = useState<Property[]>([]); | ||
|
||
const { items, collectionProps, paginationProps } = useCollection(data, { | ||
pagination: { pageSize: preferences.pageSize }, | ||
sorting: {}, | ||
selection: { | ||
keepSelection: true, | ||
}, | ||
}); | ||
|
||
return ( | ||
<Table | ||
{...collectionProps} | ||
onSelectionChange={({ detail }) => { | ||
setSelectedProperties(detail.selectedItems); | ||
onSelect(detail.selectedItems); | ||
}} | ||
ariaLabels={{ | ||
itemSelectionLabel: (isNotSelected, property) => | ||
isNotSelected ? `Select property ${property.displayName}` : `Deselect property ${property.displayName}`, | ||
}} | ||
header={<Header counter={data.length ? '(' + data.length + ')' : '(0)'}>Results</Header>} | ||
pagination={<Pagination {...paginationProps} ariaLabels={paginationLabels} />} | ||
variant='embedded' | ||
sortingDisabled | ||
columnDefinitions={columnDefinitions} | ||
visibleColumns={preferences.visibleContent} | ||
selectedItems={selectedProperties} | ||
items={items} | ||
loadingText='Loading results' | ||
selectionType='multi' | ||
trackBy='propertyId' | ||
preferences={ | ||
<CollectionPreferences | ||
{...collectionPreferencesProps} | ||
preferences={preferences} | ||
onConfirm={({ detail }) => setPreferences(detail)} | ||
/> | ||
} | ||
empty={ | ||
<Box textAlign='center' color='inherit'> | ||
<b>No results</b> | ||
<Box padding={{ bottom: 's' }} variant='p' color='inherit'> | ||
No results to display. | ||
</Box> | ||
</Box> | ||
} | ||
/> | ||
); | ||
} |
1 change: 1 addition & 0 deletions
1
packages/dashboard/src/components/queryEditor/advancedSearch/advancedResultsTable/index.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export { AdvancedResultsTable } from './advancedResultsTable'; |
Oops, something went wrong.