Aggregation: Integrate aggregation UI and GQL API (#40803)

* Fix styles for active bar segment for wildcard bar chart

* Support gql types generation for search-ui package

* Add aggregation data hook

* Connect GQL API to search aggregation UI

* Support new datum click api for the bar chart

* Connect aggregation chart with submit search logic

* Support missing groups count label

* Add BE calculated aggregation type support

* Add aggregation chart card UI

* Fix Pie chart prop interfaces

* Make limit required prop for aggregation hook

* Fix aggregation hook based on PR comments (adopt union type for result interface, simplify parse logic, update comments)

* Fix SearchAggregationResult.story.tsx (add query mocks)

* Fix search aggregation tests (add mocks)

* Fix bad storybook import

* Add integration test for aggregation drill down logic

* Fix search aggregation integration test (bar links transitions)
This commit is contained in:
Vova Kulikov 2022-08-25 17:03:47 +03:00 committed by GitHub
parent 415029ed15
commit a31f320f6b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 947 additions and 401 deletions

View File

@ -16,3 +16,5 @@ export * from './results/sidebar/SearchSidebar'
export * from './results/sidebar/SidebarButtonStrip'
export * from './results/StreamingSearchResultsList'
export * from './util'
export * from './graphql-operations'

View File

@ -3,19 +3,24 @@ import { ReactElement, useMemo } from 'react'
import { getTicks } from '@visx/scale'
import { AnyD3Scale } from '@visx/scale/lib/types/Scale'
import { SearchAggregationMode } from '@sourcegraph/shared/src/graphql-operations'
import { BarChart, BarChartProps } from '@sourcegraph/wildcard'
import { AggregationMode } from './types'
/**
* AggregationChart sets these props internally, and we don't expose them
* as public api of aggregation chart
*/
type PredefinedBarProps = 'pixelsPerXTick' | 'pixelsPerYTick' | 'maxAngleXTick' | 'getScaleXTicks' | 'getTruncatedXTick'
type PredefinedBarProps =
| 'mode'
| 'pixelsPerXTick'
| 'pixelsPerYTick'
| 'maxAngleXTick'
| 'getScaleXTicks'
| 'getTruncatedXTick'
type SharedBarProps<Datum> = Omit<BarChartProps<Datum>, PredefinedBarProps>
interface AggregationChartProps<Datum> extends SharedBarProps<Datum> {
mode: AggregationMode
export interface AggregationChartProps<Datum> extends SharedBarProps<Datum> {
mode?: SearchAggregationMode | null
}
export function AggregationChart<Datum>(props: AggregationChartProps<Datum>): ReactElement {
@ -86,12 +91,12 @@ const getTruncatedTickFromTheEnd = (tick: string): string =>
* github.com/sourcegraph/sourcegraph -> ...urcegraph/sourcegraph
* ```
*/
const getTruncationFormatter = (aggregationMode: AggregationMode): ((tick: string) => string) => {
const getTruncationFormatter = (aggregationMode?: SearchAggregationMode | null): ((tick: string) => string) => {
switch (aggregationMode) {
// These types possible have long labels with the same pattern at the start of the string,
// so we truncate their labels from the end
case AggregationMode.Repository:
case AggregationMode.FilePath:
case SearchAggregationMode.REPO:
case SearchAggregationMode.PATH:
return getTruncatedTickFromTheEnd
default:

View File

@ -0,0 +1,25 @@
.container {
position: relative;
}
.chart-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
padding: 1rem;
}
.missing-label-count {
position: absolute;
top: 10px;
right: 0;
padding: 0.25rem;
background: var(--body-bg);
color: var(--text-muted);
}

View File

@ -0,0 +1,92 @@
import { HTMLAttributes, ReactElement, MouseEvent } from 'react'
import { ParentSize } from '@visx/responsive'
import classNames from 'classnames'
import { SearchAggregationMode } from '@sourcegraph/shared/src/graphql-operations'
import { Text, Link, Tooltip } from '@sourcegraph/wildcard'
import { SearchAggregationDatum } from '../../graphql-operations'
import { AggregationChart } from './AggregationChart'
import styles from './AggregationChartCard.module.scss'
const getName = (datum: SearchAggregationDatum): string => datum.label ?? ''
const getValue = (datum: SearchAggregationDatum): number => datum.count
const getColor = (datum: SearchAggregationDatum): string => (datum.label ? 'var(--primary)' : 'var(--text-muted)')
const getLink = (datum: SearchAggregationDatum): string => datum.query ?? ''
export enum AggregationCardMode {
Data,
Loading,
Error,
}
type AggregationChartCardStateProps =
| { type: AggregationCardMode.Data; data: SearchAggregationDatum[] }
| { type: AggregationCardMode.Loading }
| { type: AggregationCardMode.Error; errorMessage: string }
interface AggregationChartCardSharedProps extends HTMLAttributes<HTMLDivElement> {
mode?: SearchAggregationMode | null
missingCount?: number
onBarLinkClick?: (query: string) => void
}
type AggregationChartCardProps = AggregationChartCardSharedProps & AggregationChartCardStateProps
export function AggregationChartCard(props: AggregationChartCardProps): ReactElement {
const { type, mode, className, missingCount, onBarLinkClick, 'aria-label': ariaLabel } = props
const handleDatumLinkClick = (event: MouseEvent, datum: SearchAggregationDatum): void => {
event.preventDefault()
onBarLinkClick?.(getLink(datum))
}
if (type === AggregationCardMode.Loading || type === AggregationCardMode.Error) {
return (
<div className={classNames(styles.container, className)}>
<div className={styles.chartOverlay}>
{type === AggregationCardMode.Loading ? (
'Loading...'
) : (
<div>
We couldnt provide aggregation for this query. {props.errorMessage}.{' '}
<Link to="">Learn more</Link>
</div>
)}
</div>
</div>
)
}
return (
<ParentSize className={classNames(className, styles.container)}>
{parent => (
<>
<AggregationChart
aria-label={ariaLabel}
width={parent.width}
height={parent.height}
data={props.data}
mode={mode}
getDatumValue={getValue}
getDatumColor={getColor}
getDatumName={getName}
getDatumLink={getLink}
onDatumLinkClick={handleDatumLinkClick}
/>
{!!missingCount && (
<Tooltip content={`Aggregation is not exhaustive, there are ${missingCount} groups more`}>
<Text size="small" className={styles.missingLabelCount}>
+{missingCount}
</Text>
</Tooltip>
)}
</>
)}
</ParentSize>
)
}

View File

@ -1,8 +1,5 @@
.aggregation-group {
&--sm {
.aggregation-type-control {
padding-left: 0.375rem;
padding-right: 0.375rem;
}
}
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}

View File

@ -2,69 +2,86 @@ import { FC, HTMLAttributes } from 'react'
import classNames from 'classnames'
import { Button, ButtonGroup } from '@sourcegraph/wildcard'
import { SearchAggregationMode } from '@sourcegraph/shared/src/graphql-operations'
import { Button, Tooltip } from '@sourcegraph/wildcard'
import { AggregationMode } from './types'
import { SearchAggregationModeAvailability } from '../../graphql-operations'
import styles from './AggregationModeControls.module.scss'
interface AggregationModeControlsProps extends HTMLAttributes<HTMLDivElement> {
mode: AggregationMode
onModeChange: (nextMode: AggregationMode) => void
mode: SearchAggregationMode | null
availability?: SearchAggregationModeAvailability[]
size?: 'sm' | 'lg'
onModeChange: (nextMode: SearchAggregationMode) => void
}
export const AggregationModeControls: FC<AggregationModeControlsProps> = props => {
const { mode, onModeChange, size, className, ...attributes } = props
const { mode, availability = [], onModeChange, size, className, ...attributes } = props
const availabilityGroups = availability.reduce((store, availability) => {
store[availability.mode] = availability
return store
}, {} as Partial<Record<SearchAggregationMode, SearchAggregationModeAvailability>>)
return (
<ButtonGroup
<div
{...attributes}
aria-label="Aggregation mode picker"
className={classNames(className, { [styles.aggregationGroupSm]: size === 'sm' })}
className={classNames(className, styles.aggregationGroup)}
>
<Button
className={styles.aggregationTypeControl}
variant="secondary"
size={size}
outline={mode !== AggregationMode.Repository}
data-testid="repo-aggregation-mode"
onClick={() => onModeChange(AggregationMode.Repository)}
>
Repo
</Button>
<Tooltip content={availabilityGroups[SearchAggregationMode.REPO]?.reasonUnavailable}>
<Button
variant="secondary"
size={size}
outline={mode !== SearchAggregationMode.REPO}
data-testid="repo-aggregation-mode"
disabled={!availabilityGroups[SearchAggregationMode.REPO]?.available}
onClick={() => onModeChange(SearchAggregationMode.REPO)}
>
Repository
</Button>
</Tooltip>
<Button
className={styles.aggregationTypeControl}
variant="secondary"
size={size}
outline={mode !== AggregationMode.FilePath}
data-testid="file-aggregation-mode"
onClick={() => onModeChange(AggregationMode.FilePath)}
>
File
</Button>
<Tooltip content={availabilityGroups[SearchAggregationMode.PATH]?.reasonUnavailable}>
<Button
variant="secondary"
size={size}
outline={mode !== SearchAggregationMode.PATH}
disabled={!availabilityGroups[SearchAggregationMode.PATH]?.available}
data-testid="file-aggregation-mode"
onClick={() => onModeChange(SearchAggregationMode.PATH)}
>
File
</Button>
</Tooltip>
<Button
className={styles.aggregationTypeControl}
variant="secondary"
size={size}
outline={mode !== AggregationMode.Author}
data-testid="author-aggregation-mode"
onClick={() => onModeChange(AggregationMode.Author)}
>
Author
</Button>
<Button
className={styles.aggregationTypeControl}
variant="secondary"
size={size}
outline={mode !== AggregationMode.CaptureGroups}
data-testid="captureGroup-aggregation-mode"
onClick={() => onModeChange(AggregationMode.CaptureGroups)}
>
Capture group
</Button>
</ButtonGroup>
<Tooltip content={availabilityGroups[SearchAggregationMode.AUTHOR]?.reasonUnavailable}>
<Button
variant="secondary"
size={size}
outline={mode !== SearchAggregationMode.AUTHOR}
disabled={!availabilityGroups[SearchAggregationMode.AUTHOR]?.available}
data-testid="author-aggregation-mode"
onClick={() => onModeChange(SearchAggregationMode.AUTHOR)}
>
Author
</Button>
</Tooltip>
<Tooltip content={availabilityGroups[SearchAggregationMode.CAPTURE_GROUP]?.reasonUnavailable}>
<Button
variant="secondary"
size={size}
outline={mode !== SearchAggregationMode.CAPTURE_GROUP}
disabled={!availabilityGroups[SearchAggregationMode.CAPTURE_GROUP]?.available}
data-testid="captureGroup-aggregation-mode"
onClick={() => onModeChange(SearchAggregationMode.CAPTURE_GROUP)}
>
Capture group
</Button>
</Tooltip>
</div>
)
}

View File

@ -26,7 +26,6 @@
.list-result-item {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
margin: 0;

View File

@ -1,7 +1,15 @@
import { MockedResponse } from '@apollo/client/testing/core'
import { Meta, Story } from '@storybook/react'
import { noop } from 'lodash'
import { BrandedStory } from '@sourcegraph/branded/src/components/BrandedStory'
import { getDocumentNode } from '@sourcegraph/http-client'
import { SearchPatternType } from '@sourcegraph/shared/src/schema'
import { MockedTestProvider } from '@sourcegraph/shared/src/testing/apollo'
import { GetSearchAggregationResult, SearchAggregationMode } from '../../graphql-operations'
import { AGGREGATION_SEARCH_QUERY } from './hooks'
import { SearchAggregationResult } from './SearchAggregationResult'
const config: Meta = {
@ -10,4 +18,88 @@ const config: Meta = {
export default config
export const SearchAggregationResultDemo: Story = () => <BrandedStory>{() => <SearchAggregationResult />}</BrandedStory>
const SEARCH_AGGREGATION_MOCK: MockedResponse<GetSearchAggregationResult> = {
request: {
query: getDocumentNode(AGGREGATION_SEARCH_QUERY),
variables: {
query: '',
patternType: 'literal',
mode: 'REPO',
limit: 30,
},
},
result: {
data: {
searchQueryAggregate: {
__typename: 'SearchQueryAggregate',
aggregations: {
__typename: 'ExhaustiveSearchAggregationResult',
mode: SearchAggregationMode.REPO,
otherGroupCount: 100,
groups: [
{
__typename: 'AggregationGroup',
label: 'sourcegraph/sourcegraph',
count: 100,
query: 'context:global insights repo:sourcegraph/sourcegraph',
},
{
__typename: 'AggregationGroup',
label: 'sourcegraph/about',
count: 80,
query: 'context:global insights repo:sourecegraph/about',
},
{
__typename: 'AggregationGroup',
label: 'sourcegraph/search-insight',
count: 60,
query: 'context:global insights repo:sourecegraph/search-insight',
},
{
__typename: 'AggregationGroup',
label: 'sourcegraph/lang-stats',
count: 40,
query: 'context:global insights repo:sourecegraph/lang-stats',
},
],
},
modeAvailability: [
{
__typename: 'AggregationModeAvailability',
mode: SearchAggregationMode.REPO,
available: true,
reasonUnavailable: null,
},
{
__typename: 'AggregationModeAvailability',
mode: SearchAggregationMode.PATH,
available: true,
reasonUnavailable: null,
},
{
__typename: 'AggregationModeAvailability',
mode: SearchAggregationMode.AUTHOR,
available: false,
reasonUnavailable: 'Author aggregation mode is unavailable',
},
{
__typename: 'AggregationModeAvailability',
mode: SearchAggregationMode.CAPTURE_GROUP,
available: false,
reasonUnavailable: 'Capture group aggregation mode is unavailable',
},
],
},
},
},
}
export const SearchAggregationResultDemo: Story = () => (
<BrandedStory>
{() => (
<MockedTestProvider mocks={[SEARCH_AGGREGATION_MOCK]}>
<SearchAggregationResult query="" patternType={SearchPatternType.literal} onQuerySubmit={noop} />
</MockedTestProvider>
)}
</BrandedStory>
)

View File

@ -1,28 +1,48 @@
import { FC, HTMLAttributes } from 'react'
import { mdiArrowCollapse, mdiPlus } from '@mdi/js'
import { ParentSize } from '@visx/responsive'
import { mdiArrowCollapse } from '@mdi/js'
import { SearchPatternType } from '@sourcegraph/shared/src/schema'
import { Button, H2, Icon } from '@sourcegraph/wildcard'
import { AggregationChart } from './AggregationChart'
import { AggregationCardMode, AggregationChartCard } from './AggregationChartCard'
import { AggregationModeControls } from './AggregationModeControls'
import { useAggregationSearchMode, useAggregationUIMode } from './hooks'
import { LANGUAGE_USAGE_DATA, LanguageUsageDatum } from './search-aggregation-mock-data'
import {
getAggregationData,
getOtherGroupCount,
useAggregationSearchMode,
useAggregationUIMode,
useSearchAggregationData,
} from './hooks'
import { AggregationUIMode } from './types'
import styles from './SearchAggregationResult.module.scss'
const getValue = (datum: LanguageUsageDatum): number => datum.value
const getColor = (datum: LanguageUsageDatum): string => datum.fill
const getLink = (datum: LanguageUsageDatum): string => datum.linkURL
const getName = (datum: LanguageUsageDatum): string => datum.name
interface SearchAggregationResultProps extends HTMLAttributes<HTMLElement> {
/**
* Current submitted query, note that this query isn't a live query
* that is synced with typed query in the search box, this query is submitted
* see `searchQueryFromURL` state in the global query Zustand store.
*/
query: string
interface SearchAggregationResultProps extends HTMLAttributes<HTMLElement> {}
/** Current search query pattern type. */
patternType: SearchPatternType
/**
* Emits whenever a user clicks one of aggregation chart segments (bars).
* That should update the query and re-trigger search (but this should be connected
* to this UI through its consumer)
*/
onQuerySubmit: (newQuery: string) => void
}
export const SearchAggregationResult: FC<SearchAggregationResultProps> = props => {
const { query, patternType, onQuerySubmit, ...attributes } = props
export const SearchAggregationResult: FC<SearchAggregationResultProps> = attributes => {
const [aggregationMode, setAggregationMode] = useAggregationSearchMode()
const [, setAggregationUIMode] = useAggregationUIMode()
const [aggregationMode, setAggregationMode] = useAggregationSearchMode()
const { data, error, loading } = useSearchAggregationData({ query, patternType, aggregationMode, limit: 30 })
const handleCollapseClick = (): void => {
setAggregationUIMode(AggregationUIMode.Sidebar)
@ -46,37 +66,48 @@ export const SearchAggregationResult: FC<SearchAggregationResultProps> = attribu
<hr className="mt-2 mb-3" />
<div className={styles.controls}>
<AggregationModeControls mode={aggregationMode} onModeChange={setAggregationMode} />
<Button variant="secondary" outline={true}>
<Icon aria-hidden={true} className="mr-1" svgPath={mdiPlus} />
Save insight
</Button>
<AggregationModeControls
mode={aggregationMode}
availability={data?.searchQueryAggregate?.modeAvailability}
onModeChange={setAggregationMode}
/>
</div>
<ParentSize className={styles.chartContainer}>
{parent => (
<AggregationChart
mode={aggregationMode}
width={parent.width}
height={parent.height}
data={LANGUAGE_USAGE_DATA}
getDatumName={getName}
getDatumValue={getValue}
getDatumColor={getColor}
getDatumLink={getLink}
/>
)}
</ParentSize>
{loading ? (
<AggregationChartCard
aria-label="Expanded search aggregation chart"
type={AggregationCardMode.Loading}
className={styles.chartContainer}
/>
) : error ? (
<AggregationChartCard
aria-label="Expanded search aggregation chart"
type={AggregationCardMode.Error}
errorMessage={error.message}
className={styles.chartContainer}
/>
) : (
<AggregationChartCard
aria-label="Expanded search aggregation chart"
mode={aggregationMode}
type={AggregationCardMode.Data}
data={getAggregationData(data)}
missingCount={getOtherGroupCount(data)}
className={styles.chartContainer}
onBarLinkClick={onQuerySubmit}
/>
)}
<ul className={styles.listResult}>
{LANGUAGE_USAGE_DATA.map(datum => (
<li key={getName(datum)} className={styles.listResultItem}>
<span>{getName(datum)}</span>
<span>{getValue(datum)}</span>
</li>
))}
</ul>
{data && (
<ul className={styles.listResult}>
{getAggregationData(data).map(datum => (
<li key={datum.label} className={styles.listResultItem}>
<span>{datum.label}</span>
<span>{datum.count}</span>
</li>
))}
</ul>
)}
</section>
)
}

View File

@ -1,9 +1,19 @@
import { useCallback, useMemo } from 'react'
import { useCallback, useEffect, useMemo } from 'react'
import { ApolloError, gql, useQuery } from '@apollo/client'
import { useHistory, useLocation } from 'react-router'
import { SearchAggregationMode } from '@sourcegraph/shared/src/graphql-operations'
import { SearchPatternType } from '@sourcegraph/shared/src/schema'
import {
GetSearchAggregationResult,
GetSearchAggregationVariables,
SearchAggregationDatum,
} from '../../graphql-operations'
import { AGGREGATION_MODE_URL_KEY, AGGREGATION_UI_MODE_URL_KEY } from './constants'
import { AggregationMode, AggregationUIMode } from './types'
import { AggregationUIMode } from './types'
interface URLStateOptions<State, SerializedState> {
urlKey: string
@ -26,7 +36,7 @@ function useSyncedWithURLState<State, SerializedState>(
const urlSearchParameters = useMemo(() => new URLSearchParams(search), [search])
const queryParameter = useMemo(
() => deserializer((urlSearchParameters.get(urlKey) as unknown) as SerializedState),
() => deserializer((urlSearchParameters.get(urlKey) as unknown) as SerializedState | null),
[urlSearchParameters, urlKey, deserializer]
)
@ -42,23 +52,28 @@ function useSyncedWithURLState<State, SerializedState>(
return [queryParameter, setNextState]
}
type SerializedAggregationMode = `${AggregationMode}`
type SerializedAggregationMode = SearchAggregationMode | ''
const aggregationModeSerializer = (mode: AggregationMode): SerializedAggregationMode => `${mode}`
const aggregationModeSerializer = (mode: SearchAggregationMode | null): SerializedAggregationMode => mode ?? ''
const aggregationModeDeserializer = (serializedValue: SerializedAggregationMode | null): AggregationMode => {
const aggregationModeDeserializer = (
serializedValue: SerializedAggregationMode | null
): SearchAggregationMode | null => {
switch (serializedValue) {
case 'repo':
return AggregationMode.Repository
case 'file':
return AggregationMode.FilePath
case 'author':
return AggregationMode.Author
case 'captureGroup':
return AggregationMode.CaptureGroups
case 'REPO':
return SearchAggregationMode.REPO
case 'PATH':
return SearchAggregationMode.PATH
case 'AUTHOR':
return SearchAggregationMode.AUTHOR
case 'CAPTURE_GROUP':
return SearchAggregationMode.CAPTURE_GROUP
// TODO Return null FE default value instead REPO when aggregation type
// will be provided by the backend.
// see https://github.com/sourcegraph/sourcegraph/issues/40425
default:
return AggregationMode.Repository
return SearchAggregationMode.REPO
}
}
@ -66,8 +81,11 @@ const aggregationModeDeserializer = (serializedValue: SerializedAggregationMode
* Shared state hook for syncing aggregation type state between different UI trough
* ULR query param {@link AGGREGATION_MODE_URL_KEY}
*/
export const useAggregationSearchMode = (): SetStateResult<AggregationMode> => {
const [aggregationMode, setAggregationMode] = useSyncedWithURLState({
export const useAggregationSearchMode = (): SetStateResult<SearchAggregationMode | null> => {
const [aggregationMode, setAggregationMode] = useSyncedWithURLState<
SearchAggregationMode | null,
SerializedAggregationMode
>({
urlKey: AGGREGATION_MODE_URL_KEY,
serializer: aggregationModeSerializer,
deserializer: aggregationModeDeserializer,
@ -76,8 +94,8 @@ export const useAggregationSearchMode = (): SetStateResult<AggregationMode> => {
return [aggregationMode, setAggregationMode]
}
type SerializedAggregationUIMode = `${AggregationUIMode}`
const aggregationUIModeSerializer = (uiMode: AggregationUIMode): SerializedAggregationUIMode => `${uiMode}`
type SerializedAggregationUIMode = AggregationUIMode
const aggregationUIModeSerializer = (uiMode: AggregationUIMode): SerializedAggregationUIMode => uiMode
const aggregationUIModeDeserializer = (serializedValue: SerializedAggregationUIMode | null): AggregationUIMode => {
switch (serializedValue) {
@ -102,3 +120,166 @@ export const useAggregationUIMode = (): SetStateResult<AggregationUIMode> => {
return [aggregationMode, setAggregationMode]
}
export const AGGREGATION_SEARCH_QUERY = gql`
fragment SearchAggregationModeAvailability on AggregationModeAvailability {
__typename
mode
available
reasonUnavailable
}
fragment SearchAggregationDatum on AggregationGroup {
__typename
label
count
query
}
query GetSearchAggregation(
$query: String!
$patternType: SearchPatternType!
$mode: SearchAggregationMode
$limit: Int!
) {
searchQueryAggregate(query: $query, patternType: $patternType) {
aggregations(mode: $mode, limit: $limit) {
__typename
... on ExhaustiveSearchAggregationResult {
mode
groups {
...SearchAggregationDatum
}
otherGroupCount
}
... on NonExhaustiveSearchAggregationResult {
mode
groups {
...SearchAggregationDatum
}
approximateOtherGroupCount
}
... on SearchAggregationNotAvailable {
reason
mode
}
}
modeAvailability {
...SearchAggregationModeAvailability
}
}
}
`
interface SearchAggregationDataInput {
query: string
patternType: SearchPatternType
aggregationMode: SearchAggregationMode | null
limit: number
}
type SearchAggregationResults =
| { data: undefined; loading: true; error: undefined }
| { data: undefined; loading: false; error: Error }
| { data: GetSearchAggregationResult; loading: false; error: undefined }
export const useSearchAggregationData = (input: SearchAggregationDataInput): SearchAggregationResults => {
const { query, patternType, aggregationMode, limit } = input
const [, setAggregationMode] = useAggregationSearchMode()
const { data, error, loading } = useQuery<GetSearchAggregationResult, GetSearchAggregationVariables>(
AGGREGATION_SEARCH_QUERY,
{
fetchPolicy: 'cache-first',
variables: { query, patternType, mode: aggregationMode, limit },
}
)
const calculatedAggregationMode = getCalculatedAggregationMode(data)
useEffect(() => {
// When we load the search result page in the first time we don't have picked
// aggregation mode yet (unless we open the search result page with predefined
// aggregation mode in the page URL)
// In case when we don't have set aggregation mode on the FE, BE will
// calculate this mode based on query that we pass to the aggregation
// query (see AGGREGATION_SEARCH_QUERY).
// When this happens we should take calculated aggregation mode and set its
// value on the frontend (UI controls, update URL value of aggregation mode)
// Catch initial page mount when aggregation mode isn't set on the FE and BE
// calculated aggregation mode automatically on the backend based on given query
if (calculatedAggregationMode && aggregationMode === null) {
setAggregationMode(calculatedAggregationMode)
}
}, [setAggregationMode, calculatedAggregationMode, aggregationMode])
if (loading) {
return { data: undefined, error: undefined, loading: true }
}
const calculatedError = getAggregationError(error, data)
if (calculatedError) {
return { data: undefined, error: calculatedError, loading: false }
}
return {
data: data as GetSearchAggregationResult,
error: undefined,
loading: false,
}
}
function getAggregationError(apolloError?: ApolloError, response?: GetSearchAggregationResult): Error | undefined {
if (apolloError) {
return apolloError
}
const aggregationData = response?.searchQueryAggregate?.aggregations
if (aggregationData?.__typename === 'SearchAggregationNotAvailable') {
return new Error(aggregationData.reason)
}
return
}
export function getAggregationData(response: GetSearchAggregationResult): SearchAggregationDatum[] {
const aggregationResult = response.searchQueryAggregate?.aggregations
switch (aggregationResult?.__typename) {
case 'ExhaustiveSearchAggregationResult':
case 'NonExhaustiveSearchAggregationResult':
return aggregationResult.groups
default:
return []
}
}
function getCalculatedAggregationMode(response?: GetSearchAggregationResult): SearchAggregationMode | null {
if (!response) {
return null
}
const aggregationResult = response.searchQueryAggregate?.aggregations
return aggregationResult?.mode ?? null
}
export function getOtherGroupCount(response: GetSearchAggregationResult): number {
const aggregationResult = response.searchQueryAggregate?.aggregations
switch (aggregationResult?.__typename) {
case 'ExhaustiveSearchAggregationResult':
return aggregationResult.otherGroupCount ?? 0
case 'NonExhaustiveSearchAggregationResult':
return aggregationResult.approximateOtherGroupCount ?? 0
default:
return 0
}
}

View File

@ -2,6 +2,6 @@ export * from './types'
export * from './hooks'
export * from './constants'
export { AggregationChart } from './AggregationChart'
export { AggregationChartCard, AggregationCardMode } from './AggregationChartCard'
export { AggregationModeControls } from './AggregationModeControls'
export { SearchAggregationResult } from './SearchAggregationResult'

View File

@ -1,102 +0,0 @@
export interface LanguageUsageDatum {
name: string
value: number
fill: string
linkURL: string
group?: string
}
// Mock data for bar chart, will be removed and replace with
// actual data in https://github.com/sourcegraph/sourcegraph/issues/39956
export const LANGUAGE_USAGE_DATA: LanguageUsageDatum[] = [
{
name: 'github/sourcegraph/Julia',
value: 1000,
fill: 'var(--primary)',
linkURL: 'https://en.wikipedia.org/wiki/JavaScript',
},
{
name: 'github/sourcegraph/sourcegraph/Erlang',
value: 700,
fill: 'var(--primary)',
linkURL: 'https://en.wikipedia.org/wiki/JavaScript',
},
{
name: 'github/sourcegraph/sourcegraph/SQL',
value: 550,
fill: 'var(--primary)',
linkURL: 'https://en.wikipedia.org/wiki/JavaScript',
},
{
name: 'github/sourcegraph/sourcegraph/Cobol',
value: 500,
fill: 'var(--primary)',
linkURL: 'https://en.wikipedia.org/wiki/JavaScript',
},
{
name: 'github/sourcegraph/sourcegraph/JavaScript',
value: 422,
fill: 'var(--primary)',
linkURL: 'https://en.wikipedia.org/wiki/JavaScript',
},
{
name: 'github/sourcegraph/sourcegraph/CSS',
value: 273,
fill: 'var(--primary)',
linkURL: 'https://en.wikipedia.org/wiki/CSS',
},
{
name: 'github/sourcegraph/sourcegraph/HTML',
value: 129,
fill: 'var(--primary)',
linkURL: 'https://en.wikipedia.org/wiki/HTML',
},
{
name: 'github/sourcegraph/sourcegraph/С++',
value: 110,
fill: 'var(--primary)',
linkURL: 'https://en.wikipedia.org/wiki/Markdown',
},
{
name: 'github/sourcegraph/sourcegraph/TypeScript',
value: 95,
fill: 'var(--primary)',
linkURL: 'https://en.wikipedia.org/wiki/Markdown',
},
{
name: 'github/sourcegraph/sourcegraph/Elm',
value: 84,
fill: 'var(--primary)',
linkURL: 'https://en.wikipedia.org/wiki/Markdown',
},
{
name: 'github/sourcegraph/sourcegraph/Rust',
value: 60,
fill: 'var(--primary)',
linkURL: 'https://en.wikipedia.org/wiki/Markdown',
},
{
name: 'github/sourcegraph/sourcegraph/Go',
value: 45,
fill: 'var(--primary)',
linkURL: 'https://en.wikipedia.org/wiki/Markdown',
},
{
name: 'github/sourcegraph/sourcegraph/Markdown',
value: 35,
fill: 'var(--primary)',
linkURL: 'https://en.wikipedia.org/wiki/Markdown',
},
{
name: 'github/sourcegraph/sourcegraph/Zig',
value: 20,
fill: 'var(--primary)',
linkURL: 'https://en.wikipedia.org/wiki/Markdown',
},
{
name: 'github/sourcegraph/sourcegraph/XML',
value: 5,
fill: 'var(--primary)',
linkURL: 'https://en.wikipedia.org/wiki/Markdown',
},
]

View File

@ -1,10 +1,3 @@
export enum AggregationMode {
Repository = 'repo',
FilePath = 'file',
Author = 'author',
CaptureGroups = 'captureGroup',
}
export enum AggregationUIMode {
Sidebar = 'sidebar',
SearchPage = 'searchPage',

View File

@ -11,7 +11,7 @@
}
.actions {
margin-top: 1rem;
margin-top: 0.5rem;
display: flex;
flex-wrap: wrap;
gap: 0.25rem;

View File

@ -1,36 +1,49 @@
import { FC } from 'react'
import { mdiArrowExpand, mdiPlus } from '@mdi/js'
import { ParentSize } from '@visx/responsive'
import { mdiArrowExpand } from '@mdi/js'
import { SearchPatternType } from '@sourcegraph/shared/src/schema'
import { Button, Icon } from '@sourcegraph/wildcard'
import {
AggregationChart,
AggregationModeControls,
AggregationUIMode,
useAggregationSearchMode,
useAggregationUIMode,
AggregationCardMode,
AggregationChartCard,
useSearchAggregationData,
getAggregationData,
getOtherGroupCount,
} from '../aggregation'
import { LANGUAGE_USAGE_DATA, LanguageUsageDatum } from '../aggregation/search-aggregation-mock-data'
import styles from './SearchAggregations.module.scss'
const getValue = (datum: LanguageUsageDatum): number => datum.value
const getColor = (datum: LanguageUsageDatum): string => datum.fill
const getLink = (datum: LanguageUsageDatum): string => datum.linkURL
const getName = (datum: LanguageUsageDatum): string => datum.name
interface SearchAggregationsProps {
/**
* Current submitted query, note that this query isn't a live query
* that is synced with typed query in the search box, this query is submitted
* see `searchQueryFromURL` state in the global query Zustand store.
*/
query: string
interface SearchAggregationsProps {}
/** Current search query pattern type. */
patternType: SearchPatternType
/**
* Emits whenever a user clicks one of aggregation chart segments (bars).
* That should update the query and re-trigger search (but this should be connected
* to this UI through its consumer)
*/
onQuerySubmit: (newQuery: string) => void
}
export const SearchAggregations: FC<SearchAggregationsProps> = props => {
const [aggregationMode, setAggregationMode] = useAggregationSearchMode()
const [aggregationUIMode, setAggregationUIMode] = useAggregationUIMode()
const { query, patternType, onQuerySubmit } = props
// Hide search aggregation side panel when we're showing the full UI mode
if (aggregationUIMode !== AggregationUIMode.Sidebar) {
return null
}
const [, setAggregationUIMode] = useAggregationUIMode()
const [aggregationMode, setAggregationMode] = useAggregationSearchMode()
const { data, error, loading } = useSearchAggregationData({ query, patternType, aggregationMode, limit: 10 })
return (
<article className="pt-2">
@ -38,23 +51,34 @@ export const SearchAggregations: FC<SearchAggregationsProps> = props => {
size="sm"
className="mb-3"
mode={aggregationMode}
availability={data?.searchQueryAggregate?.modeAvailability}
onModeChange={setAggregationMode}
/>
<ParentSize className={styles.chartContainer}>
{parent => (
<AggregationChart
mode={aggregationMode}
width={parent.width}
height={parent.height}
data={LANGUAGE_USAGE_DATA}
getDatumName={getName}
getDatumValue={getValue}
getDatumColor={getColor}
getDatumLink={getLink}
/>
)}
</ParentSize>
{loading ? (
<AggregationChartCard
aria-label="Sidebar search aggregation chart"
type={AggregationCardMode.Loading}
className={styles.chartContainer}
/>
) : error ? (
<AggregationChartCard
aria-label="Sidebar search aggregation chart"
type={AggregationCardMode.Error}
errorMessage={error.message}
className={styles.chartContainer}
/>
) : (
<AggregationChartCard
aria-label="Sidebar search aggregation chart"
mode={aggregationMode}
type={AggregationCardMode.Data}
data={getAggregationData(data)}
missingCount={getOtherGroupCount(data)}
className={styles.chartContainer}
onBarLinkClick={onQuerySubmit}
/>
)}
<footer className={styles.actions}>
<Button
@ -67,10 +91,6 @@ export const SearchAggregations: FC<SearchAggregationsProps> = props => {
>
<Icon aria-hidden={true} svgPath={mdiArrowExpand} /> Expand
</Button>
<Button variant="secondary" outline={true} size="sm">
<Icon aria-hidden={true} svgPath={mdiPlus} /> Save insight
</Button>
</footer>
</article>
)

View File

@ -23,6 +23,8 @@ import { useCoreWorkflowImprovementsEnabled } from '@sourcegraph/shared/src/sett
import { TelemetryProps } from '@sourcegraph/shared/src/telemetry/telemetryService'
import { Button, Code, Icon } from '@sourcegraph/wildcard'
import { AggregationUIMode, useAggregationUIMode } from '../aggregation'
import { getDynamicFilterLinks, getRepoFilterLinks, getSearchSnippetLinks } from './FilterLink'
import { getFiltersOfKind, useLastRepoName } from './helpers'
import { getQuickLinks } from './QuickLink'
@ -69,14 +71,20 @@ const selectFromQueryState = ({
queryState: { query },
setQueryState,
submitSearch,
searchQueryFromURL,
searchPatternType,
}: SearchQueryState): {
query: string
setQueryState: SearchQueryState['setQueryState']
submitSearch: SearchQueryState['submitSearch']
searchQueryFromURL: SearchQueryState['searchQueryFromURL']
searchPatternType: SearchQueryState['searchPatternType']
} => ({
query,
setQueryState,
submitSearch,
searchQueryFromURL,
searchPatternType,
})
export const SearchSidebar: React.FunctionComponent<SearchSidebarProps> = props => {
@ -88,7 +96,14 @@ export const SearchSidebar: React.FunctionComponent<SearchSidebarProps> = props
// The zustand store for search query state is referenced through context
// because there may be different global stores across clients
// (e.g. VS Code extension, web app)
const { query, setQueryState, submitSearch } = useSearchQueryStateStoreContext()(selectFromQueryState, shallow)
const {
query,
searchQueryFromURL,
searchPatternType,
setQueryState,
submitSearch,
} = useSearchQueryStateStoreContext()(selectFromQueryState, shallow)
const [aggregationUIMode] = useAggregationUIMode()
// Unlike onFilterClicked, this function will always append or update a filter
const submitQueryWithProps = useCallback(
@ -167,6 +182,10 @@ export const SearchSidebar: React.FunctionComponent<SearchSidebarProps> = props
[props.filters, onDynamicFilterClicked]
)
const handleAggregationBarLinkClick = (query: string): void => {
submitQueryWithProps([{ type: 'replaceQuery', value: query }])
}
let body
// collapsedSections is undefined on first render. To prevent the sections
@ -175,15 +194,19 @@ export const SearchSidebar: React.FunctionComponent<SearchSidebarProps> = props
if (collapsedSections) {
body = (
<>
{props.enableSearchAggregation && (
{props.enableSearchAggregation && aggregationUIMode === AggregationUIMode.Sidebar && (
<SearchSidebarSection
sectionId={SectionID.GROUPED_BY}
className={styles.item}
header="Grouped by"
header="Group results by"
startCollapsed={collapsedSections?.[SectionID.GROUPED_BY]}
onToggle={persistToggleState}
>
<SearchAggregations />
<SearchAggregations
query={searchQueryFromURL}
patternType={searchPatternType}
onQuerySubmit={handleAggregationBarLinkClick}
/>
</SearchSidebarSection>
)}

View File

@ -122,6 +122,10 @@ export type QueryUpdate =
type: 'toggleSubquery'
value: string
}
| {
type: 'replaceQuery'
value: string
}
export function updateQuery(query: string, updates: QueryUpdate[]): string {
return updates.reduce((query, update) => {
@ -135,6 +139,8 @@ export function updateQuery(query: string, updates: QueryUpdate[]): string {
return updateFilter(query, update.field, update.value)
case 'toggleSubquery':
return toggleSubquery(query, update.value)
case 'replaceQuery':
return update.value
}
return query
}, query)

View File

@ -10,6 +10,7 @@ const WEB_FOLDER = path.resolve(ROOT_FOLDER, './client/web')
const BROWSER_FOLDER = path.resolve(ROOT_FOLDER, './client/browser')
const SHARED_FOLDER = path.resolve(ROOT_FOLDER, './client/shared')
const SEARCH_FOLDER = path.resolve(ROOT_FOLDER, './client/search')
const SEARCH_UI_FOLDER = path.resolve(ROOT_FOLDER, './client/search-ui')
const VSCODE_FOLDER = path.resolve(ROOT_FOLDER, './client/vscode')
const JETBRAINS_FOLDER = path.resolve(ROOT_FOLDER, './client/jetbrains')
const SCHEMA_PATH = path.join(ROOT_FOLDER, './cmd/frontend/graphqlbackend/*.graphql')
@ -33,6 +34,7 @@ const BROWSER_DOCUMENTS_GLOB = [
]
const SEARCH_DOCUMENTS_GLOB = [`${SEARCH_FOLDER}/src/**/*.{ts,tsx}`]
const SEARCH_UI_DOCUMENTS_GLOB = [`${SEARCH_UI_FOLDER}/src/**/*.{ts,tsx}`]
const VSCODE_DOCUMENTS_GLOB = [`${VSCODE_FOLDER}/src/**/*.{ts,tsx}`]
@ -45,6 +47,7 @@ const ALL_DOCUMENTS_GLOB = [
...WEB_DOCUMENTS_GLOB,
...BROWSER_DOCUMENTS_GLOB,
...SEARCH_DOCUMENTS_GLOB,
...SEARCH_UI_DOCUMENTS_GLOB,
...VSCODE_DOCUMENTS_GLOB,
...JETBRAINS_DOCUMENTS_GLOB,
]),
@ -60,107 +63,122 @@ const SHARED_PLUGINS = [
* Generates TypeScript files with types for all GraphQL operations.
*/
async function generateGraphQlOperations() {
await generate(
{
schema: SCHEMA_PATH,
hooks: {
afterOneFileWrite: 'prettier --write',
},
errorsOnly: true,
config: {
preResolveTypes: true,
operationResultSuffix: 'Result',
omitOperationSuffix: true,
namingConvention: {
typeNames: 'keep',
enumValues: 'keep',
transformUnderscore: true,
try {
await generate(
{
schema: SCHEMA_PATH,
hooks: {
afterOneFileWrite: 'prettier --write',
},
declarationKind: 'interface',
avoidOptionals: {
field: true,
inputValue: false,
object: true,
errorsOnly: true,
config: {
preResolveTypes: true,
operationResultSuffix: 'Result',
omitOperationSuffix: true,
namingConvention: {
typeNames: 'keep',
enumValues: 'keep',
transformUnderscore: true,
},
declarationKind: 'interface',
avoidOptionals: {
field: true,
inputValue: false,
object: true,
},
scalars: {
DateTime: 'string',
JSON: 'object',
JSONValue: 'unknown',
GitObjectID: 'string',
JSONCString: 'string',
PublishedValue: "boolean | 'draft'",
BigInt: 'string',
},
},
scalars: {
DateTime: 'string',
JSON: 'object',
JSONValue: 'unknown',
GitObjectID: 'string',
JSONCString: 'string',
PublishedValue: "boolean | 'draft'",
BigInt: 'string',
generates: {
[path.join(BROWSER_FOLDER, './src/graphql-operations.ts')]: {
documents: BROWSER_DOCUMENTS_GLOB,
config: {
onlyOperationTypes: true,
noExport: false,
enumValues: '@sourcegraph/shared/src/graphql-operations',
interfaceNameForOperations: 'BrowserGraphQlOperations',
},
plugins: SHARED_PLUGINS,
},
[path.join(WEB_FOLDER, './src/graphql-operations.ts')]: {
documents: WEB_DOCUMENTS_GLOB,
config: {
onlyOperationTypes: true,
noExport: false,
enumValues: '@sourcegraph/shared/src/graphql-operations',
interfaceNameForOperations: 'WebGraphQlOperations',
},
plugins: SHARED_PLUGINS,
},
[path.join(SHARED_FOLDER, './src/graphql-operations.ts')]: {
documents: SHARED_DOCUMENTS_GLOB,
config: {
onlyOperationTypes: true,
noExport: false,
interfaceNameForOperations: 'SharedGraphQlOperations',
},
plugins: [...SHARED_PLUGINS, 'typescript-apollo-client-helpers'],
},
[path.join(SEARCH_UI_FOLDER, './src/graphql-operations.ts')]: {
documents: SEARCH_UI_DOCUMENTS_GLOB,
config: {
onlyOperationTypes: true,
noExport: false,
enumValues: '@sourcegraph/shared/src/graphql-operations',
interfaceNameForOperations: 'SearchUIGraphQlOperations',
},
plugins: SHARED_PLUGINS,
},
[path.join(SEARCH_FOLDER, './src/graphql-operations.ts')]: {
documents: SEARCH_DOCUMENTS_GLOB,
config: {
onlyOperationTypes: true,
noExport: false,
enumValues: '@sourcegraph/shared/src/graphql-operations',
interfaceNameForOperations: 'SearchGraphQlOperations',
},
plugins: SHARED_PLUGINS,
},
[path.join(VSCODE_FOLDER, './src/graphql-operations.ts')]: {
documents: VSCODE_DOCUMENTS_GLOB,
config: {
onlyOperationTypes: true,
noExport: false,
enumValues: '@sourcegraph/shared/src/graphql-operations',
interfaceNameForOperations: 'VSCodeGraphQlOperations',
},
plugins: SHARED_PLUGINS,
},
[path.join(JETBRAINS_FOLDER, './webview/src/graphql-operations.ts')]: {
documents: JETBRAINS_DOCUMENTS_GLOB,
config: {
onlyOperationTypes: true,
noExport: false,
enumValues: '@sourcegraph/shared/src/graphql-operations',
interfaceNameForOperations: 'JetBrainsGraphQlOperations',
},
plugins: SHARED_PLUGINS,
},
},
},
generates: {
[path.join(BROWSER_FOLDER, './src/graphql-operations.ts')]: {
documents: BROWSER_DOCUMENTS_GLOB,
config: {
onlyOperationTypes: true,
noExport: false,
enumValues: '@sourcegraph/shared/src/graphql-operations',
interfaceNameForOperations: 'BrowserGraphQlOperations',
},
plugins: SHARED_PLUGINS,
},
[path.join(WEB_FOLDER, './src/graphql-operations.ts')]: {
documents: WEB_DOCUMENTS_GLOB,
config: {
onlyOperationTypes: true,
noExport: false,
enumValues: '@sourcegraph/shared/src/graphql-operations',
interfaceNameForOperations: 'WebGraphQlOperations',
},
plugins: SHARED_PLUGINS,
},
[path.join(SHARED_FOLDER, './src/graphql-operations.ts')]: {
documents: SHARED_DOCUMENTS_GLOB,
config: {
onlyOperationTypes: true,
noExport: false,
interfaceNameForOperations: 'SharedGraphQlOperations',
},
plugins: [...SHARED_PLUGINS, 'typescript-apollo-client-helpers'],
},
[path.join(SEARCH_FOLDER, './src/graphql-operations.ts')]: {
documents: SEARCH_DOCUMENTS_GLOB,
config: {
onlyOperationTypes: true,
noExport: false,
enumValues: '@sourcegraph/shared/src/graphql-operations',
interfaceNameForOperations: 'SearchGraphQlOperations',
},
plugins: SHARED_PLUGINS,
},
[path.join(VSCODE_FOLDER, './src/graphql-operations.ts')]: {
documents: VSCODE_DOCUMENTS_GLOB,
config: {
onlyOperationTypes: true,
noExport: false,
enumValues: '@sourcegraph/shared/src/graphql-operations',
interfaceNameForOperations: 'VSCodeGraphQlOperations',
},
plugins: SHARED_PLUGINS,
},
[path.join(JETBRAINS_FOLDER, './webview/src/graphql-operations.ts')]: {
documents: JETBRAINS_DOCUMENTS_GLOB,
config: {
onlyOperationTypes: true,
noExport: false,
enumValues: '@sourcegraph/shared/src/graphql-operations',
interfaceNameForOperations: 'JetBrainsGraphQlOperations',
},
plugins: SHARED_PLUGINS,
},
},
},
true
)
true
)
} catch (error) {
console.log(error)
}
}
module.exports = {

View File

@ -4,6 +4,7 @@ import path from 'path'
import html from 'tagged-template-noop'
import { SearchGraphQlOperations } from '@sourcegraph/search'
import { SearchUIGraphQlOperations } from '@sourcegraph/search-ui'
import { SharedGraphQlOperations } from '@sourcegraph/shared/src/graphql-operations'
import { SearchEvent } from '@sourcegraph/shared/src/search/stream'
import { getConfig } from '@sourcegraph/shared/src/testing/config'
@ -22,7 +23,7 @@ import { createJsContext } from './jscontext'
export interface WebIntegrationTestContext
extends IntegrationTestContext<
WebGraphQlOperations & SharedGraphQlOperations & SearchGraphQlOperations,
WebGraphQlOperations & SharedGraphQlOperations & SearchGraphQlOperations & SearchUIGraphQlOperations,
string & keyof (WebGraphQlOperations & SharedGraphQlOperations)
> {
/**

View File

@ -1,7 +1,8 @@
import expect from 'expect'
import { test } from 'mocha'
import { SearchGraphQlOperations } from '@sourcegraph/search'
import { SearchAggregationMode, SearchGraphQlOperations } from '@sourcegraph/search'
import { GetSearchAggregationResult } from '@sourcegraph/search-ui'
import { SharedGraphQlOperations } from '@sourcegraph/shared/src/graphql-operations'
import { SearchEvent } from '@sourcegraph/shared/src/search/stream'
import { Driver, createDriverForTest } from '@sourcegraph/shared/src/testing/driver'
@ -11,7 +12,70 @@ import { WebGraphQlOperations } from '../graphql-operations'
import { WebIntegrationTestContext, createWebIntegrationTestContext } from './context'
import { commonWebGraphQlResults } from './graphQlResults'
import { createEditorAPI } from './utils'
const aggregationDefaultMock: GetSearchAggregationResult = {
searchQueryAggregate: {
__typename: 'SearchQueryAggregate',
aggregations: {
__typename: 'ExhaustiveSearchAggregationResult',
mode: SearchAggregationMode.REPO,
otherGroupCount: 100,
groups: [
{
__typename: 'AggregationGroup',
label: 'sourcegraph/sourcegraph',
count: 100,
query: 'context:global insights repo:sourcegraph/sourcegraph',
},
{
__typename: 'AggregationGroup',
label: 'sourcegraph/about',
count: 80,
query: 'context:global insights repo:sourecegraph/about',
},
{
__typename: 'AggregationGroup',
label: 'sourcegraph/search-insight',
count: 60,
query: 'context:global insights repo:sourecegraph/search-insight',
},
{
__typename: 'AggregationGroup',
label: 'sourcegraph/lang-stats',
count: 40,
query: 'context:global insights repo:sourecegraph/lang-stats',
},
],
},
modeAvailability: [
{
__typename: 'AggregationModeAvailability',
mode: SearchAggregationMode.REPO,
available: true,
reasonUnavailable: null,
},
{
__typename: 'AggregationModeAvailability',
mode: SearchAggregationMode.PATH,
available: true,
reasonUnavailable: null,
},
{
__typename: 'AggregationModeAvailability',
mode: SearchAggregationMode.AUTHOR,
available: true,
reasonUnavailable: null,
},
{
__typename: 'AggregationModeAvailability',
mode: SearchAggregationMode.CAPTURE_GROUP,
available: true,
reasonUnavailable: null,
},
],
},
}
const mockDefaultStreamEvents: SearchEvent[] = [
{
type: 'matches',
@ -45,9 +109,7 @@ const mockDefaultStreamEvents: SearchEvent[] = [
const commonSearchGraphQLResults: Partial<WebGraphQlOperations & SharedGraphQlOperations & SearchGraphQlOperations> = {
...commonWebGraphQlResults,
IsSearchContextAvailable: () => ({
isSearchContextAvailable: true,
}),
IsSearchContextAvailable: () => ({ isSearchContextAvailable: true }),
UserAreaUserProfile: () => ({
user: {
__typename: 'User',
@ -64,6 +126,8 @@ const commonSearchGraphQLResults: Partial<WebGraphQlOperations & SharedGraphQlOp
}),
}
const QUERY_INPUT_SELECTOR = '[data-testid="searchbox"] .test-query-input'
describe('Search aggregation', () => {
let driver: Driver
let testContext: WebIntegrationTestContext
@ -77,7 +141,10 @@ describe('Search aggregation', () => {
currentTest: this.currentTest!,
directory: __dirname,
})
testContext.overrideGraphQL(commonSearchGraphQLResults)
testContext.overrideGraphQL({
...commonSearchGraphQLResults,
GetSearchAggregation: () => aggregationDefaultMock,
})
testContext.overrideSearchStreamEvents(mockDefaultStreamEvents)
})
@ -119,10 +186,16 @@ describe('Search aggregation', () => {
await driver.page.waitForSelector('[aria-label="Aggregation mode picker"]')
const aggregationModesIds = ['repo', 'file', 'author', 'captureGroup']
// 'REPO', 'PATH', 'AUTHOR', 'CAPTURE_GROUP'
const aggregationCases = [
{ mode: 'REPO', id: 'repo-aggregation-mode' },
{ mode: 'PATH', id: 'file-aggregation-mode' },
{ mode: 'AUTHOR', id: 'author-aggregation-mode' },
{ mode: 'CAPTURE_GROUP', id: 'captureGroup-aggregation-mode' },
]
for (const mode of aggregationModesIds) {
await driver.page.click(`[data-testid="${mode}-aggregation-mode"]`)
for (const testCase of aggregationCases) {
await driver.page.click(`[data-testid="${testCase.id}"]`)
await driver.page.waitForFunction(
(expectedQuery: string, mode: string) => {
@ -134,7 +207,7 @@ describe('Search aggregation', () => {
},
{ timeout: 5000 },
`${origQuery}`,
mode
testCase.mode
)
}
})
@ -170,7 +243,7 @@ describe('Search aggregation', () => {
return (
query &&
query.trim() === expectedQuery &&
aggregationMode === 'file' &&
aggregationMode === 'PATH' &&
aggregationUIMode === 'searchPage'
)
},
@ -193,7 +266,7 @@ describe('Search aggregation', () => {
return (
query &&
query.trim() === expectedQuery &&
aggregationMode === 'author' &&
aggregationMode === 'AUTHOR' &&
aggregationUIMode === 'sidebar'
)
},
@ -201,5 +274,29 @@ describe('Search aggregation', () => {
`${origQuery}`
)
})
test('should update the search box query when user clicks on one of aggregation bars', async () => {
const origQuery = 'context:global insights('
await driver.page.goto(
`${driver.sourcegraphBaseUrl}/search?q=${encodeURIComponent(origQuery)}&patternType=literal`
)
const editor = await createEditorAPI(driver, QUERY_INPUT_SELECTOR)
await editor.waitForIt()
await driver.page.waitForSelector('[aria-label="chart content group"] a')
await driver.page.click('[aria-label="Sidebar search aggregation chart"] a')
expect(await editor.getValue()).toStrictEqual('insights repo:sourcegraph/sourcegraph')
await driver.page.click('[data-testid="expand-aggregation-ui"]')
await driver.page.waitForSelector('[aria-label="chart content group"] g:nth-child(2) a')
await driver.page.click(
'[aria-label="Expanded search aggregation chart"] [aria-label="chart content group"] g:nth-child(2) a'
)
expect(await editor.getValue()).toStrictEqual('insights repo:sourecegraph/about')
})
})
})

View File

@ -260,6 +260,16 @@ export const StreamingSearchResults: React.FunctionComponent<
[query, telemetryService, patternType, caseSensitive, props]
)
const handleSearchAggregationBarClick = (query: string): void => {
submitSearch({
...props,
caseSensitive,
patternType,
query,
source: 'nav',
})
}
return (
<div className={classNames(styles.container, selectedTab !== 'filters' && styles.containerWithSidebarHidden)}>
<PageTitle key="page-title" title={query} />
@ -289,7 +299,13 @@ export const StreamingSearchResults: React.FunctionComponent<
/>
{aggregationUIMode === AggregationUIMode.SearchPage && (
<SearchAggregationResult aria-label="Aggregation results panel" className={styles.contents} />
<SearchAggregationResult
query={query}
patternType={patternType}
aria-label="Aggregation results panel"
className={styles.contents}
onQuerySubmit={handleSearchAggregationBarClick}
/>
)}
{aggregationUIMode !== AggregationUIMode.SearchPage && (

View File

@ -1,8 +1,7 @@
import { ReactElement, SVGProps, useMemo } from 'react'
import { ReactElement, SVGProps, useMemo, MouseEvent } from 'react'
import { scaleBand, scaleLinear } from '@visx/scale'
import { ScaleBand } from 'd3-scale'
import { noop } from 'lodash'
import { GetScaleTicksOptions } from '../../core/components/axis/tick-formatters'
import { SvgAxisBottom, SvgAxisLeft, SvgContent, SvgRoot } from '../../core/components/SvgRoot'
@ -45,7 +44,7 @@ export function BarChart<Datum>(props: BarChartProps<Datum>): ReactElement {
getDatumColor,
getDatumLink = DEFAULT_LINK_GETTER,
getCategory = getDatumName,
onDatumLinkClick = noop,
onDatumLinkClick,
...attributes
} = props
@ -71,14 +70,14 @@ export function BarChart<Datum>(props: BarChartProps<Datum>): ReactElement {
[categories]
)
const handleBarClick = (datum: Datum): void => {
const handleBarClick = (event: MouseEvent, datum: Datum): void => {
const link = getDatumLink(datum)
if (link) {
onDatumLinkClick?.(event, datum)
if (!event.isDefaultPrevented() && link) {
window.open(link)
}
onDatumLinkClick(datum)
}
return (

View File

@ -1,4 +1,4 @@
import { ReactElement, SVGProps, useRef, useState } from 'react'
import { MouseEvent, ReactElement, SVGProps, useRef, useState } from 'react'
import { Group } from '@visx/group'
import classNames from 'classnames'
@ -29,7 +29,7 @@ interface BarChartContentProps<Datum> extends SVGProps<SVGGElement> {
getDatumHover?: (datum: Datum) => string
getDatumColor: (datum: Datum) => string | undefined
getDatumLink: (datum: Datum) => string | undefined | null
onBarClick: (datum: Datum) => void
onBarClick: (event: MouseEvent, datum: Datum) => void
}
export function BarChartContent<Datum>(props: BarChartContentProps<Datum>): ReactElement {
@ -64,6 +64,7 @@ export function BarChartContent<Datum>(props: BarChartContentProps<Datum>): Reac
>
{stacked ? (
<StackedBars
aria-label="chart content group"
categories={categories}
xScale={xScale}
yScale={yScale}
@ -77,6 +78,7 @@ export function BarChartContent<Datum>(props: BarChartContentProps<Datum>): Reac
/>
) : (
<GroupedBars
aria-label="chart content group"
activeSegment={activeSegment}
categories={categories}
xScale={xScale}

View File

@ -0,0 +1,15 @@
.bar {
&--active {
filter: brightness(110%);
}
&--fade {
:global(.theme-dark) & {
filter: brightness(0.5);
}
:global(.theme-light) & {
filter: brightness(1.5) saturate(0.15);
}
}
}

View File

@ -2,12 +2,17 @@ import { ComponentProps, MouseEvent, ReactElement, useMemo } from 'react'
import { Group } from '@visx/group'
import { scaleBand } from '@visx/scale'
import classNames from 'classnames'
import { ScaleBand, ScaleLinear } from 'd3-scale'
import { getBrowserName } from '@sourcegraph/common'
import { MaybeLink } from '../../../core'
import { ActiveSegment } from '../types'
import { Category } from '../utils/get-grouped-categories'
import styles from './GroupedBars.module.scss'
interface GroupedBarsProps<Datum> extends ComponentProps<typeof Group> {
activeSegment: ActiveSegment<Datum> | null
categories: Category<Datum>[]
@ -20,9 +25,11 @@ interface GroupedBarsProps<Datum> extends ComponentProps<typeof Group> {
getDatumLink: (datum: Datum) => string | undefined | null
onBarHover: (datum: Datum, category: Category<Datum>) => void
onBarLeave: () => void
onBarClick: (datum: Datum) => void
onBarClick: (event: MouseEvent, datum: Datum) => void
}
const isSafari = getBrowserName() === 'safari'
export function GroupedBars<Datum>(props: GroupedBarsProps<Datum>): ReactElement {
const {
width,
@ -65,7 +72,7 @@ export function GroupedBars<Datum>(props: GroupedBarsProps<Datum>): ReactElement
const [datum] = getActiveBar({ event, xScale, xCategoriesScale, categories })
if (datum) {
onBarClick(datum)
onBarClick(event, datum)
}
}
@ -84,6 +91,8 @@ export function GroupedBars<Datum>(props: GroupedBarsProps<Datum>): ReactElement
<MaybeLink
key={`bar-group-bar-${category.id}-${getDatumName(datum)}`}
to={getDatumLink(datum)}
onFocus={() => onBarHover(datum, category)}
onClick={event => onBarClick(event, datum)}
>
<rect
x={barX}
@ -91,10 +100,18 @@ export function GroupedBars<Datum>(props: GroupedBarsProps<Datum>): ReactElement
width={barWidth}
height={barHeight}
fill={getDatumColor(datum)}
rx={4}
// TODO: Move hardcoded to the public API and make it overridable
// see https://github.com/sourcegraph/sourcegraph/issues/40259
opacity={activeSegment ? (activeSegment.category.id === category.id ? 1 : 0.5) : 1}
rx={2}
opacity={
isSafari && activeSegment
? activeSegment.category.id === category.id
? 1
: 0.5
: 1
}
className={classNames({
[styles.barActive]: activeSegment && activeSegment?.category.id === category.id,
[styles.barFade]: activeSegment && activeSegment?.category.id !== category.id,
})}
/>
</MaybeLink>
)

View File

@ -1,4 +1,4 @@
import { ComponentProps, ReactElement } from 'react'
import { ComponentProps, MouseEvent, ReactElement } from 'react'
import { Group } from '@visx/group'
import { BarRounded } from '@visx/shape'
@ -16,7 +16,7 @@ interface StackedBarsProps<Datum> extends ComponentProps<typeof Group> {
getDatumColor: (datum: Datum) => string | undefined
onBarHover: (datum: Datum, category: Category<Datum>) => void
onBarLeave: () => void
onBarClick: (datum: Datum) => void
onBarClick: (event: MouseEvent, datum: Datum) => void
}
export function StackedBars<Datum>(props: StackedBarsProps<Datum>): ReactElement {
@ -60,7 +60,7 @@ export function StackedBars<Datum>(props: StackedBarsProps<Datum>): ReactElement
bottom={isFirstBar}
top={isLastBar}
onMouseEnter={() => onBarHover(stackedDatum.datum, category)}
onClick={() => onBarClick(stackedDatum.datum)}
onClick={event => onBarClick(event, stackedDatum.datum)}
onMouseLeave={onBarLeave}
/>
)

View File

@ -105,7 +105,7 @@ export function PieChart<Datum>(props: PieChartProps<Datum>): ReactElement | nul
aria-label={`Element ${index + 1} of ${arcs.length}. Name: ${getDatumName(
arc.data
)}. Value: ${getSubtitle(arc, total)}.`}
onClick={onDatumLinkClick}
onClick={event => onDatumLinkClick(event, arc.data)}
>
<PieArc
arc={arc}

View File

@ -18,7 +18,7 @@ export interface CategoricalLikeChart<Datum> {
getDatumHover?: (datum: Datum) => string
getDatumColor: (datum: Datum) => string | undefined
getDatumLink?: (datum: Datum) => string | undefined
onDatumLinkClick?: (event: React.MouseEvent) => void
onDatumLinkClick?: (event: React.MouseEvent, datum: Datum) => void
}
export interface Series<Datum> {