mirror of
https://github.com/sourcegraph/sourcegraph.git
synced 2026-02-06 18:51:59 +00:00
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:
parent
415029ed15
commit
a31f320f6b
@ -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'
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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);
|
||||
}
|
||||
@ -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 couldn’t 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>
|
||||
)
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -26,7 +26,6 @@
|
||||
|
||||
.list-result-item {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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',
|
||||
},
|
||||
]
|
||||
@ -1,10 +1,3 @@
|
||||
export enum AggregationMode {
|
||||
Repository = 'repo',
|
||||
FilePath = 'file',
|
||||
Author = 'author',
|
||||
CaptureGroups = 'captureGroup',
|
||||
}
|
||||
|
||||
export enum AggregationUIMode {
|
||||
Sidebar = 'sidebar',
|
||||
SearchPage = 'searchPage',
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 1rem;
|
||||
margin-top: 0.5rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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)
|
||||
> {
|
||||
/**
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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> {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user